diff --git a/.github/scripts/build.sh b/.github/scripts/build.sh new file mode 100755 index 000000000..76e65f49f --- /dev/null +++ b/.github/scripts/build.sh @@ -0,0 +1,13 @@ +#!/bin/bash + +set -eo pipefail + +# build with SwiftPM: +# https://developer.apple.com/documentation/swift_packages/building_swift_packages_or_apps_that_use_them_in_continuous_integration_workflows + +xcodebuild -workspace Mastodon.xcworkspace \ + -scheme Mastodon \ + -disableAutomaticPackageResolution \ + -destination "platform=iOS Simulator,name=iPhone SE (2nd generation)" \ + clean \ + build | xcpretty \ No newline at end of file diff --git a/.github/scripts/setup.sh b/.github/scripts/setup.sh new file mode 100755 index 000000000..0c2612d51 --- /dev/null +++ b/.github/scripts/setup.sh @@ -0,0 +1,9 @@ +#!/bin/bash + +sudo gem install cocoapods-keys + +# stub keys. DO NOT use in production +pod keys set notification_endpoint "" +pod keys set notification_endpoint_debug "" + +pod install diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 000000000..e1bc703a7 --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1,27 @@ +name: CI + +on: + push: + branches: + - master + - develop + - feature/* + pull_request: + branches: + - develop + +# macOS environments: https://github.com/actions/virtual-environments/tree/main/images/macos + +jobs: + build: + name: CI build + runs-on: macos-10.15 + steps: + - name: checkout + uses: actions/checkout@v2 + - name: force Xcode 12.2 + run: sudo xcode-select -switch /Applications/Xcode_12.2.app + - name: setup + run: exec ./.github/scripts/setup.sh + - name: build + run: exec ./.github/scripts/build.sh diff --git a/.gitignore b/.gitignore index a766fc629..24e748a9e 100644 --- a/.gitignore +++ b/.gitignore @@ -120,4 +120,6 @@ xcuserdata # End of https://www.toptal.com/developers/gitignore/api/swift,swiftpm,xcode,cocoapods Localization/StringsConvertor/input -Localization/StringsConvertor/output \ No newline at end of file +Localization/StringsConvertor/output +.DS_Store +/Mastodon.xcworkspace/xcshareddata/swiftpm diff --git a/AppShared/AppName.swift b/AppShared/AppName.swift new file mode 100644 index 000000000..9dbca78d8 --- /dev/null +++ b/AppShared/AppName.swift @@ -0,0 +1,12 @@ +// +// AppName.swift +// AppShared +// +// Created by MainasuK Cirno on 2021-4-27. +// + +import Foundation + +public enum AppName { + public static let groupID = "group.org.joinmastodon.mastodon-temp" +} diff --git a/AppShared/AppSecret.swift b/AppShared/AppSecret.swift new file mode 100644 index 000000000..e2305ef1a --- /dev/null +++ b/AppShared/AppSecret.swift @@ -0,0 +1,103 @@ +// +// AppSecret.swift +// AppShared +// +// Created by MainasuK Cirno on 2021-4-27. +// + + +import Foundation +import CryptoKit +import KeychainAccess +import Keys + +public final class AppSecret { + + public static let keychain = Keychain(service: "org.joinmastodon.Mastodon.keychain", accessGroup: AppName.groupID) + + static let notificationPrivateKeyName = "notification-private-key-base64" + static let notificationAuthName = "notification-auth-base64" + + public let notificationEndpoint: String + + public var notificationPrivateKey: P256.KeyAgreement.PrivateKey { + AppSecret.createOrFetchNotificationPrivateKey() + } + public var notificationPublicKey: P256.KeyAgreement.PublicKey { + notificationPrivateKey.publicKey + } + public var notificationAuth: Data { + AppSecret.createOrFetchNotificationAuth() + } + + public static let `default`: AppSecret = { + return AppSecret() + }() + + init() { + let keys = MastodonKeys() + + #if DEBUG + self.notificationEndpoint = keys.notification_endpoint_debug + #else + self.notificationEndpoint = keys.notification_endpoint + #endif + } + + public func register() { + _ = AppSecret.createOrFetchNotificationPrivateKey() + _ = AppSecret.createOrFetchNotificationAuth() + } + +} + +extension AppSecret { + + private static func createOrFetchNotificationPrivateKey() -> P256.KeyAgreement.PrivateKey { + if let encoded = AppSecret.keychain[AppSecret.notificationPrivateKeyName], + let data = Data(base64Encoded: encoded) { + do { + let privateKey = try P256.KeyAgreement.PrivateKey(rawRepresentation: data) + return privateKey + } catch { + assertionFailure() + return AppSecret.resetNotificationPrivateKey() + } + } else { + return AppSecret.resetNotificationPrivateKey() + } + } + + private static func resetNotificationPrivateKey() -> P256.KeyAgreement.PrivateKey { + let privateKey = P256.KeyAgreement.PrivateKey() + keychain[AppSecret.notificationPrivateKeyName] = privateKey.rawRepresentation.base64EncodedString() + return privateKey + } + +} + +extension AppSecret { + + private static func createOrFetchNotificationAuth() -> Data { + if let encoded = keychain[AppSecret.notificationAuthName], + let data = Data(base64Encoded: encoded) { + return data + } else { + return AppSecret.resetNotificationAuth() + } + } + + private static func resetNotificationAuth() -> Data { + let auth = AppSecret.createRandomAuthBytes() + keychain[AppSecret.notificationAuthName] = auth.base64EncodedString() + return auth + } + + private static func createRandomAuthBytes() -> Data { + let byteCount = 16 + var bytes = Data(count: byteCount) + _ = bytes.withUnsafeMutableBytes { SecRandomCopyBytes(kSecRandomDefault, byteCount, $0.baseAddress!) } + return bytes + } + +} diff --git a/AppShared/AppShared.h b/AppShared/AppShared.h new file mode 100644 index 000000000..3258d4fcb --- /dev/null +++ b/AppShared/AppShared.h @@ -0,0 +1,18 @@ +// +// AppShared.h +// AppShared +// +// Created by MainasuK Cirno on 2021-4-27. +// + +#import + +//! Project version number for AppShared. +FOUNDATION_EXPORT double AppSharedVersionNumber; + +//! Project version string for AppShared. +FOUNDATION_EXPORT const unsigned char AppSharedVersionString[]; + +// In this header, you should import all the public headers of your framework using statements like #import + + diff --git a/AppShared/Info.plist b/AppShared/Info.plist new file mode 100644 index 000000000..9bcb24442 --- /dev/null +++ b/AppShared/Info.plist @@ -0,0 +1,22 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + 1.0 + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + + diff --git a/AppShared/UserDefaults.swift b/AppShared/UserDefaults.swift new file mode 100644 index 000000000..9cecdcf60 --- /dev/null +++ b/AppShared/UserDefaults.swift @@ -0,0 +1,12 @@ +// +// UserDefaults.swift +// AppShared +// +// Created by MainasuK Cirno on 2021-4-27. +// + +import UIKit + +extension UserDefaults { + public static let shared = UserDefaults(suiteName: AppName.groupID)! +} diff --git a/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents b/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents index 3fe5fe16e..c8c07fbcb 100644 --- a/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents +++ b/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents @@ -1,11 +1,11 @@ - + - + @@ -16,13 +16,26 @@ - + - + + + + + + + + + + + + + + @@ -32,14 +45,13 @@ - - + - + @@ -49,7 +61,7 @@ - + @@ -65,47 +77,127 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + - - + + + + + + + + + + + + - - - - + + + + + + + + + + + - + - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - + + + - + + + + + + + + + + @@ -114,6 +206,7 @@ + @@ -121,31 +214,75 @@ - - + + - - - - + + + + - - - + + + + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - + + + + + - - - - - - + + + + + + + + + + + + + \ No newline at end of file diff --git a/CoreDataStack/CoreDataStack.swift b/CoreDataStack/CoreDataStack.swift index 02aa397ff..64bf9c857 100644 --- a/CoreDataStack/CoreDataStack.swift +++ b/CoreDataStack/CoreDataStack.swift @@ -8,6 +8,7 @@ import os import Foundation import CoreData +import AppShared public final class CoreDataStack { @@ -18,7 +19,7 @@ public final class CoreDataStack { } public convenience init(databaseName: String = "shared") { - let storeURL = URL.storeURL(for: "group.org.joinmastodon.mastodon-temp", databaseName: databaseName) + let storeURL = URL.storeURL(for: AppName.groupID, databaseName: databaseName) let storeDescription = NSPersistentStoreDescription(url: storeURL) self.init(persistentStoreDescriptions: [storeDescription]) } @@ -38,7 +39,7 @@ public final class CoreDataStack { }() static func persistentContainer() -> NSPersistentContainer { - let bundles = [Bundle(for: Toot.self)] + let bundles = [Bundle(for: Status.self)] guard let managedObjectModel = NSManagedObjectModel.mergedModel(from: bundles) else { fatalError("cannot locate bundles") } diff --git a/CoreDataStack/Entity/Application.swift b/CoreDataStack/Entity/Application.swift index cfbf48f7e..b40b2e5b2 100644 --- a/CoreDataStack/Entity/Application.swift +++ b/CoreDataStack/Entity/Application.swift @@ -17,14 +17,14 @@ public final class Application: NSManagedObject { @NSManaged public private(set) var website: String? @NSManaged public private(set) var vapidKey: String? - // one-to-many relationship - @NSManaged public private(set) var toots: Set + // one-to-one relationship + @NSManaged public private(set) var status: Status } public extension Application { override func awakeFromInsert() { super.awakeFromInsert() - identifier = UUID() + setPrimitiveValue(UUID(), forKey: #keyPath(Application.identifier)) } @discardableResult diff --git a/CoreDataStack/Entity/Attachment.swift b/CoreDataStack/Entity/Attachment.swift index f3071872f..16f007bf1 100644 --- a/CoreDataStack/Entity/Attachment.swift +++ b/CoreDataStack/Entity/Attachment.swift @@ -15,7 +15,7 @@ public final class Attachment: NSManagedObject { @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 previewURL: String? @NSManaged public private(set) var remoteURL: String? @NSManaged public private(set) var metaData: Data? @@ -28,7 +28,7 @@ public final class Attachment: NSManagedObject { @NSManaged public private(set) var index: NSNumber // many-to-one relastionship - @NSManaged public private(set) var toot: Toot? + @NSManaged public private(set) var status: Status? } @@ -36,7 +36,7 @@ public extension Attachment { override func awakeFromInsert() { super.awakeFromInsert() - createdAt = Date() + setPrimitiveValue(Date(), forKey: #keyPath(Attachment.createdAt)) } @discardableResult @@ -80,7 +80,7 @@ public extension Attachment { public let typeRaw: String public let url: String - public let previewURL: String + public let previewURL: String? public let remoteURL: String? public let metaData: Data? public let textURL: String? @@ -95,7 +95,7 @@ public extension Attachment { id: Attachment.ID, typeRaw: String, url: String, - previewURL: String, + previewURL: String?, remoteURL: String?, metaData: Data?, textURL: String?, diff --git a/CoreDataStack/Entity/DomainBlock.swift b/CoreDataStack/Entity/DomainBlock.swift new file mode 100644 index 000000000..3dd244c75 --- /dev/null +++ b/CoreDataStack/Entity/DomainBlock.swift @@ -0,0 +1,73 @@ +// +// DomainBlock.swift +// CoreDataStack +// +// Created by sxiaojian on 2021/4/29. +// + +import CoreData +import Foundation + +public final class DomainBlock: NSManagedObject { + @NSManaged public private(set) var blockedDomain: String + @NSManaged public private(set) var createAt: Date + + @NSManaged public private(set) var domain: String + @NSManaged public private(set) var userID: String + + override public func awakeFromInsert() { + super.awakeFromInsert() + setPrimitiveValue(Date(), forKey: #keyPath(DomainBlock.createAt)) + } +} + +extension DomainBlock { + @discardableResult + public static func insert( + into context: NSManagedObjectContext, + blockedDomain: String, + domain: String, + userID: String + ) -> DomainBlock { + let domainBlock: DomainBlock = context.insertObject() + domainBlock.domain = domain + domainBlock.blockedDomain = blockedDomain + domainBlock.userID = userID + return domainBlock + } +} + +extension DomainBlock: Managed { + public static var defaultSortDescriptors: [NSSortDescriptor] { + [NSSortDescriptor(keyPath: \DomainBlock.createAt, ascending: false)] + } +} + +extension DomainBlock { + static func predicate(domain: String) -> NSPredicate { + NSPredicate(format: "%K == %@", #keyPath(DomainBlock.domain), domain) + } + + static func predicate(userID: String) -> NSPredicate { + NSPredicate(format: "%K == %@", #keyPath(DomainBlock.userID), userID) + } + + static func predicate(blockedDomain: String) -> NSPredicate { + NSPredicate(format: "%K == %@", #keyPath(DomainBlock.blockedDomain), blockedDomain) + } + + public static func predicate(domain: String, userID: String) -> NSPredicate { + NSCompoundPredicate(andPredicateWithSubpredicates: [ + DomainBlock.predicate(domain: domain), + DomainBlock.predicate(userID: userID) + ]) + } + + public static func predicate(domain: String, userID: String, blockedDomain: String) -> NSPredicate { + NSCompoundPredicate(andPredicateWithSubpredicates: [ + DomainBlock.predicate(domain: domain), + DomainBlock.predicate(userID: userID), + DomainBlock.predicate(blockedDomain: blockedDomain) + ]) + } +} diff --git a/CoreDataStack/Entity/Emoji.swift b/CoreDataStack/Entity/Emoji.swift index f43dcbf4a..e9ee9d235 100644 --- a/CoreDataStack/Entity/Emoji.swift +++ b/CoreDataStack/Entity/Emoji.swift @@ -20,13 +20,13 @@ public final class Emoji: NSManagedObject { @NSManaged public private(set) var category: String? // many-to-one relationship - @NSManaged public private(set) var toot: Toot? + @NSManaged public private(set) var status: Status? } public extension Emoji { override func awakeFromInsert() { super.awakeFromInsert() - identifier = UUID() + setPrimitiveValue(UUID(), forKey: #keyPath(Emoji.identifier)) } @discardableResult diff --git a/CoreDataStack/Entity/History.swift b/CoreDataStack/Entity/History.swift index 664933687..6fe703e84 100644 --- a/CoreDataStack/Entity/History.swift +++ b/CoreDataStack/Entity/History.swift @@ -14,8 +14,8 @@ public final class History: NSManagedObject { @NSManaged public private(set) var createAt: Date @NSManaged public private(set) var day: Date - @NSManaged public private(set) var uses: Int - @NSManaged public private(set) var accounts: Int + @NSManaged public private(set) var uses: String + @NSManaged public private(set) var accounts: String // many-to-one relationship @NSManaged public private(set) var tag: Tag @@ -24,7 +24,7 @@ public final class History: NSManagedObject { public extension History { override func awakeFromInsert() { super.awakeFromInsert() - identifier = UUID() + setPrimitiveValue(UUID(), forKey: #keyPath(History.identifier)) } @discardableResult @@ -40,13 +40,33 @@ public extension History { } } +public extension History { + func update(day: Date) { + if self.day != day { + self.day = day + } + } + + func update(uses: String) { + if self.uses != uses { + self.uses = uses + } + } + + func update(accounts: String) { + if self.accounts != accounts { + self.accounts = accounts + } + } +} + public extension History { struct Property { public let day: Date - public let uses: Int - public let accounts: Int + public let uses: String + public let accounts: String - public init(day: Date, uses: Int, accounts: Int) { + public init(day: Date, uses: String, accounts: String) { self.day = day self.uses = uses self.accounts = accounts diff --git a/CoreDataStack/Entity/HomeTimelineIndex.swift b/CoreDataStack/Entity/HomeTimelineIndex.swift index 192e06e52..10b00aaa0 100644 --- a/CoreDataStack/Entity/HomeTimelineIndex.swift +++ b/CoreDataStack/Entity/HomeTimelineIndex.swift @@ -22,7 +22,7 @@ final public class HomeTimelineIndex: NSManagedObject { // many-to-one relationship - @NSManaged public private(set) var toot: Toot + @NSManaged public private(set) var status: Status } @@ -32,16 +32,16 @@ extension HomeTimelineIndex { public static func insert( into context: NSManagedObjectContext, property: Property, - toot: Toot + status: Status ) -> HomeTimelineIndex { let index: HomeTimelineIndex = context.insertObject() index.identifier = property.identifier index.domain = property.domain index.userID = property.userID - index.createdAt = toot.createdAt + index.createdAt = status.createdAt - index.toot = toot + index.status = status return index } @@ -52,7 +52,7 @@ extension HomeTimelineIndex { } } - // internal method for Toot call + // internal method for status call func softDelete() { deletedAt = Date() } diff --git a/CoreDataStack/Entity/MastodonAuthentication.swift b/CoreDataStack/Entity/MastodonAuthentication.swift index e58c2e877..0ee0e343b 100644 --- a/CoreDataStack/Entity/MastodonAuthentication.swift +++ b/CoreDataStack/Entity/MastodonAuthentication.swift @@ -36,12 +36,12 @@ extension MastodonAuthentication { public override func awakeFromInsert() { super.awakeFromInsert() - identifier = UUID() + setPrimitiveValue(UUID(), forKey: #keyPath(MastodonAuthentication.identifier)) let now = Date() - createdAt = now - updatedAt = now - activedAt = now + setPrimitiveValue(now, forKey: #keyPath(MastodonAuthentication.createdAt)) + setPrimitiveValue(now, forKey: #keyPath(MastodonAuthentication.updatedAt)) + setPrimitiveValue(now, forKey: #keyPath(MastodonAuthentication.activedAt)) } @discardableResult diff --git a/CoreDataStack/Entity/MastodonUser.swift b/CoreDataStack/Entity/MastodonUser.swift index bcbfe5d26..e93d923c0 100644 --- a/CoreDataStack/Entity/MastodonUser.swift +++ b/CoreDataStack/Entity/MastodonUser.swift @@ -21,22 +21,51 @@ final public class MastodonUser: NSManagedObject { @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 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 pinnedToot: Toot? + @NSManaged public private(set) var pinnedStatus: Status? @NSManaged public private(set) var mastodonAuthentication: MastodonAuthentication? // one-to-many relationship - @NSManaged public private(set) var toots: Set? + @NSManaged public private(set) var statuses: 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 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? } @@ -58,6 +87,22 @@ extension MastodonUser { 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.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 @@ -91,6 +136,128 @@ extension MastodonUser { 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(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 @@ -98,8 +265,8 @@ extension MastodonUser { } -public extension MastodonUser { - struct Property { +extension MastodonUser { + public struct Property { public let identifier: String public let domain: String @@ -109,6 +276,17 @@ public extension MastodonUser { 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 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 @@ -121,6 +299,17 @@ public extension MastodonUser { displayName: String, avatar: String, avatarStatic: String?, + header: String, + headerStatic: String?, + note: String?, + url: String?, + emojisData: Data?, + statusesCount: Int, + followingCount: Int, + followersCount: Int, + locked: Bool, + bot: Bool?, + suspended: Bool?, createdAt: Date, networkDate: Date ) { @@ -132,6 +321,17 @@ public extension MastodonUser { 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.statusesCount = statusesCount + self.followingCount = followingCount + self.followersCount = followersCount + self.locked = locked + self.bot = bot + self.suspended = suspended self.createdAt = createdAt self.networkDate = networkDate } diff --git a/CoreDataStack/Entity/Mention.swift b/CoreDataStack/Entity/Mention.swift index caec10d32..864ca4948 100644 --- a/CoreDataStack/Entity/Mention.swift +++ b/CoreDataStack/Entity/Mention.swift @@ -10,6 +10,9 @@ 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 @@ -19,21 +22,24 @@ public final class Mention: NSManagedObject { @NSManaged public private(set) var url: String // many-to-one relationship - @NSManaged public private(set) var toot: Toot + @NSManaged public private(set) var status: Status } public extension Mention { override func awakeFromInsert() { super.awakeFromInsert() - identifier = UUID() + + setPrimitiveValue(UUID(), forKey: #keyPath(Mention.identifier)) } @discardableResult static func insert( into context: NSManagedObjectContext, - property: Property + 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 diff --git a/CoreDataStack/Entity/Notification.swift b/CoreDataStack/Entity/Notification.swift new file mode 100644 index 000000000..31c361aa4 --- /dev/null +++ b/CoreDataStack/Entity/Notification.swift @@ -0,0 +1,111 @@ +// +// 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) + ]) + } + } + +} + +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 new file mode 100644 index 000000000..3ab48b444 --- /dev/null +++ b/CoreDataStack/Entity/Poll.swift @@ -0,0 +1,145 @@ +// +// 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 new file mode 100644 index 000000000..8917a7533 --- /dev/null +++ b/CoreDataStack/Entity/PollOption.swift @@ -0,0 +1,98 @@ +// +// 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/PrivateNote.swift b/CoreDataStack/Entity/PrivateNote.swift new file mode 100644 index 000000000..2e02db25c --- /dev/null +++ b/CoreDataStack/Entity/PrivateNote.swift @@ -0,0 +1,56 @@ +// +// PrivateNote.swift +// CoreDataStack +// +// Created by MainasuK Cirno on 2021-4-1. +// + +import CoreData +import Foundation + +final public class PrivateNote: NSManagedObject { + + @NSManaged public private(set) var note: String? + + @NSManaged public private(set) var updatedAt: Date + + // many-to-one relationship + @NSManaged public private(set) var to: MastodonUser? + @NSManaged public private(set) var from: MastodonUser + +} + +extension PrivateNote { + public override func awakeFromInsert() { + super.awakeFromInsert() + setPrimitiveValue(Date(), forKey: #keyPath(PrivateNote.updatedAt)) + } + + @discardableResult + public static func insert( + into context: NSManagedObjectContext, + property: Property + ) -> PrivateNote { + let privateNode: PrivateNote = context.insertObject() + privateNode.note = property.note + return privateNode + } +} + +extension PrivateNote { + public struct Property { + public let note: String? + + init(note: String) { + self.note = note + } + } + +} + +extension PrivateNote: Managed { + public static var defaultSortDescriptors: [NSSortDescriptor] { + return [NSSortDescriptor(keyPath: \PrivateNote.updatedAt, ascending: false)] + } +} + diff --git a/CoreDataStack/Entity/SearchHistory.swift b/CoreDataStack/Entity/SearchHistory.swift new file mode 100644 index 000000000..d924917ee --- /dev/null +++ b/CoreDataStack/Entity/SearchHistory.swift @@ -0,0 +1,66 @@ +// +// 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 createAt: Date + @NSManaged public private(set) var updatedAt: Date + + @NSManaged public private(set) var account: MastodonUser? + @NSManaged public private(set) var hashtag: Tag? + +} + +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, + account: MastodonUser + ) -> SearchHistory { + let searchHistory: SearchHistory = context.insertObject() + searchHistory.account = account + return searchHistory + } + + @discardableResult + public static func insert( + into context: NSManagedObjectContext, + hashtag: Tag + ) -> SearchHistory { + let searchHistory: SearchHistory = context.insertObject() + searchHistory.hashtag = hashtag + return searchHistory + } +} + +public extension SearchHistory { + func update(updatedAt: Date) { + setValue(updatedAt, forKey: #keyPath(SearchHistory.updatedAt)) + } +} + +extension SearchHistory: Managed { + public static var defaultSortDescriptors: [NSSortDescriptor] { + return [NSSortDescriptor(keyPath: \SearchHistory.updatedAt, ascending: false)] + } +} diff --git a/CoreDataStack/Entity/Setting.swift b/CoreDataStack/Entity/Setting.swift new file mode 100644 index 000000000..6fac8c351 --- /dev/null +++ b/CoreDataStack/Entity/Setting.swift @@ -0,0 +1,85 @@ +// +// Setting.swift +// CoreDataStack +// +// Created by ihugo on 2021/4/9. +// + +import CoreData +import Foundation + +public final class Setting: NSManagedObject { + + @NSManaged public var appearanceRaw: String + @NSManaged public var domain: String + @NSManaged public var userID: String + + @NSManaged public private(set) var createdAt: Date + @NSManaged public private(set) var updatedAt: Date + + // one-to-many relationships + @NSManaged public var subscriptions: Set? +} + +extension Setting { + + public override func awakeFromInsert() { + super.awakeFromInsert() + let now = Date() + setPrimitiveValue(now, forKey: #keyPath(Setting.createdAt)) + setPrimitiveValue(now, forKey: #keyPath(Setting.updatedAt)) + } + + @discardableResult + public static func insert( + into context: NSManagedObjectContext, + property: Property + ) -> Setting { + let setting: Setting = context.insertObject() + setting.appearanceRaw = property.appearanceRaw + setting.domain = property.domain + setting.userID = property.userID + return setting + } + + public func update(appearanceRaw: String) { + guard appearanceRaw != self.appearanceRaw else { return } + self.appearanceRaw = appearanceRaw + didUpdate(at: Date()) + } + + public func didUpdate(at networkDate: Date) { + self.updatedAt = networkDate + } + +} + +extension Setting { + public struct Property { + public let domain: String + public let userID: String + public let appearanceRaw: String + + public init(domain: String, userID: String, appearanceRaw: String) { + self.domain = domain + self.userID = userID + self.appearanceRaw = appearanceRaw + } + } +} + +extension Setting: Managed { + public static var defaultSortDescriptors: [NSSortDescriptor] { + return [NSSortDescriptor(keyPath: \Setting.createdAt, ascending: false)] + } +} + +extension Setting { + public static func predicate(domain: String, userID: String) -> NSPredicate { + return NSPredicate(format: "%K == %@ AND %K == %@", + #keyPath(Setting.domain), domain, + #keyPath(Setting.userID), userID + ) + } + +} diff --git a/CoreDataStack/Entity/Toot.swift b/CoreDataStack/Entity/Status.swift similarity index 59% rename from CoreDataStack/Entity/Toot.swift rename to CoreDataStack/Entity/Status.swift index b37609a21..14f687241 100644 --- a/CoreDataStack/Entity/Toot.swift +++ b/CoreDataStack/Entity/Status.swift @@ -1,5 +1,5 @@ // -// Toot.swift +// Status.swift // CoreDataStack // // Created by MainasuK Cirno on 2021/1/27. @@ -8,7 +8,7 @@ import CoreData import Foundation -public final class Toot: NSManagedObject { +public final class Status: NSManagedObject { public typealias ID = String @NSManaged public private(set) var identifier: ID @@ -24,13 +24,15 @@ public final class Toot: NSManagedObject { @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: Toot.ID? + @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) @@ -38,7 +40,8 @@ public final class Toot: NSManagedObject { // many-to-one relastionship @NSManaged public private(set) var author: MastodonUser - @NSManaged public private(set) var reblog: Toot? + @NSManaged public private(set) var reblog: Status? + @NSManaged public private(set) var replyTo: Status? // many-to-many relastionship @NSManaged public private(set) var favouritedBy: Set? @@ -48,29 +51,35 @@ public final class Toot: NSManagedObject { // one-to-one relastionship @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 reblogFrom: Set? @NSManaged public private(set) var mentions: Set? - @NSManaged public private(set) var emojis: Set? @NSManaged public private(set) var tags: Set? @NSManaged public private(set) var homeTimelineIndexes: Set? @NSManaged public private(set) var mediaAttachments: Set? + @NSManaged public private(set) var replyFrom: Set? + + @NSManaged public private(set) var inNotifications: Set? @NSManaged public private(set) var updatedAt: Date @NSManaged public private(set) var deletedAt: Date? + @NSManaged public private(set) var revealedAt: Date? } -public extension Toot { +extension Status { + @discardableResult - static func insert( + public static func insert( into context: NSManagedObjectContext, property: Property, author: MastodonUser, - reblog: Toot?, + reblog: Status?, application: Application?, + replyTo: Status?, + poll: Poll?, mentions: [Mention]?, - emojis: [Emoji]?, tags: [Tag]?, mediaAttachments: [Attachment]?, favouritedBy: MastodonUser?, @@ -78,78 +87,87 @@ public extension Toot { mutedBy: MastodonUser?, bookmarkedBy: MastodonUser?, pinnedBy: MastodonUser? - ) -> Toot { - let toot: Toot = context.insertObject() + ) -> Status { + let status: Status = context.insertObject() - toot.identifier = property.identifier - toot.domain = property.domain + status.identifier = property.identifier + status.domain = property.domain - toot.id = property.id - toot.uri = property.uri - toot.createdAt = property.createdAt - toot.content = property.content + status.id = property.id + status.uri = property.uri + status.createdAt = property.createdAt + status.content = property.content - toot.visibility = property.visibility - toot.sensitive = property.sensitive - toot.spoilerText = property.spoilerText - toot.application = application + status.visibility = property.visibility + status.sensitive = property.sensitive + status.spoilerText = property.spoilerText + status.application = application + + status.emojisData = property.emojisData - toot.reblogsCount = property.reblogsCount - toot.favouritesCount = property.favouritesCount - toot.repliesCount = property.repliesCount + status.reblogsCount = property.reblogsCount + status.favouritesCount = property.favouritesCount + status.repliesCount = property.repliesCount - toot.url = property.url - toot.inReplyToID = property.inReplyToID - toot.inReplyToAccountID = property.inReplyToAccountID + status.url = property.url + status.inReplyToID = property.inReplyToID + status.inReplyToAccountID = property.inReplyToAccountID - toot.language = property.language - toot.text = property.text + status.language = property.language + status.text = property.text - toot.author = author - toot.reblog = reblog + status.author = author + status.reblog = reblog - toot.pinnedBy = pinnedBy + status.pinnedBy = pinnedBy + status.poll = poll if let mentions = mentions { - toot.mutableSetValue(forKey: #keyPath(Toot.mentions)).addObjects(from: mentions) - } - if let emojis = emojis { - toot.mutableSetValue(forKey: #keyPath(Toot.emojis)).addObjects(from: emojis) + status.mutableSetValue(forKey: #keyPath(Status.mentions)).addObjects(from: mentions) } if let tags = tags { - toot.mutableSetValue(forKey: #keyPath(Toot.tags)).addObjects(from: tags) + status.mutableSetValue(forKey: #keyPath(Status.tags)).addObjects(from: tags) } if let mediaAttachments = mediaAttachments { - toot.mutableSetValue(forKey: #keyPath(Toot.mediaAttachments)).addObjects(from: mediaAttachments) + status.mutableSetValue(forKey: #keyPath(Status.mediaAttachments)).addObjects(from: mediaAttachments) } if let favouritedBy = favouritedBy { - toot.mutableSetValue(forKey: #keyPath(Toot.favouritedBy)).add(favouritedBy) + status.mutableSetValue(forKey: #keyPath(Status.favouritedBy)).add(favouritedBy) } if let rebloggedBy = rebloggedBy { - toot.mutableSetValue(forKey: #keyPath(Toot.rebloggedBy)).add(rebloggedBy) + status.mutableSetValue(forKey: #keyPath(Status.rebloggedBy)).add(rebloggedBy) } if let mutedBy = mutedBy { - toot.mutableSetValue(forKey: #keyPath(Toot.mutedBy)).add(mutedBy) + status.mutableSetValue(forKey: #keyPath(Status.mutedBy)).add(mutedBy) } if let bookmarkedBy = bookmarkedBy { - toot.mutableSetValue(forKey: #keyPath(Toot.bookmarkedBy)).add(bookmarkedBy) + status.mutableSetValue(forKey: #keyPath(Status.bookmarkedBy)).add(bookmarkedBy) } - toot.updatedAt = property.networkDate + status.updatedAt = property.networkDate - return toot + return status } - func update(reblogsCount: NSNumber) { + + 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 } } - func update(favouritesCount: NSNumber) { + + public func update(favouritesCount: NSNumber) { if self.favouritesCount.intValue != favouritesCount.intValue { self.favouritesCount = favouritesCount } } - func update(repliesCount: NSNumber?) { + + public func update(repliesCount: NSNumber?) { guard let count = repliesCount else { return } @@ -157,61 +175,73 @@ public extension Toot { self.repliesCount = repliesCount } } - func update(liked: Bool, mastodonUser: MastodonUser) { + + 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(Toot.favouritedBy)).addObjects(from: [mastodonUser]) + self.mutableSetValue(forKey: #keyPath(Status.favouritedBy)).add(mastodonUser) } } else { if (self.favouritedBy ?? Set()).contains(mastodonUser) { - self.mutableSetValue(forKey: #keyPath(Toot.favouritedBy)).remove(mastodonUser) + self.mutableSetValue(forKey: #keyPath(Status.favouritedBy)).remove(mastodonUser) } } } - func update(reblogged: Bool, mastodonUser: MastodonUser) { + + public func update(reblogged: Bool, by mastodonUser: MastodonUser) { if reblogged { if !(self.rebloggedBy ?? Set()).contains(mastodonUser) { - self.mutableSetValue(forKey: #keyPath(Toot.rebloggedBy)).addObjects(from: [mastodonUser]) + self.mutableSetValue(forKey: #keyPath(Status.rebloggedBy)).add(mastodonUser) } } else { if (self.rebloggedBy ?? Set()).contains(mastodonUser) { - self.mutableSetValue(forKey: #keyPath(Toot.rebloggedBy)).remove(mastodonUser) + self.mutableSetValue(forKey: #keyPath(Status.rebloggedBy)).remove(mastodonUser) } } } - func update(muted: Bool, mastodonUser: MastodonUser) { + public func update(muted: Bool, by mastodonUser: MastodonUser) { if muted { if !(self.mutedBy ?? Set()).contains(mastodonUser) { - self.mutableSetValue(forKey: #keyPath(Toot.mutedBy)).addObjects(from: [mastodonUser]) + self.mutableSetValue(forKey: #keyPath(Status.mutedBy)).add(mastodonUser) } } else { if (self.mutedBy ?? Set()).contains(mastodonUser) { - self.mutableSetValue(forKey: #keyPath(Toot.mutedBy)).remove(mastodonUser) + self.mutableSetValue(forKey: #keyPath(Status.mutedBy)).remove(mastodonUser) } } } - func update(bookmarked: Bool, mastodonUser: MastodonUser) { + public func update(bookmarked: Bool, by mastodonUser: MastodonUser) { if bookmarked { if !(self.bookmarkedBy ?? Set()).contains(mastodonUser) { - self.mutableSetValue(forKey: #keyPath(Toot.bookmarkedBy)).addObjects(from: [mastodonUser]) + self.mutableSetValue(forKey: #keyPath(Status.bookmarkedBy)).add(mastodonUser) } } else { if (self.bookmarkedBy ?? Set()).contains(mastodonUser) { - self.mutableSetValue(forKey: #keyPath(Toot.bookmarkedBy)).remove(mastodonUser) + self.mutableSetValue(forKey: #keyPath(Status.bookmarkedBy)).remove(mastodonUser) } } } - func didUpdate(at networkDate: Date) { + public func update(isReveal: Bool) { + revealedAt = isReveal ? Date() : nil + } + + public func didUpdate(at networkDate: Date) { self.updatedAt = networkDate } } -public extension Toot { - struct Property { +extension Status { + public struct Property { public let identifier: ID public let domain: String @@ -225,12 +255,14 @@ public extension Toot { 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: Toot.ID? + 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? @@ -246,11 +278,12 @@ public extension Toot { visibility: String?, sensitive: Bool, spoilerText: String?, + emojisData: Data?, reblogsCount: NSNumber, favouritesCount: NSNumber, repliesCount: NSNumber?, url: String?, - inReplyToID: Toot.ID?, + inReplyToID: Status.ID?, inReplyToAccountID: MastodonUser.ID?, language: String?, text: String?, @@ -265,6 +298,7 @@ public extension Toot { self.visibility = visibility self.sensitive = sensitive self.spoilerText = spoilerText + self.emojisData = emojisData self.reblogsCount = reblogsCount self.favouritesCount = favouritesCount self.repliesCount = repliesCount @@ -279,20 +313,20 @@ public extension Toot { } } -extension Toot: Managed { +extension Status: Managed { public static var defaultSortDescriptors: [NSSortDescriptor] { - return [NSSortDescriptor(keyPath: \Toot.createdAt, ascending: false)] + return [NSSortDescriptor(keyPath: \Status.createdAt, ascending: false)] } } -extension Toot { +extension Status { static func predicate(domain: String) -> NSPredicate { - return NSPredicate(format: "%K == %@", #keyPath(Toot.domain), domain) + return NSPredicate(format: "%K == %@", #keyPath(Status.domain), domain) } static func predicate(id: String) -> NSPredicate { - return NSPredicate(format: "%K == %@", #keyPath(Toot.id), id) + return NSPredicate(format: "%K == %@", #keyPath(Status.id), id) } public static func predicate(domain: String, id: String) -> NSPredicate { @@ -303,7 +337,7 @@ extension Toot { } static func predicate(ids: [String]) -> NSPredicate { - return NSPredicate(format: "%K IN %@", #keyPath(Toot.id), ids) + return NSPredicate(format: "%K IN %@", #keyPath(Status.id), ids) } public static func predicate(domain: String, ids: [String]) -> NSPredicate { @@ -314,10 +348,11 @@ extension Toot { } public static func notDeleted() -> NSPredicate { - return NSPredicate(format: "%K == nil", #keyPath(Toot.deletedAt)) + return NSPredicate(format: "%K == nil", #keyPath(Status.deletedAt)) } public static func deleted() -> NSPredicate { - return NSPredicate(format: "%K != nil", #keyPath(Toot.deletedAt)) + return NSPredicate(format: "%K != nil", #keyPath(Status.deletedAt)) } + } diff --git a/CoreDataStack/Entity/Subscription.swift b/CoreDataStack/Entity/Subscription.swift new file mode 100644 index 000000000..e1824be1c --- /dev/null +++ b/CoreDataStack/Entity/Subscription.swift @@ -0,0 +1,87 @@ +// +// SettingNotification+CoreDataClass.swift +// CoreDataStack +// +// Created by ihugo on 2021/4/9. +// +// + +import Foundation +import CoreData + +public final class Subscription: NSManagedObject { + + @NSManaged public var id: String? + @NSManaged public var endpoint: String? + @NSManaged public var policyRaw: String + @NSManaged public var serverKey: String? + @NSManaged public var userToken: String? + + @NSManaged public private(set) var createdAt: Date + @NSManaged public private(set) var updatedAt: Date + @NSManaged public private(set) var activedAt: Date + + // MARK: one-to-one relationships + @NSManaged public var alert: SubscriptionAlerts + + // MARK: many-to-one relationships + @NSManaged public var setting: Setting? +} + +public extension Subscription { + override func awakeFromInsert() { + super.awakeFromInsert() + let now = Date() + setPrimitiveValue(now, forKey: #keyPath(Subscription.createdAt)) + setPrimitiveValue(now, forKey: #keyPath(Subscription.updatedAt)) + setPrimitiveValue(now, forKey: #keyPath(Subscription.activedAt)) + } + + func update(activedAt: Date) { + self.activedAt = activedAt + } + + func didUpdate(at networkDate: Date) { + self.updatedAt = networkDate + } + + @discardableResult + static func insert( + into context: NSManagedObjectContext, + property: Property, + setting: Setting + ) -> Subscription { + let subscription: Subscription = context.insertObject() + subscription.policyRaw = property.policyRaw + subscription.setting = setting + return subscription + } +} + +public extension Subscription { + struct Property { + public let policyRaw: String + + public init(policyRaw: String) { + self.policyRaw = policyRaw + } + } +} + +extension Subscription: Managed { + public static var defaultSortDescriptors: [NSSortDescriptor] { + return [NSSortDescriptor(keyPath: \Subscription.createdAt, ascending: false)] + } +} + +extension Subscription { + + public static func predicate(policyRaw: String) -> NSPredicate { + return NSPredicate(format: "%K == %@", #keyPath(Subscription.policyRaw), policyRaw) + } + + public static func predicate(userToken: String) -> NSPredicate { + return NSPredicate(format: "%K == %@", #keyPath(Subscription.userToken), userToken) + } + +} diff --git a/CoreDataStack/Entity/SubscriptionAlerts.swift b/CoreDataStack/Entity/SubscriptionAlerts.swift new file mode 100644 index 000000000..613d1caf7 --- /dev/null +++ b/CoreDataStack/Entity/SubscriptionAlerts.swift @@ -0,0 +1,178 @@ +// +// PushSubscriptionAlerts+CoreDataClass.swift +// CoreDataStack +// +// Created by ihugo on 2021/4/9. +// +// + +import Foundation +import CoreData + +public final class SubscriptionAlerts: NSManagedObject { + @NSManaged public var favouriteRaw: NSNumber? + @NSManaged public var followRaw: NSNumber? + @NSManaged public var followRequestRaw: NSNumber? + @NSManaged public var mentionRaw: NSNumber? + @NSManaged public var pollRaw: NSNumber? + @NSManaged public var reblogRaw: NSNumber? + + @NSManaged public private(set) var createdAt: Date + @NSManaged public private(set) var updatedAt: Date + + // MARK: one-to-one relationships + @NSManaged public var subscription: Subscription +} + +extension SubscriptionAlerts { + + public override func awakeFromInsert() { + super.awakeFromInsert() + let now = Date() + setPrimitiveValue(now, forKey: #keyPath(SubscriptionAlerts.createdAt)) + setPrimitiveValue(now, forKey: #keyPath(SubscriptionAlerts.updatedAt)) + } + + @discardableResult + public static func insert( + into context: NSManagedObjectContext, + property: Property, + subscription: Subscription + ) -> SubscriptionAlerts { + let alerts: SubscriptionAlerts = context.insertObject() + + alerts.favouriteRaw = property.favouriteRaw + alerts.followRaw = property.followRaw + alerts.followRequestRaw = property.followRequestRaw + alerts.mentionRaw = property.mentionRaw + alerts.pollRaw = property.pollRaw + alerts.reblogRaw = property.reblogRaw + + alerts.subscription = subscription + + return alerts + } + + public func update(favourite: Bool?) { + guard self.favourite != favourite else { return } + self.favourite = favourite + + didUpdate(at: Date()) + } + + public func update(follow: Bool?) { + guard self.follow != follow else { return } + self.follow = follow + + didUpdate(at: Date()) + } + + public func update(followRequest: Bool?) { + guard self.followRequest != followRequest else { return } + self.followRequest = followRequest + + didUpdate(at: Date()) + } + + public func update(mention: Bool?) { + guard self.mention != mention else { return } + self.mention = mention + + didUpdate(at: Date()) + } + + public func update(poll: Bool?) { + guard self.poll != poll else { return } + self.poll = poll + + didUpdate(at: Date()) + } + + public func update(reblog: Bool?) { + guard self.reblog != reblog else { return } + self.reblog = reblog + + didUpdate(at: Date()) + } + + public func didUpdate(at networkDate: Date) { + self.updatedAt = networkDate + } + +} + +extension SubscriptionAlerts { + + private func boolean(from number: NSNumber?) -> Bool? { + return number.flatMap { $0.intValue == 1 } + } + + private func number(from boolean: Bool?) -> NSNumber? { + return boolean.flatMap { NSNumber(integerLiteral: $0 ? 1 : 0) } + } + + public var favourite: Bool? { + get { boolean(from: favouriteRaw) } + set { favouriteRaw = number(from: newValue) } + } + + public var follow: Bool? { + get { boolean(from: followRaw) } + set { followRaw = number(from: newValue) } + } + + public var followRequest: Bool? { + get { boolean(from: followRequestRaw) } + set { followRequestRaw = number(from: newValue) } + } + + public var mention: Bool? { + get { boolean(from: mentionRaw) } + set { mentionRaw = number(from: newValue) } + } + + public var poll: Bool? { + get { boolean(from: pollRaw) } + set { pollRaw = number(from: newValue) } + } + + public var reblog: Bool? { + get { boolean(from: reblogRaw) } + set { reblogRaw = number(from: newValue) } + } + +} + +extension SubscriptionAlerts { + public struct Property { + public let favouriteRaw: NSNumber? + public let followRaw: NSNumber? + public let followRequestRaw: NSNumber? + public let mentionRaw: NSNumber? + public let pollRaw: NSNumber? + public let reblogRaw: NSNumber? + + public init( + favourite: Bool?, + follow: Bool?, + followRequest: Bool?, + mention: Bool?, + poll: Bool?, + reblog: Bool? + ) { + self.favouriteRaw = favourite.flatMap { NSNumber(value: $0 ? 1 : 0) } + self.followRaw = follow.flatMap { NSNumber(value: $0 ? 1 : 0) } + self.followRequestRaw = followRequest.flatMap { NSNumber(value: $0 ? 1 : 0) } + self.mentionRaw = mention.flatMap { NSNumber(value: $0 ? 1 : 0) } + self.pollRaw = poll.flatMap { NSNumber(value: $0 ? 1 : 0) } + self.reblogRaw = reblog.flatMap { NSNumber(value: $0 ? 1 : 0) } + } + } + +} + +extension SubscriptionAlerts: Managed { + public static var defaultSortDescriptors: [NSSortDescriptor] { + return [NSSortDescriptor(keyPath: \SubscriptionAlerts.createdAt, ascending: false)] + } +} diff --git a/CoreDataStack/Entity/Tag.swift b/CoreDataStack/Entity/Tag.swift index b5d8be688..3044cacc0 100644 --- a/CoreDataStack/Entity/Tag.swift +++ b/CoreDataStack/Entity/Tag.swift @@ -12,13 +12,14 @@ 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 - + // many-to-many relationship - @NSManaged public private(set) var toot: Toot - + @NSManaged public private(set) var statuses: Set? + // one-to-many relationship @NSManaged public private(set) var histories: Set? } @@ -26,8 +27,16 @@ public final class Tag: NSManagedObject { public extension Tag { override func awakeFromInsert() { super.awakeFromInsert() - identifier = UUID() + 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, @@ -57,8 +66,36 @@ public extension Tag { } } -extension Tag: Managed { - public static var defaultSortDescriptors: [NSSortDescriptor] { - return [NSSortDescriptor(keyPath: \Tag.createAt, ascending: false)] +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/Protocol/Managed.swift b/CoreDataStack/Protocol/Managed.swift index 4811b9c6b..4bdff9c3e 100644 --- a/CoreDataStack/Protocol/Managed.swift +++ b/CoreDataStack/Protocol/Managed.swift @@ -8,7 +8,7 @@ import Foundation import CoreData -public protocol Managed: class, NSFetchRequestResult { +public protocol Managed: AnyObject, NSFetchRequestResult { static var entityName: String { get } static var defaultSortDescriptors: [NSSortDescriptor] { get } } diff --git a/Localization/README.md b/Localization/README.md index 1e6975f8b..b6baf1788 100644 --- a/Localization/README.md +++ b/Localization/README.md @@ -5,4 +5,16 @@ Mastodon localization template file ## How to contribute? -TBD \ No newline at end of file +TBD + +## How to maintains + +```zsh +// enter workdir +cd Mastodon +// edit i18n json +open ./Localization/app.json +// update resource +update_localization.sh + +``` \ No newline at end of file diff --git a/Localization/StringsConvertor/Sources/StringsConvertor/main.swift b/Localization/StringsConvertor/Sources/StringsConvertor/main.swift index 4ccbb3072..c60266f88 100644 --- a/Localization/StringsConvertor/Sources/StringsConvertor/main.swift +++ b/Localization/StringsConvertor/Sources/StringsConvertor/main.swift @@ -47,6 +47,7 @@ private func map(language: String) -> String? { case "ja_JP": return "ja" case "de_DE": return "de" case "pt_BR": return "pt-BR" + case "ar_SA": return "ar" default: return nil } } diff --git a/Localization/StringsConvertor/scripts/build.sh b/Localization/StringsConvertor/scripts/build.sh index 81e17745c..87087c3a0 100755 --- a/Localization/StringsConvertor/scripts/build.sh +++ b/Localization/StringsConvertor/scripts/build.sh @@ -21,6 +21,10 @@ mkdir -p input/en_US cp ../app.json ./input/en_US cp ../ios-infoPlist.json ./input/en_US +mkdir -p input/ar_SA +cp ../app.json ./input/ar_SA +cp ../ios-infoPlist.json ./input/ar_SA + # curl -o .zip -L ${Crowin_Latest_Build} # unzip -o -q .zip -d input # rm -rf .zip diff --git a/Localization/app.json b/Localization/app.json index e20e901db..21304d564 100644 --- a/Localization/app.json +++ b/Localization/app.json @@ -1,13 +1,45 @@ { "common": { "alerts": { + "common": { + "please_try_again": "Please try again.", + "please_try_again_later": "Please try again later." + }, "sign_up_failure": { "title": "Sign Up Failure" }, "server_error": { "title": "Server Error" + }, + "vote_failure": { + "title": "Vote Failure", + "poll_expired": "The poll has expired" + }, + "discard_post_content": { + "title": "Discard Publish", + "message": "Confirm discard composed post content." + }, + "publish_post_failure": { + "title": "Publish Failure", + "message": "Failed to publish the post.\nPlease check your internet connection." + }, + "sign_out": { + "title": "Sign out", + "message": "Are you sure you want to sign out?", + "confirm": "Sign Out" + }, + "block_domain": { + "title": "Are you really, really sure you want to block the entire %s? In most cases a few targeted blocks or mutes are sufficient and preferable. You will not see content from that domain in any public timelines or your notifications. Your followers from that domain will be removed.", + "block_entire_domain": "Block entire domain" + }, + "save_photo_failure": { + "title": "Save Photo Failure", + "message": "Please enable photo libaray access permission to save photo." + }, + "delete_post": { + "title": "Are you sure you want to delete this post?", + "delete": "Delete" } - }, "controls": { "actions": { @@ -17,25 +49,111 @@ "edit": "Edit", "save": "Save", "ok": "OK", + "done": "Done", "confirm": "Confirm", "continue": "Continue", "cancel": "Cancel", + "discard": "Discard", + "try_again": "Try Again", "take_photo": "Take photo", "save_photo": "Save photo", "sign_in": "Sign In", "sign_up": "Sign Up", "see_more": "See More", "preview": "Preview", - "open_in_safari": "Open in Safari" + "share": "Share", + "share_user": "Share %s", + "share_post": "Share post", + "open_in_safari": "Open in Safari", + "find_people": "Find people to follow", + "manually_search": "Manually search instead", + "skip": "Skip", + "report_user": "Report %s", + "block_domain": "Block %s", + "unblock_domain": "Unblock %s", + "settings": "Settings", + "delete": "Delete" + }, + "tabs": { + "home": "Home", + "search": "Search", + "notification": "Notification", + "profile": "Profile" }, "status": { - "user_boosted": "%s boosted", + "user_reblogged": "%s reblogged", + "user_replied_to": "Replied to %s", "show_post": "Show Post", - "status_content_warning": "content warning", - "media_content_warning": "Tap to reveal that may be sensitive" + "show_user_profile": "Show user profile", + "content_warning": "content warning", + "content_warning_text": "cw: %s", + "media_content_warning": "Tap to reveal that may be sensitive", + "poll": { + "vote": "Vote", + "vote_count": { + "single": "%d vote", + "multiple": "%d votes" + }, + "voter_count": { + "single": "%d voter", + "multiple": "%d voters" + }, + "time_left": "%s left", + "closed": "Closed" + }, + "actions": { + "reply": "Reply", + "reblog": "Reblog", + "unreblog": "Unreblog", + "favorite": "Favorite", + "unfavorite": "Unfavorite", + "menu": "Menu" + }, + "tag": { + "url": "URL", + "mention": "Mention", + "link": "Link", + "hashtag": "Hashtag", + "email": "Email", + "emoji": "Emoji" + } + }, + "firendship": { + "follow": "Follow", + "following": "Following", + "request": "Request", + "pending": "Pending", + "block": "Block", + "block_user": "Block %s", + "block_domain": "Block %s", + "unblock": "Unblock", + "unblock_user": "Unblock %s", + "blocked": "Blocked", + "mute": "Mute", + "mute_user": "Mute %s", + "unmute": "Unmute", + "unmute_user": "Unmute %s", + "muted": "Muted", + "edit_info": "Edit info" }, "timeline": { - "load_more": "Load More" + "loader": { + "load_missing_posts": "Load missing posts", + "loading_missing_posts": "Loading missing posts...", + "show_more_replies": "Show more replies" + }, + "header": { + "no_status_found": "No Status Found", + "blocking_warning": "You can’t view Artbot’s profile\n until you unblock them.\nYour account looks like this to them.", + "blocked_warning": "You can’t view Artbot’s profile\n until they unblock you.", + "suspended_warning": "This account has been suspended.", + "user_suspended_warning": "%s's account has been suspended." + }, + "accessibility": { + "count_replies": "%s replies", + "count_reblogs": "%s reblogs", + "count_favorites": "%s favorites" + } } }, "countable": { @@ -51,25 +169,46 @@ }, "server_picker": { "title": "Pick a Server,\nany server.", - "Button": { - "Category": { - "All": "All" + "button": { + "category": { + "all": "All", + "all_accessiblity_description": "Category: All", + "academia": "academia", + "activism": "activism", + "food": "food", + "furry": "furry", + "games": "games", + "general": "general", + "journalism": "journalism", + "lgbt": "lgbt", + "regional": "regional", + "art": "art", + "music": "music", + "tech": "tech" }, - "SeeLess": "See Less", - "SeeMore": "See More" + "see_less": "See Less", + "see_more": "See More" }, - "Label": { - "Language": "LANGUAGE", - "Users": "USERS", - "Category": "CATEGORY" + "label": { + "language": "LANGUAGE", + "users": "USERS", + "category": "CATEGORY" }, "input": { "placeholder": "Find a server or join your own..." + }, + "empty_state": { + "finding_servers": "Finding available servers...", + "bad_network": "Something went wrong while loading data. Check your internet connection.", + "no_results": "No results" } }, "register": { "title": "Tell us about you.", "input": { + "avatar": { + "delete": "Delete" + }, "username": { "placeholder": "username", "duplicate_prompt": "This username is taken." @@ -82,27 +221,54 @@ }, "password": { "placeholder": "password", - "prompt": "Your password needs at least:", - "prompt_eight_characters": "Eight characters" + "hint": "Your password needs at least eight characters" }, "invite": { - "registration_user_invite_request": "Why do you want to join?" + "registration_user_invite_request": "Why do you want to join?" } }, - "success": "Success", - "check_email": "Regsiter request sent. Please check your email." + "error": { + "item": { + "username": "Username", + "email": "Email", + "password": "Password", + "agreement": "Agreement", + "locale": "Locale", + "reason": "Reason" + }, + "reason": { + "blocked": "%s contains a disallowed e-mail provider", + "unreachable": "%s does not seem to exist", + "taken": "%s is already in use", + "reserved": "%s is a reserved keyword", + "accepted": "%s must be accepted", + "blank": "%s is required", + "invalid": "%s is invalid", + "too_long": "%s is too long", + "too_short": "%s is too short", + "inclusion": "%s is not a supported value" + }, + "special": { + "username_invalid": "Username must only contain alphanumeric characters and underscores", + "username_too_long": "Username is too long (can't be longer than 30 characters)", + "email_invalid": "This is not a valid e-mail address", + "password_too_short": "Password is too short (must be at least 8 characters)" + } + } }, "server_rules": { "title": "Some ground rules.", "subtitle": "These rules are set by the admins of %s.", "prompt": "By continuing, you're subject to the terms of service and privacy policy for %s.", + "terms_of_service": "terms of service", + "privacy_policy": "privacy policy", "button": { "confirm": "I Agree" } }, "confirm_email": { "title": "One last thing.", - "subtitle": "We just sent an email to %@,\ntap the link to confirm your account.", + "subtitle": "We just sent an email to %s,\ntap the link to confirm your account.", "button": { "open_email_app": "Open Email App", "dont_receive_email": "I never got an email" @@ -120,10 +286,204 @@ } }, "home_timeline": { - "title": "Home" + "title": "Home", + "navigation_bar_state": { + "offline": "Offline", + "new_posts": "See new posts", + "published": "Published!", + "Publishing": "Publishing post..." + } + }, + "suggestion_account": { + "title": "Find People to Follow", + "follow_explain": "When you follow someone, you’ll see their posts in your home feed." }, "public_timeline": { "title": "Public" + }, + "compose": { + "title": { + "new_post": "New Post", + "new_reply": "New Reply" + }, + "media_selection": { + "camera": "Take Photo", + "photo_library": "Photo Library", + "browse": "Browse" + }, + "content_input_placeholder": "Type or paste what's on your mind", + "compose_action": "Publish", + "replying_to_user": "replying to %s", + "attachment": { + "photo": "photo", + "video": "video", + "attachment_broken": "This %s is broken and can't be\nuploaded to Mastodon.", + "description_photo": "Describe photo for low vision people...", + "description_video": "Describe what’s happening for low vision people..." + }, + "poll": { + "duration_time": "Duration: %s", + "thirty_minutes": "30 minutes", + "one_hour": "1 Hour", + "six_hours": "6 Hours", + "one_day": "1 Day", + "three_days": "3 Days", + "seven_days": "7 Days", + "option_number": "Option %ld" + }, + "content_warning": { + "placeholder": "Write an accurate warning here..." + }, + "visibility": { + "public": "Public", + "unlisted": "Unlisted", + "private": "Followers only", + "direct": "Only people I mention" + }, + "accessibility": { + "append_attachment": "Append attachment", + "append_poll": "Append poll", + "remove_poll": "Remove poll", + "custom_emoji_picker": "Custom emoji picker", + "enable_content_warning": "Enable content warning", + "disable_content_warning": "Disable content warning", + "post_visibility_menu": "Post visibility menu", + "input_limit_remains_count": "Input limit remains %ld", + "input_limit_exceeds_count": "Input limit exceeds %ld" + } + }, + "profile": { + "subtitle": "%s posts", + "dashboard": { + "posts": "posts", + "following": "following", + "followers": "followers", + "accessibility": { + "count_posts": "%ld posts", + "count_following": "%ld following", + "count_followers": "%ld followers" + } + }, + "segmented_control": { + "posts": "Posts", + "replies": "Replies", + "media": "Media" + }, + "relationship_action_alert": { + "confirm_unmute_user": { + "title": "Unmute Account", + "message": "Confirm unmute %s" + }, + "confirm_unblock_usre": { + "title": "Unblock Account", + "message": "Confirm unblock %s" + } + } + }, + "search": { + "searchBar": { + "placeholder": "Search hashtags and users", + "cancel": "Cancel" + }, + "recommend": { + "button_text": "See All", + "hash_tag": { + "title": "Trending in your timeline", + "description": "Hashtags that are getting quite a bit of attention among people you follow", + "people_talking": "%s people are talking" + }, + "accounts": { + "title": "Accounts you might like", + "description": "You may like to follow these accounts", + "follow": "Follow" + } + }, + "searching": { + "segment": { + "all": "All", + "people": "People", + "hashtags": "Hashtags" + }, + "recent_search": "Recent searches", + "clear": "clear" + } + }, + "hashtag": { + "prompt": "%s people talking" + }, + "favorite": { + "title": "Your Favorites" + }, + "notification": { + "title": { + "Everything": "Everything", + "Mentions": "Mentions" + }, + "action": { + "follow": "followed you", + "favourite": "favorited your post", + "reblog": "rebloged your post", + "poll": "Your poll has ended", + "mention": "mentioned you", + "follow_request": "request to follow you" + } + }, + "thread": { + "back_title": "Post", + "title": "Post from %s", + "reblog": { + "single": "%s reblog", + "multiple": "%s reblogs" + }, + "favorite": { + "single": "%s favorite", + "multiple": "%s favorites" + } + }, + "settings": { + "title": "Settings", + "section": { + "appearance": { + "title": "Appearance", + "automatic": "Automatic", + "light": "Always Light", + "dark": "Always Dark" + }, + "notifications": { + "title": "Notifications", + "favorites": "Favorites my post", + "follows": "Follows me", + "boosts": "Reblogs my post", + "mentions": "Mentions me", + "trigger": { + "anyone": "anyone", + "follower": "a follower", + "follow": "anyone I follow", + "noone": "no one", + "title": "Notify me when" + } + }, + "boringzone": { + "title": "The Boring zone", + "terms": "Terms of Service", + "privacy": "Privacy Policy" + }, + "spicyzone": { + "title": "The spicy zone", + "clear": "Clear Media Cache", + "signout": "Sign Out" + } + } + }, + "report": { + "title": "Report %s", + "step1": "Step 1 of 2", + "step2": "Step 2 of 2", + "content1": "Are there any other posts you’d like to add to the report?", + "content2": "Is there anything the moderators should know about this report?", + "send": "Send Report", + "skip_to_send": "Send without comment", + "text_placeholder": "Type or paste additional comments" } } -} +} \ No newline at end of file diff --git a/Localization/ios-infoPlist.json b/Localization/ios-infoPlist.json index 0a260c273..f25dbcc0e 100644 --- a/Localization/ios-infoPlist.json +++ b/Localization/ios-infoPlist.json @@ -1,4 +1,4 @@ { - "NSCameraUsageDescription": "Used to take photo for toot", + "NSCameraUsageDescription": "Used to take photo for post status", "NSPhotoLibraryAddUsageDescription": "Used to save photo into the Photo Library" -} +} \ No newline at end of file diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index e5429eeac..df5d04df6 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -7,6 +7,16 @@ objects = { /* Begin PBXBuildFile section */ + 0F1E2D0B2615C39400C38565 /* DoubleTitleLabelNavigationBarTitleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F1E2D0A2615C39400C38565 /* DoubleTitleLabelNavigationBarTitleView.swift */; }; + 0F2021FB2613262F000C64BF /* HashtagTimelineViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F2021FA2613262F000C64BF /* HashtagTimelineViewController.swift */; }; + 0F202201261326E6000C64BF /* HashtagTimelineViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F202200261326E6000C64BF /* HashtagTimelineViewModel.swift */; }; + 0F20220726134DA4000C64BF /* HashtagTimelineViewModel+Diffable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F20220626134DA4000C64BF /* HashtagTimelineViewModel+Diffable.swift */; }; + 0F20220D26134E3F000C64BF /* HashtagTimelineViewModel+LoadLatestState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F20220C26134E3F000C64BF /* HashtagTimelineViewModel+LoadLatestState.swift */; }; + 0F202213261351F5000C64BF /* APIService+HashtagTimeline.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F202212261351F5000C64BF /* APIService+HashtagTimeline.swift */; }; + 0F202227261411BB000C64BF /* HashtagTimelineViewController+Provider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F202226261411BA000C64BF /* HashtagTimelineViewController+Provider.swift */; }; + 0F20222D261457EE000C64BF /* HashtagTimelineViewModel+LoadOldestState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F20222C261457EE000C64BF /* HashtagTimelineViewModel+LoadOldestState.swift */; }; + 0F20223326145E51000C64BF /* HashtagTimelineViewModel+LoadMiddleState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F20223226145E51000C64BF /* HashtagTimelineViewModel+LoadMiddleState.swift */; }; + 0F20223926146553000C64BF /* Array.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F20223826146553000C64BF /* Array.swift */; }; 0FAA0FDF25E0B57E0017CCDE /* WelcomeViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FAA0FDE25E0B57E0017CCDE /* WelcomeViewController.swift */; }; 0FAA101225E105390017CCDE /* PrimaryActionButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FAA101125E105390017CCDE /* PrimaryActionButton.swift */; }; 0FAA101C25E10E760017CCDE /* UIFont.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FAA101B25E10E760017CCDE /* UIFont.swift */; }; @@ -20,16 +30,36 @@ 0FB3D33825E6401400AAD544 /* PickServerCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FB3D33725E6401400AAD544 /* PickServerCell.swift */; }; 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 */; }; + 2D198655261C3C4300F0B013 /* SearchViewModel+LoadOldestState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D198654261C3C4300F0B013 /* SearchViewModel+LoadOldestState.swift */; }; + 2D206B7225F5D27F00143C56 /* AudioContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D206B7125F5D27F00143C56 /* AudioContainerView.swift */; }; + 2D206B8025F5F45E00143C56 /* UIImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D206B7F25F5F45E00143C56 /* UIImage.swift */; }; + 2D206B8625F5FB0900143C56 /* Double.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D206B8525F5FB0900143C56 /* Double.swift */; }; + 2D206B8C25F6015000143C56 /* AudioPlaybackService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D206B8B25F6015000143C56 /* AudioPlaybackService.swift */; }; + 2D206B9225F60EA700143C56 /* UIControl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D206B9125F60EA700143C56 /* UIControl.swift */; }; + 2D24E11D2626D8B100A59D4F /* NotificationStatusTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D24E11C2626D8B100A59D4F /* NotificationStatusTableViewCell.swift */; }; + 2D24E1232626ED9D00A59D4F /* UIView+Gesture.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D24E1222626ED9D00A59D4F /* UIView+Gesture.swift */; }; + 2D24E12D2626FD2E00A59D4F /* NotificationViewModel+LoadOldestState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D24E12C2626FD2E00A59D4F /* NotificationViewModel+LoadOldestState.swift */; }; 2D32EAAC25CB96DC00C9ED86 /* TimelineMiddleLoaderTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D32EAAB25CB96DC00C9ED86 /* TimelineMiddleLoaderTableViewCell.swift */; }; 2D32EABA25CB9B0500C9ED86 /* UIView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D32EAB925CB9B0500C9ED86 /* UIView.swift */; }; 2D32EADA25CBCC3300C9ED86 /* PublicTimelineViewModel+LoadMiddleState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D32EAD925CBCC3300C9ED86 /* PublicTimelineViewModel+LoadMiddleState.swift */; }; + 2D34D9CB261489930081BFC0 /* SearchViewController+Recommend.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D34D9CA261489930081BFC0 /* SearchViewController+Recommend.swift */; }; + 2D34D9D126148D9E0081BFC0 /* APIService+Recommend.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D34D9D026148D9E0081BFC0 /* APIService+Recommend.swift */; }; + 2D34D9DB261494120081BFC0 /* APIService+Search.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D34D9DA261494120081BFC0 /* APIService+Search.swift */; }; + 2D34D9E226149C920081BFC0 /* SearchRecommendTagsCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D34D9E126149C920081BFC0 /* SearchRecommendTagsCollectionViewCell.swift */; }; + 2D35237A26256D920031AF25 /* NotificationSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D35237926256D920031AF25 /* NotificationSection.swift */; }; + 2D35238126256F690031AF25 /* NotificationTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D35238026256F690031AF25 /* NotificationTableViewCell.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+StatusProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D38F1DE25CD46A400561493 /* HomeTimelineViewController+StatusProvider.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 */; }; @@ -38,13 +68,16 @@ 2D38F20825CD491300561493 /* DisposeBagCollectable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D38F20725CD491300561493 /* DisposeBagCollectable.swift */; }; 2D3F9E0425DFA133004262D9 /* UITapGestureRecognizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D3F9E0325DFA133004262D9 /* UITapGestureRecognizer.swift */; }; 2D42FF6125C8177C004A627A /* ActiveLabel in Frameworks */ = {isa = PBXBuildFile; productRef = 2D42FF6025C8177C004A627A /* ActiveLabel */; }; - 2D42FF6B25C817D2004A627A /* MastodonContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D42FF6A25C817D2004A627A /* MastodonContent.swift */; }; + 2D42FF6B25C817D2004A627A /* MastodonStatusContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D42FF6A25C817D2004A627A /* MastodonStatusContent.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 */; }; 2D46975E25C2A54100CF4AA9 /* NSLayoutConstraint.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D46975D25C2A54100CF4AA9 /* NSLayoutConstraint.swift */; }; - 2D46976425C2A71500CF4AA9 /* UIIamge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D46976325C2A71500CF4AA9 /* UIIamge.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 */; }; + 2D571B2F26004EC000540450 /* NavigationBarProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D571B2E26004EC000540450 /* NavigationBarProgressView.swift */; }; 2D59819B25E4A581000FB903 /* MastodonConfirmEmailViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D59819A25E4A581000FB903 /* MastodonConfirmEmailViewController.swift */; }; 2D5981A125E4A593000FB903 /* MastodonConfirmEmailViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D5981A025E4A593000FB903 /* MastodonConfirmEmailViewModel.swift */; }; 2D5981BA25E4D7F8000FB903 /* ThirdPartyMailer in Frameworks */ = {isa = PBXBuildFile; productRef = 2D5981B925E4D7F8000FB903 /* ThirdPartyMailer */; }; @@ -52,49 +85,132 @@ 2D5A3D2825CF8BC9002347D6 /* HomeTimelineViewModel+Diffable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D5A3D2725CF8BC9002347D6 /* HomeTimelineViewModel+Diffable.swift */; }; 2D5A3D3825CF8D9F002347D6 /* ScrollViewContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D5A3D3725CF8D9F002347D6 /* ScrollViewContainer.swift */; }; 2D5A3D6225CFD9CB002347D6 /* HomeTimelineViewController+DebugAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D5A3D6125CFD9CB002347D6 /* HomeTimelineViewController+DebugAction.swift */; }; - 2D61335825C188A000CAE157 /* APIService+Persist+Timeline.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D61335725C188A000CAE157 /* APIService+Persist+Timeline.swift */; }; + 2D607AD826242FC500B70763 /* NotificationViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D607AD726242FC500B70763 /* NotificationViewModel.swift */; }; + 2D6125472625436B00299647 /* Notification.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D6125462625436B00299647 /* Notification.swift */; }; + 2D61254D262547C200299647 /* APIService+Notification.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D61254C262547C200299647 /* APIService+Notification.swift */; }; + 2D61335825C188A000CAE157 /* APIService+Persist+Status.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D61335725C188A000CAE157 /* APIService+Persist+Status.swift */; }; 2D61335E25C1894B00CAE157 /* APIService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D61335D25C1894B00CAE157 /* APIService.swift */; }; 2D61336925C18A4F00CAE157 /* AlamofireNetworkActivityIndicator in Frameworks */ = {isa = PBXBuildFile; productRef = 2D61336825C18A4F00CAE157 /* AlamofireNetworkActivityIndicator */; }; + 2D650FAB25ECDC9300851B58 /* Mastodon+Entity+Error+Detail.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D650FAA25ECDC9300851B58 /* Mastodon+Entity+Error+Detail.swift */; }; + 2D694A7425F9EB4E0038ADDC /* ContentWarningOverlayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D694A7325F9EB4E0038ADDC /* ContentWarningOverlayView.swift */; }; 2D69CFF425CA9E2200C3A1B2 /* LoadMoreConfigurableTableViewContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D69CFF325CA9E2200C3A1B2 /* LoadMoreConfigurableTableViewContainer.swift */; }; - 2D69D00A25CAA00300C3A1B2 /* APIService+CoreData+Toot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D69D00925CAA00300C3A1B2 /* APIService+CoreData+Toot.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+StatusProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D76317C25C14DF400929FB9 /* PublicTimelineViewController+StatusProvider.swift */; }; + 2D76317D25C14DF500929FB9 /* PublicTimelineViewController+Provider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D76317C25C14DF400929FB9 /* PublicTimelineViewController+Provider.swift */; }; 2D76318325C14E8F00929FB9 /* PublicTimelineViewModel+Diffable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D76318225C14E8F00929FB9 /* PublicTimelineViewModel+Diffable.swift */; }; 2D76319F25C1521200929FB9 /* StatusSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D76319E25C1521200929FB9 /* StatusSection.swift */; }; 2D7631A825C1535600929FB9 /* StatusTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D7631A725C1535600929FB9 /* StatusTableViewCell.swift */; }; 2D7631B325C159F700929FB9 /* Item.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D7631B225C159F700929FB9 /* Item.swift */; }; + 2D7867192625B77500211898 /* NotificationItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D7867182625B77500211898 /* NotificationItem.swift */; }; + 2D79E701261EA5550011E398 /* APIService+CoreData+Tag.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D79E700261EA5550011E398 /* APIService+CoreData+Tag.swift */; }; 2D82B9FF25E7863200E36F0F /* OnboardingViewControllerAppearance.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D82B9FE25E7863200E36F0F /* OnboardingViewControllerAppearance.swift */; }; 2D82BA0525E7897700E36F0F /* MastodonResendEmailViewModelNavigationDelegateShim.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D82BA0425E7897700E36F0F /* MastodonResendEmailViewModelNavigationDelegateShim.swift */; }; + 2D8434F525FF465D00EECE90 /* HomeTimelineNavigationBarTitleViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D8434F425FF465D00EECE90 /* HomeTimelineNavigationBarTitleViewModel.swift */; }; + 2D8434FB25FF46B300EECE90 /* HomeTimelineNavigationBarTitleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D8434FA25FF46B300EECE90 /* HomeTimelineNavigationBarTitleView.swift */; }; + 2D84350525FF858100EECE90 /* UIScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D84350425FF858100EECE90 /* UIScrollView.swift */; }; + 2D8FCA082637EABB00137F46 /* APIService+FollowRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D8FCA072637EABB00137F46 /* APIService+FollowRequest.swift */; }; 2D927F0225C7E4F2004F19B8 /* Mention.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D927F0125C7E4F2004F19B8 /* Mention.swift */; }; 2D927F0825C7E9A8004F19B8 /* Tag.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D927F0725C7E9A8004F19B8 /* Tag.swift */; }; 2D927F0E25C7E9C9004F19B8 /* History.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D927F0D25C7E9C9004F19B8 /* History.swift */; }; 2D927F1425C7EDD9004F19B8 /* Emoji.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D927F1325C7EDD9004F19B8 /* Emoji.swift */; }; + 2D939AB525EDD8A90076FA61 /* String.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D939AB425EDD8A90076FA61 /* String.swift */; }; + 2D939AC825EE14620076FA61 /* CropViewController in Frameworks */ = {isa = PBXBuildFile; productRef = 2D939AC725EE14620076FA61 /* CropViewController */; }; + 2D939AE825EE1CF80076FA61 /* MastodonRegisterViewController+Avatar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D939AE725EE1CF80076FA61 /* MastodonRegisterViewController+Avatar.swift */; }; + 2D9DB967263A76FB007C1D71 /* BlockDomainService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D9DB966263A76FB007C1D71 /* BlockDomainService.swift */; }; + 2D9DB969263A833E007C1D71 /* DomainBlock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D9DB968263A833E007C1D71 /* DomainBlock.swift */; }; + 2D9DB96B263A91D1007C1D71 /* APIService+DomainBlock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D9DB96A263A91D1007C1D71 /* APIService+DomainBlock.swift */; }; + 2DA504692601ADE7008F4E6C /* SawToothView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DA504682601ADE7008F4E6C /* SawToothView.swift */; }; + 2DA6054725F716A2006356F9 /* PlaybackState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DA6054625F716A2006356F9 /* PlaybackState.swift */; }; + 2DA6055125F74407006356F9 /* AudioContainerViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DA6055025F74407006356F9 /* AudioContainerViewModel.swift */; }; 2DA7D04425CA52B200804E11 /* TimelineLoaderTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DA7D04325CA52B200804E11 /* TimelineLoaderTableViewCell.swift */; }; 2DA7D04A25CA52CB00804E11 /* TimelineBottomLoaderTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DA7D04925CA52CB00804E11 /* TimelineBottomLoaderTableViewCell.swift */; }; 2DA7D05725CA693F00804E11 /* Application.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DA7D05625CA693F00804E11 /* Application.swift */; }; + 2DAC9E38262FC2320062E1A6 /* SuggestionAccountViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DAC9E37262FC2320062E1A6 /* SuggestionAccountViewController.swift */; }; + 2DAC9E3E262FC2400062E1A6 /* SuggestionAccountViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DAC9E3D262FC2400062E1A6 /* SuggestionAccountViewModel.swift */; }; + 2DAC9E46262FC9FD0062E1A6 /* SuggestionAccountTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DAC9E45262FC9FD0062E1A6 /* SuggestionAccountTableViewCell.swift */; }; + 2DB72C8C262D764300CE6173 /* Mastodon+Entity+Notification+Type.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DB72C8B262D764300CE6173 /* Mastodon+Entity+Notification+Type.swift */; }; + 2DCB73FD2615C13900EC03D4 /* SearchRecommendCollectionHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DCB73FC2615C13900EC03D4 /* SearchRecommendCollectionHeader.swift */; }; + 2DE0FAC12615F04D00CDF649 /* RecommendHashTagSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DE0FAC02615F04D00CDF649 /* RecommendHashTagSection.swift */; }; + 2DE0FAC82615F5F000CDF649 /* SearchRecommendAccountsCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DE0FAC72615F5F000CDF649 /* SearchRecommendAccountsCollectionViewCell.swift */; }; + 2DE0FACE2615F7AD00CDF649 /* RecommendAccountSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DE0FACD2615F7AD00CDF649 /* RecommendAccountSection.swift */; }; 2DF123A725C3B0210020F248 /* ActiveLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DF123A625C3B0210020F248 /* ActiveLabel.swift */; }; 2DF75B9B25D0E27500694EC8 /* StatusProviderFacade.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DF75B9A25D0E27500694EC8 /* StatusProviderFacade.swift */; }; - 2DF75BA125D0E29D00694EC8 /* StatusProvider+TimelinePostTableViewCellDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DF75BA025D0E29D00694EC8 /* StatusProvider+TimelinePostTableViewCellDelegate.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 */; }; - 45B49097460EDE530AD5AA72 /* Pods_Mastodon.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A4ABE34829701A4496C5BB64 /* Pods_Mastodon.framework */; }; + 2DFAD5272616F9D300F9EE7C /* SearchViewController+Searching.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DFAD5262616F9D300F9EE7C /* SearchViewController+Searching.swift */; }; + 2DFAD5372617010500F9EE7C /* SearchingTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DFAD5362617010500F9EE7C /* SearchingTableViewCell.swift */; }; + 5B24BBDA262DB14800A9381B /* ReportViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B24BBD7262DB14800A9381B /* ReportViewModel.swift */; }; + 5B24BBDB262DB14800A9381B /* ReportViewModel+Diffable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B24BBD8262DB14800A9381B /* ReportViewModel+Diffable.swift */; }; + 5B24BBE2262DB19100A9381B /* APIService+Report.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B24BBE1262DB19100A9381B /* APIService+Report.swift */; }; + 5B8E055826319E47006E3C53 /* ReportFooterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B8E055726319E47006E3C53 /* ReportFooterView.swift */; }; + 5B90C45E262599800002E742 /* SettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B90C456262599800002E742 /* SettingsViewModel.swift */; }; + 5B90C45F262599800002E742 /* SettingsToggleTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B90C459262599800002E742 /* SettingsToggleTableViewCell.swift */; }; + 5B90C460262599800002E742 /* SettingsAppearanceTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B90C45A262599800002E742 /* SettingsAppearanceTableViewCell.swift */; }; + 5B90C461262599800002E742 /* SettingsLinkTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B90C45B262599800002E742 /* SettingsLinkTableViewCell.swift */; }; + 5B90C462262599800002E742 /* SettingsSectionHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B90C45C262599800002E742 /* SettingsSectionHeader.swift */; }; + 5B90C46E26259B2C0002E742 /* Subscription.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B90C46C26259B2C0002E742 /* Subscription.swift */; }; + 5B90C46F26259B2C0002E742 /* Setting.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B90C46D26259B2C0002E742 /* Setting.swift */; }; + 5B90C47F26259BA90002E742 /* SubscriptionAlerts.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B90C47E26259BA90002E742 /* SubscriptionAlerts.swift */; }; + 5B90C48526259BF10002E742 /* APIService+Subscriptions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B90C48426259BF10002E742 /* APIService+Subscriptions.swift */; }; + 5B90C48B26259C120002E742 /* APIService+CoreData+Subscriptions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B90C48A26259C120002E742 /* APIService+CoreData+Subscriptions.swift */; }; + 5BB04FD5262E7AFF0043BFF6 /* ReportViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BB04FD4262E7AFF0043BFF6 /* ReportViewController.swift */; }; + 5BB04FDB262EA3070043BFF6 /* ReportHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BB04FDA262EA3070043BFF6 /* ReportHeaderView.swift */; }; + 5BB04FE9262EFC300043BFF6 /* ReportedStatusTableviewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BB04FE8262EFC300043BFF6 /* ReportedStatusTableviewCell.swift */; }; + 5BB04FEF262F0DCB0043BFF6 /* ReportViewModel+Data.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BB04FEE262F0DCB0043BFF6 /* ReportViewModel+Data.swift */; }; + 5BB04FF5262F0E6D0043BFF6 /* ReportSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BB04FF4262F0E6D0043BFF6 /* ReportSection.swift */; }; + 5D0393902612D259007FE196 /* WebViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D03938F2612D259007FE196 /* WebViewController.swift */; }; + 5D0393962612D266007FE196 /* WebViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D0393952612D266007FE196 /* WebViewModel.swift */; }; 5D526FE225BE9AC400460CB9 /* MastodonSDK in Frameworks */ = {isa = PBXBuildFile; productRef = 5D526FE125BE9AC400460CB9 /* MastodonSDK */; }; + 5DA732CC2629CEF500A92342 /* UIView+Remove.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DA732CB2629CEF500A92342 /* UIView+Remove.swift */; }; + 5DDDF1932617442700311060 /* Mastodon+Entity+Account.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DDDF1922617442700311060 /* Mastodon+Entity+Account.swift */; }; + 5DDDF1992617447F00311060 /* Mastodon+Entity+Tag.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DDDF1982617447F00311060 /* Mastodon+Entity+Tag.swift */; }; + 5DDDF1A92617489F00311060 /* Mastodon+Entity+History.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DDDF1A82617489F00311060 /* Mastodon+Entity+History.swift */; }; + 5DF1054125F886D400D6C0D4 /* ViedeoPlaybackService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DF1054025F886D400D6C0D4 /* ViedeoPlaybackService.swift */; }; + 5DF1054725F8870E00D6C0D4 /* VideoPlayerViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DF1054625F8870E00D6C0D4 /* VideoPlayerViewModel.swift */; }; + 5DF1056425F887CB00D6C0D4 /* AVPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DF1056325F887CB00D6C0D4 /* AVPlayer.swift */; }; + 5DF1057925F88A1D00D6C0D4 /* PlayerContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DF1057825F88A1D00D6C0D4 /* PlayerContainerView.swift */; }; + 5DF1057F25F88A4100D6C0D4 /* TouchBlockingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DF1057E25F88A4100D6C0D4 /* TouchBlockingView.swift */; }; + 5DF1058525F88AE500D6C0D4 /* NeedsDependency+AVPlayerViewControllerDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DF1058425F88AE500D6C0D4 /* NeedsDependency+AVPlayerViewControllerDelegate.swift */; }; + 5DFC35DF262068D20045711D /* SearchViewController+Follow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DFC35DE262068D20045711D /* SearchViewController+Follow.swift */; }; 5E0DEC05797A7E6933788DDB /* Pods_MastodonTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 452147B2903DF38070FE56A2 /* Pods_MastodonTests.framework */; }; 5E44BF88AD33646E64727BCF /* Pods_MastodonTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CD92E0F10BDE4FE7C4B999F2 /* Pods_MastodonTests.framework */; }; + 7D603B3DC600BB75956FAD7D /* Pods_Mastodon_NotificationService.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 79D7EB88A6A8B34DFDFC96FC /* Pods_Mastodon_NotificationService.framework */; }; + 87FFDA5D898A5C42ADCB35E7 /* Pods_Mastodon.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A4ABE34829701A4496C5BB64 /* Pods_Mastodon.framework */; }; + D86919F5080C3F228CCD17D1 /* Pods_Mastodon_AppShared.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 46531DECCAB422F507B2274D /* Pods_Mastodon_AppShared.framework */; }; + DB00CA972632DDB600A54956 /* CommonOSLog in Frameworks */ = {isa = PBXBuildFile; productRef = DB00CA962632DDB600A54956 /* CommonOSLog */; }; DB0140A125C40C0600F9F3CF /* MastodonPinBasedAuthenticationViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0140A025C40C0600F9F3CF /* MastodonPinBasedAuthenticationViewController.swift */; }; DB0140A825C40C1500F9F3CF /* MastodonPinBasedAuthenticationViewModelNavigationDelegateShim.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0140A725C40C1500F9F3CF /* MastodonPinBasedAuthenticationViewModelNavigationDelegateShim.swift */; }; DB0140AE25C40C7300F9F3CF /* MastodonPinBasedAuthenticationViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0140AD25C40C7300F9F3CF /* MastodonPinBasedAuthenticationViewModel.swift */; }; DB0140BD25C40D7500F9F3CF /* CommonOSLog in Frameworks */ = {isa = PBXBuildFile; productRef = DB0140BC25C40D7500F9F3CF /* CommonOSLog */; }; DB0140CF25C42AEE00F9F3CF /* OSLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0140CE25C42AEE00F9F3CF /* OSLog.swift */; }; - DB084B5725CBC56C00F898ED /* Toot.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB084B5625CBC56C00F898ED /* Toot.swift */; }; + DB02CDAB26256A9500D0A2AF /* ThreadReplyLoaderTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB02CDAA26256A9500D0A2AF /* ThreadReplyLoaderTableViewCell.swift */; }; + DB02CDBF2625AE5000D0A2AF /* AdaptiveUserInterfaceStyleBarButtonItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB02CDBE2625AE5000D0A2AF /* AdaptiveUserInterfaceStyleBarButtonItem.swift */; }; + DB084B5725CBC56C00F898ED /* Status.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB084B5625CBC56C00F898ED /* Status.swift */; }; DB0AC6FC25CD02E600D75117 /* APIService+Instance.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0AC6FB25CD02E600D75117 /* APIService+Instance.swift */; }; + DB0F8150264D1E2500F2A12B /* PickServerLoaderTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0F814F264D1E2500F2A12B /* PickServerLoaderTableViewCell.swift */; }; DB118A8225E4B6E600FAB162 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = DB118A8125E4B6E600FAB162 /* Preview Assets.xcassets */; }; DB118A8C25E4BFB500FAB162 /* HighlightDimmableButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB118A8B25E4BFB500FAB162 /* HighlightDimmableButton.swift */; }; + DB1D186C25EF5BA7003F1F23 /* PollTableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB1D186B25EF5BA7003F1F23 /* PollTableView.swift */; }; + DB1E346825F518E20079D7DF /* CategoryPickerSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB1E346725F518E20079D7DF /* CategoryPickerSection.swift */; }; + DB1E347825F519300079D7DF /* PickServerItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB1E347725F519300079D7DF /* PickServerItem.swift */; }; + DB1FD43625F26899004CFCFC /* MastodonPickServerViewModel+LoadIndexedServerState.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB1FD43525F26899004CFCFC /* MastodonPickServerViewModel+LoadIndexedServerState.swift */; }; + DB1FD44425F26CCC004CFCFC /* PickServerSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB1FD44325F26CCC004CFCFC /* PickServerSection.swift */; }; + DB1FD45025F26FA1004CFCFC /* MastodonPickServerViewModel+Diffable.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB1FD44F25F26FA1004CFCFC /* MastodonPickServerViewModel+Diffable.swift */; }; + DB1FD45A25F27898004CFCFC /* CategoryPickerItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB1FD45925F27898004CFCFC /* CategoryPickerItem.swift */; }; + DB221B16260C395900AEFE46 /* CustomEmojiPickerInputViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB221B15260C395900AEFE46 /* CustomEmojiPickerInputViewModel.swift */; }; DB2B3ABC25E37E15007045F9 /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = DB2B3ABE25E37E15007045F9 /* InfoPlist.strings */; }; DB2B3AE925E38850007045F9 /* UIViewPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB2B3AE825E38850007045F9 /* UIViewPreview.swift */; }; DB2F073525E8ECF000957B2D /* AuthenticationViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB2F073325E8ECF000957B2D /* AuthenticationViewModel.swift */; }; + DB2FF510260B113300ADA9FE /* ComposeStatusPollExpiresOptionCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB2FF50F260B113300ADA9FE /* ComposeStatusPollExpiresOptionCollectionViewCell.swift */; }; + DB35B0B32643D821006AC73B /* TwitterTextEditor in Frameworks */ = {isa = PBXBuildFile; productRef = DB35B0B22643D821006AC73B /* TwitterTextEditor */; }; + DB35B0B42643D821006AC73B /* TwitterTextEditor in Embed Frameworks */ = {isa = PBXBuildFile; productRef = DB35B0B22643D821006AC73B /* TwitterTextEditor */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; }; + DB35FC1F2612F1D9006193C9 /* ProfileRelationshipActionButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB35FC1E2612F1D9006193C9 /* ProfileRelationshipActionButton.swift */; }; + DB35FC252612FD7A006193C9 /* ProfileFieldView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB35FC242612FD7A006193C9 /* ProfileFieldView.swift */; }; + DB35FC2F26130172006193C9 /* MastodonField.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB35FC2E26130172006193C9 /* MastodonField.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 */; }; @@ -104,6 +220,17 @@ DB427DE225BAA00100D1B89D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = DB427DE025BAA00100D1B89D /* LaunchScreen.storyboard */; }; DB427DED25BAA00100D1B89D /* MastodonTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB427DEC25BAA00100D1B89D /* MastodonTests.swift */; }; DB427DF825BAA00100D1B89D /* MastodonUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB427DF725BAA00100D1B89D /* MastodonUITests.swift */; }; + DB44384F25E8C1FA008912A2 /* CALayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB44384E25E8C1FA008912A2 /* CALayer.swift */; }; + DB44767B260B3B8C00B66B82 /* CustomEmojiPickerInputView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB44767A260B3B8C00B66B82 /* CustomEmojiPickerInputView.swift */; }; + DB447681260B3ED600B66B82 /* CustomEmojiPickerSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB447680260B3ED600B66B82 /* CustomEmojiPickerSection.swift */; }; + DB44768B260B3F2100B66B82 /* CustomEmojiPickerItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB44768A260B3F2100B66B82 /* CustomEmojiPickerItem.swift */; }; + DB447691260B406600B66B82 /* CustomEmojiPickerItemCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB447690260B406600B66B82 /* CustomEmojiPickerItemCollectionViewCell.swift */; }; + DB447697260B439000B66B82 /* CustomEmojiPickerHeaderCollectionReusableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB447696260B439000B66B82 /* CustomEmojiPickerHeaderCollectionReusableView.swift */; }; + DB4481AD25EE155900BEFB67 /* Poll.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB4481AC25EE155900BEFB67 /* Poll.swift */; }; + DB4481B325EE16D000BEFB67 /* PollOption.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB4481B225EE16D000BEFB67 /* PollOption.swift */; }; + DB4481B925EE289600BEFB67 /* UITableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB4481B825EE289600BEFB67 /* UITableView.swift */; }; + DB4481C625EE2ADA00BEFB67 /* PollSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB4481C525EE2ADA00BEFB67 /* PollSection.swift */; }; + DB4481CC25EE2AFE00BEFB67 /* PollItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB4481CB25EE2AFE00BEFB67 /* PollItem.swift */; }; DB4563BD25E11A24004DA0B9 /* KeyboardResponderService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB4563BC25E11A24004DA0B9 /* KeyboardResponderService.swift */; }; DB45FAB625CA5485005A8AC7 /* UIAlertController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB45FAB525CA5485005A8AC7 /* UIAlertController.swift */; }; DB45FAD725CA6C76005A8AC7 /* UIBarButtonItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB45FAD625CA6C76005A8AC7 /* UIBarButtonItem.swift */; }; @@ -113,16 +240,97 @@ DB45FAF925CA80A2005A8AC7 /* APIService+CoreData+MastodonAuthentication.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB45FAF825CA80A2005A8AC7 /* APIService+CoreData+MastodonAuthentication.swift */; }; DB45FB0F25CA87D0005A8AC7 /* AuthenticationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB45FB0E25CA87D0005A8AC7 /* AuthenticationService.swift */; }; DB45FB1D25CA9D23005A8AC7 /* APIService+HomeTimeline.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB45FB1C25CA9D23005A8AC7 /* APIService+HomeTimeline.swift */; }; + DB47229725F9EFAD00DA7F53 /* NSManagedObjectContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB47229625F9EFAD00DA7F53 /* NSManagedObjectContext.swift */; }; + 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 */; }; + DB49A61425FF2C5600B98345 /* EmojiService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB49A61325FF2C5600B98345 /* EmojiService.swift */; }; + DB49A61F25FF32AA00B98345 /* EmojiService+CustomEmojiViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB49A61E25FF32AA00B98345 /* EmojiService+CustomEmojiViewModel.swift */; }; + DB49A62525FF334C00B98345 /* EmojiService+CustomEmojiViewModel+LoadState.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB49A62425FF334C00B98345 /* EmojiService+CustomEmojiViewModel+LoadState.swift */; }; + DB49A62B25FF36C700B98345 /* APIService+CustomEmoji.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB49A62A25FF36C700B98345 /* APIService+CustomEmoji.swift */; }; + DB49A63D25FF609300B98345 /* PlayerContainerView+MediaTypeIndicotorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB49A63C25FF609300B98345 /* PlayerContainerView+MediaTypeIndicotorView.swift */; }; DB5086A525CC0B7000C2C187 /* AvatarBarButtonItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB5086A425CC0B7000C2C187 /* AvatarBarButtonItem.swift */; }; DB5086AB25CC0BBB00C2C187 /* AvatarConfigurableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB5086AA25CC0BBB00C2C187 /* AvatarConfigurableView.swift */; }; DB5086B825CC0D6400C2C187 /* Kingfisher in Frameworks */ = {isa = PBXBuildFile; productRef = DB5086B725CC0D6400C2C187 /* Kingfisher */; }; DB5086BE25CC0D9900C2C187 /* SplashPreference.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB5086BD25CC0D9900C2C187 /* SplashPreference.swift */; }; + DB51D172262832380062B7A1 /* BlurHashDecode.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB51D170262832380062B7A1 /* BlurHashDecode.swift */; }; + DB51D173262832380062B7A1 /* BlurHashEncode.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB51D171262832380062B7A1 /* BlurHashEncode.swift */; }; + DB55D33025FB630A0002F825 /* TwitterTextEditor+String.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB55D32F25FB630A0002F825 /* TwitterTextEditor+String.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 */; }; + 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 */; }; + DB6180E626391B550018D199 /* MediaPreviewTransitionController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6180E526391B550018D199 /* MediaPreviewTransitionController.swift */; }; + DB6180E926391BDF0018D199 /* MediaHostToMediaPreviewViewControllerAnimatedTransitioning.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6180E826391BDF0018D199 /* MediaHostToMediaPreviewViewControllerAnimatedTransitioning.swift */; }; + DB6180EB26391C140018D199 /* MediaPreviewTransitionItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6180EA26391C140018D199 /* MediaPreviewTransitionItem.swift */; }; + DB6180ED26391C6C0018D199 /* TransitioningMath.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6180EC26391C6C0018D199 /* TransitioningMath.swift */; }; + DB6180EF26391CA50018D199 /* MediaPreviewImageViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6180EE26391CA50018D199 /* MediaPreviewImageViewController.swift */; }; + DB6180F226391CF40018D199 /* MediaPreviewImageViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6180F126391CF40018D199 /* MediaPreviewImageViewModel.swift */; }; + DB6180F426391D110018D199 /* MediaPreviewImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6180F326391D110018D199 /* MediaPreviewImageView.swift */; }; + 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 */; }; + DB66728C25F9F8DC00D60309 /* ComposeViewModel+Diffable.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB66728B25F9F8DC00D60309 /* ComposeViewModel+Diffable.swift */; }; + DB66729625F9F91600D60309 /* ComposeStatusSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB66729525F9F91600D60309 /* ComposeStatusSection.swift */; }; + DB66729C25F9F91F00D60309 /* ComposeStatusItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB66729B25F9F91F00D60309 /* ComposeStatusItem.swift */; }; + DB68045B2636DC6A00430867 /* MastodonNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB68045A2636DC6A00430867 /* MastodonNotification.swift */; }; + DB6804662636DC9000430867 /* String.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D939AB425EDD8A90076FA61 /* String.swift */; }; + DB68046C2636DC9E00430867 /* MastodonNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB68045A2636DC6A00430867 /* MastodonNotification.swift */; }; + DB6804832637CD4C00430867 /* AppShared.h in Headers */ = {isa = PBXBuildFile; fileRef = DB6804812637CD4C00430867 /* AppShared.h */; settings = {ATTRIBUTES = (Public, ); }; }; + DB6804862637CD4C00430867 /* AppShared.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DB68047F2637CD4C00430867 /* AppShared.framework */; }; + DB6804872637CD4C00430867 /* AppShared.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = DB68047F2637CD4C00430867 /* AppShared.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + DB6804922637CD8700430867 /* AppName.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6804912637CD8700430867 /* AppName.swift */; }; + DB6804A52637CDCC00430867 /* AppShared.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DB68047F2637CD4C00430867 /* AppShared.framework */; }; + DB6804C82637CE2F00430867 /* AppShared.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DB68047F2637CD4C00430867 /* AppShared.framework */; }; + DB6804D12637CE4700430867 /* UserDefaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6804D02637CE4700430867 /* UserDefaults.swift */; }; + DB6804FD2637CFEC00430867 /* AppSecret.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6804FC2637CFEC00430867 /* AppSecret.swift */; }; + DB6805102637D0F800430867 /* KeychainAccess in Frameworks */ = {isa = PBXBuildFile; productRef = DB68050F2637D0F800430867 /* KeychainAccess */; }; + DB6805262637D7DD00430867 /* AppShared.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DB68047F2637CD4C00430867 /* AppShared.framework */; }; DB68586425E619B700F0A850 /* NSKeyValueObservation.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB68586325E619B700F0A850 /* NSKeyValueObservation.swift */; }; - DB68A04A25E9027700CFDF14 /* DarkContentStatusBarStyleNavigationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB68A04925E9027700CFDF14 /* DarkContentStatusBarStyleNavigationController.swift */; }; + DB68A04A25E9027700CFDF14 /* AdaptiveStatusBarStyleNavigationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB68A04925E9027700CFDF14 /* AdaptiveStatusBarStyleNavigationController.swift */; }; DB68A05D25E9055900CFDF14 /* Settings.bundle in Resources */ = {isa = PBXBuildFile; fileRef = DB68A05C25E9055900CFDF14 /* Settings.bundle */; }; DB68A06325E905E000CFDF14 /* UIApplication.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB68A06225E905E000CFDF14 /* UIApplication.swift */; }; + DB6B35182601FA3400DC1E11 /* MastodonAttachmentService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6B35172601FA3400DC1E11 /* MastodonAttachmentService.swift */; }; + DB6B351E2601FAEE00DC1E11 /* ComposeStatusAttachmentCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6B351D2601FAEE00DC1E11 /* ComposeStatusAttachmentCollectionViewCell.swift */; }; + DB6C8C0F25F0A6AE00AAA452 /* Mastodon+Entity+Error.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6C8C0E25F0A6AE00AAA452 /* Mastodon+Entity+Error.swift */; }; + DB6D1B24263684C600ACB481 /* UserDefaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6D1B23263684C600ACB481 /* UserDefaults.swift */; }; + DB6D1B3D2636857500ACB481 /* AppearancePreference.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6D1B3C2636857500ACB481 /* AppearancePreference.swift */; }; + DB6D1B44263691CF00ACB481 /* Mastodon+API+Subscriptions+Policy.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6D1B43263691CF00ACB481 /* Mastodon+API+Subscriptions+Policy.swift */; }; + DB6D9F3526351B7A008423CD /* NotificationService+Decrypt.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6D9F3426351B7A008423CD /* NotificationService+Decrypt.swift */; }; + DB6D9F42263527CE008423CD /* AlamofireImage in Frameworks */ = {isa = PBXBuildFile; productRef = DB6D9F41263527CE008423CD /* AlamofireImage */; }; + DB6D9F4926353FD7008423CD /* Subscription.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6D9F4826353FD6008423CD /* Subscription.swift */; }; + DB6D9F502635761F008423CD /* SubscriptionAlerts.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6D9F4F2635761F008423CD /* SubscriptionAlerts.swift */; }; + DB6D9F57263577D2008423CD /* APIService+CoreData+Setting.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6D9F56263577D2008423CD /* APIService+CoreData+Setting.swift */; }; + DB6D9F6326357848008423CD /* SettingService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6D9F6226357848008423CD /* SettingService.swift */; }; + DB6D9F6F2635807F008423CD /* Setting.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6D9F6E2635807F008423CD /* Setting.swift */; }; + DB6D9F76263587C7008423CD /* SettingFetchedResultController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6D9F75263587C7008423CD /* SettingFetchedResultController.swift */; }; + DB6D9F7D26358ED4008423CD /* SettingsSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6D9F7C26358ED4008423CD /* SettingsSection.swift */; }; + DB6D9F8426358EEC008423CD /* SettingsItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6D9F8326358EEC008423CD /* SettingsItem.swift */; }; + DB6D9F9726367249008423CD /* SettingsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6D9F9626367249008423CD /* SettingsViewController.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 */; }; + DB73B490261F030A002E9E9F /* SafariActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB73B48F261F030A002E9E9F /* SafariActivity.swift */; }; + DB75BF1E263C1C1B00EDBF1F /* CustomScheduler.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB75BF1D263C1C1B00EDBF1F /* CustomScheduler.swift */; }; + DB789A0B25F9F2950071ACA0 /* ComposeViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB789A0A25F9F2950071ACA0 /* ComposeViewController.swift */; }; + DB789A1225F9F2CC0071ACA0 /* ComposeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB789A1125F9F2CC0071ACA0 /* ComposeViewModel.swift */; }; + DB789A1C25F9F76A0071ACA0 /* ComposeStatusContentCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB789A1B25F9F76A0071ACA0 /* ComposeStatusContentCollectionViewCell.swift */; }; + DB789A2B25F9F7AB0071ACA0 /* ComposeRepliedToStatusContentCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB789A2A25F9F7AB0071ACA0 /* ComposeRepliedToStatusContentCollectionViewCell.swift */; }; + DB7F48452620241000796008 /* ProfileHeaderViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB7F48442620241000796008 /* ProfileHeaderViewModel.swift */; }; + DB8190C62601FF0400020C08 /* AttachmentContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB8190C52601FF0400020C08 /* AttachmentContainerView.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, ); }; }; @@ -132,7 +340,7 @@ DB89BA1B25C1107F008580ED /* Collection.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB89BA1825C1107F008580ED /* Collection.swift */; }; DB89BA1C25C1107F008580ED /* NSManagedObjectContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB89BA1925C1107F008580ED /* NSManagedObjectContext.swift */; }; DB89BA1D25C1107F008580ED /* URL.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB89BA1A25C1107F008580ED /* URL.swift */; }; - DB89BA2725C110B4008580ED /* Toot.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB89BA2625C110B4008580ED /* Toot.swift */; }; + DB89BA2725C110B4008580ED /* Status.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB89BA2625C110B4008580ED /* Status.swift */; }; DB89BA3725C1145C008580ED /* CoreData.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = DB89BA3525C1145C008580ED /* CoreData.xcdatamodeld */; }; DB89BA4325C1165F008580ED /* NetworkUpdatable.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB89BA4125C1165F008580ED /* NetworkUpdatable.swift */; }; DB89BA4425C1165F008580ED /* Managed.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB89BA4225C1165F008580ED /* Managed.swift */; }; @@ -145,12 +353,29 @@ 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 */; }; + 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 */; }; + DB938F0926240F3C00E5B6C1 /* RemoteThreadViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB938F0826240F3C00E5B6C1 /* RemoteThreadViewModel.swift */; }; + 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 */; }; 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 */; }; + DB9A485C2603010E008B817C /* PHPickerResultLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9A485B2603010E008B817C /* PHPickerResultLoader.swift */; }; + DB9A486C26032AC1008B817C /* AttachmentContainerView+EmptyStateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9A486B26032AC1008B817C /* AttachmentContainerView+EmptyStateView.swift */; }; + DB9A487E2603456B008B817C /* UITextView+Placeholder in Frameworks */ = {isa = PBXBuildFile; productRef = DB9A487D2603456B008B817C /* UITextView+Placeholder */; }; + DB9A488A26034D40008B817C /* ComposeViewModel+PublishState.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9A488926034D40008B817C /* ComposeViewModel+PublishState.swift */; }; + DB9A489026035963008B817C /* APIService+Media.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9A488F26035963008B817C /* APIService+Media.swift */; }; + DB9A48962603685D008B817C /* MastodonAttachmentService+UploadState.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9A48952603685D008B817C /* MastodonAttachmentService+UploadState.swift */; }; DB9D6BE925E4F5340051B173 /* SearchViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9D6BE825E4F5340051B173 /* SearchViewController.swift */; }; DB9D6BF825E4F5690051B173 /* NotificationViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9D6BF725E4F5690051B173 /* NotificationViewController.swift */; }; DB9D6BFF25E4F5940051B173 /* ProfileViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9D6BFE25E4F5940051B173 /* ProfileViewController.swift */; }; @@ -158,9 +383,66 @@ 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 */; }; + DB9E0D6F25EE008500CFDD76 /* UIInterpolatingMotionEffect.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9E0D6E25EE008500CFDD76 /* UIInterpolatingMotionEffect.swift */; }; + DBA0A10925FB3C2B0079C110 /* RoundedEdgesButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBA0A10825FB3C2B0079C110 /* RoundedEdgesButton.swift */; }; + DBA0A11325FB3FC10079C110 /* ComposeToolbarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBA0A11225FB3FC10079C110 /* ComposeToolbarView.swift */; }; + DBA5E7A3263AD0A3004598BB /* PhotoLibraryService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBA5E7A2263AD0A3004598BB /* PhotoLibraryService.swift */; }; + DBA5E7A5263BD28C004598BB /* ContextMenuImagePreviewViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBA5E7A4263BD28C004598BB /* ContextMenuImagePreviewViewModel.swift */; }; + DBA5E7A9263BD3A4004598BB /* ContextMenuImagePreviewViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBA5E7A8263BD3A4004598BB /* ContextMenuImagePreviewViewController.swift */; }; + DBA5E7AB263BD3F5004598BB /* TimelineTableViewCellContextMenuConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBA5E7AA263BD3F5004598BB /* TimelineTableViewCellContextMenuConfiguration.swift */; }; + DBABE3EC25ECAC4B00879EE5 /* WelcomeIllustrationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBABE3EB25ECAC4B00879EE5 /* WelcomeIllustrationView.swift */; }; + 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 */; }; + DBAE3FA92617106E004B8251 /* MastodonMetricFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBAE3FA82617106E004B8251 /* MastodonMetricFormatter.swift */; }; + DBAE3FAF26172FC0004B8251 /* RemoteProfileViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBAE3FAE26172FC0004B8251 /* RemoteProfileViewModel.swift */; }; + DBAFB7352645463500371D5F /* Emojis.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBAFB7342645463500371D5F /* Emojis.swift */; }; + DBB525082611EAC0002F1F29 /* Tabman in Frameworks */ = {isa = PBXBuildFile; productRef = DBB525072611EAC0002F1F29 /* Tabman */; }; + DBB5250E2611EBAF002F1F29 /* ProfileSegmentedViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBB5250D2611EBAF002F1F29 /* ProfileSegmentedViewController.swift */; }; + DBB525212611EBD6002F1F29 /* ProfilePagingViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBB525202611EBD6002F1F29 /* ProfilePagingViewController.swift */; }; + DBB525302611EBF3002F1F29 /* ProfilePagingViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBB5252F2611EBF3002F1F29 /* ProfilePagingViewModel.swift */; }; + DBB525362611ECEB002F1F29 /* UserTimelineViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBB525352611ECEB002F1F29 /* UserTimelineViewController.swift */; }; + DBB525412611ED54002F1F29 /* ProfileHeaderViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBB525402611ED54002F1F29 /* ProfileHeaderViewController.swift */; }; + DBB525502611ED6D002F1F29 /* ProfileHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBB5254F2611ED6D002F1F29 /* ProfileHeaderView.swift */; }; + DBB525562611EDCA002F1F29 /* UserTimelineViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBB525552611EDCA002F1F29 /* UserTimelineViewModel.swift */; }; + DBB5255E2611F07A002F1F29 /* ProfileViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBB5255D2611F07A002F1F29 /* ProfileViewModel.swift */; }; + DBB525642612C988002F1F29 /* MeProfileViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBB525632612C988002F1F29 /* MeProfileViewModel.swift */; }; + DBB5256E2612D5A1002F1F29 /* ProfileStatusDashboardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBB5256D2612D5A1002F1F29 /* ProfileStatusDashboardView.swift */; }; + DBB525852612D6DD002F1F29 /* ProfileStatusDashboardMeterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBB525842612D6DD002F1F29 /* ProfileStatusDashboardMeterView.swift */; }; + DBB9759C262462E1004620BD /* ThreadMetaView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBB9759B262462E1004620BD /* ThreadMetaView.swift */; }; + DBBE1B4525F3474B0081417A /* MastodonPickServerAppearance.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBBE1B4425F3474B0081417A /* MastodonPickServerAppearance.swift */; }; + DBC7A672260C897100E57475 /* StatusContentWarningEditorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBC7A671260C897100E57475 /* StatusContentWarningEditorView.swift */; }; + DBC7A67C260DFADE00E57475 /* StatusPublishService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBC7A67B260DFADE00E57475 /* StatusPublishService.swift */; }; + DBCBED1726132DB500B49291 /* UserTimelineViewModel+Diffable.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBCBED1626132DB500B49291 /* UserTimelineViewModel+Diffable.swift */; }; + DBCBED1D26132E1A00B49291 /* StatusFetchedResultsController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBCBED1C26132E1A00B49291 /* StatusFetchedResultsController.swift */; }; + DBCC3B30261440A50045B23D /* UITabBarController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBCC3B2F261440A50045B23D /* UITabBarController.swift */; }; + DBCC3B36261440BA0045B23D /* UINavigationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBCC3B35261440BA0045B23D /* UINavigationController.swift */; }; + DBCC3B89261454BA0045B23D /* CGImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBCC3B88261454BA0045B23D /* CGImage.swift */; }; + DBCC3B8F26148F7B0045B23D /* CachedProfileViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBCC3B8E26148F7B0045B23D /* CachedProfileViewModel.swift */; }; + DBCC3B9526157E6E0045B23D /* APIService+Relationship.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBCC3B9426157E6E0045B23D /* APIService+Relationship.swift */; }; + DBCC3B9B261584A00045B23D /* PrivateNote.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBCC3B9A2615849F0045B23D /* PrivateNote.swift */; }; + DBCCC71E25F73297007E1AB6 /* APIService+Reblog.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBCCC71D25F73297007E1AB6 /* APIService+Reblog.swift */; }; DBD9149025DF6D8D00903DFD /* APIService+Onboarding.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBD9148F25DF6D8D00903DFD /* APIService+Onboarding.swift */; }; DBE0821525CD382600FD6BBD /* MastodonRegisterViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBE0821425CD382600FD6BBD /* MastodonRegisterViewController.swift */; }; DBE0822425CD3F1E00FD6BBD /* MastodonRegisterViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBE0822325CD3F1E00FD6BBD /* MastodonRegisterViewModel.swift */; }; + DBE3CDBB261C427900430CC6 /* TimelineHeaderTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBE3CDBA261C427900430CC6 /* TimelineHeaderTableViewCell.swift */; }; + DBE3CDCF261C42ED00430CC6 /* TimelineHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBE3CDCE261C42ED00430CC6 /* TimelineHeaderView.swift */; }; + DBE3CDEC261C6B2900430CC6 /* FavoriteViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBE3CDEB261C6B2900430CC6 /* FavoriteViewController.swift */; }; + DBE3CDFB261C6CA500430CC6 /* FavoriteViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBE3CDFA261C6CA500430CC6 /* FavoriteViewModel.swift */; }; + DBE3CE01261D623D00430CC6 /* FavoriteViewModel+State.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBE3CE00261D623D00430CC6 /* FavoriteViewModel+State.swift */; }; + DBE3CE07261D6A0E00430CC6 /* FavoriteViewModel+Diffable.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBE3CE06261D6A0E00430CC6 /* FavoriteViewModel+Diffable.swift */; }; + DBE3CE0D261D767100430CC6 /* FavoriteViewController+Provider.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBE3CE0C261D767100430CC6 /* FavoriteViewController+Provider.swift */; }; + DBE3CE13261D7D4200430CC6 /* StatusTableViewControllerAspect.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBE3CE12261D7D4200430CC6 /* StatusTableViewControllerAspect.swift */; }; + DBE54ABF2636C889004E7C0B /* UserDefaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6D1B23263684C600ACB481 /* UserDefaults.swift */; }; + DBE54AC62636C89F004E7C0B /* NotificationPreference.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBE54AC52636C89F004E7C0B /* NotificationPreference.swift */; }; + DBE54ACC2636C8FD004E7C0B /* NotificationPreference.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBE54AC52636C89F004E7C0B /* NotificationPreference.swift */; }; + DBF8AE16263293E400C9C23C /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBF8AE15263293E400C9C23C /* NotificationService.swift */; }; + DBF8AE1A263293E400C9C23C /* NotificationService.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = DBF8AE13263293E400C9C23C /* NotificationService.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + DBF8AE862632992800C9C23C /* Base85 in Frameworks */ = {isa = PBXBuildFile; productRef = DBF8AE852632992800C9C23C /* Base85 */; }; + DBF96326262EC0A6001D8D25 /* AuthenticationServices.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DBF96325262EC0A6001D8D25 /* AuthenticationServices.framework */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -178,6 +460,34 @@ remoteGlobalIDString = DB427DD125BAA00100D1B89D; remoteInfo = Mastodon; }; + DB6804842637CD4C00430867 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = DB427DCA25BAA00100D1B89D /* Project object */; + proxyType = 1; + remoteGlobalIDString = DB68047E2637CD4C00430867; + remoteInfo = AppShared; + }; + DB6804A72637CDCC00430867 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = DB427DCA25BAA00100D1B89D /* Project object */; + proxyType = 1; + remoteGlobalIDString = DB68047E2637CD4C00430867; + remoteInfo = AppShared; + }; + DB6804C92637CE3000430867 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = DB427DCA25BAA00100D1B89D /* Project object */; + proxyType = 1; + remoteGlobalIDString = DB68047E2637CD4C00430867; + remoteInfo = AppShared; + }; + DB6805282637D7DD00430867 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = DB427DCA25BAA00100D1B89D /* Project object */; + proxyType = 1; + remoteGlobalIDString = DB68047E2637CD4C00430867; + remoteInfo = AppShared; + }; DB89B9F825C10FD0008580ED /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = DB427DCA25BAA00100D1B89D /* Project object */; @@ -199,6 +509,13 @@ remoteGlobalIDString = DB89B9ED25C10FD0008580ED; remoteInfo = CoreDataStack; }; + DBF8AE18263293E400C9C23C /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = DB427DCA25BAA00100D1B89D /* Project object */; + proxyType = 1; + remoteGlobalIDString = DBF8AE12263293E400C9C23C; + remoteInfo = NotificationService; + }; /* End PBXContainerItemProxy section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -208,14 +525,37 @@ dstPath = ""; dstSubfolderSpec = 10; files = ( + DB6804872637CD4C00430867 /* AppShared.framework in Embed Frameworks */, DB89BA0425C10FD0008580ED /* CoreDataStack.framework in Embed Frameworks */, + DB35B0B42643D821006AC73B /* TwitterTextEditor in Embed Frameworks */, ); name = "Embed Frameworks"; runOnlyForDeploymentPostprocessing = 0; }; + DBF8AE1B263293E400C9C23C /* Embed App Extensions */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 13; + files = ( + DBF8AE1A263293E400C9C23C /* NotificationService.appex in Embed App Extensions */, + ); + name = "Embed App Extensions"; + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 0F1E2D0A2615C39400C38565 /* DoubleTitleLabelNavigationBarTitleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DoubleTitleLabelNavigationBarTitleView.swift; sourceTree = ""; }; + 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 = ""; }; 0FAA101B25E10E760017CCDE /* UIFont.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIFont.swift; sourceTree = ""; }; @@ -228,16 +568,36 @@ 0FB3D33125E5F50E00AAD544 /* PickServerSearchCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PickServerSearchCell.swift; sourceTree = ""; }; 0FB3D33725E6401400AAD544 /* PickServerCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PickServerCell.swift; 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 = ""; }; + 2D198654261C3C4300F0B013 /* SearchViewModel+LoadOldestState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SearchViewModel+LoadOldestState.swift"; sourceTree = ""; }; + 2D206B7125F5D27F00143C56 /* AudioContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioContainerView.swift; sourceTree = ""; }; + 2D206B7F25F5F45E00143C56 /* UIImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIImage.swift; sourceTree = ""; }; + 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 = ""; }; + 2D35238026256F690031AF25 /* NotificationTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationTableViewCell.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+StatusProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HomeTimelineViewController+StatusProvider.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 = ""; }; @@ -245,66 +605,155 @@ 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 = ""; }; - 2D42FF6A25C817D2004A627A /* MastodonContent.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MastodonContent.swift; sourceTree = ""; }; + 2D42FF6A25C817D2004A627A /* MastodonStatusContent.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MastodonStatusContent.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 = ""; }; 2D46975D25C2A54100CF4AA9 /* NSLayoutConstraint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NSLayoutConstraint.swift; sourceTree = ""; }; - 2D46976325C2A71500CF4AA9 /* UIIamge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIIamge.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 = ""; }; + 2D571B2E26004EC000540450 /* NavigationBarProgressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationBarProgressView.swift; sourceTree = ""; }; 2D59819A25E4A581000FB903 /* MastodonConfirmEmailViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonConfirmEmailViewController.swift; sourceTree = ""; }; 2D5981A025E4A593000FB903 /* MastodonConfirmEmailViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonConfirmEmailViewModel.swift; sourceTree = ""; }; 2D5A3D0225CF8742002347D6 /* ControlContainableScrollViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ControlContainableScrollViews.swift; sourceTree = ""; }; 2D5A3D2725CF8BC9002347D6 /* HomeTimelineViewModel+Diffable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HomeTimelineViewModel+Diffable.swift"; sourceTree = ""; }; 2D5A3D3725CF8D9F002347D6 /* ScrollViewContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScrollViewContainer.swift; sourceTree = ""; }; 2D5A3D6125CFD9CB002347D6 /* HomeTimelineViewController+DebugAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HomeTimelineViewController+DebugAction.swift"; sourceTree = ""; }; - 2D61335725C188A000CAE157 /* APIService+Persist+Timeline.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Persist+Timeline.swift"; sourceTree = ""; }; + 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+Toot.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "APIService+CoreData+Toot.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+StatusProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PublicTimelineViewController+StatusProvider.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 = ""; }; + 2D939AB425EDD8A90076FA61 /* String.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = String.swift; sourceTree = ""; }; + 2D939AE725EE1CF80076FA61 /* MastodonRegisterViewController+Avatar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MastodonRegisterViewController+Avatar.swift"; sourceTree = ""; }; + 2D9DB966263A76FB007C1D71 /* BlockDomainService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlockDomainService.swift; sourceTree = ""; }; + 2D9DB968263A833E007C1D71 /* DomainBlock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DomainBlock.swift; sourceTree = ""; }; + 2D9DB96A263A91D1007C1D71 /* APIService+DomainBlock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+DomainBlock.swift"; sourceTree = ""; }; + 2DA504682601ADE7008F4E6C /* SawToothView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SawToothView.swift; sourceTree = ""; }; + 2DA6054625F716A2006356F9 /* PlaybackState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaybackState.swift; sourceTree = ""; }; + 2DA6055025F74407006356F9 /* AudioContainerViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioContainerViewModel.swift; sourceTree = ""; }; 2DA7D04325CA52B200804E11 /* TimelineLoaderTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineLoaderTableViewCell.swift; sourceTree = ""; }; 2DA7D04925CA52CB00804E11 /* TimelineBottomLoaderTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineBottomLoaderTableViewCell.swift; sourceTree = ""; }; 2DA7D05025CA545E00804E11 /* LoadMoreConfigurableTableViewContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadMoreConfigurableTableViewContainer.swift; sourceTree = ""; }; 2DA7D05625CA693F00804E11 /* Application.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Application.swift; sourceTree = ""; }; + 2DAC9E37262FC2320062E1A6 /* SuggestionAccountViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionAccountViewController.swift; sourceTree = ""; }; + 2DAC9E3D262FC2400062E1A6 /* SuggestionAccountViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionAccountViewModel.swift; sourceTree = ""; }; + 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+TimelinePostTableViewCellDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "StatusProvider+TimelinePostTableViewCellDelegate.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 = ""; }; + 2DFAD5262616F9D300F9EE7C /* SearchViewController+Searching.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SearchViewController+Searching.swift"; sourceTree = ""; }; + 2DFAD5362617010500F9EE7C /* SearchingTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchingTableViewCell.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 = ""; }; 3C030226D3C73DCC23D67452 /* Pods_Mastodon_MastodonUITests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Mastodon_MastodonUITests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 452147B2903DF38070FE56A2 /* Pods_MastodonTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_MastodonTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 459EA4F43058CAB47719E963 /* Pods-Mastodon-MastodonUITests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Mastodon-MastodonUITests.debug.xcconfig"; path = "Target Support Files/Pods-Mastodon-MastodonUITests/Pods-Mastodon-MastodonUITests.debug.xcconfig"; sourceTree = ""; }; + 46531DECCAB422F507B2274D /* Pods_Mastodon_AppShared.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Mastodon_AppShared.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 5B24BBD7262DB14800A9381B /* ReportViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ReportViewModel.swift; sourceTree = ""; }; + 5B24BBD8262DB14800A9381B /* ReportViewModel+Diffable.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "ReportViewModel+Diffable.swift"; sourceTree = ""; }; + 5B24BBE1262DB19100A9381B /* APIService+Report.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "APIService+Report.swift"; sourceTree = ""; }; + 5B8E055726319E47006E3C53 /* ReportFooterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportFooterView.swift; sourceTree = ""; }; + 5B90C456262599800002E742 /* SettingsViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SettingsViewModel.swift; sourceTree = ""; }; + 5B90C459262599800002E742 /* SettingsToggleTableViewCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SettingsToggleTableViewCell.swift; sourceTree = ""; }; + 5B90C45A262599800002E742 /* SettingsAppearanceTableViewCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SettingsAppearanceTableViewCell.swift; sourceTree = ""; }; + 5B90C45B262599800002E742 /* SettingsLinkTableViewCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SettingsLinkTableViewCell.swift; sourceTree = ""; }; + 5B90C45C262599800002E742 /* SettingsSectionHeader.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SettingsSectionHeader.swift; sourceTree = ""; }; + 5B90C46C26259B2C0002E742 /* Subscription.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Subscription.swift; sourceTree = ""; }; + 5B90C46D26259B2C0002E742 /* Setting.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Setting.swift; sourceTree = ""; }; + 5B90C47E26259BA90002E742 /* SubscriptionAlerts.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SubscriptionAlerts.swift; sourceTree = ""; }; + 5B90C48426259BF10002E742 /* APIService+Subscriptions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "APIService+Subscriptions.swift"; sourceTree = ""; }; + 5B90C48A26259C120002E742 /* APIService+CoreData+Subscriptions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "APIService+CoreData+Subscriptions.swift"; sourceTree = ""; }; + 5BB04FD4262E7AFF0043BFF6 /* ReportViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportViewController.swift; sourceTree = ""; }; + 5BB04FDA262EA3070043BFF6 /* ReportHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportHeaderView.swift; sourceTree = ""; }; + 5BB04FE8262EFC300043BFF6 /* ReportedStatusTableviewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportedStatusTableviewCell.swift; sourceTree = ""; }; + 5BB04FEE262F0DCB0043BFF6 /* ReportViewModel+Data.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ReportViewModel+Data.swift"; sourceTree = ""; }; + 5BB04FF4262F0E6D0043BFF6 /* ReportSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportSection.swift; sourceTree = ""; }; + 5D03938F2612D259007FE196 /* WebViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewController.swift; sourceTree = ""; }; + 5D0393952612D266007FE196 /* WebViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewModel.swift; sourceTree = ""; }; + 5DA732CB2629CEF500A92342 /* UIView+Remove.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIView+Remove.swift"; sourceTree = ""; }; + 5DDDF1922617442700311060 /* Mastodon+Entity+Account.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Mastodon+Entity+Account.swift"; sourceTree = ""; }; + 5DDDF1982617447F00311060 /* Mastodon+Entity+Tag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Mastodon+Entity+Tag.swift"; sourceTree = ""; }; + 5DDDF1A82617489F00311060 /* Mastodon+Entity+History.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Mastodon+Entity+History.swift"; sourceTree = ""; }; + 5DF1054025F886D400D6C0D4 /* ViedeoPlaybackService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViedeoPlaybackService.swift; sourceTree = ""; }; + 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 = ""; }; 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 = ""; }; + 79D7EB88A6A8B34DFDFC96FC /* Pods_Mastodon_NotificationService.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Mastodon_NotificationService.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 8ED8C4B1F1BA2DCFF2926BB1 /* Pods-Mastodon-NotificationService.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Mastodon-NotificationService.debug.xcconfig"; path = "Target Support Files/Pods-Mastodon-NotificationService/Pods-Mastodon-NotificationService.debug.xcconfig"; sourceTree = ""; }; 9780A4C98FFC65B32B50D1C0 /* Pods-MastodonTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-MastodonTests.release.xcconfig"; path = "Target Support Files/Pods-MastodonTests/Pods-MastodonTests.release.xcconfig"; sourceTree = ""; }; A4ABE34829701A4496C5BB64 /* Pods_Mastodon.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Mastodon.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + B31D44635FCF6452F7E1B865 /* Pods-Mastodon-AppShared.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Mastodon-AppShared.release.xcconfig"; path = "Target Support Files/Pods-Mastodon-AppShared/Pods-Mastodon-AppShared.release.xcconfig"; sourceTree = ""; }; + B44342AC2B6585F8295F1DDF /* Pods-Mastodon-NotificationService.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Mastodon-NotificationService.release.xcconfig"; path = "Target Support Files/Pods-Mastodon-NotificationService/Pods-Mastodon-NotificationService.release.xcconfig"; sourceTree = ""; }; BB482D32A7B9825BF5327C4F /* Pods-Mastodon-MastodonUITests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Mastodon-MastodonUITests.release.xcconfig"; path = "Target Support Files/Pods-Mastodon-MastodonUITests/Pods-Mastodon-MastodonUITests.release.xcconfig"; sourceTree = ""; }; CD92E0F10BDE4FE7C4B999F2 /* Pods_MastodonTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_MastodonTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + D7D7CF93E262178800077512 /* Pods-Mastodon-AppShared.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Mastodon-AppShared.debug.xcconfig"; path = "Target Support Files/Pods-Mastodon-AppShared/Pods-Mastodon-AppShared.debug.xcconfig"; sourceTree = ""; }; DB0140A025C40C0600F9F3CF /* MastodonPinBasedAuthenticationViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonPinBasedAuthenticationViewController.swift; sourceTree = ""; }; DB0140A725C40C1500F9F3CF /* MastodonPinBasedAuthenticationViewModelNavigationDelegateShim.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MastodonPinBasedAuthenticationViewModelNavigationDelegateShim.swift; sourceTree = ""; }; DB0140AD25C40C7300F9F3CF /* MastodonPinBasedAuthenticationViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonPinBasedAuthenticationViewModel.swift; sourceTree = ""; }; DB0140CE25C42AEE00F9F3CF /* OSLog.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OSLog.swift; sourceTree = ""; }; - DB084B5625CBC56C00F898ED /* Toot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Toot.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 = ""; }; + 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 = ""; }; + DB0F814D264CFFD300F2A12B /* ar */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ar; path = ar.lproj/Localizable.strings; sourceTree = ""; }; + DB0F814E264CFFD300F2A12B /* ar */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ar; path = ar.lproj/InfoPlist.strings; sourceTree = ""; }; + DB0F814F264D1E2500F2A12B /* PickServerLoaderTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PickServerLoaderTableViewCell.swift; sourceTree = ""; }; DB118A8125E4B6E600FAB162 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; DB118A8B25E4BFB500FAB162 /* HighlightDimmableButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HighlightDimmableButton.swift; sourceTree = ""; }; + DB1D186B25EF5BA7003F1F23 /* PollTableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollTableView.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 = ""; }; + DB1FD43525F26899004CFCFC /* MastodonPickServerViewModel+LoadIndexedServerState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MastodonPickServerViewModel+LoadIndexedServerState.swift"; sourceTree = ""; }; + DB1FD44325F26CCC004CFCFC /* PickServerSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PickServerSection.swift; sourceTree = ""; }; + DB1FD44F25F26FA1004CFCFC /* MastodonPickServerViewModel+Diffable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MastodonPickServerViewModel+Diffable.swift"; sourceTree = ""; }; + DB1FD45925F27898004CFCFC /* CategoryPickerItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CategoryPickerItem.swift; sourceTree = ""; }; + DB1FD45F25F278AF004CFCFC /* CategoryPickerSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CategoryPickerSection.swift; sourceTree = ""; }; + DB221B15260C395900AEFE46 /* CustomEmojiPickerInputViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomEmojiPickerInputViewModel.swift; sourceTree = ""; }; DB2B3ABD25E37E15007045F9 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/InfoPlist.strings; sourceTree = ""; }; DB2B3AE825E38850007045F9 /* UIViewPreview.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UIViewPreview.swift; 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 = ""; }; + DB35FC1E2612F1D9006193C9 /* ProfileRelationshipActionButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileRelationshipActionButton.swift; sourceTree = ""; }; + DB35FC242612FD7A006193C9 /* ProfileFieldView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileFieldView.swift; sourceTree = ""; }; + DB35FC2E26130172006193C9 /* MastodonField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonField.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; }; @@ -320,6 +769,17 @@ DB427DF325BAA00100D1B89D /* MastodonUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = MastodonUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; DB427DF725BAA00100D1B89D /* MastodonUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonUITests.swift; sourceTree = ""; }; DB427DF925BAA00100D1B89D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + DB44384E25E8C1FA008912A2 /* CALayer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CALayer.swift; sourceTree = ""; }; + DB44767A260B3B8C00B66B82 /* CustomEmojiPickerInputView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomEmojiPickerInputView.swift; sourceTree = ""; }; + DB447680260B3ED600B66B82 /* CustomEmojiPickerSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomEmojiPickerSection.swift; sourceTree = ""; }; + DB44768A260B3F2100B66B82 /* CustomEmojiPickerItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomEmojiPickerItem.swift; sourceTree = ""; }; + DB447690260B406600B66B82 /* CustomEmojiPickerItemCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomEmojiPickerItemCollectionViewCell.swift; sourceTree = ""; }; + DB447696260B439000B66B82 /* CustomEmojiPickerHeaderCollectionReusableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomEmojiPickerHeaderCollectionReusableView.swift; sourceTree = ""; }; + 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 = ""; }; DB4563BC25E11A24004DA0B9 /* KeyboardResponderService.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = KeyboardResponderService.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 = ""; }; @@ -329,15 +789,90 @@ DB45FAF825CA80A2005A8AC7 /* APIService+CoreData+MastodonAuthentication.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+CoreData+MastodonAuthentication.swift"; sourceTree = ""; }; DB45FB0E25CA87D0005A8AC7 /* AuthenticationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthenticationService.swift; sourceTree = ""; }; 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 = ""; }; + DB49A61325FF2C5600B98345 /* EmojiService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiService.swift; sourceTree = ""; }; + DB49A61E25FF32AA00B98345 /* EmojiService+CustomEmojiViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "EmojiService+CustomEmojiViewModel.swift"; sourceTree = ""; }; + DB49A62425FF334C00B98345 /* EmojiService+CustomEmojiViewModel+LoadState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "EmojiService+CustomEmojiViewModel+LoadState.swift"; sourceTree = ""; }; + 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 = ""; }; DB5086A425CC0B7000C2C187 /* AvatarBarButtonItem.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AvatarBarButtonItem.swift; sourceTree = ""; }; DB5086AA25CC0BBB00C2C187 /* AvatarConfigurableView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AvatarConfigurableView.swift; sourceTree = ""; }; DB5086BD25CC0D9900C2C187 /* SplashPreference.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SplashPreference.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 = ""; }; + DB55D32F25FB630A0002F825 /* TwitterTextEditor+String.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "TwitterTextEditor+String.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 = ""; }; + 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 = ""; }; + DB6180E526391B550018D199 /* MediaPreviewTransitionController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPreviewTransitionController.swift; sourceTree = ""; }; + DB6180E826391BDF0018D199 /* MediaHostToMediaPreviewViewControllerAnimatedTransitioning.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaHostToMediaPreviewViewControllerAnimatedTransitioning.swift; sourceTree = ""; }; + DB6180EA26391C140018D199 /* MediaPreviewTransitionItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPreviewTransitionItem.swift; sourceTree = ""; }; + DB6180EC26391C6C0018D199 /* TransitioningMath.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransitioningMath.swift; sourceTree = ""; }; + DB6180EE26391CA50018D199 /* MediaPreviewImageViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPreviewImageViewController.swift; sourceTree = ""; }; + DB6180F126391CF40018D199 /* MediaPreviewImageViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPreviewImageViewModel.swift; sourceTree = ""; }; + DB6180F326391D110018D199 /* MediaPreviewImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPreviewImageView.swift; sourceTree = ""; }; + 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 = ""; }; + DB66728B25F9F8DC00D60309 /* ComposeViewModel+Diffable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ComposeViewModel+Diffable.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 = ""; }; + DB68045A2636DC6A00430867 /* MastodonNotification.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonNotification.swift; sourceTree = ""; }; + DB68047F2637CD4C00430867 /* AppShared.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = AppShared.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + DB6804812637CD4C00430867 /* AppShared.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = AppShared.h; sourceTree = ""; }; + DB6804822637CD4C00430867 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + DB6804912637CD8700430867 /* AppName.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppName.swift; sourceTree = ""; }; + DB6804D02637CE4700430867 /* UserDefaults.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserDefaults.swift; sourceTree = ""; }; + DB6804FC2637CFEC00430867 /* AppSecret.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSecret.swift; sourceTree = ""; }; + DB68053E2638011000430867 /* NotificationService.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = NotificationService.entitlements; sourceTree = ""; }; DB68586325E619B700F0A850 /* NSKeyValueObservation.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NSKeyValueObservation.swift; sourceTree = ""; }; - DB68A04925E9027700CFDF14 /* DarkContentStatusBarStyleNavigationController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DarkContentStatusBarStyleNavigationController.swift; sourceTree = ""; }; + 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 = ""; }; + DB6B35172601FA3400DC1E11 /* MastodonAttachmentService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonAttachmentService.swift; sourceTree = ""; }; + DB6B351D2601FAEE00DC1E11 /* ComposeStatusAttachmentCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeStatusAttachmentCollectionViewCell.swift; sourceTree = ""; }; + DB6C8C0E25F0A6AE00AAA452 /* Mastodon+Entity+Error.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Mastodon+Entity+Error.swift"; sourceTree = ""; }; + DB6D1B23263684C600ACB481 /* UserDefaults.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserDefaults.swift; sourceTree = ""; }; + DB6D1B3C2636857500ACB481 /* AppearancePreference.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppearancePreference.swift; sourceTree = ""; }; + DB6D1B43263691CF00ACB481 /* Mastodon+API+Subscriptions+Policy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Mastodon+API+Subscriptions+Policy.swift"; sourceTree = ""; }; + DB6D9F3426351B7A008423CD /* NotificationService+Decrypt.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NotificationService+Decrypt.swift"; sourceTree = ""; }; + DB6D9F4826353FD6008423CD /* Subscription.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Subscription.swift; sourceTree = ""; }; + DB6D9F4F2635761F008423CD /* SubscriptionAlerts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubscriptionAlerts.swift; sourceTree = ""; }; + DB6D9F56263577D2008423CD /* APIService+CoreData+Setting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+CoreData+Setting.swift"; sourceTree = ""; }; + DB6D9F6226357848008423CD /* SettingService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingService.swift; sourceTree = ""; }; + DB6D9F6E2635807F008423CD /* Setting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Setting.swift; sourceTree = ""; }; + DB6D9F75263587C7008423CD /* SettingFetchedResultController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingFetchedResultController.swift; sourceTree = ""; }; + DB6D9F7C26358ED4008423CD /* SettingsSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsSection.swift; sourceTree = ""; }; + DB6D9F8326358EEC008423CD /* SettingsItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsItem.swift; sourceTree = ""; }; + DB6D9F9626367249008423CD /* SettingsViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SettingsViewController.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 = ""; }; + DB73B48F261F030A002E9E9F /* SafariActivity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SafariActivity.swift; sourceTree = ""; }; + DB75BF1D263C1C1B00EDBF1F /* CustomScheduler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomScheduler.swift; sourceTree = ""; }; + DB789A0A25F9F2950071ACA0 /* ComposeViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeViewController.swift; sourceTree = ""; }; + DB789A1125F9F2CC0071ACA0 /* ComposeViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeViewModel.swift; sourceTree = ""; }; + DB789A1B25F9F76A0071ACA0 /* ComposeStatusContentCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeStatusContentCollectionViewCell.swift; sourceTree = ""; }; + DB789A2A25F9F7AB0071ACA0 /* ComposeRepliedToStatusContentCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeRepliedToStatusContentCollectionViewCell.swift; sourceTree = ""; }; + DB7F48442620241000796008 /* ProfileHeaderViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileHeaderViewModel.swift; sourceTree = ""; }; + DB8190C52601FF0400020C08 /* AttachmentContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttachmentContainerView.swift; sourceTree = ""; }; + 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 = ""; }; @@ -349,7 +884,7 @@ DB89BA1825C1107F008580ED /* Collection.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Collection.swift; sourceTree = ""; }; DB89BA1925C1107F008580ED /* NSManagedObjectContext.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NSManagedObjectContext.swift; sourceTree = ""; }; DB89BA1A25C1107F008580ED /* URL.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = URL.swift; sourceTree = ""; }; - DB89BA2625C110B4008580ED /* Toot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Toot.swift; sourceTree = ""; }; + DB89BA2625C110B4008580ED /* Status.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Status.swift; sourceTree = ""; }; DB89BA3625C1145C008580ED /* CoreData.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = CoreData.xcdatamodel; sourceTree = ""; }; DB89BA4125C1165F008580ED /* NetworkUpdatable.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NetworkUpdatable.swift; sourceTree = ""; }; DB89BA4225C1165F008580ED /* Managed.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Managed.swift; sourceTree = ""; }; @@ -362,12 +897,28 @@ 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 = ""; }; + 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 = ""; }; + DB938F0826240F3C00E5B6C1 /* RemoteThreadViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteThreadViewModel.swift; sourceTree = ""; }; + 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 = ""; }; 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 = ""; }; + DB9A485B2603010E008B817C /* PHPickerResultLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PHPickerResultLoader.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 = ""; }; + DB9A488F26035963008B817C /* APIService+Media.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Media.swift"; sourceTree = ""; }; + DB9A48952603685D008B817C /* MastodonAttachmentService+UploadState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MastodonAttachmentService+UploadState.swift"; sourceTree = ""; }; DB9D6BE825E4F5340051B173 /* SearchViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchViewController.swift; sourceTree = ""; }; DB9D6BF725E4F5690051B173 /* NotificationViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationViewController.swift; sourceTree = ""; }; DB9D6BFE25E4F5940051B173 /* ProfileViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileViewController.swift; sourceTree = ""; }; @@ -375,11 +926,65 @@ 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 = ""; }; + DB9E0D6E25EE008500CFDD76 /* UIInterpolatingMotionEffect.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIInterpolatingMotionEffect.swift; sourceTree = ""; }; + DBA0A10825FB3C2B0079C110 /* RoundedEdgesButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoundedEdgesButton.swift; sourceTree = ""; }; + DBA0A11225FB3FC10079C110 /* ComposeToolbarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeToolbarView.swift; sourceTree = ""; }; + DBA5E7A2263AD0A3004598BB /* PhotoLibraryService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhotoLibraryService.swift; sourceTree = ""; }; + DBA5E7A4263BD28C004598BB /* ContextMenuImagePreviewViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextMenuImagePreviewViewModel.swift; sourceTree = ""; }; + DBA5E7A8263BD3A4004598BB /* ContextMenuImagePreviewViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextMenuImagePreviewViewController.swift; sourceTree = ""; }; + DBA5E7AA263BD3F5004598BB /* TimelineTableViewCellContextMenuConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineTableViewCellContextMenuConfiguration.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 = ""; }; + DBAE3FA82617106E004B8251 /* MastodonMetricFormatter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonMetricFormatter.swift; sourceTree = ""; }; + DBAE3FAE26172FC0004B8251 /* RemoteProfileViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteProfileViewModel.swift; sourceTree = ""; }; + DBAFB7342645463500371D5F /* Emojis.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Emojis.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 = ""; }; + DBB5252F2611EBF3002F1F29 /* ProfilePagingViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfilePagingViewModel.swift; sourceTree = ""; }; + DBB525352611ECEB002F1F29 /* UserTimelineViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserTimelineViewController.swift; sourceTree = ""; }; + DBB525402611ED54002F1F29 /* ProfileHeaderViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileHeaderViewController.swift; sourceTree = ""; }; + DBB5254F2611ED6D002F1F29 /* ProfileHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileHeaderView.swift; sourceTree = ""; }; + DBB525552611EDCA002F1F29 /* UserTimelineViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserTimelineViewModel.swift; sourceTree = ""; }; + DBB5255D2611F07A002F1F29 /* ProfileViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileViewModel.swift; sourceTree = ""; }; + DBB525632612C988002F1F29 /* MeProfileViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeProfileViewModel.swift; sourceTree = ""; }; + DBB5256D2612D5A1002F1F29 /* ProfileStatusDashboardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileStatusDashboardView.swift; sourceTree = ""; }; + DBB525842612D6DD002F1F29 /* ProfileStatusDashboardMeterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileStatusDashboardMeterView.swift; sourceTree = ""; }; + DBB9759B262462E1004620BD /* ThreadMetaView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadMetaView.swift; sourceTree = ""; }; + DBBE1B4425F3474B0081417A /* MastodonPickServerAppearance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonPickServerAppearance.swift; sourceTree = ""; }; + DBC7A671260C897100E57475 /* StatusContentWarningEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusContentWarningEditorView.swift; sourceTree = ""; }; + DBC7A67B260DFADE00E57475 /* StatusPublishService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusPublishService.swift; sourceTree = ""; }; + DBCBED1626132DB500B49291 /* UserTimelineViewModel+Diffable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UserTimelineViewModel+Diffable.swift"; sourceTree = ""; }; + DBCBED1C26132E1A00B49291 /* StatusFetchedResultsController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusFetchedResultsController.swift; sourceTree = ""; }; + DBCC3B2F261440A50045B23D /* UITabBarController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UITabBarController.swift; sourceTree = ""; }; + DBCC3B35261440BA0045B23D /* UINavigationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UINavigationController.swift; sourceTree = ""; }; + DBCC3B88261454BA0045B23D /* CGImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CGImage.swift; sourceTree = ""; }; + DBCC3B8E26148F7B0045B23D /* CachedProfileViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CachedProfileViewModel.swift; sourceTree = ""; }; + DBCC3B9426157E6E0045B23D /* APIService+Relationship.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Relationship.swift"; sourceTree = ""; }; + DBCC3B9A2615849F0045B23D /* PrivateNote.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrivateNote.swift; sourceTree = ""; }; + DBCCC71D25F73297007E1AB6 /* APIService+Reblog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Reblog.swift"; sourceTree = ""; }; DBD9148F25DF6D8D00903DFD /* APIService+Onboarding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Onboarding.swift"; sourceTree = ""; }; DBE0821425CD382600FD6BBD /* MastodonRegisterViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonRegisterViewController.swift; sourceTree = ""; }; DBE0822325CD3F1E00FD6BBD /* MastodonRegisterViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonRegisterViewModel.swift; sourceTree = ""; }; + DBE3CDBA261C427900430CC6 /* TimelineHeaderTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineHeaderTableViewCell.swift; sourceTree = ""; }; + DBE3CDCE261C42ED00430CC6 /* TimelineHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineHeaderView.swift; sourceTree = ""; }; + DBE3CDEB261C6B2900430CC6 /* FavoriteViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoriteViewController.swift; sourceTree = ""; }; + 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 = ""; }; DBF53F5F25C14E88008AAC7B /* Mastodon.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; name = Mastodon.xctestplan; path = Mastodon/Mastodon.xctestplan; sourceTree = ""; }; DBF53F6025C14E9D008AAC7B /* MastodonSDK.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = MastodonSDK.xctestplan; sourceTree = ""; }; + DBF8AE13263293E400C9C23C /* NotificationService.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = NotificationService.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + DBF8AE15263293E400C9C23C /* NotificationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationService.swift; sourceTree = ""; }; + DBF8AE17263293E400C9C23C /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + DBF96325262EC0A6001D8D25 /* AuthenticationServices.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = AuthenticationServices.framework; path = System/Library/Frameworks/AuthenticationServices.framework; sourceTree = SDKROOT; }; 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 = ""; }; /* End PBXFileReference section */ @@ -388,15 +993,22 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + DB35B0B32643D821006AC73B /* TwitterTextEditor in Frameworks */, DB0140BD25C40D7500F9F3CF /* CommonOSLog in Frameworks */, DB89BA0325C10FD0008580ED /* CoreDataStack.framework in Frameworks */, 2D42FF6125C8177C004A627A /* ActiveLabel in Frameworks */, + DB9A487E2603456B008B817C /* UITextView+Placeholder in Frameworks */, + 2D939AC825EE14620076FA61 /* CropViewController in Frameworks */, + DBB525082611EAC0002F1F29 /* Tabman in Frameworks */, 5D526FE225BE9AC400460CB9 /* MastodonSDK in Frameworks */, + DB6804862637CD4C00430867 /* AppShared.framework in Frameworks */, DB5086B825CC0D6400C2C187 /* Kingfisher in Frameworks */, + DBF96326262EC0A6001D8D25 /* AuthenticationServices.framework in Frameworks */, 2D61336925C18A4F00CAE157 /* AlamofireNetworkActivityIndicator in Frameworks */, DB3D0FF325BAA61700EAA174 /* AlamofireImage in Frameworks */, 2D5981BA25E4D7F8000FB903 /* ThirdPartyMailer in Frameworks */, - 45B49097460EDE530AD5AA72 /* Pods_Mastodon.framework in Frameworks */, + 87FFDA5D898A5C42ADCB35E7 /* Pods_Mastodon.framework in Frameworks */, + DB6804C82637CE2F00430867 /* AppShared.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -417,10 +1029,20 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + DB68047C2637CD4C00430867 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + DB6805102637D0F800430867 /* KeychainAccess in Frameworks */, + D86919F5080C3F228CCD17D1 /* Pods_Mastodon_AppShared.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; DB89B9EB25C10FD0008580ED /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + DB6805262637D7DD00430867 /* AppShared.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -432,12 +1054,39 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + DBF8AE10263293E400C9C23C /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + DB00CA972632DDB600A54956 /* CommonOSLog in Frameworks */, + DB6D9F42263527CE008423CD /* AlamofireImage in Frameworks */, + DBF8AE862632992800C9C23C /* Base85 in Frameworks */, + DB6804A52637CDCC00430867 /* AppShared.framework in Frameworks */, + 7D603B3DC600BB75956FAD7D /* Pods_Mastodon_NotificationService.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 0F2021F5261325ED000C64BF /* HashtagTimeline */ = { + isa = PBXGroup; + children = ( + 0F2021FA2613262F000C64BF /* HashtagTimelineViewController.swift */, + 0F202226261411BA000C64BF /* HashtagTimelineViewController+Provider.swift */, + 0F202200261326E6000C64BF /* HashtagTimelineViewModel.swift */, + 0F20220626134DA4000C64BF /* HashtagTimelineViewModel+Diffable.swift */, + 0F20220C26134E3F000C64BF /* HashtagTimelineViewModel+LoadLatestState.swift */, + 0F20222C261457EE000C64BF /* HashtagTimelineViewModel+LoadOldestState.swift */, + 0F20223226145E51000C64BF /* HashtagTimelineViewModel+LoadMiddleState.swift */, + ); + path = HashtagTimeline; + sourceTree = ""; + }; 0FAA0FDD25E0B5700017CCDE /* Welcome */ = { isa = PBXGroup; children = ( + DBABE3F125ECAC4E00879EE5 /* View */, 0FAA0FDE25E0B57E0017CCDE /* WelcomeViewController.swift */, ); path = Welcome; @@ -446,11 +1095,14 @@ 0FAA102525E1125D0017CCDE /* PickServer */ = { isa = PBXGroup; children = ( - 0FB3D31825E525DE00AAD544 /* CollectionViewCell */, 0FB3D30D25E525C000AAD544 /* View */, + 0FB3D31825E525DE00AAD544 /* CollectionViewCell */, 0FB3D2FC25E4CB4B00AAD544 /* TableViewCell */, + DBBE1B4425F3474B0081417A /* MastodonPickServerAppearance.swift */, 0FAA102625E1126A0017CCDE /* MastodonPickServerViewController.swift */, 0FB3D2F625E4C24D00AAD544 /* MastodonPickServerViewModel.swift */, + DB1FD44F25F26FA1004CFCFC /* MastodonPickServerViewModel+Diffable.swift */, + DB1FD43525F26899004CFCFC /* MastodonPickServerViewModel+LoadIndexedServerState.swift */, ); path = PickServer; sourceTree = ""; @@ -462,6 +1114,7 @@ 0FB3D30725E524C600AAD544 /* PickServerCategoriesCell.swift */, 0FB3D33125E5F50E00AAD544 /* PickServerSearchCell.swift */, 0FB3D33725E6401400AAD544 /* PickServerCell.swift */, + DB0F814F264D1E2500F2A12B /* PickServerLoaderTableViewCell.swift */, ); path = TableViewCell; sourceTree = ""; @@ -470,6 +1123,7 @@ isa = PBXGroup; children = ( 0FB3D30E25E525CD00AAD544 /* PickServerCategoryView.swift */, + DB9282B125F3222800823B15 /* PickServerEmptyStateView.swift */, ); path = View; sourceTree = ""; @@ -491,6 +1145,10 @@ BB482D32A7B9825BF5327C4F /* Pods-Mastodon-MastodonUITests.release.xcconfig */, EC6E707B68A67DB08EC288FA /* Pods-MastodonTests.debug.xcconfig */, 9780A4C98FFC65B32B50D1C0 /* Pods-MastodonTests.release.xcconfig */, + 8ED8C4B1F1BA2DCFF2926BB1 /* Pods-Mastodon-NotificationService.debug.xcconfig */, + B44342AC2B6585F8295F1DDF /* Pods-Mastodon-NotificationService.release.xcconfig */, + D7D7CF93E262178800077512 /* Pods-Mastodon-AppShared.debug.xcconfig */, + B31D44635FCF6452F7E1B865 /* Pods-Mastodon-AppShared.release.xcconfig */, ); path = Pods; sourceTree = ""; @@ -499,10 +1157,34 @@ isa = PBXGroup; children = ( 2D152A8B25C295CC009AA50C /* StatusView.swift */, + 2D694A7325F9EB4E0038ADDC /* ContentWarningOverlayView.swift */, + 2D571B2E26004EC000540450 /* NavigationBarProgressView.swift */, + DB87D44A2609C11900D12C0D /* PollOptionView.swift */, + DBE3CDCE261C42ED00430CC6 /* TimelineHeaderView.swift */, + 0F1E2D0A2615C39400C38565 /* DoubleTitleLabelNavigationBarTitleView.swift */, + DBB9759B262462E1004620BD /* ThreadMetaView.swift */, ); path = Content; sourceTree = ""; }; + 2D34D9E026149C550081BFC0 /* CollectionViewCell */ = { + isa = PBXGroup; + children = ( + 2D34D9E126149C920081BFC0 /* SearchRecommendTagsCollectionViewCell.swift */, + 2DE0FAC72615F5F000CDF649 /* SearchRecommendAccountsCollectionViewCell.swift */, + ); + path = CollectionViewCell; + sourceTree = ""; + }; + 2D35237F26256F470031AF25 /* TableViewCell */ = { + isa = PBXGroup; + children = ( + 2D35238026256F690031AF25 /* NotificationTableViewCell.swift */, + 2D24E11C2626D8B100A59D4F /* NotificationStatusTableViewCell.swift */, + ); + path = TableViewCell; + sourceTree = ""; + }; 2D364F7025E66D5B00204FDC /* ResendEmail */ = { isa = PBXGroup; children = ( @@ -516,8 +1198,9 @@ 2D38F1D325CD463600561493 /* HomeTimeline */ = { isa = PBXGroup; children = ( + DB1F239626117C360057430E /* View */, 2D38F1D425CD465300561493 /* HomeTimelineViewController.swift */, - 2D38F1DE25CD46A400561493 /* HomeTimelineViewController+StatusProvider.swift */, + 2D38F1DE25CD46A400561493 /* HomeTimelineViewController+Provider.swift */, 2D5A3D6125CFD9CB002347D6 /* HomeTimelineViewController+DebugAction.swift */, 2D38F1E425CD46C100561493 /* HomeTimelineViewModel.swift */, 2D5A3D2725CF8BC9002347D6 /* HomeTimelineViewModel+Diffable.swift */, @@ -533,7 +1216,9 @@ children = ( 2D38F1FD25CD481700561493 /* StatusProvider.swift */, 2DF75B9A25D0E27500694EC8 /* StatusProviderFacade.swift */, - 2DF75BA025D0E29D00694EC8 /* StatusProvider+TimelinePostTableViewCellDelegate.swift */, + 2DF75BA025D0E29D00694EC8 /* StatusProvider+StatusTableViewCellDelegate.swift */, + DB59F0FD25EF5D96001F1DAB /* StatusProvider+UITableViewDelegate.swift */, + DB71FD4525F8C6D200512AE1 /* StatusProvider+UITableViewDataSourcePrefetching.swift */, ); path = StatusProvider; sourceTree = ""; @@ -553,10 +1238,19 @@ 2D42FF8425C8224F004A627A /* HitTestExpandedButton.swift */, DB118A8B25E4BFB500FAB162 /* HighlightDimmableButton.swift */, 0FAA101125E105390017CCDE /* PrimaryActionButton.swift */, + DBA0A10825FB3C2B0079C110 /* RoundedEdgesButton.swift */, ); path = Button; sourceTree = ""; }; + 2D4AD89A2631659400613EFC /* CollectionViewCell */ = { + isa = PBXGroup; + children = ( + 2D4AD89B263165B500613EFC /* SuggestionAccountCollectionViewCell.swift */, + ); + path = CollectionViewCell; + sourceTree = ""; + }; 2D59819925E4A55C000FB903 /* ConfirmEmail */ = { isa = PBXGroup; children = ( @@ -571,6 +1265,12 @@ children = ( 2D5A3D0225CF8742002347D6 /* ControlContainableScrollViews.swift */, DB2B3AE825E38850007045F9 /* UIViewPreview.swift */, + DB55D32F25FB630A0002F825 /* TwitterTextEditor+String.swift */, + DB9A485B2603010E008B817C /* PHPickerResultLoader.swift */, + DB51D170262832380062B7A1 /* BlurHashDecode.swift */, + DB51D171262832380062B7A1 /* BlurHashEncode.swift */, + DB6180EC26391C6C0018D199 /* TransitioningMath.swift */, + DB75BF1D263C1C1B00EDBF1F /* CustomScheduler.swift */, ); path = Vender; sourceTree = ""; @@ -579,8 +1279,19 @@ isa = PBXGroup; children = ( DB45FB0425CA87B4005A8AC7 /* APIService */, + DB49A61925FF327D00B98345 /* EmojiService */, + DB9A489B26036E19008B817C /* MastodonAttachmentService */, DB45FB0E25CA87D0005A8AC7 /* AuthenticationService.swift */, DB4563BC25E11A24004DA0B9 /* KeyboardResponderService.swift */, + 2D206B8B25F6015000143C56 /* AudioPlaybackService.swift */, + 2DA6054625F716A2006356F9 /* PlaybackState.swift */, + 5DF1054025F886D400D6C0D4 /* ViedeoPlaybackService.swift */, + DB71FD4B25F8C80E00512AE1 /* StatusPrefetchingService.swift */, + DBC7A67B260DFADE00E57475 /* StatusPublishService.swift */, + 2D9DB966263A76FB007C1D71 /* BlockDomainService.swift */, + DB4924E126312AB200E9DB22 /* NotificationService.swift */, + DB6D9F6226357848008423CD /* SettingService.swift */, + DBA5E7A2263AD0A3004598BB /* PhotoLibraryService.swift */, ); path = Service; sourceTree = ""; @@ -588,7 +1299,9 @@ 2D61335625C1887F00CAE157 /* Persist */ = { isa = PBXGroup; children = ( - 2D61335725C188A000CAE157 /* APIService+Persist+Timeline.swift */, + 2D61335725C188A000CAE157 /* APIService+Persist+Status.swift */, + DB71FD3525F8A16C00512AE1 /* APIService+Persist+PersistMemo.swift */, + DB71FD3B25F8A1C500512AE1 /* APIService+Persist+PersistCache.swift */, ); path = Persist; sourceTree = ""; @@ -597,11 +1310,15 @@ isa = PBXGroup; children = ( 2D38F1FC25CD47D900561493 /* StatusProvider */, + DBAE3F742615DD63004B8251 /* UserProvider */, DB5086AA25CC0BBB00C2C187 /* AvatarConfigurableView.swift */, 2D69CFF325CA9E2200C3A1B2 /* LoadMoreConfigurableTableViewContainer.swift */, + 2D5A3D3725CF8D9F002347D6 /* ScrollViewContainer.swift */, + DB59F10325EF5EBC001F1DAB /* TableViewCellHeightCacheableContainer.swift */, 2D38F1C525CD37F400561493 /* ContentOffsetAdjustableTimelineViewControllerDelegate.swift */, 2D38F20725CD491300561493 /* DisposeBagCollectable.swift */, - 2D5A3D3725CF8D9F002347D6 /* ScrollViewContainer.swift */, + 5DF1058425F88AE500D6C0D4 /* NeedsDependency+AVPlayerViewControllerDelegate.swift */, + DBE3CE12261D7D4200430CC6 /* StatusTableViewControllerAspect.swift */, ); path = Protocol; sourceTree = ""; @@ -610,7 +1327,7 @@ isa = PBXGroup; children = ( 2D76316425C14BD100929FB9 /* PublicTimelineViewController.swift */, - 2D76317C25C14DF400929FB9 /* PublicTimelineViewController+StatusProvider.swift */, + 2D76317C25C14DF400929FB9 /* PublicTimelineViewController+Provider.swift */, 2D76316A25C14D4C00929FB9 /* PublicTimelineViewModel.swift */, 2D32EAD925CBCC3300C9ED86 /* PublicTimelineViewModel+LoadMiddleState.swift */, 2D76318225C14E8F00929FB9 /* PublicTimelineViewModel+Diffable.swift */, @@ -622,8 +1339,9 @@ 2D76319C25C151DE00929FB9 /* Diffiable */ = { isa = PBXGroup; children = ( - 2D7631B125C159E700929FB9 /* Item */, 2D76319D25C151F600929FB9 /* Section */, + 2D7631B125C159E700929FB9 /* Item */, + DBCBED2226132E1D00B49291 /* FetchedResultsController */, ); path = Diffiable; sourceTree = ""; @@ -632,6 +1350,18 @@ isa = PBXGroup; children = ( 2D76319E25C1521200929FB9 /* StatusSection.swift */, + DB4481C525EE2ADA00BEFB67 /* PollSection.swift */, + DB1FD44325F26CCC004CFCFC /* PickServerSection.swift */, + DB1E346725F518E20079D7DF /* CategoryPickerSection.swift */, + 2DE0FAC02615F04D00CDF649 /* RecommendHashTagSection.swift */, + 2DE0FACD2615F7AD00CDF649 /* RecommendAccountSection.swift */, + 2D4AD8A126316CD200613EFC /* SelectedAccountSection.swift */, + 2D35237926256D920031AF25 /* NotificationSection.swift */, + 2D198648261C0B8500F0B013 /* SearchResultSection.swift */, + DB66729525F9F91600D60309 /* ComposeStatusSection.swift */, + DB447680260B3ED600B66B82 /* CustomEmojiPickerSection.swift */, + DB6D9F7C26358ED4008423CD /* SettingsSection.swift */, + 5BB04FF4262F0E6D0043BFF6 /* ReportSection.swift */, ); path = Section; sourceTree = ""; @@ -639,9 +1369,11 @@ 2D7631A425C1532200929FB9 /* Share */ = { isa = PBXGroup; children = ( + 5D03938E2612D200007FE196 /* Webview */, DB68A04F25E9028800CFDF14 /* NavigationController */, DB9D6C2025E502C60051B173 /* ViewModel */, 2D7631A525C1532D00929FB9 /* View */, + DBA5E7A6263BD298004598BB /* ContextMenu */, ); path = Share; sourceTree = ""; @@ -649,10 +1381,14 @@ 2D7631A525C1532D00929FB9 /* View */ = { isa = PBXGroup; children = ( + 2DA504672601ADBA008F4E6C /* Decoration */, 2D42FF8325C82245004A627A /* Button */, 2D42FF7C25C82207004A627A /* ToolBar */, DB9D6C1325E4F97A0051B173 /* Container */, + DBA9B90325F1D4420012E7B6 /* Control */, 2D152A8A25C295B8009AA50C /* Content */, + DB87D45C2609DE6600D12C0D /* TextField */, + DB1D187125EF5BBD003F1F23 /* TableView */, 2D7631A625C1533800929FB9 /* TableviewCell */, ); path = View; @@ -663,8 +1399,12 @@ children = ( 2D7631A725C1535600929FB9 /* StatusTableViewCell.swift */, 2DA7D04325CA52B200804E11 /* TimelineLoaderTableViewCell.swift */, + DB938F3226243D6200E5B6C1 /* TimelineTopLoaderTableViewCell.swift */, 2DA7D04925CA52CB00804E11 /* TimelineBottomLoaderTableViewCell.swift */, 2D32EAAB25CB96DC00C9ED86 /* TimelineMiddleLoaderTableViewCell.swift */, + DBE3CDBA261C427900430CC6 /* TimelineHeaderTableViewCell.swift */, + DB02CDAA26256A9500D0A2AF /* ThreadReplyLoaderTableViewCell.swift */, + DB92CF7125E7BB98002C1017 /* PollOptionTableViewCell.swift */, ); path = TableviewCell; sourceTree = ""; @@ -673,10 +1413,54 @@ isa = PBXGroup; children = ( 2D7631B225C159F700929FB9 /* Item.swift */, + 2D198642261BF09500F0B013 /* SearchResultItem.swift */, + 2D4AD8A726316D3500613EFC /* SelectedAccountItem.swift */, + 2D7867182625B77500211898 /* NotificationItem.swift */, + DB4481CB25EE2AFE00BEFB67 /* PollItem.swift */, + DB1E347725F519300079D7DF /* PickServerItem.swift */, + DB1FD45925F27898004CFCFC /* CategoryPickerItem.swift */, + DB66729B25F9F91F00D60309 /* ComposeStatusItem.swift */, + DB44768A260B3F2100B66B82 /* CustomEmojiPickerItem.swift */, + DB6D9F8326358EEC008423CD /* SettingsItem.swift */, ); path = Item; sourceTree = ""; }; + 2DA504672601ADBA008F4E6C /* Decoration */ = { + isa = PBXGroup; + children = ( + 2DA504682601ADE7008F4E6C /* SawToothView.swift */, + ); + path = Decoration; + sourceTree = ""; + }; + 2DAC9E36262FC20B0062E1A6 /* SuggestionAccount */ = { + isa = PBXGroup; + children = ( + 2DAC9E37262FC2320062E1A6 /* SuggestionAccountViewController.swift */, + 2DAC9E3D262FC2400062E1A6 /* SuggestionAccountViewModel.swift */, + 2D4AD89A2631659400613EFC /* CollectionViewCell */, + 2DAC9E43262FC9DE0062E1A6 /* TableViewCell */, + ); + path = SuggestionAccount; + sourceTree = ""; + }; + 2DAC9E43262FC9DE0062E1A6 /* TableViewCell */ = { + isa = PBXGroup; + children = ( + 2DAC9E45262FC9FD0062E1A6 /* SuggestionAccountTableViewCell.swift */, + ); + path = TableViewCell; + sourceTree = ""; + }; + 2DE0FAC62615F5D200CDF649 /* View */ = { + isa = PBXGroup; + children = ( + 2DCB73FC2615C13900EC03D4 /* SearchRecommendCollectionHeader.swift */, + ); + path = View; + sourceTree = ""; + }; 2DF75BB725D1473400694EC8 /* Stack */ = { isa = PBXGroup; children = ( @@ -686,16 +1470,79 @@ path = Stack; sourceTree = ""; }; + 2DFAD5212616F8E300F9EE7C /* TableViewCell */ = { + isa = PBXGroup; + children = ( + 2DFAD5362617010500F9EE7C /* SearchingTableViewCell.swift */, + ); + path = TableViewCell; + sourceTree = ""; + }; 3FE14AD363ED19AE7FF210A6 /* Frameworks */ = { isa = PBXGroup; children = ( + DBF96325262EC0A6001D8D25 /* AuthenticationServices.framework */, A4ABE34829701A4496C5BB64 /* Pods_Mastodon.framework */, 3C030226D3C73DCC23D67452 /* Pods_Mastodon_MastodonUITests.framework */, 452147B2903DF38070FE56A2 /* Pods_MastodonTests.framework */, + 79D7EB88A6A8B34DFDFC96FC /* Pods_Mastodon_NotificationService.framework */, + 46531DECCAB422F507B2274D /* Pods_Mastodon_AppShared.framework */, ); name = Frameworks; sourceTree = ""; }; + 5B24BBD6262DB14800A9381B /* Report */ = { + isa = PBXGroup; + children = ( + 5B24BBD7262DB14800A9381B /* ReportViewModel.swift */, + 5B24BBD8262DB14800A9381B /* ReportViewModel+Diffable.swift */, + 5BB04FEE262F0DCB0043BFF6 /* ReportViewModel+Data.swift */, + 5BB04FD4262E7AFF0043BFF6 /* ReportViewController.swift */, + 5BB04FDA262EA3070043BFF6 /* ReportHeaderView.swift */, + 5B8E055726319E47006E3C53 /* ReportFooterView.swift */, + 5BB04FE8262EFC300043BFF6 /* ReportedStatusTableviewCell.swift */, + ); + path = Report; + sourceTree = ""; + }; + 5B90C455262599800002E742 /* Settings */ = { + isa = PBXGroup; + children = ( + 5B90C457262599800002E742 /* View */, + DB6D9F9626367249008423CD /* SettingsViewController.swift */, + 5B90C456262599800002E742 /* SettingsViewModel.swift */, + ); + path = Settings; + sourceTree = ""; + }; + 5B90C457262599800002E742 /* View */ = { + isa = PBXGroup; + children = ( + 5B90C458262599800002E742 /* Cell */, + 5B90C45C262599800002E742 /* SettingsSectionHeader.swift */, + ); + path = View; + sourceTree = ""; + }; + 5B90C458262599800002E742 /* Cell */ = { + isa = PBXGroup; + children = ( + 5B90C459262599800002E742 /* SettingsToggleTableViewCell.swift */, + 5B90C45A262599800002E742 /* SettingsAppearanceTableViewCell.swift */, + 5B90C45B262599800002E742 /* SettingsLinkTableViewCell.swift */, + ); + path = Cell; + sourceTree = ""; + }; + 5D03938E2612D200007FE196 /* Webview */ = { + isa = PBXGroup; + children = ( + 5D03938F2612D259007FE196 /* WebViewController.swift */, + 5D0393952612D266007FE196 /* WebViewModel.swift */, + ); + path = Webview; + sourceTree = ""; + }; DB01409B25C40BB600F9F3CF /* Onboarding */ = { isa = PBXGroup; children = ( @@ -724,13 +1571,34 @@ DB084B5125CBC56300F898ED /* CoreDataStack */ = { isa = PBXGroup; children = ( + DB084B5625CBC56C00F898ED /* Status.swift */, DB45FAE225CA7181005A8AC7 /* MastodonUser.swift */, - DB084B5625CBC56C00F898ED /* Toot.swift */, DB9D6C3725E508BE0051B173 /* Attachment.swift */, + DB6D9F6E2635807F008423CD /* Setting.swift */, + DB6D9F4826353FD6008423CD /* Subscription.swift */, + DB6D9F4F2635761F008423CD /* SubscriptionAlerts.swift */, + DBAFB7342645463500371D5F /* Emojis.swift */, ); path = CoreDataStack; sourceTree = ""; }; + DB1D187125EF5BBD003F1F23 /* TableView */ = { + isa = PBXGroup; + children = ( + DB1D186B25EF5BA7003F1F23 /* PollTableView.swift */, + ); + path = TableView; + sourceTree = ""; + }; + DB1F239626117C360057430E /* View */ = { + isa = PBXGroup; + children = ( + 2D8434F425FF465D00EECE90 /* HomeTimelineNavigationBarTitleViewModel.swift */, + 2D8434FA25FF46B300EECE90 /* HomeTimelineNavigationBarTitleView.swift */, + ); + path = View; + sourceTree = ""; + }; DB3D0FF725BAA68500EAA174 /* Supporting Files */ = { isa = PBXGroup; children = ( @@ -765,6 +1633,8 @@ DB427DF625BAA00100D1B89D /* MastodonUITests */, DB89B9EF25C10FD0008580ED /* CoreDataStack */, DB89B9FC25C10FD0008580ED /* CoreDataStackTests */, + DBF8AE14263293E400C9C23C /* NotificationService */, + DB6804802637CD4C00430867 /* AppShared */, DB427DD325BAA00100D1B89D /* Products */, 1EBA4F56E920856A3FC84ACB /* Pods */, 3FE14AD363ED19AE7FF210A6 /* Frameworks */, @@ -780,6 +1650,8 @@ DB427DF325BAA00100D1B89D /* MastodonUITests.xctest */, DB89B9EE25C10FD0008580ED /* CoreDataStack.framework */, DB89B9F625C10FD0008580ED /* CoreDataStackTests.xctest */, + DBF8AE13263293E400C9C23C /* NotificationService.appex */, + DB68047F2637CD4C00430867 /* AppShared.framework */, ); name = Products; sourceTree = ""; @@ -794,8 +1666,10 @@ 2D61335525C1886800CAE157 /* Service */, DB8AF55525C1379F002E6C99 /* Scene */, DB8AF54125C13647002E6C99 /* Coordinator */, + DB9E0D6925EDFFE500CFDD76 /* Helper */, DB8AF56225C138BC002E6C99 /* Extension */, 2D5A3D0125CF8640002347D6 /* Vender */, + DB73B495261F030D002E9E9F /* Activity */, DB5086CB25CC0DB400C2C187 /* Preference */, 2D69CFF225CA9E2200C3A1B2 /* Protocol */, DB98338425C945ED00AD9700 /* Generated */, @@ -829,15 +1703,34 @@ DB45FB0925CA87BC005A8AC7 /* CoreData */, 2D61335625C1887F00CAE157 /* Persist */, 2D61335D25C1894B00CAE157 /* APIService.swift */, + DB98337E25C9452D00AD9700 /* APIService+APIError.swift */, DBD9148F25DF6D8D00903DFD /* APIService+Onboarding.swift */, 2DF75BA625D10E1000694EC8 /* APIService+Favorite.swift */, - DB98337E25C9452D00AD9700 /* APIService+APIError.swift */, + DBCCC71D25F73297007E1AB6 /* APIService+Reblog.swift */, DB98336A25C9420100AD9700 /* APIService+App.swift */, DB98337025C9443200AD9700 /* APIService+Authentication.swift */, DB98339B25C96DE600AD9700 /* APIService+Account.swift */, + 2D9DB96A263A91D1007C1D71 /* APIService+DomainBlock.swift */, 2D04F42425C255B9003F936F /* APIService+PublicTimeline.swift */, DB45FB1C25CA9D23005A8AC7 /* APIService+HomeTimeline.swift */, + DB482A4A261340A7008AE74C /* APIService+UserTimeline.swift */, DB0AC6FB25CD02E600D75117 /* APIService+Instance.swift */, + DB59F10D25EF724F001F1DAB /* APIService+Poll.swift */, + DB71FD5125F8CCAA00512AE1 /* APIService+Status.swift */, + DB938F1426241FDF00E5B6C1 /* APIService+Thread.swift */, + DB49A62A25FF36C700B98345 /* APIService+CustomEmoji.swift */, + 2D61254C262547C200299647 /* APIService+Notification.swift */, + DB9A488F26035963008B817C /* APIService+Media.swift */, + 2D34D9D026148D9E0081BFC0 /* APIService+Recommend.swift */, + 2D34D9DA261494120081BFC0 /* APIService+Search.swift */, + 0F202212261351F5000C64BF /* APIService+HashtagTimeline.swift */, + DBCC3B9426157E6E0045B23D /* APIService+Relationship.swift */, + 5B24BBE1262DB19100A9381B /* APIService+Report.swift */, + DBAE3F932616E28B004B8251 /* APIService+Follow.swift */, + 2D8FCA072637EABB00137F46 /* APIService+FollowRequest.swift */, + DBAE3F8D2616E0B1004B8251 /* APIService+Block.swift */, + DBAE3F9D2616E308004B8251 /* APIService+Mute.swift */, + 5B90C48426259BF10002E742 /* APIService+Subscriptions.swift */, ); path = APIService; sourceTree = ""; @@ -845,21 +1738,111 @@ DB45FB0925CA87BC005A8AC7 /* CoreData */ = { isa = PBXGroup; children = ( - 2D69D00925CAA00300C3A1B2 /* APIService+CoreData+Toot.swift */, + 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 */, ); path = CoreData; sourceTree = ""; }; + DB49A61925FF327D00B98345 /* EmojiService */ = { + isa = PBXGroup; + children = ( + DB49A61325FF2C5600B98345 /* EmojiService.swift */, + DB49A61E25FF32AA00B98345 /* EmojiService+CustomEmojiViewModel.swift */, + DB49A62425FF334C00B98345 /* EmojiService+CustomEmojiViewModel+LoadState.swift */, + ); + path = EmojiService; + sourceTree = ""; + }; DB5086CB25CC0DB400C2C187 /* Preference */ = { isa = PBXGroup; children = ( DB5086BD25CC0D9900C2C187 /* SplashPreference.swift */, + DB6D1B3C2636857500ACB481 /* AppearancePreference.swift */, + DBE54AC52636C89F004E7C0B /* NotificationPreference.swift */, ); path = Preference; sourceTree = ""; }; + DB55D32225FB4D320002F825 /* View */ = { + isa = PBXGroup; + children = ( + DBA0A11225FB3FC10079C110 /* ComposeToolbarView.swift */, + DB8190C52601FF0400020C08 /* AttachmentContainerView.swift */, + DB9A486B26032AC1008B817C /* AttachmentContainerView+EmptyStateView.swift */, + DB44767A260B3B8C00B66B82 /* CustomEmojiPickerInputView.swift */, + DB221B15260C395900AEFE46 /* CustomEmojiPickerInputViewModel.swift */, + DBC7A671260C897100E57475 /* StatusContentWarningEditorView.swift */, + ); + path = View; + sourceTree = ""; + }; + DB6180DE263919350018D199 /* MediaPreview */ = { + isa = PBXGroup; + children = ( + DB6180E1263919780018D199 /* Paging */, + DB6180DC263918E30018D199 /* MediaPreviewViewController.swift */, + DB6180F926391F2E0018D199 /* MediaPreviewViewModel.swift */, + ); + path = MediaPreview; + sourceTree = ""; + }; + DB6180E1263919780018D199 /* Paging */ = { + isa = PBXGroup; + children = ( + DB6180F026391CAB0018D199 /* Image */, + DB6180DF2639194B0018D199 /* MediaPreviewPagingViewController.swift */, + ); + path = Paging; + sourceTree = ""; + }; + DB6180E426391A500018D199 /* Transition */ = { + isa = PBXGroup; + children = ( + DB6180E226391A4C0018D199 /* ViewControllerAnimatedTransitioning.swift */, + DB6180E726391B580018D199 /* MediaPreview */, + ); + path = Transition; + sourceTree = ""; + }; + DB6180E726391B580018D199 /* MediaPreview */ = { + isa = PBXGroup; + children = ( + DB6180E526391B550018D199 /* MediaPreviewTransitionController.swift */, + DB6180E826391BDF0018D199 /* MediaHostToMediaPreviewViewControllerAnimatedTransitioning.swift */, + DB6180EA26391C140018D199 /* MediaPreviewTransitionItem.swift */, + DB6180F526391D580018D199 /* MediaPreviewableViewController.swift */, + DB6180F726391D660018D199 /* MediaPreviewingViewController.swift */, + ); + path = MediaPreview; + sourceTree = ""; + }; + DB6180F026391CAB0018D199 /* Image */ = { + isa = PBXGroup; + children = ( + DB6180EE26391CA50018D199 /* MediaPreviewImageViewController.swift */, + DB6180F126391CF40018D199 /* MediaPreviewImageViewModel.swift */, + DB6180F326391D110018D199 /* MediaPreviewImageView.swift */, + ); + path = Image; + sourceTree = ""; + }; + DB6804802637CD4C00430867 /* AppShared */ = { + isa = PBXGroup; + children = ( + DB6804812637CD4C00430867 /* AppShared.h */, + DB6804822637CD4C00430867 /* Info.plist */, + DB6804912637CD8700430867 /* AppName.swift */, + DB6804FC2637CFEC00430867 /* AppSecret.swift */, + DB6804D02637CE4700430867 /* UserDefaults.swift */, + ); + path = AppShared; + sourceTree = ""; + }; DB68A03825E900CC00CFDF14 /* Share */ = { isa = PBXGroup; children = ( @@ -872,11 +1855,25 @@ DB68A04F25E9028800CFDF14 /* NavigationController */ = { isa = PBXGroup; children = ( - DB68A04925E9027700CFDF14 /* DarkContentStatusBarStyleNavigationController.swift */, + DB68A04925E9027700CFDF14 /* AdaptiveStatusBarStyleNavigationController.swift */, ); path = NavigationController; sourceTree = ""; }; + DB6C8C0525F0921200AAA452 /* MastodonSDK */ = { + isa = PBXGroup; + children = ( + DB6C8C0E25F0A6AE00AAA452 /* Mastodon+Entity+Error.swift */, + 5DDDF1922617442700311060 /* Mastodon+Entity+Account.swift */, + 5DDDF1982617447F00311060 /* Mastodon+Entity+Tag.swift */, + 5DDDF1A82617489F00311060 /* Mastodon+Entity+History.swift */, + 2D650FAA25ECDC9300851B58 /* Mastodon+Entity+Error+Detail.swift */, + 2DB72C8B262D764300CE6173 /* Mastodon+Entity+Notification+Type.swift */, + DB6D1B43263691CF00ACB481 /* Mastodon+API+Subscriptions+Policy.swift */, + ); + path = MastodonSDK; + sourceTree = ""; + }; DB72602125E36A2500235243 /* ServerRules */ = { isa = PBXGroup; children = ( @@ -886,6 +1883,50 @@ path = ServerRules; sourceTree = ""; }; + DB73B495261F030D002E9E9F /* Activity */ = { + isa = PBXGroup; + children = ( + DB73B48F261F030A002E9E9F /* SafariActivity.swift */, + ); + path = Activity; + sourceTree = ""; + }; + DB789A1025F9F29B0071ACA0 /* Compose */ = { + isa = PBXGroup; + children = ( + DB55D32225FB4D320002F825 /* View */, + DB789A2125F9F76D0071ACA0 /* CollectionViewCell */, + DB789A0A25F9F2950071ACA0 /* ComposeViewController.swift */, + DB789A1125F9F2CC0071ACA0 /* ComposeViewModel.swift */, + DB66728B25F9F8DC00D60309 /* ComposeViewModel+Diffable.swift */, + DB9A488926034D40008B817C /* ComposeViewModel+PublishState.swift */, + ); + path = Compose; + sourceTree = ""; + }; + DB789A2125F9F76D0071ACA0 /* CollectionViewCell */ = { + isa = PBXGroup; + children = ( + DB789A2A25F9F7AB0071ACA0 /* ComposeRepliedToStatusContentCollectionViewCell.swift */, + DB789A1B25F9F76A0071ACA0 /* ComposeStatusContentCollectionViewCell.swift */, + DB6B351D2601FAEE00DC1E11 /* ComposeStatusAttachmentCollectionViewCell.swift */, + DB87D4442609BE0500D12C0D /* ComposeStatusPollOptionCollectionViewCell.swift */, + DB87D4502609CF1E00D12C0D /* ComposeStatusPollOptionAppendEntryCollectionViewCell.swift */, + DB2FF50F260B113300ADA9FE /* ComposeStatusPollExpiresOptionCollectionViewCell.swift */, + DB447690260B406600B66B82 /* CustomEmojiPickerItemCollectionViewCell.swift */, + DB447696260B439000B66B82 /* CustomEmojiPickerHeaderCollectionReusableView.swift */, + ); + path = CollectionViewCell; + sourceTree = ""; + }; + DB87D45C2609DE6600D12C0D /* TextField */ = { + isa = PBXGroup; + children = ( + DB87D4562609DD5300D12C0D /* DeleteBackwardResponseTextField.swift */, + ); + path = TextField; + sourceTree = ""; + }; DB89B9EF25C10FD0008580ED /* CoreDataStack */ = { isa = PBXGroup; children = ( @@ -924,7 +1965,10 @@ DB89BA2C25C110B7008580ED /* Entity */ = { isa = PBXGroup; children = ( - DB89BA2625C110B4008580ED /* Toot.swift */, + DB89BA2625C110B4008580ED /* Status.swift */, + 2D9DB968263A833E007C1D71 /* DomainBlock.swift */, + 2D6125462625436B00299647 /* Notification.swift */, + 2D0B7A1C261D839600B44727 /* SearchHistory.swift */, DB8AF52425C131D1002E6C99 /* MastodonUser.swift */, DB8AF56725C13E2A002E6C99 /* HomeTimelineIndex.swift */, 2D927F0125C7E4F2004F19B8 /* Mention.swift */, @@ -934,6 +1978,12 @@ 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 */, ); path = Entity; sourceTree = ""; @@ -978,13 +2028,21 @@ isa = PBXGroup; children = ( 2D7631A425C1532200929FB9 /* Share */, + DB6180E426391A500018D199 /* Transition */, DB8AF54E25C13703002E6C99 /* MainTab */, DB01409B25C40BB600F9F3CF /* Onboarding */, 2D38F1D325CD463600561493 /* HomeTimeline */, 2D76316325C14BAC00929FB9 /* PublicTimeline */, + 5B24BBD6262DB14800A9381B /* Report */, + 0F2021F5261325ED000C64BF /* HashtagTimeline */, + 2DAC9E36262FC20B0062E1A6 /* SuggestionAccount */, DB9D6BEE25E4F5370051B173 /* Search */, + 5B90C455262599800002E742 /* Settings */, DB9D6BFD25E4F57B0051B173 /* Notification */, DB9D6C0825E4F5A60051B173 /* Profile */, + DB789A1025F9F29B0071ACA0 /* Compose */, + DB938EEB2623F52600E5B6C1 /* Thread */, + DB6180DE263919350018D199 /* MediaPreview */, ); path = Scene; sourceTree = ""; @@ -993,29 +2051,60 @@ isa = PBXGroup; children = ( DB084B5125CBC56300F898ED /* CoreDataStack */, - DB0140CE25C42AEE00F9F3CF /* OSLog.swift */, - 2D3F9E0325DFA133004262D9 /* UITapGestureRecognizer.swift */, - DB8AF55C25C138B7002E6C99 /* UIViewController.swift */, - 2D46975D25C2A54100CF4AA9 /* NSLayoutConstraint.swift */, - 2D46976325C2A71500CF4AA9 /* UIIamge.swift */, + DB6C8C0525F0921200AAA452 /* MastodonSDK */, + DB44384E25E8C1FA008912A2 /* CALayer.swift */, 2DF123A625C3B0210020F248 /* ActiveLabel.swift */, + 2D46975D25C2A54100CF4AA9 /* NSLayoutConstraint.swift */, DB68586325E619B700F0A850 /* NSKeyValueObservation.swift */, - 2D42FF6A25C817D2004A627A /* MastodonContent.swift */, + DB0140CE25C42AEE00F9F3CF /* OSLog.swift */, DB68A06225E905E000CFDF14 /* UIApplication.swift */, DB45FAB525CA5485005A8AC7 /* UIAlertController.swift */, 2D42FF8E25C8228A004A627A /* UIButton.swift */, DB45FAD625CA6C76005A8AC7 /* UIBarButtonItem.swift */, - 2D32EAB925CB9B0500C9ED86 /* UIView.swift */, + DB4481B825EE289600BEFB67 /* UITableView.swift */, 0FAA101B25E10E760017CCDE /* UIFont.swift */, + 2D939AB425EDD8A90076FA61 /* String.swift */, + 2D206B7F25F5F45E00143C56 /* UIImage.swift */, + DBCC3B88261454BA0045B23D /* CGImage.swift */, + 2D206B8525F5FB0900143C56 /* Double.swift */, + 2D206B9125F60EA700143C56 /* UIControl.swift */, + 5DF1056325F887CB00D6C0D4 /* AVPlayer.swift */, + DB47229625F9EFAD00DA7F53 /* NSManagedObjectContext.swift */, + 2D32EAB925CB9B0500C9ED86 /* UIView.swift */, + 5DA732CB2629CEF500A92342 /* UIView+Remove.swift */, + DB8AF55C25C138B7002E6C99 /* UIViewController.swift */, + 2D24E1222626ED9D00A59D4F /* UIView+Gesture.swift */, + 2D3F9E0325DFA133004262D9 /* UITapGestureRecognizer.swift */, + 2D84350425FF858100EECE90 /* UIScrollView.swift */, + DB9E0D6E25EE008500CFDD76 /* UIInterpolatingMotionEffect.swift */, + 0F20223826146553000C64BF /* Array.swift */, + DBCC3B2F261440A50045B23D /* UITabBarController.swift */, + DBCC3B35261440BA0045B23D /* UINavigationController.swift */, + DB6D1B23263684C600ACB481 /* UserDefaults.swift */, ); path = Extension; sourceTree = ""; }; + DB938EEB2623F52600E5B6C1 /* Thread */ = { + isa = PBXGroup; + children = ( + DB938EE52623F50700E5B6C1 /* ThreadViewController.swift */, + DB938F24262438D600E5B6C1 /* ThreadViewController+Provider.swift */, + DB938EEC2623F79B00E5B6C1 /* ThreadViewModel.swift */, + DB938F1E2624382F00E5B6C1 /* ThreadViewModel+Diffable.swift */, + DB938F0E2624119800E5B6C1 /* ThreadViewModel+LoadThreadState.swift */, + DB938F0226240EA300E5B6C1 /* CachedThreadViewModel.swift */, + DB938F0826240F3C00E5B6C1 /* RemoteThreadViewModel.swift */, + ); + path = Thread; + sourceTree = ""; + }; DB98335F25C93B0400AD9700 /* Recovered References */ = { isa = PBXGroup; children = ( CD92E0F10BDE4FE7C4B999F2 /* Pods_MastodonTests.framework */, 2DA7D05025CA545E00804E11 /* LoadMoreConfigurableTableViewContainer.swift */, + DB1FD45F25F278AF004CFCFC /* CategoryPickerSection.swift */, ); name = "Recovered References"; sourceTree = ""; @@ -1029,10 +2118,27 @@ path = Generated; sourceTree = ""; }; + DB9A489B26036E19008B817C /* MastodonAttachmentService */ = { + isa = PBXGroup; + children = ( + DB6B35172601FA3400DC1E11 /* MastodonAttachmentService.swift */, + DB9A48952603685D008B817C /* MastodonAttachmentService+UploadState.swift */, + ); + path = MastodonAttachmentService; + sourceTree = ""; + }; DB9D6BEE25E4F5370051B173 /* Search */ = { isa = PBXGroup; children = ( + 2DFAD5212616F8E300F9EE7C /* TableViewCell */, + 2DE0FAC62615F5D200CDF649 /* View */, DB9D6BE825E4F5340051B173 /* SearchViewController.swift */, + 2D34D9CA261489930081BFC0 /* SearchViewController+Recommend.swift */, + 2DFAD5262616F9D300F9EE7C /* SearchViewController+Searching.swift */, + 5DFC35DE262068D20045711D /* SearchViewController+Follow.swift */, + 2D6DE3FF26141DF600A63F6A /* SearchViewModel.swift */, + 2D198654261C3C4300F0B013 /* SearchViewModel+LoadOldestState.swift */, + 2D34D9E026149C550081BFC0 /* CollectionViewCell */, ); path = Search; sourceTree = ""; @@ -1041,6 +2147,11 @@ isa = PBXGroup; children = ( DB9D6BF725E4F5690051B173 /* NotificationViewController.swift */, + 2D607AD726242FC500B70763 /* NotificationViewModel.swift */, + 2D084B8C26258EA3003AA3AF /* NotificationViewModel+diffable.swift */, + 2D084B9226259545003AA3AF /* NotificationViewModel+LoadLatestState.swift */, + 2D24E12C2626FD2E00A59D4F /* NotificationViewModel+LoadOldestState.swift */, + 2D35237F26256F470031AF25 /* TableViewCell */, ); path = Notification; sourceTree = ""; @@ -1048,7 +2159,16 @@ DB9D6C0825E4F5A60051B173 /* Profile */ = { isa = PBXGroup; children = ( + DBB525132611EBB1002F1F29 /* Segmented */, + DBB525462611ED57002F1F29 /* Header */, + DBB5253B2611ECF5002F1F29 /* Timeline */, + DBE3CDF1261C6B3100430CC6 /* Favorite */, DB9D6BFE25E4F5940051B173 /* ProfileViewController.swift */, + DBAE3F812615DDA3004B8251 /* ProfileViewController+UserProvider.swift */, + DBB5255D2611F07A002F1F29 /* ProfileViewModel.swift */, + DBCC3B8E26148F7B0045B23D /* CachedProfileViewModel.swift */, + DBAE3FAE26172FC0004B8251 /* RemoteProfileViewModel.swift */, + DBB525632612C988002F1F29 /* MeProfileViewModel.swift */, ); path = Profile; sourceTree = ""; @@ -1057,6 +2177,10 @@ isa = PBXGroup; children = ( DB9D6C0D25E4F9780051B173 /* MosaicImageViewContainer.swift */, + 2D206B7125F5D27F00143C56 /* AudioContainerView.swift */, + 5DF1057825F88A1D00D6C0D4 /* PlayerContainerView.swift */, + DB49A63C25FF609300B98345 /* PlayerContainerView+MediaTypeIndicotorView.swift */, + 5DF1057E25F88A4100D6C0D4 /* TouchBlockingView.swift */, ); path = Container; sourceTree = ""; @@ -1065,22 +2189,173 @@ isa = PBXGroup; children = ( DB9D6C2225E502C60051B173 /* MosaicImageViewModel.swift */, + 2DA6055025F74407006356F9 /* AudioContainerViewModel.swift */, + 5DF1054625F8870E00D6C0D4 /* VideoPlayerViewModel.swift */, ); path = ViewModel; sourceTree = ""; }; + DB9E0D6925EDFFE500CFDD76 /* Helper */ = { + isa = PBXGroup; + children = ( + 2D42FF6A25C817D2004A627A /* MastodonStatusContent.swift */, + DB35FC2E26130172006193C9 /* MastodonField.swift */, + DBAE3FA82617106E004B8251 /* MastodonMetricFormatter.swift */, + ); + path = Helper; + sourceTree = ""; + }; + DBA5E7A6263BD298004598BB /* ContextMenu */ = { + isa = PBXGroup; + children = ( + DBA5E7A7263BD29F004598BB /* ImagePreview */, + ); + path = ContextMenu; + sourceTree = ""; + }; + DBA5E7A7263BD29F004598BB /* ImagePreview */ = { + isa = PBXGroup; + children = ( + DBA5E7A4263BD28C004598BB /* ContextMenuImagePreviewViewModel.swift */, + DBA5E7A8263BD3A4004598BB /* ContextMenuImagePreviewViewController.swift */, + DBA5E7AA263BD3F5004598BB /* TimelineTableViewCellContextMenuConfiguration.swift */, + ); + path = ImagePreview; + sourceTree = ""; + }; + DBA9B90325F1D4420012E7B6 /* Control */ = { + isa = PBXGroup; + children = ( + DB71FD2B25F86A5100512AE1 /* AvatarStackContainerButton.swift */, + DB59F11725EFA35B001F1DAB /* StripProgressView.swift */, + DB02CDBE2625AE5000D0A2AF /* AdaptiveUserInterfaceStyleBarButtonItem.swift */, + ); + path = Control; + sourceTree = ""; + }; + DBABE3F125ECAC4E00879EE5 /* View */ = { + isa = PBXGroup; + children = ( + DBABE3EB25ECAC4B00879EE5 /* WelcomeIllustrationView.swift */, + ); + path = View; + sourceTree = ""; + }; + DBAE3F742615DD63004B8251 /* UserProvider */ = { + isa = PBXGroup; + children = ( + DBAE3F672615DD60004B8251 /* UserProvider.swift */, + DBAE3F872615DDF4004B8251 /* UserProviderFacade.swift */, + ); + path = UserProvider; + sourceTree = ""; + }; + DBB525132611EBB1002F1F29 /* Segmented */ = { + isa = PBXGroup; + children = ( + DBB525262611EBDA002F1F29 /* Paging */, + DBB5250D2611EBAF002F1F29 /* ProfileSegmentedViewController.swift */, + ); + path = Segmented; + sourceTree = ""; + }; + DBB525262611EBDA002F1F29 /* Paging */ = { + isa = PBXGroup; + children = ( + DBB525202611EBD6002F1F29 /* ProfilePagingViewController.swift */, + DBB5252F2611EBF3002F1F29 /* ProfilePagingViewModel.swift */, + ); + path = Paging; + sourceTree = ""; + }; + DBB5253B2611ECF5002F1F29 /* Timeline */ = { + isa = PBXGroup; + children = ( + DBB525352611ECEB002F1F29 /* UserTimelineViewController.swift */, + DB482A44261335BA008AE74C /* UserTimelineViewController+Provider.swift */, + DBB525552611EDCA002F1F29 /* UserTimelineViewModel.swift */, + DBCBED1626132DB500B49291 /* UserTimelineViewModel+Diffable.swift */, + DB482A3E261331E8008AE74C /* UserTimelineViewModel+State.swift */, + ); + path = Timeline; + sourceTree = ""; + }; + DBB525462611ED57002F1F29 /* Header */ = { + isa = PBXGroup; + children = ( + DBB525732612D5A5002F1F29 /* View */, + DBB525402611ED54002F1F29 /* ProfileHeaderViewController.swift */, + DB7F48442620241000796008 /* ProfileHeaderViewModel.swift */, + ); + path = Header; + sourceTree = ""; + }; + DBB525732612D5A5002F1F29 /* View */ = { + isa = PBXGroup; + children = ( + DBB5254F2611ED6D002F1F29 /* ProfileHeaderView.swift */, + DBB5256D2612D5A1002F1F29 /* ProfileStatusDashboardView.swift */, + DBB525842612D6DD002F1F29 /* ProfileStatusDashboardMeterView.swift */, + DB35FC1E2612F1D9006193C9 /* ProfileRelationshipActionButton.swift */, + DB35FC242612FD7A006193C9 /* ProfileFieldView.swift */, + ); + path = View; + sourceTree = ""; + }; + DBCBED2226132E1D00B49291 /* FetchedResultsController */ = { + isa = PBXGroup; + children = ( + DBCBED1C26132E1A00B49291 /* StatusFetchedResultsController.swift */, + DB6D9F75263587C7008423CD /* SettingFetchedResultController.swift */, + ); + path = FetchedResultsController; + sourceTree = ""; + }; DBE0821A25CD382900FD6BBD /* Register */ = { isa = PBXGroup; children = ( DBE0821425CD382600FD6BBD /* MastodonRegisterViewController.swift */, + 2D939AE725EE1CF80076FA61 /* MastodonRegisterViewController+Avatar.swift */, DBE0822325CD3F1E00FD6BBD /* MastodonRegisterViewModel.swift */, ); path = Register; sourceTree = ""; }; + DBE3CDF1261C6B3100430CC6 /* Favorite */ = { + isa = PBXGroup; + children = ( + DBE3CDEB261C6B2900430CC6 /* FavoriteViewController.swift */, + DBE3CE0C261D767100430CC6 /* FavoriteViewController+Provider.swift */, + DBE3CDFA261C6CA500430CC6 /* FavoriteViewModel.swift */, + DBE3CE06261D6A0E00430CC6 /* FavoriteViewModel+Diffable.swift */, + DBE3CE00261D623D00430CC6 /* FavoriteViewModel+State.swift */, + ); + path = Favorite; + sourceTree = ""; + }; + DBF8AE14263293E400C9C23C /* NotificationService */ = { + isa = PBXGroup; + children = ( + DB68053E2638011000430867 /* NotificationService.entitlements */, + DBF8AE15263293E400C9C23C /* NotificationService.swift */, + DB6D9F3426351B7A008423CD /* NotificationService+Decrypt.swift */, + DB68045A2636DC6A00430867 /* MastodonNotification.swift */, + DBF8AE17263293E400C9C23C /* Info.plist */, + ); + path = NotificationService; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXHeadersBuildPhase section */ + DB68047A2637CD4C00430867 /* Headers */ = { + isa = PBXHeadersBuildPhase; + buildActionMask = 2147483647; + files = ( + DB6804832637CD4C00430867 /* AppShared.h in Headers */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; DB89B9E925C10FD0008580ED /* Headers */ = { isa = PBXHeadersBuildPhase; buildActionMask = 2147483647; @@ -1103,11 +2378,15 @@ DB427DD025BAA00100D1B89D /* Resources */, 5532CB85BBE168B25B20720B /* [CP] Embed Pods Frameworks */, DB89BA0825C10FD0008580ED /* Embed Frameworks */, + DBF8AE1B263293E400C9C23C /* Embed App Extensions */, ); buildRules = ( ); dependencies = ( DB89BA0225C10FD0008580ED /* PBXTargetDependency */, + DBF8AE19263293E400C9C23C /* PBXTargetDependency */, + DB6804852637CD4C00430867 /* PBXTargetDependency */, + DB6804CA2637CE3000430867 /* PBXTargetDependency */, ); name = Mastodon; packageProductDependencies = ( @@ -1118,6 +2397,10 @@ DB0140BC25C40D7500F9F3CF /* CommonOSLog */, DB5086B725CC0D6400C2C187 /* Kingfisher */, 2D5981B925E4D7F8000FB903 /* ThirdPartyMailer */, + 2D939AC725EE14620076FA61 /* CropViewController */, + DB9A487D2603456B008B817C /* UITextView+Placeholder */, + DBB525072611EAC0002F1F29 /* Tabman */, + DB35B0B22643D821006AC73B /* TwitterTextEditor */, ); productName = Mastodon; productReference = DB427DD225BAA00100D1B89D /* Mastodon.app */; @@ -1162,6 +2445,28 @@ productReference = DB427DF325BAA00100D1B89D /* MastodonUITests.xctest */; productType = "com.apple.product-type.bundle.ui-testing"; }; + DB68047E2637CD4C00430867 /* AppShared */ = { + isa = PBXNativeTarget; + buildConfigurationList = DB6804882637CD4C00430867 /* Build configuration list for PBXNativeTarget "AppShared" */; + buildPhases = ( + C6B7D3A8ACD77F6620D0E0AD /* [CP] Check Pods Manifest.lock */, + DB68047A2637CD4C00430867 /* Headers */, + DB68047B2637CD4C00430867 /* Sources */, + DB68047C2637CD4C00430867 /* Frameworks */, + DB68047D2637CD4C00430867 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = AppShared; + packageProductDependencies = ( + DB68050F2637D0F800430867 /* KeychainAccess */, + ); + productName = AppShared; + productReference = DB68047F2637CD4C00430867 /* AppShared.framework */; + productType = "com.apple.product-type.framework"; + }; DB89B9ED25C10FD0008580ED /* CoreDataStack */ = { isa = PBXNativeTarget; buildConfigurationList = DB89BA0525C10FD0008580ED /* Build configuration list for PBXNativeTarget "CoreDataStack" */; @@ -1174,6 +2479,7 @@ buildRules = ( ); dependencies = ( + DB6805292637D7DD00430867 /* PBXTargetDependency */, ); name = CoreDataStack; productName = CoreDataStack; @@ -1199,6 +2505,30 @@ productReference = DB89B9F625C10FD0008580ED /* CoreDataStackTests.xctest */; productType = "com.apple.product-type.bundle.unit-test"; }; + DBF8AE12263293E400C9C23C /* NotificationService */ = { + isa = PBXNativeTarget; + buildConfigurationList = DBF8AE1E263293E400C9C23C /* Build configuration list for PBXNativeTarget "NotificationService" */; + buildPhases = ( + 0DC740704503CA6BED56F5C8 /* [CP] Check Pods Manifest.lock */, + DBF8AE0F263293E400C9C23C /* Sources */, + DBF8AE10263293E400C9C23C /* Frameworks */, + DBF8AE11263293E400C9C23C /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + DB6804A82637CDCC00430867 /* PBXTargetDependency */, + ); + name = NotificationService; + packageProductDependencies = ( + DBF8AE852632992800C9C23C /* Base85 */, + DB00CA962632DDB600A54956 /* CommonOSLog */, + DB6D9F41263527CE008423CD /* AlamofireImage */, + ); + productName = NotificationService; + productReference = DBF8AE13263293E400C9C23C /* NotificationService.appex */; + productType = "com.apple.product-type.app-extension"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -1220,6 +2550,10 @@ CreatedOnToolsVersion = 12.4; TestTargetID = DB427DD125BAA00100D1B89D; }; + DB68047E2637CD4C00430867 = { + CreatedOnToolsVersion = 12.4; + LastSwiftMigration = 1240; + }; DB89B9ED25C10FD0008580ED = { CreatedOnToolsVersion = 12.4; LastSwiftMigration = 1240; @@ -1228,6 +2562,9 @@ CreatedOnToolsVersion = 12.4; TestTargetID = DB427DD125BAA00100D1B89D; }; + DBF8AE12263293E400C9C23C = { + CreatedOnToolsVersion = 12.4; + }; }; }; buildConfigurationList = DB427DCD25BAA00100D1B89D /* Build configuration list for PBXProject "Mastodon" */; @@ -1237,6 +2574,7 @@ knownRegions = ( en, Base, + ar, ); mainGroup = DB427DC925BAA00100D1B89D; packageReferences = ( @@ -1246,6 +2584,12 @@ DB0140BB25C40D7500F9F3CF /* XCRemoteSwiftPackageReference "CommonOSLog" */, DB5086B625CC0D6400C2C187 /* XCRemoteSwiftPackageReference "Kingfisher" */, 2D5981B825E4D7F8000FB903 /* XCRemoteSwiftPackageReference "ThirdPartyMailer" */, + 2D939AC625EE14620076FA61 /* XCRemoteSwiftPackageReference "TOCropViewController" */, + DB9A487C2603456B008B817C /* XCRemoteSwiftPackageReference "UITextView-Placeholder" */, + DBB525062611EAC0002F1F29 /* XCRemoteSwiftPackageReference "Tabman" */, + DBF8AE842632992700C9C23C /* XCRemoteSwiftPackageReference "Base85" */, + DB6804722637CC1200430867 /* XCRemoteSwiftPackageReference "KeychainAccess" */, + DB35B0B12643D821006AC73B /* XCRemoteSwiftPackageReference "TwitterTextEditor" */, ); productRefGroup = DB427DD325BAA00100D1B89D /* Products */; projectDirPath = ""; @@ -1256,6 +2600,8 @@ DB427DF225BAA00100D1B89D /* MastodonUITests */, DB89B9ED25C10FD0008580ED /* CoreDataStack */, DB89B9F525C10FD0008580ED /* CoreDataStackTests */, + DBF8AE12263293E400C9C23C /* NotificationService */, + DB68047E2637CD4C00430867 /* AppShared */, ); }; /* End PBXProject section */ @@ -1289,6 +2635,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + DB68047D2637CD4C00430867 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; DB89B9EC25C10FD0008580ED /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -1303,9 +2656,38 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + DBF8AE11263293E400C9C23C /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ + 0DC740704503CA6BED56F5C8 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-Mastodon-NotificationService-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + 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; + }; 5532CB85BBE168B25B20720B /* [CP] Embed Pods Frameworks */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; @@ -1367,6 +2749,28 @@ 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; }; + C6B7D3A8ACD77F6620D0E0AD /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-Mastodon-AppShared-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + 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; + }; DB3D100425BAA71500EAA174 /* ShellScript */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; @@ -1430,115 +2834,360 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + DBB525212611EBD6002F1F29 /* ProfilePagingViewController.swift in Sources */, + DB35FC2F26130172006193C9 /* MastodonField.swift in Sources */, + DB6180EB26391C140018D199 /* MediaPreviewTransitionItem.swift in Sources */, DB98337125C9443200AD9700 /* APIService+Authentication.swift in Sources */, + DBE3CDCF261C42ED00430CC6 /* TimelineHeaderView.swift in Sources */, + DBAE3F8E2616E0B1004B8251 /* APIService+Block.swift in Sources */, + 5DF1057F25F88A4100D6C0D4 /* TouchBlockingView.swift in Sources */, 0FAA0FDF25E0B57E0017CCDE /* WelcomeViewController.swift in Sources */, + 2D206B8C25F6015000143C56 /* AudioPlaybackService.swift in Sources */, 2D59819B25E4A581000FB903 /* MastodonConfirmEmailViewController.swift in Sources */, DB45FB1D25CA9D23005A8AC7 /* APIService+HomeTimeline.swift in Sources */, + DB49A61425FF2C5600B98345 /* EmojiService.swift in Sources */, + DBCC3B9526157E6E0045B23D /* APIService+Relationship.swift in Sources */, 2D7631B325C159F700929FB9 /* Item.swift in Sources */, + 5DF1054125F886D400D6C0D4 /* ViedeoPlaybackService.swift in Sources */, + DB6B35182601FA3400DC1E11 /* MastodonAttachmentService.swift in Sources */, 0FB3D2F725E4C24D00AAD544 /* MastodonPickServerViewModel.swift in Sources */, 2D61335E25C1894B00CAE157 /* APIService.swift in Sources */, + 2D9DB967263A76FB007C1D71 /* BlockDomainService.swift in Sources */, + DB66729625F9F91600D60309 /* ComposeStatusSection.swift in Sources */, + DB482A3F261331E8008AE74C /* UserTimelineViewModel+State.swift in Sources */, 2D38F1F725CD47AC00561493 /* HomeTimelineViewModel+LoadOldestState.swift in Sources */, + 2DFAD5372617010500F9EE7C /* SearchingTableViewCell.swift in Sources */, + DB447681260B3ED600B66B82 /* CustomEmojiPickerSection.swift in Sources */, + 5BB04FEF262F0DCB0043BFF6 /* ReportViewModel+Data.swift in Sources */, + 5B8E055826319E47006E3C53 /* ReportFooterView.swift in Sources */, + DBB525502611ED6D002F1F29 /* ProfileHeaderView.swift in Sources */, 0FB3D33225E5F50E00AAD544 /* PickServerSearchCell.swift in Sources */, + DB71FD5225F8CCAA00512AE1 /* APIService+Status.swift in Sources */, + 5D0393962612D266007FE196 /* WebViewModel.swift in Sources */, + 5B24BBDA262DB14800A9381B /* ReportViewModel.swift in Sources */, 2D5A3D3825CF8D9F002347D6 /* ScrollViewContainer.swift in Sources */, + DB6180EF26391CA50018D199 /* MediaPreviewImageViewController.swift in Sources */, + DB1E347825F519300079D7DF /* PickServerItem.swift in Sources */, + DB1FD45A25F27898004CFCFC /* CategoryPickerItem.swift in Sources */, + DB6180F626391D580018D199 /* MediaPreviewableViewController.swift in Sources */, + 2D571B2F26004EC000540450 /* NavigationBarProgressView.swift in Sources */, 0FAA101225E105390017CCDE /* PrimaryActionButton.swift in Sources */, DB8AF53025C13561002E6C99 /* AppContext.swift in Sources */, + DB92CF7225E7BB98002C1017 /* PollOptionTableViewCell.swift in Sources */, DB72602725E36A6F00235243 /* MastodonServerRulesViewModel.swift in Sources */, 2D364F7225E66D7500204FDC /* MastodonResendEmailViewController.swift in Sources */, 2D38F1F125CD477D00561493 /* HomeTimelineViewModel+LoadMiddleState.swift in Sources */, DB68A06325E905E000CFDF14 /* UIApplication.swift in Sources */, + DB02CDAB26256A9500D0A2AF /* ThreadReplyLoaderTableViewCell.swift in Sources */, + DBB5255E2611F07A002F1F29 /* ProfileViewModel.swift in Sources */, + 2D8434FB25FF46B300EECE90 /* HomeTimelineNavigationBarTitleView.swift in Sources */, + 0F1E2D0B2615C39400C38565 /* DoubleTitleLabelNavigationBarTitleView.swift in Sources */, + DB9A488A26034D40008B817C /* ComposeViewModel+PublishState.swift in Sources */, DB45FAD725CA6C76005A8AC7 /* UIBarButtonItem.swift in Sources */, + 2DA504692601ADE7008F4E6C /* SawToothView.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 */, + 2D607AD826242FC500B70763 /* NotificationViewModel.swift in Sources */, + DBA0A10925FB3C2B0079C110 /* RoundedEdgesButton.swift in Sources */, + DBABE3EC25ECAC4B00879EE5 /* WelcomeIllustrationView.swift in Sources */, + DB71FD4625F8C6D200512AE1 /* StatusProvider+UITableViewDataSourcePrefetching.swift in Sources */, + 2D8FCA082637EABB00137F46 /* APIService+FollowRequest.swift in Sources */, 2D152A8C25C295CC009AA50C /* StatusView.swift in Sources */, + 2DE0FAC82615F5F000CDF649 /* SearchRecommendAccountsCollectionViewCell.swift in Sources */, + 2DFAD5272616F9D300F9EE7C /* SearchViewController+Searching.swift in Sources */, 2D42FF8525C8224F004A627A /* HitTestExpandedButton.swift in Sources */, DB72601C25E36A2100235243 /* MastodonServerRulesViewController.swift in Sources */, + DBB5250E2611EBAF002F1F29 /* ProfileSegmentedViewController.swift in Sources */, 2D42FF8F25C8228A004A627A /* UIButton.swift in Sources */, + DB789A0B25F9F2950071ACA0 /* ComposeViewController.swift in Sources */, + DB938F0926240F3C00E5B6C1 /* RemoteThreadViewModel.swift in Sources */, + DB75BF1E263C1C1B00EDBF1F /* CustomScheduler.swift in Sources */, 0FAA102725E1126A0017CCDE /* MastodonPickServerViewController.swift in Sources */, + DB59F0FE25EF5D96001F1DAB /* StatusProvider+UITableViewDelegate.swift in Sources */, DB68586425E619B700F0A850 /* NSKeyValueObservation.swift in Sources */, - 2D61335825C188A000CAE157 /* APIService+Persist+Timeline.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 */, + 2D24E1232626ED9D00A59D4F /* UIView+Gesture.swift in Sources */, DB45FAE325CA7181005A8AC7 /* MastodonUser.swift in Sources */, + DB2FF510260B113300ADA9FE /* ComposeStatusPollExpiresOptionCollectionViewCell.swift in Sources */, + 0F202213261351F5000C64BF /* APIService+HashtagTimeline.swift in Sources */, DB0AC6FC25CD02E600D75117 /* APIService+Instance.swift in Sources */, - 2D69D00A25CAA00300C3A1B2 /* APIService+CoreData+Toot.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 */, + DB49A62B25FF36C700B98345 /* APIService+CustomEmoji.swift in Sources */, + DBCBED1D26132E1A00B49291 /* StatusFetchedResultsController.swift in Sources */, + 2D79E701261EA5550011E398 /* APIService+CoreData+Tag.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 */, + 2D939AB525EDD8A90076FA61 /* String.swift in Sources */, + DB4481B925EE289600BEFB67 /* UITableView.swift in Sources */, + 2D35238126256F690031AF25 /* NotificationTableViewCell.swift in Sources */, + DBE3CDBB261C427900430CC6 /* TimelineHeaderTableViewCell.swift in Sources */, 0FAA101C25E10E760017CCDE /* UIFont.swift in Sources */, 2D38F1D525CD465300561493 /* HomeTimelineViewController.swift in Sources */, DB98338825C945ED00AD9700 /* Assets.swift in Sources */, + DB6180E926391BDF0018D199 /* MediaHostToMediaPreviewViewControllerAnimatedTransitioning.swift in Sources */, + DB6B351E2601FAEE00DC1E11 /* ComposeStatusAttachmentCollectionViewCell.swift in Sources */, 2DA7D04425CA52B200804E11 /* TimelineLoaderTableViewCell.swift in Sources */, + DB87D44B2609C11900D12C0D /* PollOptionView.swift in Sources */, + 2D4AD8A826316D3500613EFC /* SelectedAccountItem.swift in Sources */, + DBE3CDFB261C6CA500430CC6 /* FavoriteViewModel.swift in Sources */, DB8AF52F25C13561002E6C99 /* DocumentStore.swift in Sources */, + 2D206B7225F5D27F00143C56 /* AudioContainerView.swift in Sources */, DB9D6C2425E502C60051B173 /* MosaicImageViewModel.swift in Sources */, + DBE3CE01261D623D00430CC6 /* FavoriteViewModel+State.swift in Sources */, 2D82BA0525E7897700E36F0F /* MastodonResendEmailViewModelNavigationDelegateShim.swift in Sources */, + DB55D33025FB630A0002F825 /* TwitterTextEditor+String.swift in Sources */, 2D38F1EB25CD477000561493 /* HomeTimelineViewModel+LoadLatestState.swift in Sources */, + 0F202201261326E6000C64BF /* HashtagTimelineViewModel.swift in Sources */, + DB6D9F9726367249008423CD /* SettingsViewController.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 */, + DB0F8150264D1E2500F2A12B /* PickServerLoaderTableViewCell.swift in Sources */, DB98337F25C9452D00AD9700 /* APIService+APIError.swift in Sources */, + DB9E0D6F25EE008500CFDD76 /* UIInterpolatingMotionEffect.swift in Sources */, + DBB9759C262462E1004620BD /* ThreadMetaView.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 */, + DBA5E7AB263BD3F5004598BB /* TimelineTableViewCellContextMenuConfiguration.swift in Sources */, + DB73B490261F030A002E9E9F /* SafariActivity.swift in Sources */, + DB6D1B44263691CF00ACB481 /* Mastodon+API+Subscriptions+Policy.swift in Sources */, + DB9A48962603685D008B817C /* MastodonAttachmentService+UploadState.swift in Sources */, + 2D198643261BF09500F0B013 /* SearchResultItem.swift in Sources */, + 2DAC9E38262FC2320062E1A6 /* SuggestionAccountViewController.swift in Sources */, + DB66728C25F9F8DC00D60309 /* ComposeViewModel+Diffable.swift in Sources */, + DB6180E02639194B0018D199 /* MediaPreviewPagingViewController.swift in Sources */, DBE0822425CD3F1E00FD6BBD /* MastodonRegisterViewModel.swift in Sources */, 2DF75B9B25D0E27500694EC8 /* StatusProviderFacade.swift in Sources */, DB5086A525CC0B7000C2C187 /* AvatarBarButtonItem.swift in Sources */, + 5B90C45E262599800002E742 /* SettingsViewModel.swift in Sources */, 2D82B9FF25E7863200E36F0F /* OnboardingViewControllerAppearance.swift in Sources */, + 5DF1054725F8870E00D6C0D4 /* VideoPlayerViewModel.swift in Sources */, + 2DE0FAC12615F04D00CDF649 /* RecommendHashTagSection.swift in Sources */, + DBA5E7A5263BD28C004598BB /* ContextMenuImagePreviewViewModel.swift in Sources */, 2D38F1E525CD46C100561493 /* HomeTimelineViewModel.swift in Sources */, + DBCC3B36261440BA0045B23D /* UINavigationController.swift in Sources */, + DBB525852612D6DD002F1F29 /* ProfileStatusDashboardMeterView.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 */, 0FB3D2FE25E4CB6400AAD544 /* PickServerTitleCell.swift in Sources */, - 2D38F1DF25CD46A400561493 /* HomeTimelineViewController+StatusProvider.swift in Sources */, + 5DA732CC2629CEF500A92342 /* UIView+Remove.swift in Sources */, + 2D38F1DF25CD46A400561493 /* HomeTimelineViewController+Provider.swift in Sources */, + 2D206B9225F60EA700143C56 /* UIControl.swift in Sources */, + 2D9DB96B263A91D1007C1D71 /* APIService+DomainBlock.swift in Sources */, 2D46975E25C2A54100CF4AA9 /* NSLayoutConstraint.swift in Sources */, 2D45E5BF25C9549700A6D639 /* PublicTimelineViewModel+State.swift in Sources */, - 2D46976425C2A71500CF4AA9 /* UIIamge.swift in Sources */, DB8AF55D25C138B7002E6C99 /* UIViewController.swift in Sources */, + DB7F48452620241000796008 /* ProfileHeaderViewModel.swift in Sources */, 2D3F9E0425DFA133004262D9 /* UITapGestureRecognizer.swift in Sources */, + 5DDDF1992617447F00311060 /* Mastodon+Entity+Tag.swift in Sources */, + 5B90C45F262599800002E742 /* SettingsToggleTableViewCell.swift in Sources */, + 2D694A7425F9EB4E0038ADDC /* ContentWarningOverlayView.swift in Sources */, + 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 */, + DB44767B260B3B8C00B66B82 /* CustomEmojiPickerInputView.swift in Sources */, + 0F20222D261457EE000C64BF /* HashtagTimelineViewModel+LoadOldestState.swift in Sources */, DB4563BD25E11A24004DA0B9 /* KeyboardResponderService.swift in Sources */, DB5086BE25CC0D9900C2C187 /* SplashPreference.swift in Sources */, - 2DF75BA125D0E29D00694EC8 /* StatusProvider+TimelinePostTableViewCellDelegate.swift in Sources */, + 5B90C462262599800002E742 /* SettingsSectionHeader.swift in Sources */, + DB44768B260B3F2100B66B82 /* CustomEmojiPickerItem.swift in Sources */, + 2DF75BA125D0E29D00694EC8 /* StatusProvider+StatusTableViewCellDelegate.swift in Sources */, + 5DF1056425F887CB00D6C0D4 /* AVPlayer.swift in Sources */, + 2DA6054725F716A2006356F9 /* PlaybackState.swift in Sources */, + DB35FC1F2612F1D9006193C9 /* ProfileRelationshipActionButton.swift in Sources */, + DBC7A672260C897100E57475 /* StatusContentWarningEditorView.swift in Sources */, + DB59F10E25EF724F001F1DAB /* APIService+Poll.swift in Sources */, + DB47229725F9EFAD00DA7F53 /* NSManagedObjectContext.swift in Sources */, + 2D34D9D126148D9E0081BFC0 /* APIService+Recommend.swift in Sources */, + DBB525562611EDCA002F1F29 /* UserTimelineViewModel.swift in Sources */, 2D42FF7E25C82218004A627A /* ActionToolBarContainer.swift in Sources */, + DB221B16260C395900AEFE46 /* CustomEmojiPickerInputViewModel.swift in Sources */, DB0140A125C40C0600F9F3CF /* MastodonPinBasedAuthenticationViewController.swift in Sources */, + DB789A1C25F9F76A0071ACA0 /* ComposeStatusContentCollectionViewCell.swift in Sources */, + DB1FD43625F26899004CFCFC /* MastodonPickServerViewModel+LoadIndexedServerState.swift in Sources */, + 2D939AE825EE1CF80076FA61 /* MastodonRegisterViewController+Avatar.swift in Sources */, + 2D084B8D26258EA3003AA3AF /* NotificationViewModel+diffable.swift in Sources */, + DB6D1B24263684C600ACB481 /* UserDefaults.swift in Sources */, + DB1D186C25EF5BA7003F1F23 /* PollTableView.swift in Sources */, 2D5981A125E4A593000FB903 /* MastodonConfirmEmailViewModel.swift in Sources */, + DB87D4452609BE0500D12C0D /* ComposeStatusPollOptionCollectionViewCell.swift in Sources */, + DB6D9F7D26358ED4008423CD /* SettingsSection.swift in Sources */, DB8AF55025C13703002E6C99 /* MainTabBarController.swift in Sources */, DB9D6BE925E4F5340051B173 /* SearchViewController.swift in Sources */, 2D38F1C625CD37F400561493 /* ContentOffsetAdjustableTimelineViewControllerDelegate.swift in Sources */, + 2D24E11D2626D8B100A59D4F /* NotificationStatusTableViewCell.swift in Sources */, + DB6C8C0F25F0A6AE00AAA452 /* Mastodon+Entity+Error.swift in Sources */, + DB1E346825F518E20079D7DF /* CategoryPickerSection.swift in Sources */, + 2D61254D262547C200299647 /* APIService+Notification.swift in Sources */, + DBB525642612C988002F1F29 /* MeProfileViewModel.swift in Sources */, + 5BB04FE9262EFC300043BFF6 /* ReportedStatusTableviewCell.swift in Sources */, + DBAE3F822615DDA3004B8251 /* ProfileViewController+UserProvider.swift in Sources */, + DB938EED2623F79B00E5B6C1 /* ThreadViewModel.swift in Sources */, + DBC7A67C260DFADE00E57475 /* StatusPublishService.swift in Sources */, + 2DCB73FD2615C13900EC03D4 /* SearchRecommendCollectionHeader.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 */, DB2F073525E8ECF000957B2D /* AuthenticationViewModel.swift in Sources */, - DB68A04A25E9027700CFDF14 /* DarkContentStatusBarStyleNavigationController.swift in Sources */, + DB68A04A25E9027700CFDF14 /* AdaptiveStatusBarStyleNavigationController.swift in Sources */, 0FB3D33825E6401400AAD544 /* PickServerCell.swift in Sources */, + DB6D9F8426358EEC008423CD /* SettingsItem.swift in Sources */, 2D364F7825E66D8300204FDC /* MastodonResendEmailViewModel.swift in Sources */, DB8AF54525C13647002E6C99 /* NeedsDependency.swift in Sources */, DB9D6BF825E4F5690051B173 /* NotificationViewController.swift in Sources */, + 2DAC9E46262FC9FD0062E1A6 /* SuggestionAccountTableViewCell.swift in Sources */, + DBA5E7A9263BD3A4004598BB /* ContextMenuImagePreviewViewController.swift in Sources */, DB45FADD25CA6F6B005A8AC7 /* APIService+CoreData+MastodonUser.swift in Sources */, 2D32EABA25CB9B0500C9ED86 /* UIView.swift in Sources */, 2D38F20825CD491300561493 /* DisposeBagCollectable.swift in Sources */, + 0F20220D26134E3F000C64BF /* HashtagTimelineViewModel+LoadLatestState.swift in Sources */, + DBCC3B8F26148F7B0045B23D /* CachedProfileViewModel.swift in Sources */, + DB49A63D25FF609300B98345 /* PlayerContainerView+MediaTypeIndicotorView.swift in Sources */, + DB6180F826391D660018D199 /* MediaPreviewingViewController.swift in Sources */, DB0140CF25C42AEE00F9F3CF /* OSLog.swift in Sources */, + DB44384F25E8C1FA008912A2 /* CALayer.swift in Sources */, + 2D34D9CB261489930081BFC0 /* SearchViewController+Recommend.swift in Sources */, + DB938F1526241FDF00E5B6C1 /* APIService+Thread.swift in Sources */, + DB482A45261335BA008AE74C /* UserTimelineViewController+Provider.swift in Sources */, + 2D206B8625F5FB0900143C56 /* Double.swift in Sources */, + DB9A485C2603010E008B817C /* PHPickerResultLoader.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 */, + 2DB72C8C262D764300CE6173 /* Mastodon+Entity+Notification+Type.swift in Sources */, DB118A8C25E4BFB500FAB162 /* HighlightDimmableButton.swift in Sources */, - DB084B5725CBC56C00F898ED /* Toot.swift in Sources */, + DBAE3FA92617106E004B8251 /* MastodonMetricFormatter.swift in Sources */, + 2D35237A26256D920031AF25 /* NotificationSection.swift in Sources */, + DB084B5725CBC56C00F898ED /* Status.swift in Sources */, + 2D4AD89C263165B500613EFC /* SuggestionAccountCollectionViewCell.swift in Sources */, + DB447691260B406600B66B82 /* CustomEmojiPickerItemCollectionViewCell.swift in Sources */, + DB9282B225F3222800823B15 /* PickServerEmptyStateView.swift in Sources */, DB0140A825C40C1500F9F3CF /* MastodonPinBasedAuthenticationViewModelNavigationDelegateShim.swift in Sources */, + DB1FD45025F26FA1004CFCFC /* MastodonPickServerViewModel+Diffable.swift in Sources */, DB2B3AE925E38850007045F9 /* UIViewPreview.swift in Sources */, + DB68046C2636DC9E00430867 /* MastodonNotification.swift in Sources */, + DBAE3F9E2616E308004B8251 /* APIService+Mute.swift in Sources */, DB427DD625BAA00100D1B89D /* AppDelegate.swift in Sources */, + DB6D9F57263577D2008423CD /* APIService+CoreData+Setting.swift in Sources */, DB9D6C0E25E4F9780051B173 /* MosaicImageViewContainer.swift in Sources */, + DB71FD3625F8A16C00512AE1 /* APIService+Persist+PersistMemo.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 */, DB98336B25C9420100AD9700 /* APIService+App.swift in Sources */, + DBA0A11325FB3FC10079C110 /* ComposeToolbarView.swift in Sources */, 2D32EADA25CBCC3300C9ED86 /* PublicTimelineViewModel+LoadMiddleState.swift in Sources */, + 5B90C48526259BF10002E742 /* APIService+Subscriptions.swift in Sources */, + 0F20223926146553000C64BF /* Array.swift in Sources */, + 5B90C460262599800002E742 /* SettingsAppearanceTableViewCell.swift in Sources */, DB8AF54425C13647002E6C99 /* SceneCoordinator.swift in Sources */, + 5DF1058525F88AE500D6C0D4 /* NeedsDependency+AVPlayerViewControllerDelegate.swift in Sources */, + DB447697260B439000B66B82 /* CustomEmojiPickerHeaderCollectionReusableView.swift in Sources */, DB45FAF925CA80A2005A8AC7 /* APIService+CoreData+MastodonAuthentication.swift in Sources */, 2D04F42525C255B9003F936F /* APIService+PublicTimeline.swift in Sources */, - 2D76317D25C14DF500929FB9 /* PublicTimelineViewController+StatusProvider.swift in Sources */, + 2D34D9E226149C920081BFC0 /* SearchRecommendTagsCollectionViewCell.swift in Sources */, + 2D76317D25C14DF500929FB9 /* PublicTimelineViewController+Provider.swift in Sources */, + 0F20223326145E51000C64BF /* HashtagTimelineViewModel+LoadMiddleState.swift in Sources */, + 2D206B8025F5F45E00143C56 /* UIImage.swift in Sources */, + DB6D9F502635761F008423CD /* SubscriptionAlerts.swift in Sources */, + 0F20220726134DA4000C64BF /* HashtagTimelineViewModel+Diffable.swift in Sources */, + DBE54AC62636C89F004E7C0B /* NotificationPreference.swift in Sources */, 2D5A3D2825CF8BC9002347D6 /* HomeTimelineViewModel+Diffable.swift in Sources */, DB98339C25C96DE600AD9700 /* APIService+Account.swift in Sources */, - 2D42FF6B25C817D2004A627A /* MastodonContent.swift in Sources */, + 2D42FF6B25C817D2004A627A /* MastodonStatusContent.swift in Sources */, 2DF75BA725D10E1000694EC8 /* APIService+Favorite.swift in Sources */, DB9D6C3825E508BE0051B173 /* Attachment.swift in Sources */, + 5DFC35DF262068D20045711D /* SearchViewController+Follow.swift in Sources */, DB8AF52E25C13561002E6C99 /* ViewStateStore.swift in Sources */, 2DA7D04A25CA52CB00804E11 /* TimelineBottomLoaderTableViewCell.swift in Sources */, 2D76318325C14E8F00929FB9 /* PublicTimelineViewModel+Diffable.swift in Sources */, + DB6180FA26391F2E0018D199 /* MediaPreviewViewModel.swift in Sources */, + 2D198655261C3C4300F0B013 /* SearchViewModel+LoadOldestState.swift in Sources */, + 0F202227261411BB000C64BF /* HashtagTimelineViewController+Provider.swift in Sources */, 2D7631A825C1535600929FB9 /* StatusTableViewCell.swift in Sources */, 2D76316525C14BD100929FB9 /* PublicTimelineViewController.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 */, + 0F2021FB2613262F000C64BF /* HashtagTimelineViewController.swift in Sources */, + DBCC3B30261440A50045B23D /* UITabBarController.swift in Sources */, + DB8190C62601FF0400020C08 /* AttachmentContainerView.swift in Sources */, + DB51D173262832380062B7A1 /* BlurHashEncode.swift in Sources */, DB5086AB25CC0BBB00C2C187 /* AvatarConfigurableView.swift in Sources */, DB0140AE25C40C7300F9F3CF /* MastodonPinBasedAuthenticationViewModel.swift in Sources */, 2D32EAAC25CB96DC00C9ED86 /* TimelineMiddleLoaderTableViewCell.swift in Sources */, + DB71FD2C25F86A5100512AE1 /* AvatarStackContainerButton.swift in Sources */, + DB87D4512609CF1E00D12C0D /* ComposeStatusPollOptionAppendEntryCollectionViewCell.swift in Sources */, + DB9A489026035963008B817C /* APIService+Media.swift in Sources */, + 2D198649261C0B8500F0B013 /* SearchResultSection.swift in Sources */, + DBB525302611EBF3002F1F29 /* ProfilePagingViewModel.swift in Sources */, 2D5A3D6225CFD9CB002347D6 /* HomeTimelineViewController+DebugAction.swift in Sources */, + DB49A62525FF334C00B98345 /* EmojiService+CustomEmojiViewModel+LoadState.swift in Sources */, + DB4924E226312AB200E9DB22 /* NotificationService.swift in Sources */, + DB6D9F6F2635807F008423CD /* Setting.swift in Sources */, + DBB525412611ED54002F1F29 /* ProfileHeaderViewController.swift in Sources */, DB9D6BFF25E4F5940051B173 /* ProfileViewController.swift in Sources */, 0FB3D30825E524C600AAD544 /* PickServerCategoriesCell.swift in Sources */, + 2D4AD8A226316CD200613EFC /* SelectedAccountSection.swift in Sources */, + DB789A1225F9F2CC0071ACA0 /* ComposeViewModel.swift in Sources */, + DBB525362611ECEB002F1F29 /* UserTimelineViewController.swift in Sources */, + DB6D1B3D2636857500ACB481 /* AppearancePreference.swift in Sources */, + DB938F3326243D6200E5B6C1 /* TimelineTopLoaderTableViewCell.swift in Sources */, 2D38F1FE25CD481700561493 /* StatusProvider.swift in Sources */, + 5B24BBE2262DB19100A9381B /* APIService+Report.swift in Sources */, + 5BB04FF5262F0E6D0043BFF6 /* ReportSection.swift in Sources */, + DB938F25262438D600E5B6C1 /* ThreadViewController+Provider.swift in Sources */, + DB66729C25F9F91F00D60309 /* ComposeStatusItem.swift in Sources */, + DB6180E326391A4C0018D199 /* ViewControllerAnimatedTransitioning.swift in Sources */, 0FB3D31E25E534C700AAD544 /* PickServerCategoryCollectionViewCell.swift in Sources */, + 5DF1057925F88A1D00D6C0D4 /* PlayerContainerView.swift in Sources */, + DB6D9F4926353FD7008423CD /* Subscription.swift in Sources */, DB45FB0F25CA87D0005A8AC7 /* AuthenticationService.swift in Sources */, + DB6180ED26391C6C0018D199 /* TransitioningMath.swift in Sources */, + 2D6DE40026141DF600A63F6A /* SearchViewModel.swift in Sources */, + DB51D172262832380062B7A1 /* BlurHashDecode.swift in Sources */, + DBAFB7352645463500371D5F /* Emojis.swift in Sources */, + DBCC3B89261454BA0045B23D /* CGImage.swift in Sources */, + DBCCC71E25F73297007E1AB6 /* APIService+Reblog.swift in Sources */, + DB789A2B25F9F7AB0071ACA0 /* ComposeRepliedToStatusContentCollectionViewCell.swift in Sources */, + DBE3CE13261D7D4200430CC6 /* StatusTableViewControllerAspect.swift in Sources */, + 5BB04FD5262E7AFF0043BFF6 /* ReportViewController.swift in Sources */, + DBAE3F942616E28B004B8251 /* APIService+Follow.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1558,6 +3207,16 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + DB68047B2637CD4C00430867 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + DB6804D12637CE4700430867 /* UserDefaults.swift in Sources */, + DB6804922637CD8700430867 /* AppName.swift in Sources */, + DB6804FD2637CFEC00430867 /* AppSecret.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; DB89B9EA25C10FD0008580ED /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -1566,22 +3225,31 @@ 2D927F1425C7EDD9004F19B8 /* Emoji.swift in Sources */, 2DF75BC725D1475D00694EC8 /* ManagedObjectContextObjectsDidChange.swift in Sources */, DB89BA1225C1105C008580ED /* CoreDataStack.swift in Sources */, + 5B90C46F26259B2C0002E742 /* Setting.swift in Sources */, DB89BA1C25C1107F008580ED /* NSManagedObjectContext.swift in Sources */, DB9D6C2E25E504AC0051B173 /* Attachment.swift in Sources */, 2D927F0E25C7E9C9004F19B8 /* History.swift in Sources */, + DBCC3B9B261584A00045B23D /* PrivateNote.swift in Sources */, DB89BA3725C1145C008580ED /* CoreData.xcdatamodeld in Sources */, DB8AF52525C131D1002E6C99 /* MastodonUser.swift in Sources */, DB89BA1B25C1107F008580ED /* Collection.swift in Sources */, - DB89BA2725C110B4008580ED /* Toot.swift in Sources */, + DB4481AD25EE155900BEFB67 /* Poll.swift in Sources */, + DB89BA2725C110B4008580ED /* Status.swift in Sources */, 2D152A9225C2980C009AA50C /* UIFont.swift in Sources */, + DB4481B325EE16D000BEFB67 /* PollOption.swift in Sources */, DB89BA4425C1165F008580ED /* Managed.swift in Sources */, + 2D6125472625436B00299647 /* Notification.swift in Sources */, DB89BA4325C1165F008580ED /* NetworkUpdatable.swift in Sources */, DB8AF56825C13E2A002E6C99 /* HomeTimelineIndex.swift in Sources */, DB45FAED25CA7A9A005A8AC7 /* MastodonAuthentication.swift in Sources */, 2DF75BB925D1474100694EC8 /* ManagedObjectObserver.swift in Sources */, 2D927F0225C7E4F2004F19B8 /* Mention.swift in Sources */, DB89BA1D25C1107F008580ED /* URL.swift in Sources */, + 2D9DB969263A833E007C1D71 /* DomainBlock.swift in Sources */, + 2D0B7A1D261D839600B44727 /* SearchHistory.swift in Sources */, 2D927F0825C7E9A8004F19B8 /* Tag.swift in Sources */, + 5B90C46E26259B2C0002E742 /* Subscription.swift in Sources */, + 5B90C47F26259BA90002E742 /* SubscriptionAlerts.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1593,6 +3261,19 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + DBF8AE0F263293E400C9C23C /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + DBE54ACC2636C8FD004E7C0B /* NotificationPreference.swift in Sources */, + DB68045B2636DC6A00430867 /* MastodonNotification.swift in Sources */, + DBE54ABF2636C889004E7C0B /* UserDefaults.swift in Sources */, + DB6D9F3526351B7A008423CD /* NotificationService+Decrypt.swift in Sources */, + DB6804662636DC9000430867 /* String.swift in Sources */, + DBF8AE16263293E400C9C23C /* NotificationService.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ @@ -1606,6 +3287,26 @@ target = DB427DD125BAA00100D1B89D /* Mastodon */; targetProxy = DB427DF425BAA00100D1B89D /* PBXContainerItemProxy */; }; + DB6804852637CD4C00430867 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = DB68047E2637CD4C00430867 /* AppShared */; + targetProxy = DB6804842637CD4C00430867 /* PBXContainerItemProxy */; + }; + DB6804A82637CDCC00430867 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = DB68047E2637CD4C00430867 /* AppShared */; + targetProxy = DB6804A72637CDCC00430867 /* PBXContainerItemProxy */; + }; + DB6804CA2637CE3000430867 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = DB68047E2637CD4C00430867 /* AppShared */; + targetProxy = DB6804C92637CE3000430867 /* PBXContainerItemProxy */; + }; + DB6805292637D7DD00430867 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = DB68047E2637CD4C00430867 /* AppShared */; + targetProxy = DB6805282637D7DD00430867 /* PBXContainerItemProxy */; + }; DB89B9F925C10FD0008580ED /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = DB89B9ED25C10FD0008580ED /* CoreDataStack */; @@ -1621,6 +3322,11 @@ target = DB89B9ED25C10FD0008580ED /* CoreDataStack */; targetProxy = DB89BA0125C10FD0008580ED /* PBXContainerItemProxy */; }; + DBF8AE19263293E400C9C23C /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = DBF8AE12263293E400C9C23C /* NotificationService */; + targetProxy = DBF8AE18263293E400C9C23C /* PBXContainerItemProxy */; + }; /* End PBXTargetDependency section */ /* Begin PBXVariantGroup section */ @@ -1628,6 +3334,7 @@ isa = PBXVariantGroup; children = ( DB2B3ABD25E37E15007045F9 /* en */, + DB0F814E264CFFD300F2A12B /* ar */, ); name = InfoPlist.strings; sourceTree = ""; @@ -1636,6 +3343,7 @@ isa = PBXVariantGroup; children = ( DB3D100E25BAA75E00EAA174 /* en */, + DB0F814D264CFFD300F2A12B /* ar */, ); name = Localizable.strings; sourceTree = ""; @@ -1663,6 +3371,7 @@ isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; @@ -1724,6 +3433,7 @@ isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; @@ -1784,7 +3494,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = Mastodon/Mastodon.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2; + CURRENT_PROJECT_VERSION = 4; DEVELOPMENT_ASSET_PATHS = "Mastodon/Resources/Preview\\ Assets.xcassets"; DEVELOPMENT_TEAM = 7LFDZ96332; INFOPLIST_FILE = Mastodon/Info.plist; @@ -1792,7 +3502,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 0.1.0; + MARKETING_VERSION = 0.4.0; PRODUCT_BUNDLE_IDENTIFIER = org.joinmastodon.Mastodon; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -1811,7 +3521,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = Mastodon/Mastodon.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2; + CURRENT_PROJECT_VERSION = 4; DEVELOPMENT_ASSET_PATHS = "Mastodon/Resources/Preview\\ Assets.xcassets"; DEVELOPMENT_TEAM = 7LFDZ96332; INFOPLIST_FILE = Mastodon/Info.plist; @@ -1819,7 +3529,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 0.1.0; + MARKETING_VERSION = 0.4.0; PRODUCT_BUNDLE_IDENTIFIER = org.joinmastodon.Mastodon; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -1910,6 +3620,67 @@ }; name = Release; }; + DB6804892637CD4C00430867 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = D7D7CF93E262178800077512 /* Pods-Mastodon-AppShared.debug.xcconfig */; + buildSettings = { + APPLICATION_EXTENSION_API_ONLY = YES; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEFINES_MODULE = YES; + DEVELOPMENT_TEAM = 7LFDZ96332; + DYLIB_COMPATIBILITY_VERSION = 1; + DYLIB_CURRENT_VERSION = 1; + DYLIB_INSTALL_NAME_BASE = "@rpath"; + INFOPLIST_FILE = AppShared/Info.plist; + INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = org.joinmastodon.Mastodon.AppShared; + PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; + SKIP_INSTALL = YES; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + VERSIONING_SYSTEM = "apple-generic"; + VERSION_INFO_PREFIX = ""; + }; + name = Debug; + }; + DB68048A2637CD4C00430867 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = B31D44635FCF6452F7E1B865 /* Pods-Mastodon-AppShared.release.xcconfig */; + buildSettings = { + APPLICATION_EXTENSION_API_ONLY = YES; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEFINES_MODULE = YES; + DEVELOPMENT_TEAM = 7LFDZ96332; + DYLIB_COMPATIBILITY_VERSION = 1; + DYLIB_CURRENT_VERSION = 1; + DYLIB_INSTALL_NAME_BASE = "@rpath"; + INFOPLIST_FILE = AppShared/Info.plist; + INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = org.joinmastodon.Mastodon.AppShared; + PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; + SKIP_INSTALL = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + VERSIONING_SYSTEM = "apple-generic"; + VERSION_INFO_PREFIX = ""; + }; + name = Release; + }; DB89BA0625C10FD0008580ED /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -2007,6 +3778,48 @@ }; name = Release; }; + DBF8AE1C263293E400C9C23C /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 8ED8C4B1F1BA2DCFF2926BB1 /* Pods-Mastodon-NotificationService.debug.xcconfig */; + buildSettings = { + CODE_SIGN_ENTITLEMENTS = NotificationService/NotificationService.entitlements; + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = 7LFDZ96332; + INFOPLIST_FILE = NotificationService/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = org.joinmastodon.Mastodon.NotificationService; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + DBF8AE1D263293E400C9C23C /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = B44342AC2B6585F8295F1DDF /* Pods-Mastodon-NotificationService.release.xcconfig */; + buildSettings = { + CODE_SIGN_ENTITLEMENTS = NotificationService/NotificationService.entitlements; + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = 7LFDZ96332; + INFOPLIST_FILE = NotificationService/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = org.joinmastodon.Mastodon.NotificationService; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -2046,6 +3859,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + DB6804882637CD4C00430867 /* Build configuration list for PBXNativeTarget "AppShared" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + DB6804892637CD4C00430867 /* Debug */, + DB68048A2637CD4C00430867 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; DB89BA0525C10FD0008580ED /* Build configuration list for PBXNativeTarget "CoreDataStack" */ = { isa = XCConfigurationList; buildConfigurations = ( @@ -2064,6 +3886,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + DBF8AE1E263293E400C9C23C /* Build configuration list for PBXNativeTarget "NotificationService" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + DBF8AE1C263293E400C9C23C /* Debug */, + DBF8AE1D263293E400C9C23C /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ @@ -2071,8 +3902,8 @@ isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/TwidereProject/ActiveLabel.swift"; requirement = { - kind = upToNextMajorVersion; - minimumVersion = 4.0.0; + kind = exactVersion; + version = 5.0.2; }; }; 2D5981B825E4D7F8000FB903 /* XCRemoteSwiftPackageReference "ThirdPartyMailer" */ = { @@ -2091,6 +3922,14 @@ minimumVersion = 3.1.0; }; }; + 2D939AC625EE14620076FA61 /* XCRemoteSwiftPackageReference "TOCropViewController" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/TimOliver/TOCropViewController.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 2.6.0; + }; + }; DB0140BB25C40D7500F9F3CF /* XCRemoteSwiftPackageReference "CommonOSLog" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/MainasuK/CommonOSLog"; @@ -2099,6 +3938,14 @@ minimumVersion = 0.1.1; }; }; + DB35B0B12643D821006AC73B /* XCRemoteSwiftPackageReference "TwitterTextEditor" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/twitter/TwitterTextEditor"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 1.1.0; + }; + }; DB3D0FF125BAA61700EAA174 /* XCRemoteSwiftPackageReference "AlamofireImage" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/Alamofire/AlamofireImage.git"; @@ -2115,6 +3962,38 @@ minimumVersion = 6.1.0; }; }; + DB6804722637CC1200430867 /* XCRemoteSwiftPackageReference "KeychainAccess" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/kishikawakatsumi/KeychainAccess.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 4.2.2; + }; + }; + DB9A487C2603456B008B817C /* XCRemoteSwiftPackageReference "UITextView-Placeholder" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/MainasuK/UITextView-Placeholder"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 1.4.1; + }; + }; + DBB525062611EAC0002F1F29 /* XCRemoteSwiftPackageReference "Tabman" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/uias/Tabman"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 2.11.0; + }; + }; + DBF8AE842632992700C9C23C /* XCRemoteSwiftPackageReference "Base85" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/MainasuK/Base85.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 1.0.1; + }; + }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ @@ -2133,15 +4012,30 @@ package = 2D61336725C18A4F00CAE157 /* XCRemoteSwiftPackageReference "AlamofireNetworkActivityIndicator" */; productName = AlamofireNetworkActivityIndicator; }; + 2D939AC725EE14620076FA61 /* CropViewController */ = { + isa = XCSwiftPackageProductDependency; + package = 2D939AC625EE14620076FA61 /* XCRemoteSwiftPackageReference "TOCropViewController" */; + productName = CropViewController; + }; 5D526FE125BE9AC400460CB9 /* MastodonSDK */ = { isa = XCSwiftPackageProductDependency; productName = MastodonSDK; }; + DB00CA962632DDB600A54956 /* CommonOSLog */ = { + isa = XCSwiftPackageProductDependency; + package = DB0140BB25C40D7500F9F3CF /* XCRemoteSwiftPackageReference "CommonOSLog" */; + productName = CommonOSLog; + }; DB0140BC25C40D7500F9F3CF /* CommonOSLog */ = { isa = XCSwiftPackageProductDependency; package = DB0140BB25C40D7500F9F3CF /* XCRemoteSwiftPackageReference "CommonOSLog" */; productName = CommonOSLog; }; + DB35B0B22643D821006AC73B /* TwitterTextEditor */ = { + isa = XCSwiftPackageProductDependency; + package = DB35B0B12643D821006AC73B /* XCRemoteSwiftPackageReference "TwitterTextEditor" */; + productName = TwitterTextEditor; + }; DB3D0FF225BAA61700EAA174 /* AlamofireImage */ = { isa = XCSwiftPackageProductDependency; package = DB3D0FF125BAA61700EAA174 /* XCRemoteSwiftPackageReference "AlamofireImage" */; @@ -2152,6 +4046,31 @@ package = DB5086B625CC0D6400C2C187 /* XCRemoteSwiftPackageReference "Kingfisher" */; productName = Kingfisher; }; + DB68050F2637D0F800430867 /* KeychainAccess */ = { + isa = XCSwiftPackageProductDependency; + package = DB6804722637CC1200430867 /* XCRemoteSwiftPackageReference "KeychainAccess" */; + productName = KeychainAccess; + }; + DB6D9F41263527CE008423CD /* AlamofireImage */ = { + isa = XCSwiftPackageProductDependency; + package = DB3D0FF125BAA61700EAA174 /* XCRemoteSwiftPackageReference "AlamofireImage" */; + productName = AlamofireImage; + }; + DB9A487D2603456B008B817C /* UITextView+Placeholder */ = { + isa = XCSwiftPackageProductDependency; + package = DB9A487C2603456B008B817C /* XCRemoteSwiftPackageReference "UITextView-Placeholder" */; + productName = "UITextView+Placeholder"; + }; + DBB525072611EAC0002F1F29 /* Tabman */ = { + isa = XCSwiftPackageProductDependency; + package = DBB525062611EAC0002F1F29 /* XCRemoteSwiftPackageReference "Tabman" */; + productName = Tabman; + }; + DBF8AE852632992800C9C23C /* Base85 */ = { + isa = XCSwiftPackageProductDependency; + package = DBF8AE842632992700C9C23C /* XCRemoteSwiftPackageReference "Base85" */; + productName = Base85; + }; /* End XCSwiftPackageProductDependency section */ /* Begin XCVersionGroup section */ diff --git a/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist b/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist index b1a7a744c..326857269 100644 --- a/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist +++ b/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist @@ -4,49 +4,38 @@ SchemeUserState + AppShared.xcscheme_^#shared#^_ + + orderHint + 18 + CoreDataStack.xcscheme_^#shared#^_ orderHint - 10 + 14 Mastodon - RTL.xcscheme_^#shared#^_ orderHint - 9 + 2 Mastodon - Release.xcscheme_^#shared#^_ orderHint - 1 + 0 Mastodon.xcscheme_^#shared#^_ orderHint - 0 + 1 + + NotificationService.xcscheme_^#shared#^_ + + orderHint + 15 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 456a6e967..38325ae97 100644 --- a/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -6,8 +6,8 @@ "repositoryURL": "https://github.com/TwidereProject/ActiveLabel.swift", "state": { "branch": null, - "revision": "d6cf96e0ca4f2269021bcf8f11381ab57897f84a", - "version": "4.0.0" + "revision": "2132a7bf8da2bea74bb49b0b815950ea4d8f6a7e", + "version": "5.0.2" } }, { @@ -15,8 +15,8 @@ "repositoryURL": "https://github.com/Alamofire/Alamofire.git", "state": { "branch": null, - "revision": "eaf6e622dd41b07b251d8f01752eab31bc811493", - "version": "5.4.1" + "revision": "4d19ad82f80cc71ff829b941ded114c56f4f604c", + "version": "5.4.2" } }, { @@ -24,8 +24,8 @@ "repositoryURL": "https://github.com/Alamofire/AlamofireImage.git", "state": { "branch": null, - "revision": "3e8edbeb75227f8542aa87f90240cf0424d6362f", - "version": "4.1.0" + "revision": "98cbb00ce0ec5fc8e52a5b50a6bfc08d3e5aee10", + "version": "4.2.0" } }, { @@ -37,6 +37,15 @@ "version": "3.1.0" } }, + { + "package": "Base85", + "repositoryURL": "https://github.com/MainasuK/Base85.git", + "state": { + "branch": null, + "revision": "626be96816618689627f806b5c875b5adb6346ef", + "version": "1.0.1" + } + }, { "package": "CommonOSLog", "repositoryURL": "https://github.com/MainasuK/CommonOSLog", @@ -46,13 +55,31 @@ "version": "0.1.1" } }, + { + "package": "KeychainAccess", + "repositoryURL": "https://github.com/kishikawakatsumi/KeychainAccess.git", + "state": { + "branch": null, + "revision": "84e546727d66f1adc5439debad16270d0fdd04e7", + "version": "4.2.2" + } + }, { "package": "Kingfisher", "repositoryURL": "https://github.com/onevcat/Kingfisher.git", "state": { "branch": null, - "revision": "daebf8ddf974164d1b9a050c8231e263f3106b09", - "version": "6.1.0" + "revision": "bbc4bc4def7eb05a7ba8e1219f80ee9be327334e", + "version": "6.2.1" + } + }, + { + "package": "Pageboy", + "repositoryURL": "https://github.com/uias/Pageboy", + "state": { + "branch": null, + "revision": "34ecb6e7c4e0e07494960ab2f7cc9a02293915a6", + "version": "3.6.2" } }, { @@ -78,8 +105,17 @@ "repositoryURL": "https://github.com/SwiftyJSON/SwiftyJSON.git", "state": { "branch": null, - "revision": "2b6054efa051565954e1d2b9da831680026cd768", - "version": "5.0.0" + "revision": "b3dcd7dbd0d488e1a7077cb33b00f2083e382f07", + "version": "5.0.1" + } + }, + { + "package": "Tabman", + "repositoryURL": "https://github.com/uias/Tabman", + "state": { + "branch": null, + "revision": "f43489cdd743ba7ad86a422ebb5fcbf34e333df4", + "version": "2.11.1" } }, { @@ -90,6 +126,33 @@ "revision": "923c60ee7588da47db8cfc4e0f5b96e5e605ef84", "version": "1.7.1" } + }, + { + "package": "TOCropViewController", + "repositoryURL": "https://github.com/TimOliver/TOCropViewController.git", + "state": { + "branch": null, + "revision": "dad97167bf1be16aeecd109130900995dd01c515", + "version": "2.6.0" + } + }, + { + "package": "TwitterTextEditor", + "repositoryURL": "https://github.com/twitter/TwitterTextEditor", + "state": { + "branch": null, + "revision": "dfe0edc3bcb6703ee2fd0e627f95e726b63e732a", + "version": "1.1.0" + } + }, + { + "package": "UITextView+Placeholder", + "repositoryURL": "https://github.com/MainasuK/UITextView-Placeholder", + "state": { + "branch": null, + "revision": "20f513ded04a040cdf5467f0891849b1763ede3b", + "version": "1.4.1" + } } ] }, diff --git a/Mastodon/Activity/SafariActivity.swift b/Mastodon/Activity/SafariActivity.swift new file mode 100644 index 000000000..e10a0b082 --- /dev/null +++ b/Mastodon/Activity/SafariActivity.swift @@ -0,0 +1,62 @@ +// +// SafariActivity.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-8. +// + +import UIKit +import SafariServices + +final class SafariActivity: UIActivity { + + weak var sceneCoordinator: SceneCoordinator? + var url: NSURL? + + init(sceneCoordinator: SceneCoordinator) { + self.sceneCoordinator = sceneCoordinator + } + + override var activityType: UIActivity.ActivityType? { + return UIActivity.ActivityType("org.joinmastodon.Mastodon.safari-activity") + } + + override var activityTitle: String? { + return L10n.Common.Controls.Actions.openInSafari + } + + override var activityImage: UIImage? { + return UIImage(systemName: "safari") + } + + override func canPerform(withActivityItems activityItems: [Any]) -> Bool { + for item in activityItems { + guard let _ = item as? NSURL, sceneCoordinator != nil else { continue } + return true + } + + return false + } + + override func prepare(withActivityItems activityItems: [Any]) { + for item in activityItems { + guard let url = item as? NSURL else { continue } + self.url = url + } + } + + override var activityViewController: UIViewController? { + return nil + } + + override func perform() { + guard let url = url else { + activityDidFinish(false) + return + } + + sceneCoordinator?.present(scene: .safari(url: url as URL), from: nil, transition: .safariPresent(animated: true, completion: nil)) + activityDidFinish(true) + } + +} diff --git a/Mastodon/Coordinator/NeedsDependency.swift b/Mastodon/Coordinator/NeedsDependency.swift index 70421a822..d6a24cce3 100644 --- a/Mastodon/Coordinator/NeedsDependency.swift +++ b/Mastodon/Coordinator/NeedsDependency.swift @@ -7,7 +7,7 @@ import UIKit -protocol NeedsDependency: class { +protocol NeedsDependency: AnyObject { var context: AppContext! { get set } var coordinator: SceneCoordinator! { get set } } diff --git a/Mastodon/Coordinator/SceneCoordinator.swift b/Mastodon/Coordinator/SceneCoordinator.swift index fa2963386..3768f7d3d 100644 --- a/Mastodon/Coordinator/SceneCoordinator.swift +++ b/Mastodon/Coordinator/SceneCoordinator.swift @@ -13,6 +13,7 @@ final public class SceneCoordinator { private weak var scene: UIScene! private weak var sceneDelegate: SceneDelegate! private weak var appContext: AppContext! + private weak var tabBarController: MainTabBarController! let id = UUID().uuidString @@ -33,8 +34,8 @@ extension SceneCoordinator { case custom(transitioningDelegate: UIViewControllerTransitioningDelegate) case customPush case safariPresent(animated: Bool, completion: (() -> Void)? = nil) - case activityViewControllerPresent(animated: Bool, completion: (() -> Void)? = nil) case alertController(animated: Bool, completion: (() -> Void)? = nil) + case activityViewControllerPresent(animated: Bool, completion: (() -> Void)? = nil) } enum Scene { @@ -46,10 +47,37 @@ extension SceneCoordinator { case mastodonServerRules(viewModel: MastodonServerRulesViewModel) case mastodonConfirmEmail(viewModel: MastodonConfirmEmailViewModel) case mastodonResendEmail(viewModel: MastodonResendEmailViewModel) + case mastodonWebView(viewModel:WebViewModel) + + // compose + case compose(viewModel: ComposeViewModel) + + // thread + case thread(viewModel: ThreadViewModel) + + // Hashtag Timeline + case hashtagTimeline(viewModel: HashtagTimelineViewModel) + + // profile + case profile(viewModel: ProfileViewModel) + case favorite(viewModel: FavoriteViewModel) + + // setting + case settings(viewModel: SettingsViewModel) + + // report + case report(viewModel: ReportViewModel) + + // suggestion account + case suggestionAccount(viewModel: SuggestionAccountViewModel) + + // media preview + case mediaPreview(viewModel: MediaPreviewViewModel) // misc + case safari(url: URL) case alertController(alertController: UIAlertController) - + case activityViewController(activityViewController: UIActivityViewController, sourceView: UIView?, barButtonItem: UIBarButtonItem?) #if DEBUG case publicTimeline #endif @@ -76,13 +104,14 @@ extension SceneCoordinator { func setup() { let viewController = MainTabBarController(context: appContext, coordinator: self) sceneDelegate.window?.rootViewController = viewController + tabBarController = viewController } func setupOnboardingIfNeeds(animated: Bool) { // Check user authentication status and show onboarding if needs do { let request = MastodonAuthentication.sortedFetchRequest - if try appContext.managedObjectContext.fetch(request).isEmpty { + if try appContext.managedObjectContext.count(for: request) == 0 { DispatchQueue.main.async { self.present( scene: .welcome, @@ -104,6 +133,17 @@ extension SceneCoordinator { guard var presentingViewController = sender ?? sceneDelegate.window?.rootViewController?.topMost else { return nil } + // adapt for child controller + if let navigationControllerVisibleViewController = presentingViewController.navigationController?.visibleViewController { + switch viewController { + case is ProfileViewController: + let barButtonItem = UIBarButtonItem(title: navigationControllerVisibleViewController.navigationItem.title, style: .plain, target: nil, action: nil) + barButtonItem.tintColor = .white + navigationControllerVisibleViewController.navigationItem.backBarButtonItem = barButtonItem + default: + navigationControllerVisibleViewController.navigationItem.backBarButtonItem = nil + } + } if let mainTabBarController = presentingViewController as? MainTabBarController, let navigationController = mainTabBarController.selectedViewController as? UINavigationController, @@ -116,17 +156,18 @@ extension SceneCoordinator { presentingViewController.show(viewController, sender: sender) case .showDetail: - let navigationController = UINavigationController(rootViewController: viewController) + let navigationController = AdaptiveStatusBarStyleNavigationController(rootViewController: viewController) presentingViewController.showDetailViewController(navigationController, sender: sender) case .modal(let animated, let completion): let modalNavigationController: UINavigationController = { if scene.isOnboarding { - return DarkContentStatusBarStyleNavigationController(rootViewController: viewController) + return AdaptiveStatusBarStyleNavigationController(rootViewController: viewController) } else { return UINavigationController(rootViewController: viewController) } }() + modalNavigationController.modalPresentationCapturesStatusBarAppearance = true if let adaptivePresentationControllerDelegate = viewController as? UIAdaptivePresentationControllerDelegate { modalNavigationController.presentationController?.delegate = adaptivePresentationControllerDelegate } @@ -143,18 +184,24 @@ extension SceneCoordinator { sender?.navigationController?.pushViewController(viewController, animated: true) case .safariPresent(let animated, let completion): - presentingViewController.present(viewController, animated: animated, completion: completion) - - case .activityViewControllerPresent(let animated, let completion): + viewController.modalPresentationCapturesStatusBarAppearance = true presentingViewController.present(viewController, animated: animated, completion: completion) case .alertController(let animated, let completion): + viewController.modalPresentationCapturesStatusBarAppearance = true + presentingViewController.present(viewController, animated: animated, completion: completion) + + case .activityViewControllerPresent(let animated, let completion): + viewController.modalPresentationCapturesStatusBarAppearance = true presentingViewController.present(viewController, animated: animated, completion: completion) } return viewController } + func switchToTabBar(tab: MainTabBarController.Tab) { + tabBarController.selectedIndex = tab.rawValue + } } private extension SceneCoordinator { @@ -190,6 +237,48 @@ private extension SceneCoordinator { let _viewController = MastodonResendEmailViewController() _viewController.viewModel = viewModel viewController = _viewController + case .mastodonWebView(let viewModel): + let _viewController = WebViewController() + _viewController.viewModel = viewModel + viewController = _viewController + case .compose(let viewModel): + let _viewController = ComposeViewController() + _viewController.viewModel = viewModel + viewController = _viewController + case .thread(let viewModel): + let _viewController = ThreadViewController() + _viewController.viewModel = viewModel + viewController = _viewController + case .hashtagTimeline(let viewModel): + let _viewController = HashtagTimelineViewController() + _viewController.viewModel = viewModel + viewController = _viewController + case .profile(let viewModel): + let _viewController = ProfileViewController() + _viewController.viewModel = viewModel + viewController = _viewController + case .favorite(let viewModel): + let _viewController = FavoriteViewController() + _viewController.viewModel = viewModel + viewController = _viewController + case .settings(let viewModel): + let _viewController = SettingsViewController() + _viewController.viewModel = viewModel + viewController = _viewController + case .suggestionAccount(let viewModel): + let _viewController = SuggestionAccountViewController() + _viewController.viewModel = viewModel + viewController = _viewController + case .mediaPreview(let viewModel): + let _viewController = MediaPreviewViewController() + _viewController.viewModel = viewModel + viewController = _viewController + case .safari(let url): + guard let scheme = url.scheme?.lowercased(), + scheme == "http" || scheme == "https" else { + return nil + } + viewController = SFSafariViewController(url: url) case .alertController(let alertController): if let popoverPresentationController = alertController.popoverPresentationController { assert( @@ -199,6 +288,18 @@ private extension SceneCoordinator { ) } viewController = alertController + case .activityViewController(let activityViewController, let sourceView, let barButtonItem): + activityViewController.popoverPresentationController?.sourceView = sourceView + activityViewController.popoverPresentationController?.barButtonItem = barButtonItem + viewController = activityViewController + case .settings(let viewModel): + let _viewController = SettingsViewController() + _viewController.viewModel = viewModel + viewController = _viewController + case .report(let viewModel): + let _viewController = ReportViewController() + _viewController.viewModel = viewModel + viewController = _viewController #if DEBUG case .publicTimeline: let _viewController = PublicTimelineViewController() diff --git a/Mastodon/Diffiable/FetchedResultsController/SettingFetchedResultController.swift b/Mastodon/Diffiable/FetchedResultsController/SettingFetchedResultController.swift new file mode 100644 index 000000000..52eafc6b6 --- /dev/null +++ b/Mastodon/Diffiable/FetchedResultsController/SettingFetchedResultController.swift @@ -0,0 +1,64 @@ +// +// SettingFetchedResultController.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-25. +// + +import os.log +import UIKit +import Combine +import CoreData +import CoreDataStack +import MastodonSDK + +final class SettingFetchedResultController: NSObject { + + var disposeBag = Set() + + let fetchedResultsController: NSFetchedResultsController + + // input + + // output + let settings = CurrentValueSubject<[Setting], Never>([]) + + init(managedObjectContext: NSManagedObjectContext, additionalPredicate: NSPredicate?) { + self.fetchedResultsController = { + let fetchRequest = Setting.sortedFetchRequest + fetchRequest.returnsObjectsAsFaults = false + if let additionalPredicate = additionalPredicate { + fetchRequest.predicate = additionalPredicate + } + fetchRequest.fetchBatchSize = 20 + let controller = NSFetchedResultsController( + fetchRequest: fetchRequest, + managedObjectContext: managedObjectContext, + sectionNameKeyPath: nil, + cacheName: nil + ) + + return controller + }() + super.init() + + fetchedResultsController.delegate = self + + do { + try self.fetchedResultsController.performFetch() + } catch { + assertionFailure(error.localizedDescription) + } + } + +} + +// MARK: - NSFetchedResultsControllerDelegate +extension SettingFetchedResultController: NSFetchedResultsControllerDelegate { + func controller(_ controller: NSFetchedResultsController, didChangeContentWith snapshot: NSDiffableDataSourceSnapshotReference) { + os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + + let objects = fetchedResultsController.fetchedObjects ?? [] + self.settings.value = objects + } +} diff --git a/Mastodon/Diffiable/FetchedResultsController/StatusFetchedResultsController.swift b/Mastodon/Diffiable/FetchedResultsController/StatusFetchedResultsController.swift new file mode 100644 index 000000000..dd373b29f --- /dev/null +++ b/Mastodon/Diffiable/FetchedResultsController/StatusFetchedResultsController.swift @@ -0,0 +1,87 @@ +// +// StatusFetchedResultsController.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-30. +// + +import os.log +import UIKit +import Combine +import CoreData +import CoreDataStack +import MastodonSDK + +final class StatusFetchedResultsController: NSObject { + + var disposeBag = Set() + + let fetchedResultsController: NSFetchedResultsController + + // input + let domain = CurrentValueSubject(nil) + let statusIDs = CurrentValueSubject<[Mastodon.Entity.Status.ID], Never>([]) + + // output + let objectIDs = CurrentValueSubject<[NSManagedObjectID], Never>([]) + + init(managedObjectContext: NSManagedObjectContext, domain: String?, additionalTweetPredicate: NSPredicate?) { + self.domain.value = domain ?? "" + self.fetchedResultsController = { + let fetchRequest = Status.sortedFetchRequest + fetchRequest.predicate = Status.predicate(domain: domain ?? "", ids: []) + fetchRequest.returnsObjectsAsFaults = false + fetchRequest.fetchBatchSize = 20 + let controller = NSFetchedResultsController( + fetchRequest: fetchRequest, + managedObjectContext: managedObjectContext, + sectionNameKeyPath: nil, + cacheName: nil + ) + + return controller + }() + super.init() + + fetchedResultsController.delegate = self + + Publishers.CombineLatest( + self.domain.removeDuplicates().eraseToAnyPublisher(), + self.statusIDs.removeDuplicates().eraseToAnyPublisher() + ) + .receive(on: DispatchQueue.main) + .sink { [weak self] domain, ids in + guard let self = self else { return } + var predicates = [Status.predicate(domain: domain ?? "", ids: ids)] + if let additionalPredicate = additionalTweetPredicate { + predicates.append(additionalPredicate) + } + self.fetchedResultsController.fetchRequest.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: predicates) + do { + try self.fetchedResultsController.performFetch() + } catch { + assertionFailure(error.localizedDescription) + } + } + .store(in: &disposeBag) + } + +} + +// MARK: - NSFetchedResultsControllerDelegate +extension StatusFetchedResultsController: 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 objects = fetchedResultsController.fetchedObjects ?? [] + + let items: [NSManagedObjectID] = objects + .compactMap { object in + indexes.firstIndex(of: object.id).map { index in (index, object) } + } + .sorted { $0.0 < $1.0 } + .map { $0.1.objectID } + self.objectIDs.value = items + } +} diff --git a/Mastodon/Diffiable/Item/CategoryPickerItem.swift b/Mastodon/Diffiable/Item/CategoryPickerItem.swift new file mode 100644 index 000000000..0f2cdcc21 --- /dev/null +++ b/Mastodon/Diffiable/Item/CategoryPickerItem.swift @@ -0,0 +1,113 @@ +// +// CategoryPickerItem.swift +// Mastodon +// +// Created by Cirno MainasuK on 2021/3/5. +// + +import Foundation +import MastodonSDK + +/// Note: update Equatable when change case +enum CategoryPickerItem { + case all + case category(category: Mastodon.Entity.Category) +} + +extension CategoryPickerItem { + var title: String { + switch self { + case .all: + return L10n.Scene.ServerPicker.Button.Category.all + case .category(let category): + switch category.category { + case .academia: + return "📚" + case .activism: + return "✊" + case .food: + return "ðŸ•" + case .furry: + return "ðŸ¦" + case .games: + return "🕹" + case .general: + return "💬" + case .journalism: + return "📰" + case .lgbt: + return "ðŸ³ï¸â€ðŸŒˆ" + case .regional: + return "ðŸ“" + case .art: + return "🎨" + case .music: + return "🎼" + case .tech: + return "📱" + case ._other: + return "â“" + } + } + } + + var accessibilityDescription: String { + switch self { + case .all: + return L10n.Scene.ServerPicker.Button.Category.allAccessiblityDescription + case .category(let category): + switch category.category { + case .academia: + return L10n.Scene.ServerPicker.Button.Category.academia + case .activism: + return L10n.Scene.ServerPicker.Button.Category.activism + case .food: + return L10n.Scene.ServerPicker.Button.Category.food + case .furry: + return L10n.Scene.ServerPicker.Button.Category.furry + case .games: + return L10n.Scene.ServerPicker.Button.Category.games + case .general: + return L10n.Scene.ServerPicker.Button.Category.general + case .journalism: + return L10n.Scene.ServerPicker.Button.Category.journalism + case .lgbt: + return L10n.Scene.ServerPicker.Button.Category.lgbt + case .regional: + return L10n.Scene.ServerPicker.Button.Category.regional + case .art: + return L10n.Scene.ServerPicker.Button.Category.art + case .music: + return L10n.Scene.ServerPicker.Button.Category.music + case .tech: + return L10n.Scene.ServerPicker.Button.Category.tech + case ._other: + return "â“" // FIXME: + } + } + } +} + +extension CategoryPickerItem: Equatable { + static func == (lhs: CategoryPickerItem, rhs: CategoryPickerItem) -> Bool { + switch (lhs, rhs) { + case (.all, .all): + return true + case (.category(let categoryLeft), .category(let categoryRight)): + return categoryLeft.category.rawValue == categoryRight.category.rawValue + default: + return false + } + } +} + +extension CategoryPickerItem: Hashable { + func hash(into hasher: inout Hasher) { + switch self { + case .all: + hasher.combine(String(describing: CategoryPickerItem.all.self)) + case .category(let category): + hasher.combine(category.category.rawValue) + } + } +} diff --git a/Mastodon/Diffiable/Item/ComposeStatusItem.swift b/Mastodon/Diffiable/Item/ComposeStatusItem.swift new file mode 100644 index 000000000..d60a76e82 --- /dev/null +++ b/Mastodon/Diffiable/Item/ComposeStatusItem.swift @@ -0,0 +1,137 @@ +// +// ComposeStatusItem.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-11. +// + +import Foundation +import Combine +import CoreData + +/// Note: update Equatable when change case +enum ComposeStatusItem { + case replyTo(statusObjectID: NSManagedObjectID) + case input(replyToStatusObjectID: NSManagedObjectID?, attribute: ComposeStatusAttribute) + case attachment(attachmentService: MastodonAttachmentService) + case pollOption(attribute: ComposePollOptionAttribute) + case pollOptionAppendEntry + case pollExpiresOption(attribute: ComposePollExpiresOptionAttribute) +} + +extension ComposeStatusItem: Equatable { } + +extension ComposeStatusItem: Hashable { } + +extension ComposeStatusItem { + final class ComposeStatusAttribute: Equatable, Hashable { + private let id = UUID() + + let avatarURL = CurrentValueSubject(nil) + let displayName = CurrentValueSubject(nil) + let username = CurrentValueSubject(nil) + let composeContent = CurrentValueSubject(nil) + + let isContentWarningComposing = CurrentValueSubject(false) + let contentWarningContent = CurrentValueSubject("") + + static func == (lhs: ComposeStatusAttribute, rhs: ComposeStatusAttribute) -> Bool { + return lhs.avatarURL.value == rhs.avatarURL.value && + lhs.displayName.value == rhs.displayName.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 + } + + func hash(into hasher: inout Hasher) { + hasher.combine(id) + } + } +} + +protocol ComposePollAttributeDelegate: AnyObject { + func composePollAttribute(_ attribute: ComposeStatusItem.ComposePollOptionAttribute, pollOptionDidChange: String?) +} + +extension ComposeStatusItem { + final class ComposePollOptionAttribute: Equatable, Hashable { + private let id = UUID() + + var disposeBag = Set() + weak var delegate: ComposePollAttributeDelegate? + + let option = CurrentValueSubject("") + + init() { + option + .sink { [weak self] option in + guard let self = self else { return } + self.delegate?.composePollAttribute(self, pollOptionDidChange: option) + } + .store(in: &disposeBag) + } + + deinit { + disposeBag.removeAll() + } + + static func == (lhs: ComposePollOptionAttribute, rhs: ComposePollOptionAttribute) -> Bool { + return lhs.id == rhs.id && + lhs.option.value == rhs.option.value + } + + func hash(into hasher: inout Hasher) { + hasher.combine(id) + } + } +} + +extension ComposeStatusItem { + final class ComposePollExpiresOptionAttribute: Equatable, Hashable { + private let id = UUID() + + let expiresOption = CurrentValueSubject(.thirtyMinutes) + + + static func == (lhs: ComposePollExpiresOptionAttribute, rhs: ComposePollExpiresOptionAttribute) -> Bool { + return lhs.id == rhs.id && + lhs.expiresOption.value == rhs.expiresOption.value + } + + func hash(into hasher: inout Hasher) { + hasher.combine(id) + } + + enum ExpiresOption: Equatable, Hashable, CaseIterable { + case thirtyMinutes + case oneHour + case sixHours + case oneDay + case threeDays + case sevenDays + + var title: String { + switch self { + case .thirtyMinutes: return L10n.Scene.Compose.Poll.thirtyMinutes + case .oneHour: return L10n.Scene.Compose.Poll.oneHour + case .sixHours: return L10n.Scene.Compose.Poll.sixHours + case .oneDay: return L10n.Scene.Compose.Poll.oneDay + case .threeDays: return L10n.Scene.Compose.Poll.threeDays + case .sevenDays: return L10n.Scene.Compose.Poll.sevenDays + } + } + + var seconds: Int { + switch self { + case .thirtyMinutes: return 60 * 30 + case .oneHour: return 60 * 60 * 1 + case .sixHours: return 60 * 60 * 6 + case .oneDay: return 60 * 60 * 24 + case .threeDays: return 60 * 60 * 24 * 3 + case .sevenDays: return 60 * 60 * 24 * 7 + } + } + } + } +} diff --git a/Mastodon/Diffiable/Item/CustomEmojiPickerItem.swift b/Mastodon/Diffiable/Item/CustomEmojiPickerItem.swift new file mode 100644 index 000000000..52f522703 --- /dev/null +++ b/Mastodon/Diffiable/Item/CustomEmojiPickerItem.swift @@ -0,0 +1,36 @@ +// +// CustomEmojiPickerItem.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-24. +// + +import Foundation +import MastodonSDK + +enum CustomEmojiPickerItem { + case emoji(attribute: CustomEmojiAttribute) +} + +extension CustomEmojiPickerItem: Equatable, Hashable { } + +extension CustomEmojiPickerItem { + final class CustomEmojiAttribute: Equatable, Hashable { + let id = UUID() + + let emoji: Mastodon.Entity.Emoji + + init(emoji: Mastodon.Entity.Emoji) { + self.emoji = emoji + } + + static func == (lhs: CustomEmojiPickerItem.CustomEmojiAttribute, rhs: CustomEmojiPickerItem.CustomEmojiAttribute) -> Bool { + return lhs.id == rhs.id && + lhs.emoji.shortcode == rhs.emoji.shortcode + } + + func hash(into hasher: inout Hasher) { + hasher.combine(id) + } + } +} diff --git a/Mastodon/Diffiable/Item/Item.swift b/Mastodon/Diffiable/Item/Item.swift index c6a182b4d..04a1262d5 100644 --- a/Mastodon/Diffiable/Item/Item.swift +++ b/Mastodon/Diffiable/Item/Item.swift @@ -5,6 +5,7 @@ // Created by sxiaojian on 2021/1/27. // +import Combine import CoreData import CoreDataStack import Foundation @@ -13,45 +14,82 @@ import MastodonSDK /// Note: update Equatable when change case enum Item { // timeline - case homeTimelineIndex(objectID: NSManagedObjectID, attribute: StatusTimelineAttribute) + 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 toot(objectID: NSManagedObjectID, attribute: StatusTimelineAttribute) + case status(objectID: NSManagedObjectID, attribute: StatusAttribute) // loader case homeMiddleLoader(upperTimelineIndexAnchorObjectID: NSManagedObjectID) - case publicMiddleLoader(tootID: String) + case publicMiddleLoader(statusID: String) + case topLoader case bottomLoader -} - -protocol StatusContentWarningAttribute { - var isStatusTextSensitive: Bool { get set } - var isStatusSensitive: Bool { get set } + + case emptyStateHeader(attribute: EmptyStateHeaderAttribute) + + // reports + case reportStatus(objectID: NSManagedObjectID, attribute: ReportStatusAttribute) } extension Item { - class StatusTimelineAttribute: Hashable, StatusContentWarningAttribute { - var isStatusTextSensitive: Bool - var isStatusSensitive: Bool + class StatusAttribute { + var isSeparatorLineHidden: Bool + + let isImageLoaded = CurrentValueSubject(false) + let isRevealing = CurrentValueSubject(false) - public init( - isStatusTextSensitive: Bool, - isStatusSensitive: Bool - ) { - self.isStatusTextSensitive = isStatusTextSensitive - self.isStatusSensitive = isStatusSensitive + init(isSeparatorLineHidden: Bool = false) { + self.isSeparatorLineHidden = isSeparatorLineHidden + } + } + + class EmptyStateHeaderAttribute: Hashable { + let id = UUID() + let reason: Reason + + enum Reason: Equatable { + case noStatusFound + case blocking + case blocked + 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, blocking): return true + case (.blocked, blocked): return true + case (.suspended(let nameLeft), .suspended(let nameRight)): return nameLeft == nameRight + default: return false + } + } } - static func == (lhs: Item.StatusTimelineAttribute, rhs: Item.StatusTimelineAttribute) -> Bool { - return lhs.isStatusTextSensitive == rhs.isStatusTextSensitive && - lhs.isStatusSensitive == rhs.isStatusSensitive + 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(isStatusTextSensitive) - hasher.combine(isStatusSensitive) + hasher.combine(id) + } + } + + class ReportStatusAttribute: StatusAttribute { + var isSelected: Bool + + init(isSeparatorLineHidden: Bool = false, isSelected: Bool = false) { + self.isSelected = isSelected + super.init(isSeparatorLineHidden: isSeparatorLineHidden) } - } } @@ -60,14 +98,28 @@ extension Item: Equatable { switch (lhs, rhs) { case (.homeTimelineIndex(let objectIDLeft, _), .homeTimelineIndex(let objectIDRight, _)): return objectIDLeft == objectIDRight - case (.toot(let objectIDLeft, _), .toot(let 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 (.bottomLoader, .bottomLoader): - return true - case (.publicMiddleLoader(let upperLeft), .publicMiddleLoader(let upperRight)): - return upperLeft == upperRight 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 (.emptyStateHeader(let attributeLeft), .emptyStateHeader(let attributeRight)): + return attributeLeft == attributeRight + case (.reportStatus(let objectIDLeft, _), .reportStatus(let objectIDRight, _)): + return objectIDLeft == objectIDRight default: return false } @@ -79,16 +131,30 @@ extension Item: Hashable { switch self { case .homeTimelineIndex(let objectID, _): hasher.combine(objectID) - case .toot(let 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 .publicMiddleLoader(let upper): - hasher.combine(String(describing: Item.publicMiddleLoader.self)) - hasher.combine(upper) 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 .emptyStateHeader(let attribute): + hasher.combine(attribute) + case .reportStatus(let objectID, _): + hasher.combine(objectID) } } } diff --git a/Mastodon/Diffiable/Item/NotificationItem.swift b/Mastodon/Diffiable/Item/NotificationItem.swift new file mode 100644 index 000000000..f26d2e43d --- /dev/null +++ b/Mastodon/Diffiable/Item/NotificationItem.swift @@ -0,0 +1,39 @@ +// +// NotificationItem.swift +// Mastodon +// +// Created by sxiaojian on 2021/4/13. +// + +import CoreData +import Foundation + +enum NotificationItem { + case notification(objectID: NSManagedObjectID, attribute: Item.StatusAttribute) + + 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 (.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 .bottomLoader: + hasher.combine(String(describing: NotificationItem.bottomLoader.self)) + } + } +} diff --git a/Mastodon/Diffiable/Item/PickServerItem.swift b/Mastodon/Diffiable/Item/PickServerItem.swift new file mode 100644 index 000000000..1ae38ba1c --- /dev/null +++ b/Mastodon/Diffiable/Item/PickServerItem.swift @@ -0,0 +1,94 @@ +// +// PickServerItem.swift +// Mastodon +// +// Created by Cirno MainasuK on 2021/3/5. +// + +import Foundation +import MastodonSDK + +/// Note: update Equatable when change case +enum PickServerItem { + case header + case categoryPicker(items: [CategoryPickerItem]) + case search + case server(server: Mastodon.Entity.Server, attribute: ServerItemAttribute) + case loader(attribute: LoaderItemAttribute) +} + +extension PickServerItem { + final class ServerItemAttribute: Equatable, Hashable { + var isLast: Bool + var isExpand: Bool + + init(isLast: Bool, isExpand: Bool) { + self.isLast = isLast + self.isExpand = isExpand + } + + static func == (lhs: PickServerItem.ServerItemAttribute, rhs: PickServerItem.ServerItemAttribute) -> Bool { + return lhs.isExpand == rhs.isExpand + } + + func hash(into hasher: inout Hasher) { + hasher.combine(isExpand) + } + } + + final class LoaderItemAttribute: Equatable, Hashable { + let id = UUID() + + var isLast: Bool + var isNoResult: Bool + + init(isLast: Bool, isEmptyResult: Bool) { + self.isLast = isLast + self.isNoResult = isEmptyResult + } + + static func == (lhs: PickServerItem.LoaderItemAttribute, rhs: PickServerItem.LoaderItemAttribute) -> Bool { + return lhs.id == rhs.id + } + + func hash(into hasher: inout Hasher) { + hasher.combine(id) + } + } +} + +extension PickServerItem: Equatable { + static func == (lhs: PickServerItem, rhs: PickServerItem) -> Bool { + switch (lhs, rhs) { + case (.header, .header): + return true + case (.categoryPicker(let itemsLeft), .categoryPicker(let itemsRight)): + return itemsLeft == itemsRight + case (.search, .search): + return true + case (.server(let serverLeft, _), .server(let serverRight, _)): + return serverLeft.domain == serverRight.domain + case (.loader(let attributeLeft), loader(let attributeRight)): + return attributeLeft == attributeRight + default: + return false + } + } +} + +extension PickServerItem: Hashable { + func hash(into hasher: inout Hasher) { + switch self { + case .header: + hasher.combine(String(describing: PickServerItem.header.self)) + case .categoryPicker(let items): + hasher.combine(items) + case .search: + hasher.combine(String(describing: PickServerItem.search.self)) + case .server(let server, _): + hasher.combine(server.domain) + case .loader(let attribute): + hasher.combine(attribute) + } + } +} diff --git a/Mastodon/Diffiable/Item/PollItem.swift b/Mastodon/Diffiable/Item/PollItem.swift new file mode 100644 index 000000000..1e7bd4ce7 --- /dev/null +++ b/Mastodon/Diffiable/Item/PollItem.swift @@ -0,0 +1,68 @@ +// +// PollItem.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-2. +// + +import Foundation +import CoreData + +/// Note: update Equatable when change case +enum PollItem { + case opion(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 (.opion(let objectIDLeft, _), .opion(let objectIDRight, _)): + return objectIDLeft == objectIDRight + } + } +} + + +extension PollItem: Hashable { + func hash(into hasher: inout Hasher) { + switch self { + case .opion(let objectID, _): + hasher.combine(objectID) + } + } +} diff --git a/Mastodon/Diffiable/Item/SearchResultItem.swift b/Mastodon/Diffiable/Item/SearchResultItem.swift new file mode 100644 index 000000000..53a36e2e5 --- /dev/null +++ b/Mastodon/Diffiable/Item/SearchResultItem.swift @@ -0,0 +1,58 @@ +// +// SearchResultItem.swift +// Mastodon +// +// Created by sxiaojian on 2021/4/6. +// + +import CoreData +import Foundation +import MastodonSDK + +enum SearchResultItem { + case hashtag(tag: Mastodon.Entity.Tag) + + case account(account: Mastodon.Entity.Account) + + case accountObjectID(accountObjectID: NSManagedObjectID) + + case hashtagObjectID(hashtagObjectID: NSManagedObjectID) + + case bottomLoader +} + +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 (.bottomLoader, .bottomLoader): + return true + case (.accountObjectID(let idLeft),.accountObjectID(let idRight)): + return idLeft == idRight + case (.hashtagObjectID(let idLeft),.hashtagObjectID(let idRight)): + return idLeft == idRight + default: + return false + } + } +} + +extension SearchResultItem: Hashable { + func hash(into hasher: inout Hasher) { + switch self { + case .account(let account): + hasher.combine(account) + case .hashtag(let tag): + hasher.combine(tag) + case .accountObjectID(let id): + hasher.combine(id) + case .hashtagObjectID(let id): + hasher.combine(id) + case .bottomLoader: + hasher.combine(String(describing: SearchResultItem.bottomLoader.self)) + } + } +} diff --git a/Mastodon/Diffiable/Item/SelectedAccountItem.swift b/Mastodon/Diffiable/Item/SelectedAccountItem.swift new file mode 100644 index 000000000..dbfe25cea --- /dev/null +++ b/Mastodon/Diffiable/Item/SelectedAccountItem.swift @@ -0,0 +1,38 @@ +// +// SelectedAccountItem.swift +// Mastodon +// +// Created by sxiaojian on 2021/4/22. +// + +import CoreData +import Foundation + +enum SelectedAccountItem { + case accountObjectID(accountObjectID: NSManagedObjectID) + case placeHolder(uuid: UUID) +} + +extension SelectedAccountItem: Equatable { + static func == (lhs: SelectedAccountItem, rhs: SelectedAccountItem) -> Bool { + switch (lhs, rhs) { + case (.accountObjectID(let idLeft), .accountObjectID(let idRight)): + return idLeft == idRight + case (.placeHolder(let uuidLeft), .placeHolder(let uuidRight)): + return uuidLeft == uuidRight + default: + return false + } + } +} + +extension SelectedAccountItem: Hashable { + func hash(into hasher: inout Hasher) { + switch self { + case .accountObjectID(let id): + hasher.combine(id) + case .placeHolder(let id): + hasher.combine(id.uuidString) + } + } +} diff --git a/Mastodon/Diffiable/Item/SettingsItem.swift b/Mastodon/Diffiable/Item/SettingsItem.swift new file mode 100644 index 000000000..8aabdc741 --- /dev/null +++ b/Mastodon/Diffiable/Item/SettingsItem.swift @@ -0,0 +1,67 @@ +// +// SettingsItem.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-25. +// + +import UIKit +import CoreData + +enum SettingsItem: Hashable { + case apperance(settingObjectID: NSManagedObjectID) + case notification(settingObjectID: NSManagedObjectID, switchMode: NotificationSwitchMode) + case boringZone(item: Link) + case spicyZone(item: Link) +} + +extension SettingsItem { + + enum AppearanceMode: String { + case automatic + case light + case dark + } + + enum NotificationSwitchMode: CaseIterable { + case favorite + case follow + case reblog + case mention + + var title: String { + switch self { + case .favorite: return L10n.Scene.Settings.Section.Notifications.favorites + case .follow: return L10n.Scene.Settings.Section.Notifications.follows + case .reblog: return L10n.Scene.Settings.Section.Notifications.boosts + case .mention: return L10n.Scene.Settings.Section.Notifications.mentions + } + } + } + + enum Link: CaseIterable { + case termsOfService + case privacyPolicy + case clearMediaCache + case signOut + + var title: String { + switch self { + case .termsOfService: return L10n.Scene.Settings.Section.Boringzone.terms + case .privacyPolicy: return L10n.Scene.Settings.Section.Boringzone.privacy + case .clearMediaCache: return L10n.Scene.Settings.Section.Spicyzone.clear + case .signOut: return L10n.Scene.Settings.Section.Spicyzone.signout + } + } + + var textColor: UIColor { + switch self { + case .termsOfService: return .systemBlue + case .privacyPolicy: return .systemBlue + case .clearMediaCache: return .systemRed + case .signOut: return .systemRed + } + } + } + +} diff --git a/Mastodon/Diffiable/Section/CategoryPickerSection.swift b/Mastodon/Diffiable/Section/CategoryPickerSection.swift new file mode 100644 index 000000000..7ab93cc5e --- /dev/null +++ b/Mastodon/Diffiable/Section/CategoryPickerSection.swift @@ -0,0 +1,52 @@ +// +// CategoryPickerSection.swift +// Mastodon +// +// Created by Cirno MainasuK on 2021/3/5. +// + +import UIKit + +enum CategoryPickerSection: Equatable, Hashable { + case main +} + +extension CategoryPickerSection { + static func collectionViewDiffableDataSource( + for collectionView: UICollectionView, + dependency: NeedsDependency + ) -> UICollectionViewDiffableDataSource { + UICollectionViewDiffableDataSource(collectionView: collectionView) { [weak dependency] collectionView, indexPath, item -> UICollectionViewCell? in + guard let _ = dependency else { return nil } + let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: PickServerCategoryCollectionViewCell.self), for: indexPath) as! PickServerCategoryCollectionViewCell + switch item { + case .all: + cell.categoryView.titleLabel.font = .systemFont(ofSize: 17) + case .category: + cell.categoryView.titleLabel.font = .systemFont(ofSize: 28) + } + cell.categoryView.titleLabel.text = item.title + cell.observe(\.isSelected, options: [.initial, .new]) { cell, _ in + if cell.isSelected { + cell.categoryView.bgView.backgroundColor = Asset.Colors.brandBlue.color + cell.categoryView.bgView.applyShadow(color: Asset.Colors.brandBlue.color, alpha: 1, x: 0, y: 0, blur: 4.0) + if case .all = item { + cell.categoryView.titleLabel.textColor = .white + } + } else { + cell.categoryView.bgView.backgroundColor = Asset.Colors.Background.secondaryGroupedSystemBackground.color + cell.categoryView.bgView.applyShadow(color: Asset.Colors.brandBlue.color, alpha: 0, x: 0, y: 0, blur: 0.0) + if case .all = item { + cell.categoryView.titleLabel.textColor = Asset.Colors.brandBlue.color + } + } + } + .store(in: &cell.observations) + + cell.isAccessibilityElement = true + cell.accessibilityLabel = item.accessibilityDescription + + return cell + } + } +} diff --git a/Mastodon/Diffiable/Section/ComposeStatusSection.swift b/Mastodon/Diffiable/Section/ComposeStatusSection.swift new file mode 100644 index 000000000..2b7aecae0 --- /dev/null +++ b/Mastodon/Diffiable/Section/ComposeStatusSection.swift @@ -0,0 +1,312 @@ +// +// ComposeStatusSection.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-11. +// + +import UIKit +import Combine +import CoreData +import CoreDataStack +import TwitterTextEditor +import AlamofireImage + +enum ComposeStatusSection: Equatable, Hashable { + case repliedTo + case status + case attachment + case poll +} + +extension ComposeStatusSection { + enum ComposeKind { + case post + case hashtag(hashtag: String) + case mention(mastodonUserObjectID: NSManagedObjectID) + case reply(repliedToStatusObjectID: NSManagedObjectID) + } +} + +extension ComposeStatusSection { + static func collectionViewDiffableDataSource( + for collectionView: UICollectionView, + dependency: NeedsDependency, + managedObjectContext: NSManagedObjectContext, + composeKind: ComposeKind, + repliedToCellFrameSubscriber: CurrentValueSubject, + customEmojiPickerInputViewModel: CustomEmojiPickerInputViewModel, + textEditorViewTextAttributesDelegate: TextEditorViewTextAttributesDelegate, + composeStatusAttachmentTableViewCellDelegate: ComposeStatusAttachmentCollectionViewCellDelegate, + composeStatusPollOptionCollectionViewCellDelegate: ComposeStatusPollOptionCollectionViewCellDelegate, + composeStatusNewPollOptionCollectionViewCellDelegate: ComposeStatusPollOptionAppendEntryCollectionViewCellDelegate, + composeStatusPollExpiresOptionCollectionViewCellDelegate: ComposeStatusPollExpiresOptionCollectionViewCellDelegate + ) -> UICollectionViewDiffableDataSource { + UICollectionViewDiffableDataSource(collectionView: collectionView) { [ + weak customEmojiPickerInputViewModel, + weak textEditorViewTextAttributesDelegate, + weak composeStatusAttachmentTableViewCellDelegate, + weak composeStatusPollOptionCollectionViewCellDelegate, + weak composeStatusNewPollOptionCollectionViewCellDelegate, + weak composeStatusPollExpiresOptionCollectionViewCellDelegate + ] collectionView, indexPath, item -> UICollectionViewCell? in + switch item { + case .replyTo(let replyToStatusObjectID): + let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: ComposeRepliedToStatusContentCollectionViewCell.self), for: indexPath) as! ComposeRepliedToStatusContentCollectionViewCell + managedObjectContext.perform { + guard let replyTo = managedObjectContext.object(with: replyToStatusObjectID) as? Status else { + return + } + let status = replyTo.reblog ?? replyTo + + // set avatar + cell.statusView.configure(with: AvatarConfigurableViewConfiguration(avatarImageURL: status.author.avatarImageURL())) + // set name username + cell.statusView.nameLabel.text = { + let author = status.author + return author.displayName.isEmpty ? author.username : author.displayName + }() + cell.statusView.usernameLabel.text = "@" + (status.reblog ?? status).author.acct + // set text + //status.emoji + cell.statusView.activeTextLabel.configure(content: status.content, emojiDict: [:]) + // set date + cell.statusView.dateLabel.text = status.createdAt.shortTimeAgoSinceNow + + cell.framePublisher.assign(to: \.value, on: repliedToCellFrameSubscriber).store(in: &cell.disposeBag) + } + return cell + case .input(let replyToStatusObjectID, let attribute): + let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: ComposeStatusContentCollectionViewCell.self), for: indexPath) as! ComposeStatusContentCollectionViewCell + cell.statusContentWarningEditorView.textView.text = attribute.contentWarningContent.value + cell.textEditorView.text = attribute.composeContent.value ?? "" + managedObjectContext.perform { + guard let replyToStatusObjectID = replyToStatusObjectID, + let replyTo = managedObjectContext.object(with: replyToStatusObjectID) as? Status else { + cell.statusView.headerContainerView.isHidden = true + return + } + cell.statusView.headerContainerView.isHidden = false + cell.statusView.headerIconLabel.attributedText = StatusView.iconAttributedString(image: StatusView.replyIconImage) + cell.statusView.headerInfoLabel.text = L10n.Scene.Compose.replyingToUser(replyTo.author.displayNameWithFallback) + } + ComposeStatusSection.configureStatusContent(cell: cell, attribute: attribute) + cell.textEditorView.textAttributesDelegate = textEditorViewTextAttributesDelegate + cell.composeContent + .removeDuplicates() + .receive(on: DispatchQueue.main) + .sink { text in + // self size input cell + // needs restore content offset to resolve issue #83 + let oldContentOffset = collectionView.contentOffset + collectionView.collectionViewLayout.invalidateLayout() + collectionView.layoutIfNeeded() + collectionView.contentOffset = oldContentOffset + + // bind input data + attribute.composeContent.value = text + } + .store(in: &cell.disposeBag) + attribute.isContentWarningComposing + .receive(on: DispatchQueue.main) + .sink { isContentWarningComposing in + // self size input cell + collectionView.collectionViewLayout.invalidateLayout() + cell.statusContentWarningEditorView.containerView.isHidden = !isContentWarningComposing + cell.statusContentWarningEditorView.alpha = 0 + UIView.animate(withDuration: 0.33, delay: 0, options: [.curveEaseOut]) { + cell.statusContentWarningEditorView.alpha = 1 + } completion: { _ in + // do nothing + } + } + .store(in: &cell.disposeBag) + cell.contentWarningContent + .removeDuplicates() + .receive(on: DispatchQueue.main) + .sink { text in + // self size input cell + collectionView.collectionViewLayout.invalidateLayout() + // bind input data + attribute.contentWarningContent.value = text + } + .store(in: &cell.disposeBag) + ComposeStatusSection.configureCustomEmojiPicker(viewModel: customEmojiPickerInputViewModel, customEmojiReplacableTextInput: cell.textEditorView, disposeBag: &cell.disposeBag) + ComposeStatusSection.configureCustomEmojiPicker(viewModel: customEmojiPickerInputViewModel, customEmojiReplacableTextInput: cell.statusContentWarningEditorView.textView, disposeBag: &cell.disposeBag) + + return cell + case .attachment(let attachmentService): + let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: ComposeStatusAttachmentCollectionViewCell.self), for: indexPath) as! ComposeStatusAttachmentCollectionViewCell + cell.attachmentContainerView.descriptionTextView.text = attachmentService.description.value + cell.delegate = composeStatusAttachmentTableViewCellDelegate + attachmentService.imageData + .receive(on: DispatchQueue.main) + .sink { imageData in + let size = cell.attachmentContainerView.previewImageView.frame.size != .zero ? cell.attachmentContainerView.previewImageView.frame.size : CGSize(width: 1, height: 1) + guard let imageData = imageData, + let image = UIImage(data: imageData) else { + let placeholder = UIImage.placeholder( + size: size, + color: Asset.Colors.Background.systemGroupedBackground.color + ) + .af.imageRounded( + withCornerRadius: AttachmentContainerView.containerViewCornerRadius + ) + cell.attachmentContainerView.previewImageView.image = placeholder + return + } + cell.attachmentContainerView.previewImageView.image = image + .af.imageAspectScaled(toFill: size) + .af.imageRounded(withCornerRadius: AttachmentContainerView.containerViewCornerRadius) + } + .store(in: &cell.disposeBag) + Publishers.CombineLatest( + attachmentService.uploadStateMachineSubject.eraseToAnyPublisher(), + attachmentService.error.eraseToAnyPublisher() + ) + .receive(on: DispatchQueue.main) + .sink { uploadState, error in + cell.attachmentContainerView.emptyStateView.isHidden = error == nil + cell.attachmentContainerView.descriptionBackgroundView.isHidden = error != nil + if let _ = error { + cell.attachmentContainerView.activityIndicatorView.stopAnimating() + } else { + guard let uploadState = uploadState else { return } + switch uploadState { + case is MastodonAttachmentService.UploadState.Finish, + is MastodonAttachmentService.UploadState.Fail: + cell.attachmentContainerView.activityIndicatorView.stopAnimating() + default: + break + } + } + } + .store(in: &cell.disposeBag) + NotificationCenter.default.publisher( + for: UITextView.textDidChangeNotification, + object: cell.attachmentContainerView.descriptionTextView + ) + .receive(on: DispatchQueue.main) + .sink { notification in + guard let textField = notification.object as? UITextView else { return } + let text = textField.text?.trimmingCharacters(in: .whitespacesAndNewlines) + attachmentService.description.value = text + } + .store(in: &cell.disposeBag) + return cell + case .pollOption(let attribute): + let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: ComposeStatusPollOptionCollectionViewCell.self), for: indexPath) as! ComposeStatusPollOptionCollectionViewCell + cell.pollOptionView.optionTextField.text = attribute.option.value + cell.pollOptionView.optionTextField.placeholder = L10n.Scene.Compose.Poll.optionNumber(indexPath.item + 1) + cell.pollOption + .receive(on: DispatchQueue.main) + .assign(to: \.value, on: attribute.option) + .store(in: &cell.disposeBag) + cell.delegate = composeStatusPollOptionCollectionViewCellDelegate + ComposeStatusSection.configureCustomEmojiPicker(viewModel: customEmojiPickerInputViewModel, customEmojiReplacableTextInput: cell.pollOptionView.optionTextField, disposeBag: &cell.disposeBag) + return cell + case .pollOptionAppendEntry: + let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: ComposeStatusPollOptionAppendEntryCollectionViewCell.self), for: indexPath) as! ComposeStatusPollOptionAppendEntryCollectionViewCell + cell.delegate = composeStatusNewPollOptionCollectionViewCellDelegate + return cell + case .pollExpiresOption(let attribute): + let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: ComposeStatusPollExpiresOptionCollectionViewCell.self), for: indexPath) as! ComposeStatusPollExpiresOptionCollectionViewCell + cell.durationButton.setTitle(L10n.Scene.Compose.Poll.durationTime(attribute.expiresOption.value.title), for: .normal) + attribute.expiresOption + .receive(on: DispatchQueue.main) + .sink { expiresOption in + cell.durationButton.setTitle(L10n.Scene.Compose.Poll.durationTime(expiresOption.title), for: .normal) + } + .store(in: &cell.disposeBag) + cell.delegate = composeStatusPollExpiresOptionCollectionViewCellDelegate + return cell + } + } + } +} + +extension ComposeStatusSection { + + static func configureStatusContent( + cell: ComposeStatusContentCollectionViewCell, + 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.CombineLatest( + attribute.displayName.eraseToAnyPublisher(), + attribute.username.eraseToAnyPublisher() + ) + .receive(on: DispatchQueue.main) + .sink { displayName, username in + cell.statusView.nameLabel.text = displayName + cell.statusView.usernameLabel.text = username.flatMap { "@" + $0 } ?? " " + } + .store(in: &cell.disposeBag) + + // bind compose content + cell.composeContent + .map { $0 as String? } + .assign(to: \.value, on: attribute.composeContent) + .store(in: &cell.disposeBag) + } + +} + +protocol CustomEmojiReplacableTextInput: AnyObject { + var inputView: UIView? { get set } + func reloadInputViews() + + // UIKeyInput + func insertText(_ text: String) + // UIResponder + var isFirstResponder: Bool { get } +} + +class CustomEmojiReplacableTextInputReference { + weak var value: CustomEmojiReplacableTextInput? + + init(value: CustomEmojiReplacableTextInput? = nil) { + self.value = value + } +} + +extension TextEditorView: CustomEmojiReplacableTextInput { + func insertText(_ text: String) { + try? updateByReplacing(range: selectedRange, with: text, selectedRange: nil) + } + + public override var isFirstResponder: Bool { + return isEditing + } + +} +extension UITextField: CustomEmojiReplacableTextInput { } +extension UITextView: CustomEmojiReplacableTextInput { } + +extension ComposeStatusSection { + + static func configureCustomEmojiPicker( + viewModel: CustomEmojiPickerInputViewModel?, + customEmojiReplacableTextInput: CustomEmojiReplacableTextInput, + disposeBag: inout Set + ) { + guard let viewModel = viewModel else { return } + viewModel.isCustomEmojiComposing + .receive(on: DispatchQueue.main) + .sink { [weak viewModel] isCustomEmojiComposing in + guard let viewModel = viewModel else { return } + customEmojiReplacableTextInput.inputView = isCustomEmojiComposing ? viewModel.customEmojiPickerInputView : nil + customEmojiReplacableTextInput.reloadInputViews() + viewModel.append(customEmojiReplacableTextInput: customEmojiReplacableTextInput) + } + .store(in: &disposeBag) + } + +} diff --git a/Mastodon/Diffiable/Section/CustomEmojiPickerSection.swift b/Mastodon/Diffiable/Section/CustomEmojiPickerSection.swift new file mode 100644 index 000000000..20dc5b809 --- /dev/null +++ b/Mastodon/Diffiable/Section/CustomEmojiPickerSection.swift @@ -0,0 +1,62 @@ +// +// CustomEmojiPickerSection.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-24. +// + +import UIKit +import Kingfisher + +enum CustomEmojiPickerSection: Equatable, Hashable { + case emoji(name: String) +} + +extension CustomEmojiPickerSection { + static func collectionViewDiffableDataSource( + for collectionView: UICollectionView, + dependency: NeedsDependency + ) -> UICollectionViewDiffableDataSource { + let dataSource = UICollectionViewDiffableDataSource(collectionView: collectionView) { [weak dependency] collectionView, indexPath, item -> UICollectionViewCell? in + guard let _ = dependency else { return nil } + switch item { + case .emoji(let attribute): + let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: CustomEmojiPickerItemCollectionViewCell.self), for: indexPath) as! CustomEmojiPickerItemCollectionViewCell + let placeholder = UIImage.placeholder(size: CustomEmojiPickerItemCollectionViewCell.itemSize, color: .systemFill) + .af.imageRounded(withCornerRadius: 4) + cell.emojiImageView.kf.setImage( + with: URL(string: attribute.emoji.url), + placeholder: placeholder, + options: [ + .transition(.fade(0.2)) + ], + completionHandler: nil + ) + cell.accessibilityLabel = attribute.emoji.shortcode + return cell + } + } + + dataSource.supplementaryViewProvider = { [weak dataSource] collectionView, kind, indexPath -> UICollectionReusableView? in + guard let dataSource = dataSource else { return nil } + let sections = dataSource.snapshot().sectionIdentifiers + guard indexPath.section < sections.count else { return nil } + let section = sections[indexPath.section] + + switch kind { + case String(describing: CustomEmojiPickerHeaderCollectionReusableView.self): + let header = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: String(describing: CustomEmojiPickerHeaderCollectionReusableView.self), for: indexPath) as! CustomEmojiPickerHeaderCollectionReusableView + switch section { + case .emoji(let name): + header.titlelabel.text = name + } + return header + default: + assertionFailure() + return nil + } + } + + return dataSource + } +} diff --git a/Mastodon/Diffiable/Section/NotificationSection.swift b/Mastodon/Diffiable/Section/NotificationSection.swift new file mode 100644 index 000000000..ead5d48f8 --- /dev/null +++ b/Mastodon/Diffiable/Section/NotificationSection.swift @@ -0,0 +1,134 @@ +// +// NotificationSection.swift +// Mastodon +// +// Created by sxiaojian on 2021/4/13. +// + +import Combine +import CoreData +import CoreDataStack +import Foundation +import MastodonSDK +import UIKit + +enum NotificationSection: Equatable, Hashable { + case main +} + +extension NotificationSection { + static func tableViewDiffableDataSource( + for tableView: UITableView, + timestampUpdatePublisher: AnyPublisher, + managedObjectContext: NSManagedObjectContext, + delegate: NotificationTableViewCellDelegate, + dependency: NeedsDependency + ) -> 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): + + let notification = managedObjectContext.object(with: objectID) as! MastodonNotification + guard let type = Mastodon.Entity.Notification.NotificationType(rawValue: notification.typeRaw) else { + assertionFailure() + return nil + } + let timeText = notification.createAt.shortTimeAgoSinceNow + + let actionText = type.actionText + let actionImageName = type.actionImageName + let color = type.color + + if let status = notification.status { + let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: NotificationStatusTableViewCell.self), for: indexPath) as! NotificationStatusTableViewCell + cell.delegate = delegate + let activeMastodonAuthenticationBox = dependency.context.authenticationService.activeMastodonAuthenticationBox.value + let requestUserID = activeMastodonAuthenticationBox?.userID ?? "" + 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, + dependency: dependency, + readableLayoutFrame: frame, + timestampUpdatePublisher: timestampUpdatePublisher, + status: status, + requestUserID: requestUserID, + statusItemAttribute: attribute + ) + timestampUpdatePublisher + .sink { _ in + let timeText = notification.createAt.shortTimeAgoSinceNow + cell.actionLabel.text = actionText + " · " + timeText + } + .store(in: &cell.disposeBag) + cell.actionImageBackground.backgroundColor = color + cell.actionLabel.text = actionText + " · " + timeText + cell.nameLabel.text = notification.account.displayName.isEmpty ? notification.account.username : notification.account.displayName + if let url = notification.account.avatarImageURL() { + cell.avatatImageView.af.setImage( + withURL: url, + placeholderImage: UIImage.placeholder(color: .systemFill), + imageTransition: .crossDissolve(0.2) + ) + } + cell.avatatImageView.gesture().sink { [weak cell] _ in + cell?.delegate?.userAvatarDidPressed(notification: notification) + } + .store(in: &cell.disposeBag) + if let actionImage = UIImage(systemName: actionImageName, withConfiguration: UIImage.SymbolConfiguration(pointSize: 12, weight: .semibold))?.withRenderingMode(.alwaysTemplate) { + cell.actionImageView.image = actionImage + } + return cell + + } else { + let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: NotificationTableViewCell.self), for: indexPath) as! NotificationTableViewCell + cell.delegate = delegate + timestampUpdatePublisher + .sink { _ in + let timeText = notification.createAt.shortTimeAgoSinceNow + cell.actionLabel.text = actionText + " · " + timeText + } + .store(in: &cell.disposeBag) + 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.actionImageBackground.backgroundColor = color + cell.actionLabel.text = actionText + " · " + timeText + cell.nameLabel.text = notification.account.displayName.isEmpty ? notification.account.username : notification.account.displayName + if let url = notification.account.avatarImageURL() { + cell.avatatImageView.af.setImage( + withURL: url, + placeholderImage: UIImage.placeholder(color: .systemFill), + imageTransition: .crossDissolve(0.2) + ) + } + cell.avatatImageView.gesture().sink { [weak cell] _ in + cell?.delegate?.userAvatarDidPressed(notification: notification) + } + .store(in: &cell.disposeBag) + if let actionImage = UIImage(systemName: actionImageName, withConfiguration: UIImage.SymbolConfiguration(pointSize: 12, weight: .semibold))?.withRenderingMode(.alwaysTemplate) { + cell.actionImageView.image = actionImage + } + cell.buttonStackView.isHidden = (type != .followRequest) + return cell + } + case .bottomLoader: + let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self)) as! TimelineBottomLoaderTableViewCell + cell.startAnimating() + return cell + } + } + } +} + diff --git a/Mastodon/Diffiable/Section/PickServerSection.swift b/Mastodon/Diffiable/Section/PickServerSection.swift new file mode 100644 index 000000000..aaafb8ce7 --- /dev/null +++ b/Mastodon/Diffiable/Section/PickServerSection.swift @@ -0,0 +1,163 @@ +// +// PickServerSection.swift +// Mastodon +// +// Created by Cirno MainasuK on 2021/3/5. +// + +import UIKit +import MastodonSDK +import Kanna +import AlamofireImage + +enum PickServerSection: Equatable, Hashable { + case header + case category + case search + case servers +} + +extension PickServerSection { + static func tableViewDiffableDataSource( + for tableView: UITableView, + dependency: NeedsDependency, + pickServerCategoriesCellDelegate: PickServerCategoriesCellDelegate, + pickServerSearchCellDelegate: PickServerSearchCellDelegate, + pickServerCellDelegate: PickServerCellDelegate + ) -> UITableViewDiffableDataSource { + UITableViewDiffableDataSource(tableView: tableView) { [ + weak dependency, + weak pickServerCategoriesCellDelegate, + weak pickServerSearchCellDelegate, + weak pickServerCellDelegate + ] tableView, indexPath, item -> UITableViewCell? in + guard let dependency = dependency else { return nil } + switch item { + case .header: + let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: PickServerTitleCell.self), for: indexPath) as! PickServerTitleCell + return cell + case .categoryPicker(let items): + let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: PickServerCategoriesCell.self), for: indexPath) as! PickServerCategoriesCell + cell.delegate = pickServerCategoriesCellDelegate + cell.diffableDataSource = CategoryPickerSection.collectionViewDiffableDataSource( + for: cell.collectionView, + dependency: dependency + ) + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([.main]) + snapshot.appendItems(items, toSection: .main) + cell.diffableDataSource?.apply(snapshot, animatingDifferences: false, completion: nil) + return cell + case .search: + let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: PickServerSearchCell.self), for: indexPath) as! PickServerSearchCell + cell.delegate = pickServerSearchCellDelegate + return cell + case .server(let server, let attribute): + let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: PickServerCell.self), for: indexPath) as! PickServerCell + PickServerSection.configure(cell: cell, server: server, attribute: attribute) + cell.delegate = pickServerCellDelegate + return cell + case .loader(let attribute): + let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: PickServerLoaderTableViewCell.self), for: indexPath) as! PickServerLoaderTableViewCell + PickServerSection.configure(cell: cell, attribute: attribute) + return cell + } + } + } +} + +extension PickServerSection { + + static func configure(cell: PickServerCell, server: Mastodon.Entity.Server, attribute: PickServerItem.ServerItemAttribute) { + cell.domainLabel.text = server.domain + cell.descriptionLabel.text = { + guard let html = try? HTML(html: server.description, encoding: .utf8) else { + return server.description + } + + return html.text ?? server.description + }() + cell.langValueLabel.text = server.language.uppercased() + cell.usersValueLabel.text = parseUsersCount(server.totalUsers) + cell.categoryValueLabel.text = server.category.uppercased() + + cell.updateExpandMode(mode: attribute.isExpand ? .expand : .collapse) + + if attribute.isLast { + cell.containerView.layer.maskedCorners = [ + .layerMinXMaxYCorner, + .layerMaxXMaxYCorner + ] + cell.containerView.layer.cornerCurve = .continuous + cell.containerView.layer.cornerRadius = MastodonPickServerAppearance.tableViewCornerRadius + } else { + cell.containerView.layer.cornerRadius = 0 + } + + cell.expandMode + .receive(on: DispatchQueue.main) + .sink { mode in + switch mode { + case .collapse: + // do nothing + break + case .expand: + let placeholderImage = UIImage.placeholder(size: cell.thumbnailImageView.frame.size, color: .systemFill) + .af.imageRounded(withCornerRadius: 3.0, divideRadiusByImageScale: false) + guard let proxiedThumbnail = server.proxiedThumbnail, + let url = URL(string: proxiedThumbnail) else { + cell.thumbnailImageView.image = placeholderImage + cell.thumbnailActivityIdicator.stopAnimating() + return + } + cell.thumbnailImageView.isHidden = false + cell.thumbnailActivityIdicator.startAnimating() + + cell.thumbnailImageView.af.setImage( + withURL: url, + placeholderImage: placeholderImage, + filter: AspectScaledToFillSizeWithRoundedCornersFilter(size: cell.thumbnailImageView.frame.size, radius: 3), + imageTransition: .crossDissolve(0.33), + completion: { [weak cell] response in + switch response.result { + case .success, .failure: + cell?.thumbnailActivityIdicator.stopAnimating() + } + } + ) + } + } + .store(in: &cell.disposeBag) + } + + private static func parseUsersCount(_ usersCount: Int) -> String { + switch usersCount { + case 0..<1000: + return "\(usersCount)" + default: + let usersCountInThousand = Float(usersCount) / 1000.0 + return String(format: "%.1fK", usersCountInThousand) + } + } + +} + +extension PickServerSection { + + static func configure(cell: PickServerLoaderTableViewCell, attribute: PickServerItem.LoaderItemAttribute) { + if attribute.isLast { + cell.containerView.layer.maskedCorners = [ + .layerMinXMaxYCorner, + .layerMaxXMaxYCorner + ] + cell.containerView.layer.cornerCurve = .continuous + cell.containerView.layer.cornerRadius = MastodonPickServerAppearance.tableViewCornerRadius + } else { + cell.containerView.layer.cornerRadius = 0 + } + + attribute.isNoResult ? cell.stopAnimating() : cell.startAnimating() + cell.emptyStatusLabel.isHidden = !attribute.isNoResult + } + +} diff --git a/Mastodon/Diffiable/Section/PollSection.swift b/Mastodon/Diffiable/Section/PollSection.swift new file mode 100644 index 000000000..044f4fb9d --- /dev/null +++ b/Mastodon/Diffiable/Section/PollSection.swift @@ -0,0 +1,99 @@ +// +// 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 .opion(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) + } + 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: + cell.pollOptionView.checkmarkBackgroundView.backgroundColor = .systemBackground + cell.pollOptionView.checkmarkBackgroundView.layer.borderColor = UIColor.systemGray3.cgColor + cell.pollOptionView.checkmarkBackgroundView.layer.borderWidth = 1 + cell.pollOptionView.checkmarkBackgroundView.isHidden = false + cell.pollOptionView.checkmarkImageView.isHidden = true + case .on: + cell.pollOptionView.checkmarkBackgroundView.backgroundColor = .systemBackground + 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.Background.Poll.highlight.color : Asset.Colors.Background.Poll.disabled.color + cell.pollOptionView.voteProgressStripView.setProgress(CGFloat(percentage), animated: animated) + } + } + +} diff --git a/Mastodon/Diffiable/Section/RecommendAccountSection.swift b/Mastodon/Diffiable/Section/RecommendAccountSection.swift new file mode 100644 index 000000000..64019e580 --- /dev/null +++ b/Mastodon/Diffiable/Section/RecommendAccountSection.swift @@ -0,0 +1,49 @@ +// +// RecommendAccountSection.swift +// Mastodon +// +// Created by sxiaojian on 2021/4/1. +// + +import CoreData +import CoreDataStack +import Foundation +import MastodonSDK +import UIKit + +enum RecommendAccountSection: Equatable, Hashable { + case main +} + +extension RecommendAccountSection { + static func collectionViewDiffableDataSource( + for collectionView: UICollectionView, + 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 + let user = managedObjectContext.object(with: objectID) as! MastodonUser + cell.delegate = delegate + cell.config(with: user) + return cell + } + } + + 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/Section/RecommendHashTagSection.swift b/Mastodon/Diffiable/Section/RecommendHashTagSection.swift new file mode 100644 index 000000000..502086910 --- /dev/null +++ b/Mastodon/Diffiable/Section/RecommendHashTagSection.swift @@ -0,0 +1,26 @@ +// +// 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/Section/ReportSection.swift b/Mastodon/Diffiable/Section/ReportSection.swift new file mode 100644 index 000000000..6faaae6c2 --- /dev/null +++ b/Mastodon/Diffiable/Section/ReportSection.swift @@ -0,0 +1,66 @@ +// +// 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, + dependency: dependency, + readableLayoutFrame: tableView.readableContentGuide.layoutFrame, + timestampUpdatePublisher: timestampUpdatePublisher, + status: status, + requestUserID: requestUserID, + statusItemAttribute: attribute + ) + } + + // defalut to select the report status + if attribute.isSelected { + tableView.selectRow(at: indexPath, animated: false, scrollPosition: .none) + } else { + tableView.deselectRow(at: indexPath, animated: false) + } + + return cell + default: + return nil + } + } + } +} diff --git a/Mastodon/Diffiable/Section/SearchResultSection.swift b/Mastodon/Diffiable/Section/SearchResultSection.swift new file mode 100644 index 000000000..1b9230ee0 --- /dev/null +++ b/Mastodon/Diffiable/Section/SearchResultSection.swift @@ -0,0 +1,53 @@ +// +// SearchResultSection.swift +// Mastodon +// +// Created by sxiaojian on 2021/4/6. +// + +import Foundation +import MastodonSDK +import UIKit +import CoreData +import CoreDataStack + +enum SearchResultSection: Equatable, Hashable { + case account + case hashtag + case mixed + case bottomLoader +} + +extension SearchResultSection { + static func tableViewDiffableDataSource( + for tableView: UITableView, + dependency: NeedsDependency + ) -> UITableViewDiffableDataSource { + UITableViewDiffableDataSource(tableView: tableView) { (tableView, indexPath, result) -> UITableViewCell? in + switch result { + case .account(let account): + let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: SearchingTableViewCell.self), for: indexPath) as! SearchingTableViewCell + cell.config(with: account) + return cell + case .hashtag(let tag): + let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: SearchingTableViewCell.self), for: indexPath) as! SearchingTableViewCell + cell.config(with: tag) + return cell + case .hashtagObjectID(let hashtagObjectID): + let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: SearchingTableViewCell.self), for: indexPath) as! SearchingTableViewCell + let tag = dependency.context.managedObjectContext.object(with: hashtagObjectID) as! Tag + cell.config(with: tag) + return cell + case .accountObjectID(let accountObjectID): + let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: SearchingTableViewCell.self), for: indexPath) as! SearchingTableViewCell + let user = dependency.context.managedObjectContext.object(with: accountObjectID) as! MastodonUser + cell.config(with: user) + return cell + case .bottomLoader: + let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self)) as! TimelineBottomLoaderTableViewCell + cell.startAnimating() + return cell + } + } + } +} diff --git a/Mastodon/Diffiable/Section/SelectedAccountSection.swift b/Mastodon/Diffiable/Section/SelectedAccountSection.swift new file mode 100644 index 000000000..4f18ef873 --- /dev/null +++ b/Mastodon/Diffiable/Section/SelectedAccountSection.swift @@ -0,0 +1,35 @@ +// +// SelectedAccountSection.swift +// Mastodon +// +// Created by sxiaojian on 2021/4/22. +// + +import CoreData +import CoreDataStack +import Foundation +import MastodonSDK +import UIKit + +enum SelectedAccountSection: Equatable, Hashable { + case main +} + +extension SelectedAccountSection { + static func collectionViewDiffableDataSource( + for collectionView: UICollectionView, + managedObjectContext: NSManagedObjectContext + ) -> UICollectionViewDiffableDataSource { + UICollectionViewDiffableDataSource(collectionView: collectionView) { collectionView, indexPath, item -> UICollectionViewCell? in + let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: SuggestionAccountCollectionViewCell.self), for: indexPath) as! SuggestionAccountCollectionViewCell + switch item { + case .accountObjectID(let objectID): + let user = managedObjectContext.object(with: objectID) as! MastodonUser + cell.config(with: user) + case .placeHolder: + cell.configAsPlaceHolder() + } + return cell + } + } +} diff --git a/Mastodon/Diffiable/Section/SettingsSection.swift b/Mastodon/Diffiable/Section/SettingsSection.swift new file mode 100644 index 000000000..7ec78a2ed --- /dev/null +++ b/Mastodon/Diffiable/Section/SettingsSection.swift @@ -0,0 +1,24 @@ +// +// SettingsSection.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-25. +// + +import Foundation + +enum SettingsSection: Hashable { + case apperance + case notifications + case boringZone + case spicyZone + + var title: String { + switch self { + case .apperance: return L10n.Scene.Settings.Section.Appearance.title + case .notifications: return L10n.Scene.Settings.Section.Notifications.title + case .boringZone: return L10n.Scene.Settings.Section.Boringzone.title + case .spicyZone: return L10n.Scene.Settings.Section.Spicyzone.title + } + } +} diff --git a/Mastodon/Diffiable/Section/StatusSection.swift b/Mastodon/Diffiable/Section/StatusSection.swift index 4fac88b4c..d139e061f 100644 --- a/Mastodon/Diffiable/Section/StatusSection.swift +++ b/Mastodon/Diffiable/Section/StatusSection.swift @@ -10,6 +10,12 @@ import CoreData import CoreDataStack import os.log import UIKit +import AVKit + +protocol StatusCell: DisposeBagCollectable { + var statusView: StatusView { get } + var pollCountdownSubscription: AnyCancellable? { get set } +} enum StatusSection: Equatable, Hashable { case main @@ -21,11 +27,18 @@ extension StatusSection { dependency: NeedsDependency, managedObjectContext: NSManagedObjectContext, timestampUpdatePublisher: AnyPublisher, - timelinePostTableViewCellDelegate: StatusTableViewCellDelegate, - timelineMiddleLoaderTableViewCellDelegate: TimelineMiddleLoaderTableViewCellDelegate? + statusTableViewCellDelegate: StatusTableViewCellDelegate, + timelineMiddleLoaderTableViewCellDelegate: TimelineMiddleLoaderTableViewCellDelegate?, + threadReplyLoaderTableViewCellDelegate: ThreadReplyLoaderTableViewCellDelegate? ) -> UITableViewDiffableDataSource { - UITableViewDiffableDataSource(tableView: tableView) { [weak timelinePostTableViewCellDelegate, weak timelineMiddleLoaderTableViewCellDelegate] tableView, indexPath, item -> UITableViewCell? in - guard let timelinePostTableViewCellDelegate = timelinePostTableViewCellDelegate else { return UITableViewCell() } + UITableViewDiffableDataSource(tableView: tableView) { [ + weak dependency, + weak statusTableViewCellDelegate, + weak timelineMiddleLoaderTableViewCellDelegate, + weak threadReplyLoaderTableViewCellDelegate + ] tableView, indexPath, item -> UITableViewCell? in + guard let dependency = dependency else { return UITableViewCell() } + guard let statusTableViewCellDelegate = statusTableViewCellDelegate else { return UITableViewCell() } switch item { case .homeTimelineIndex(objectID: let objectID, let attribute): @@ -34,81 +47,191 @@ extension StatusSection { // configure cell managedObjectContext.performAndWait { let timelineIndex = managedObjectContext.object(with: objectID) as! HomeTimelineIndex - StatusSection.configure(cell: cell, readableLayoutFrame: tableView.readableContentGuide.layoutFrame, timestampUpdatePublisher: timestampUpdatePublisher, toot: timelineIndex.toot, requestUserID: timelineIndex.userID, statusContentWarningAttribute: attribute) + StatusSection.configure( + cell: cell, + dependency: dependency, + readableLayoutFrame: tableView.readableContentGuide.layoutFrame, + timestampUpdatePublisher: timestampUpdatePublisher, + status: timelineIndex.status, + requestUserID: timelineIndex.userID, + statusItemAttribute: attribute + ) } - cell.delegate = timelinePostTableViewCellDelegate + cell.delegate = statusTableViewCellDelegate + cell.isAccessibilityElement = true return cell - case .toot(let objectID, let attribute): + 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 toot = managedObjectContext.object(with: objectID) as! Toot - StatusSection.configure(cell: cell, readableLayoutFrame: tableView.readableContentGuide.layoutFrame, timestampUpdatePublisher: timestampUpdatePublisher, toot: toot, requestUserID: requestUserID, statusContentWarningAttribute: attribute) + let status = managedObjectContext.object(with: objectID) as! Status + StatusSection.configure( + cell: cell, + dependency: dependency, + readableLayoutFrame: tableView.readableContentGuide.layoutFrame, + timestampUpdatePublisher: timestampUpdatePublisher, + status: status, + requestUserID: requestUserID, + statusItemAttribute: attribute + ) + + switch item { + case .root: + StatusSection.configureThreadMeta(cell: cell, status: status) + ManagedObjectObserver.observe(object: status.reblog ?? status) + .receive(on: DispatchQueue.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: + cell.statusView.activeTextLabel.isAccessibilityElement = false + var accessibilityElements: [Any] = [] + accessibilityElements.append(cell.statusView.avatarView) + accessibilityElements.append(cell.statusView.nameLabel) + accessibilityElements.append(cell.statusView.dateLabel) + accessibilityElements.append(contentsOf: cell.statusView.activeTextLabel.createAccessibilityElements()) + 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 + cell.accessibilityElements = nil } - cell.delegate = timelinePostTableViewCellDelegate return cell - case .publicMiddleLoader(let upperTimelineTootID): + case .leafBottomLoader: + let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: ThreadReplyLoaderTableViewCell.self), for: indexPath) as! ThreadReplyLoaderTableViewCell + cell.delegate = threadReplyLoaderTableViewCellDelegate + return cell + case .publicMiddleLoader(let upperTimelineStatusID): let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: TimelineMiddleLoaderTableViewCell.self), for: indexPath) as! TimelineMiddleLoaderTableViewCell cell.delegate = timelineMiddleLoaderTableViewCellDelegate - timelineMiddleLoaderTableViewCellDelegate?.configure(cell: cell, upperTimelineTootID: upperTimelineTootID, timelineIndexobjectID: nil) + timelineMiddleLoaderTableViewCellDelegate?.configure(cell: cell, upperTimelineStatusID: upperTimelineStatusID, timelineIndexobjectID: nil) return cell case .homeMiddleLoader(let upperTimelineIndexObjectID): let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: TimelineMiddleLoaderTableViewCell.self), for: indexPath) as! TimelineMiddleLoaderTableViewCell cell.delegate = timelineMiddleLoaderTableViewCellDelegate - timelineMiddleLoaderTableViewCellDelegate?.configure(cell: cell, upperTimelineTootID: nil, timelineIndexobjectID: upperTimelineIndexObjectID) + timelineMiddleLoaderTableViewCellDelegate?.configure(cell: cell, upperTimelineStatusID: nil, timelineIndexobjectID: upperTimelineIndexObjectID) + return cell + case .topLoader: + let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self), for: indexPath) as! TimelineBottomLoaderTableViewCell + cell.startAnimating() return cell case .bottomLoader: let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self), for: indexPath) as! TimelineBottomLoaderTableViewCell - cell.activityIndicatorView.startAnimating() + cell.startAnimating() return cell + case .emptyStateHeader(let attribute): + let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: TimelineHeaderTableViewCell.self), for: indexPath) as! TimelineHeaderTableViewCell + StatusSection.configureEmptyStateHeader(cell: cell, attribute: attribute) + return cell + case .reportStatus: + return UITableViewCell() } } } +} +extension StatusSection { + static func configure( - cell: StatusTableViewCell, + cell: StatusCell, + dependency: NeedsDependency, readableLayoutFrame: CGRect?, timestampUpdatePublisher: AnyPublisher, - toot: Toot, + status: Status, requestUserID: String, - statusContentWarningAttribute: StatusContentWarningAttribute? + statusItemAttribute: Item.StatusAttribute ) { - // set header - cell.statusView.headerContainerStackView.isHidden = toot.reblog == nil - cell.statusView.headerInfoLabel.text = { - let author = toot.author - let name = author.displayName.isEmpty ? author.username : author.displayName - return L10n.Common.Controls.Status.userBoosted(name) - }() + // safely cancel the listenser when deleted + ManagedObjectObserver.observe(object: status.reblog ?? status) + .receive(on: DispatchQueue.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) - // set name username avatar - cell.statusView.nameLabel.text = { - let author = (toot.reblog ?? toot).author + + // set header + StatusSection.configureHeader(cell: cell, status: status) + ManagedObjectObserver.observe(object: status) + .receive(on: DispatchQueue.main) + .sink { _ in + // do nothing + } receiveValue: { [weak cell] change in + guard let cell = cell else { return } + guard case .update(let object) = change.changeType, + let newStatus = object as? Status else { return } + StatusSection.configureHeader(cell: cell, status: newStatus) + } + .store(in: &cell.disposeBag) + + // set name username + let nameText: String = { + let author = (status.reblog ?? status).author return author.displayName.isEmpty ? author.username : author.displayName }() - cell.statusView.usernameLabel.text = "@" + (toot.reblog ?? toot).author.acct - cell.statusView.configure(with: AvatarConfigurableViewConfiguration(avatarImageURL: (toot.reblog ?? toot).author.avatarImageURL())) + cell.statusView.nameLabel.configure(content: nameText, emojiDict: (status.reblog ?? status).author.emojiDict) + cell.statusView.usernameLabel.text = "@" + (status.reblog ?? status).author.acct + + // set 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())) + } // set text - cell.statusView.activeTextLabel.config(content: (toot.reblog ?? toot).content) + cell.statusView.activeTextLabel.configure( + content: (status.reblog ?? status).content, + emojiDict: (status.reblog ?? status).emojiDict + ) + cell.statusView.activeTextLabel.accessibilityLanguage = (status.reblog ?? status).language - // set status text content warning - let spoilerText = (toot.reblog ?? toot).spoilerText ?? "" - let isStatusTextSensitive = statusContentWarningAttribute?.isStatusTextSensitive ?? !spoilerText.isEmpty - cell.statusView.isStatusTextSensitive = isStatusTextSensitive - cell.statusView.updateContentWarningDisplay(isHidden: !isStatusTextSensitive) - cell.statusView.contentWarningTitle.text = { - if spoilerText.isEmpty { - return L10n.Common.Controls.Status.statusContentWarning - } else { - return L10n.Common.Controls.Status.statusContentWarning + ": \(spoilerText)" - } - }() + // set visibility + if let visibility = (status.reblog ?? status).visibility { + 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((toot.reblog ?? toot).mediaAttachments ?? []).sorted { $0.index.compare($1.index) == .orderedAscending } + let mediaAttachments = Array((status.reblog ?? status).mediaAttachments ?? []).sorted { $0.index.compare($1.index) == .orderedAscending } // set image let mosiacImageViewModel = MosaicImageViewModel(mediaAttachments: mediaAttachments) @@ -124,79 +247,593 @@ extension StatusSection { }() let scale: CGFloat = { switch mosiacImageViewModel.metas.count { - case 1: return 1.3 - default: return 0.7 + case 1: return 1.3 + default: return 0.7 } }() return CGSize(width: maxWidth, height: maxWidth * scale) }() - if mosiacImageViewModel.metas.count == 1 { - let meta = mosiacImageViewModel.metas[0] - let imageView = cell.statusView.statusMosaicImageView.setupImageView(aspectRatio: meta.size, maxSize: imageViewMaxSize) + let blurhashImageCache = dependency.context.documentStore.blurhashImageCache + let mosaics: [MosaicImageViewContainer.ConfigurableMosaic] = { + if mosiacImageViewModel.metas.count == 1 { + let meta = mosiacImageViewModel.metas[0] + let mosaic = cell.statusView.statusMosaicImageViewContainer.setupImageView(aspectRatio: meta.size, maxSize: imageViewMaxSize) + return [mosaic] + } else { + let mosaics = cell.statusView.statusMosaicImageViewContainer.setupImageViews(count: mosiacImageViewModel.metas.count, maxHeight: imageViewMaxSize.height) + return mosaics + } + }() + for (i, mosiac) in mosaics.enumerated() { + let (imageView, blurhashOverlayImageView) = mosiac + let meta = mosiacImageViewModel.metas[i] + let blurhashImageDataKey = meta.url.absoluteString as NSString + if let blurhashImageData = blurhashImageCache.object(forKey: meta.url.absoluteString as NSString), + let image = UIImage(data: blurhashImageData as Data) { + blurhashOverlayImageView.image = image + } else { + meta.blurhashImagePublisher() + .receive(on: DispatchQueue.main) + .sink { [weak cell] image in + blurhashOverlayImageView.image = image + image?.pngData().flatMap { + blurhashImageCache.setObject($0 as NSData, forKey: blurhashImageDataKey) + } + } + .store(in: &cell.disposeBag) + } imageView.af.setImage( withURL: meta.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 + Publishers.CombineLatest( + statusItemAttribute.isImageLoaded, + statusItemAttribute.isRevealing ) + .receive(on: DispatchQueue.main) + .sink { [weak cell] isImageLoaded, isMediaRevealing in + guard let cell = cell else { return } + guard isImageLoaded else { + blurhashOverlayImageView.alpha = 1 + blurhashOverlayImageView.isHidden = false + return + } + + blurhashOverlayImageView.alpha = isMediaRevealing ? 0 : 1 + if isMediaRevealing { + let animator = UIViewPropertyAnimator(duration: 0.33, curve: .easeInOut) + animator.addAnimations { + blurhashOverlayImageView.alpha = isMediaRevealing ? 0 : 1 + } + animator.startAnimation() + } else { + cell.statusView.drawContentWarningImageView() + } + } + .store(in: &cell.disposeBag) + } + cell.statusView.statusMosaicImageViewContainer.isHidden = mosiacImageViewModel.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: dependency.context.audioPlaybackService) } else { - let imageViews = cell.statusView.statusMosaicImageView.setupImageViews(count: mosiacImageViewModel.metas.count, maxHeight: imageViewMaxSize.height) - for (i, imageView) in imageViews.enumerated() { - let meta = mosiacImageViewModel.metas[i] - imageView.af.setImage( - withURL: meta.url, - placeholderImage: UIImage.placeholder(color: .systemFill), - imageTransition: .crossDissolve(0.2) + 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: maxWidth * scale) + }() + + if let videoAttachment = mediaAttachments.filter({ $0.type == .gifv || $0.type == .video }).first, + let videoPlayerViewModel = dependency.context.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) + if videoPlayerViewModel.videoKind == .gif { + playerContainerView.setMediaIndicator(isHidden: false) + } else { + videoPlayerViewModel.timeControlStatus.sink { timeControlStatus in + UIView.animate(withDuration: 0.33) { + switch timeControlStatus { + case .playing: + playerContainerView.setMediaIndicator(isHidden: true) + case .paused, .waitingToPlayAtSpecifiedRate: + playerContainerView.setMediaIndicator(isHidden: false) + @unknown default: + assertionFailure() + } + } + } + .store(in: &cell.disposeBag) + } + playerContainerView.isHidden = false + + } else { + cell.statusView.playerContainerView.playerViewController.player?.pause() + cell.statusView.playerContainerView.playerViewController.player = nil + } + + // set text content warning + StatusSection.configureContentWarningOverlay( + statusView: cell.statusView, + status: status, + attribute: statusItemAttribute, + documentStore: dependency.context.documentStore, + animated: false + ) + // observe model change + ManagedObjectObserver.observe(object: status) + .receive(on: DispatchQueue.main) + .sink { _ in + // do nothing + } receiveValue: { [weak dependency, weak cell] change in + guard let cell = cell else { return } + guard let dependency = dependency else { return } + guard case .update(let object) = change.changeType, + let status = object as? Status else { return } + StatusSection.configureContentWarningOverlay( + statusView: cell.statusView, + status: status, + attribute: statusItemAttribute, + documentStore: dependency.context.documentStore, + animated: true ) } - } - cell.statusView.statusMosaicImageView.isHidden = mosiacImageViewModel.metas.isEmpty - let isStatusSensitive = statusContentWarningAttribute?.isStatusSensitive ?? (toot.reblog ?? toot).sensitive - cell.statusView.statusMosaicImageView.blurVisualEffectView.effect = isStatusSensitive ? MosaicImageViewContainer.blurVisualEffect : nil - cell.statusView.statusMosaicImageView.vibrancyVisualEffectView.alpha = isStatusSensitive ? 1.0 : 0.0 - - // toolbar - let replyCountTitle: String = { - let count = (toot.reblog ?? toot).repliesCount?.intValue ?? 0 - return StatusSection.formattedNumberTitleForActionButton(count) - }() - cell.statusView.actionToolbarContainer.replyButton.setTitle(replyCountTitle, for: .normal) + .store(in: &cell.disposeBag) - let isLike = (toot.reblog ?? toot).favouritedBy.flatMap { $0.contains(where: { $0.id == requestUserID }) } ?? false - let favoriteCountTitle: String = { - let count = (toot.reblog ?? toot).favouritesCount.intValue - return StatusSection.formattedNumberTitleForActionButton(count) - }() - cell.statusView.actionToolbarContainer.starButton.setTitle(favoriteCountTitle, for: .normal) - cell.statusView.actionToolbarContainer.isStarButtonHighlight = isLike + // set poll + let poll = (status.reblog ?? status).poll + StatusSection.configurePoll( + cell: cell, + poll: poll, + requestUserID: requestUserID, + updateProgressAnimated: false, + timestampUpdatePublisher: timestampUpdatePublisher + ) + if let poll = 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, + timestampUpdatePublisher: timestampUpdatePublisher + ) + } + .store(in: &cell.disposeBag) + } + + if let statusTableViewCell = cell as? StatusTableViewCell { + // toolbar + StatusSection.configureActionToolBar( + cell: statusTableViewCell, + dependency: dependency, + status: status, + requestUserID: requestUserID + ) + // separator line + statusTableViewCell.separatorLine.isHidden = statusItemAttribute.isSeparatorLineHidden + } // set date - let createdAt = (toot.reblog ?? toot).createdAt + let createdAt = (status.reblog ?? status).createdAt cell.statusView.dateLabel.text = createdAt.shortTimeAgoSinceNow timestampUpdatePublisher - .sink { _ in + .sink { [weak cell] _ in + guard let cell = cell else { return } cell.statusView.dateLabel.text = createdAt.shortTimeAgoSinceNow + cell.statusView.dateLabel.accessibilityLabel = createdAt.timeAgoSinceNow } .store(in: &cell.disposeBag) // observe model change - ManagedObjectObserver.observe(object: toot.reblog ?? toot) + ManagedObjectObserver.observe(object: status.reblog ?? status) .receive(on: DispatchQueue.main) .sink { _ in // do nothing - } receiveValue: { change in + } receiveValue: { [weak dependency, weak cell] change in + guard let dependency = dependency else { return } guard case .update(let object) = change.changeType, - let newToot = object as? Toot else { return } - let targetToot = newToot.reblog ?? newToot - - let isLike = targetToot.favouritedBy.flatMap { $0.contains(where: { $0.id == requestUserID }) } ?? false - let favoriteCount = targetToot.favouritesCount.intValue - let favoriteCountTitle = StatusSection.formattedNumberTitleForActionButton(favoriteCount) - cell.statusView.actionToolbarContainer.starButton.setTitle(favoriteCountTitle, for: .normal) - cell.statusView.actionToolbarContainer.isStarButtonHighlight = isLike - os_log("%{public}s[%{public}ld], %{public}s: like count label for toot %s did update: %ld", (#file as NSString).lastPathComponent, #line, #function, targetToot.id, favoriteCount) + let status = object as? Status, + !status.isDeleted else { return } + guard let statusTableViewCell = cell as? StatusTableViewCell else { return } + StatusSection.configureActionToolBar( + cell: statusTableViewCell, + dependency: dependency, + status: status, + requestUserID: requestUserID + ) + + os_log("%{public}s[%{public}ld], %{public}s: reblog count label for status %s did update: %ld", (#file as NSString).lastPathComponent, #line, #function, status.id, status.reblogsCount.intValue) + os_log("%{public}s[%{public}ld], %{public}s: like count label for status %s did update: %ld", (#file as NSString).lastPathComponent, #line, #function, status.id, status.favouritesCount.intValue) } .store(in: &cell.disposeBag) } + + static func configureContentWarningOverlay( + statusView: StatusView, + status: Status, + 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 L10n.Common.Controls.Status.contentWarningText(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 { + statusView.updateRevealContentWarningButton(isRevealing: true) + statusView.updateContentWarningDisplay(isHidden: true, animated: animated) + attribute.isRevealing.value = true + } else { + statusView.updateRevealContentWarningButton(isRevealing: false) + statusView.updateContentWarningDisplay(isHidden: false, animated: animated) + attribute.isRevealing.value = false + } + 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: .visualEffectView) + statusView.playerContainerView.contentWarningOverlayView.update(isRevealing: true, style: .visualEffectView) + } else { + statusView.updateRevealContentWarningButton(isRevealing: false) + statusView.statusMosaicImageViewContainer.contentWarningOverlayView.update(isRevealing: false, style: .visualEffectView) + statusView.playerContainerView.contentWarningOverlayView.update(isRevealing: false, style: .visualEffectView) + } + } + 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 + cell.threadMetaView.dateLabel.text = { + let formatter = DateFormatter() + 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) + let reblogCountTitle: String = { + let count = status.reblogsCount.intValue + if count > 1 { + return L10n.Scene.Thread.Reblog.multiple(String(count)) + } else { + return L10n.Scene.Thread.Reblog.single(String(count)) + } + }() + cell.threadMetaView.reblogButton.setTitle(reblogCountTitle, for: .normal) + + let favoriteCountTitle: String = { + let count = status.favouritesCount.intValue + if count > 1 { + return L10n.Scene.Thread.Favorite.multiple(String(count)) + } else { + return L10n.Scene.Thread.Favorite.single(String(count)) + } + }() + cell.threadMetaView.favoriteButton.setTitle(favoriteCountTitle, for: .normal) + + cell.threadMetaView.isHidden = false + } + + + static func configureHeader( + cell: StatusCell, + status: Status + ) { + if status.reblog != nil { + cell.statusView.headerContainerView.isHidden = false + cell.statusView.headerIconLabel.attributedText = 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) + }() + cell.statusView.headerInfoLabel.configure(content: headerText, emojiDict: status.author.emojiDict) + cell.statusView.headerInfoLabel.isAccessibilityElement = true + } else if status.inReplyToID != nil { + cell.statusView.headerContainerView.isHidden = false + cell.statusView.headerIconLabel.attributedText = 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) + }() + cell.statusView.headerInfoLabel.configure(content: headerText, emojiDict: status.replyTo?.author.emojiDict ?? [:]) + cell.statusView.headerInfoLabel.isAccessibilityElement = true + } else { + cell.statusView.headerContainerView.isHidden = true + cell.statusView.headerInfoLabel.isAccessibilityElement = false + } + } + + 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.Common.Controls.Timeline.Accessibility.countReplies($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.Common.Controls.Timeline.Accessibility.countReblogs(status.reblogsCount.intValue) + }() + // 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.Common.Controls.Timeline.Accessibility.countReblogs(status.favouritesCount.intValue) + }() + Publishers.CombineLatest( + dependency.context.blockDomainService.blockedDomains, + ManagedObjectObserver.observe(object: status.authorForUserProvider) + .assertNoFailure() + ) + .receive(on: DispatchQueue.main) + .sink { [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) + self.setupStatusMoreButtonMenu(cell: cell, dependency: dependency, status: status) + } + + static func configurePoll( + cell: StatusCell, + poll: Poll?, + requestUserID: String, + updateProgressAnimated: Bool, + timestampUpdatePublisher: AnyPublisher + ) { + 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 + if count > 1 { + return L10n.Common.Controls.Status.Poll.VoterCount.single(count) + } else { + return L10n.Common.Controls.Status.Poll.VoterCount.multiple(count) + } + } else { + let count = poll.votesCount.intValue + if count > 1 { + return L10n.Common.Controls.Status.Poll.VoteCount.single(count) + } else { + return L10n.Common.Controls.Status.Poll.VoteCount.multiple(count) + } + } + }() + if poll.expired { + cell.pollCountdownSubscription = nil + cell.statusView.pollCountdownLabel.text = L10n.Common.Controls.Status.Poll.closed + } else if let expiresAt = poll.expiresAt { + cell.statusView.pollCountdownLabel.text = L10n.Common.Controls.Status.Poll.timeLeft(expiresAt.shortTimeAgoSinceNow) + cell.pollCountdownSubscription = timestampUpdatePublisher + .sink { _ in + cell.statusView.pollCountdownLabel.text = L10n.Common.Controls.Status.Poll.timeLeft(expiresAt.shortTimeAgoSinceNow) + } + } else { + // assertionFailure() + cell.pollCountdownSubscription = nil + cell.statusView.pollCountdownLabel.text = "-" + } + + 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.opion(objectID: option.objectID, attribute: attribute) + return option + } + snapshot.appendItems(pollItems, toSection: .main) + cell.statusView.pollTableViewDataSource?.apply(snapshot, animatingDifferences: false, completion: nil) + } + + static func configureEmptyStateHeader( + cell: TimelineHeaderTableViewCell, + attribute: Item.EmptyStateHeaderAttribute + ) { + cell.timelineHeaderView.iconImageView.image = attribute.reason.iconImage + cell.timelineHeaderView.messageLabel.text = attribute.reason.message + } } extension StatusSection { @@ -204,4 +841,37 @@ extension StatusSection { 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 + ) + } } diff --git a/Mastodon/Extension/AVPlayer.swift b/Mastodon/Extension/AVPlayer.swift new file mode 100644 index 000000000..3e9c06cc2 --- /dev/null +++ b/Mastodon/Extension/AVPlayer.swift @@ -0,0 +1,22 @@ +// +// AVPlayer.swift +// Mastodon +// +// Created by xiaojian sun on 2021/3/10. +// + +import AVKit + +// MARK: - CustomDebugStringConvertible +extension AVPlayer.TimeControlStatus: CustomDebugStringConvertible { + public var debugDescription: String { + switch self { + case .paused: return "paused" + case .waitingToPlayAtSpecifiedRate: return "waitingToPlayAtSpecifiedRate" + case .playing: return "playing" + @unknown default: + assertionFailure() + return "" + } + } +} diff --git a/Mastodon/Extension/ActiveLabel.swift b/Mastodon/Extension/ActiveLabel.swift index 9219701f5..4e1d855b1 100644 --- a/Mastodon/Extension/ActiveLabel.swift +++ b/Mastodon/Extension/ActiveLabel.swift @@ -14,40 +14,179 @@ extension ActiveLabel { enum Style { case `default` - case timelineHeaderView + case statusHeader + case statusName + case profileField } convenience init(style: Style) { self.init() - switch style { - case .default: - font = .preferredFont(forTextStyle: .body) - textColor = Asset.Colors.Label.primary.color - case .timelineHeaderView: - font = .preferredFont(forTextStyle: .footnote) - textColor = .secondaryLabel - } - numberOfLines = 0 lineSpacing = 5 mentionColor = Asset.Colors.Label.highlight.color hashtagColor = Asset.Colors.Label.highlight.color URLColor = Asset.Colors.Label.highlight.color + emojiPlaceholderColor = .systemFill + #if DEBUG text = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua." + #endif + + accessibilityContainerType = .semanticGroup + + switch style { + case .default: + font = .preferredFont(forTextStyle: .body) + textColor = Asset.Colors.Label.primary.color + case .statusHeader: + font = UIFontMetrics(forTextStyle: .footnote).scaledFont(for: .systemFont(ofSize: 13, weight: .medium), maximumPointSize: 17) + textColor = Asset.Colors.Label.secondary.color + numberOfLines = 1 + case .statusName: + font = .systemFont(ofSize: 17, weight: .semibold) + textColor = Asset.Colors.Label.primary.color + numberOfLines = 1 + case .profileField: + font = .preferredFont(forTextStyle: .body) + textColor = Asset.Colors.Label.primary.color + numberOfLines = 1 + } } } extension ActiveLabel { - func config(content: String) { + /// status content + func configure(content: String, emojiDict: MastodonStatusContent.EmojiDict) { activeEntities.removeAll() - if let parseResult = try? TootContent.parse(toot: content) { + + if let parseResult = try? MastodonStatusContent.parse(content: content, emojiDict: emojiDict) { text = parseResult.trimmed activeEntities = parseResult.activeEntities + accessibilityLabel = parseResult.original } else { text = "" + accessibilityLabel = nil } } + + /// account note + func configure(note: String, emojiDict: MastodonStatusContent.EmojiDict) { + configure(content: note, emojiDict: emojiDict) + } } +extension ActiveLabel { + /// account field + func configure(field: String) { + activeEntities.removeAll() + let parseResult = MastodonField.parse(field: field) + text = parseResult.value + activeEntities = parseResult.activeEntities + accessibilityLabel = parseResult.value + } +} + +extension ActiveEntity { + + var accessibilityLabelDescription: String { + switch self.type { + case .email: return L10n.Common.Controls.Status.Tag.email + case .hashtag: return L10n.Common.Controls.Status.Tag.hashtag + case .mention: return L10n.Common.Controls.Status.Tag.mention + case .url: return L10n.Common.Controls.Status.Tag.url + case .emoji: return L10n.Common.Controls.Status.Tag.emoji + } + } + + var accessibilityValueDescription: String { + switch self.type { + case .email(let text, _): return text + case .hashtag(let text, _): return text + case .mention(let text, _): return text + case .url(_, let trimmed, _, _): return trimmed + case .emoji(let text, _, _): return text + } + } + + func accessibilityElement(in accessibilityContainer: Any) -> ActiveLabelAccessibilityElement? { + if case .emoji = self.type { + return nil + } + + let element = ActiveLabelAccessibilityElement(accessibilityContainer: accessibilityContainer) + element.accessibilityTraits = .button + element.accessibilityLabel = accessibilityLabelDescription + element.accessibilityValue = accessibilityValueDescription + return element + } +} + +final class ActiveLabelAccessibilityElement: UIAccessibilityElement { + var index: Int! +} + +// MARK: - UIAccessibilityContainer +extension ActiveLabel { + + func createAccessibilityElements() -> [UIAccessibilityElement] { + var elements: [UIAccessibilityElement] = [] + + let element = ActiveLabelAccessibilityElement(accessibilityContainer: self) + element.accessibilityTraits = .staticText + element.accessibilityLabel = accessibilityLabel + element.accessibilityFrame = superview!.convert(frame, to: nil) + element.accessibilityLanguage = accessibilityLanguage + elements.append(element) + + for eneity in activeEntities { + guard let element = eneity.accessibilityElement(in: self) else { continue } + var glyphRange = NSRange() + layoutManager.characterRange(forGlyphRange: eneity.range, actualGlyphRange: &glyphRange) + let rect = layoutManager.boundingRect(forGlyphRange: glyphRange, in: textContainer) + element.accessibilityFrame = self.convert(rect, to: nil) + element.accessibilityContainer = self + elements.append(element) + } + + return elements + } + +// public override func accessibilityElementCount() -> Int { +// return 1 + activeEntities.count +// } +// +// public override func accessibilityElement(at index: Int) -> Any? { +// if index == 0 { +// let element = ActiveLabelAccessibilityElement(accessibilityContainer: self) +// element.accessibilityTraits = .staticText +// element.accessibilityLabel = accessibilityLabel +// element.accessibilityFrame = superview!.convert(frame, to: nil) +// element.index = index +// return element +// } +// +// let index = index - 1 +// guard index < activeEntities.count else { return nil } +// let eneity = activeEntities[index] +// guard let element = eneity.accessibilityElement(in: self) else { return nil } +// +// var glyphRange = NSRange() +// layoutManager.characterRange(forGlyphRange: eneity.range, actualGlyphRange: &glyphRange) +// let rect = layoutManager.boundingRect(forGlyphRange: glyphRange, in: textContainer) +// element.accessibilityFrame = self.convert(rect, to: nil) +// element.accessibilityContainer = self +// +// return element +// } +// +// public override func index(ofAccessibilityElement element: Any) -> Int { +// guard let element = element as? ActiveLabelAccessibilityElement, +// let index = element.index else { +// return NSNotFound +// } +// +// return index +// } + +} diff --git a/Mastodon/Extension/Array.swift b/Mastodon/Extension/Array.swift new file mode 100644 index 000000000..42f8594d1 --- /dev/null +++ b/Mastodon/Extension/Array.swift @@ -0,0 +1,99 @@ +// +// Array.swift +// Mastodon +// +// Created by BradGao on 2021/3/31. +// + +import Foundation + +/// https://www.hackingwithswift.com/example-code/language/how-to-remove-duplicate-items-from-an-array +extension Array where Element: Hashable { + func removingDuplicates() -> [Element] { + var addedDict = [Element: Bool]() + + return filter { + addedDict.updateValue(true, forKey: $0) == nil + } + } + + mutating func removeDuplicates() { + self = self.removingDuplicates() + } +} + +// +// CryptoSwift +// +// Copyright (C) 2014-2017 Marcin Krzyżanowski +// This software is provided 'as-is', without any express or implied warranty. +// +// In no event will the authors be held liable for any damages arising from the use of this software. +// +// Permission is granted to anyone to use this software for any purpose,including commercial applications, and to alter it and redistribute it freely, subject to the following restrictions: +// +// - The origin of this software must not be misrepresented; you must not claim that you wrote the original software. If you use this software in a product, an acknowledgment in the product documentation is required. +// - Altered source versions must be plainly marked as such, and must not be misrepresented as being the original software. +// - This notice may not be removed or altered from any source or binary distribution. +// + +extension Array { + init(reserveCapacity: Int) { + self = Array() + self.reserveCapacity(reserveCapacity) + } + + var slice: ArraySlice { + self[self.startIndex ..< self.endIndex] + } +} + +extension Array where Element == UInt8 { + public init(hex: String) { + self.init(reserveCapacity: hex.unicodeScalars.lazy.underestimatedCount) + var buffer: UInt8? + var skip = hex.hasPrefix("0x") ? 2 : 0 + for char in hex.unicodeScalars.lazy { + guard skip == 0 else { + skip -= 1 + continue + } + guard char.value >= 48 && char.value <= 102 else { + removeAll() + return + } + let v: UInt8 + let c: UInt8 = UInt8(char.value) + switch c { + case let c where c <= 57: + v = c - 48 + case let c where c >= 65 && c <= 70: + v = c - 55 + case let c where c >= 97: + v = c - 87 + default: + removeAll() + return + } + if let b = buffer { + append(b << 4 | v) + buffer = nil + } else { + buffer = v + } + } + if let b = buffer { + append(b) + } + } + + public func toHexString() -> String { + `lazy`.reduce(into: "") { + var s = String($1, radix: 16) + if s.count == 1 { + s = "0" + s + } + $0 += s + } + } +} diff --git a/Mastodon/Extension/CALayer.swift b/Mastodon/Extension/CALayer.swift new file mode 100644 index 000000000..41ce739ee --- /dev/null +++ b/Mastodon/Extension/CALayer.swift @@ -0,0 +1,51 @@ +// +// CALayer.swift +// Mastodon +// +// Created by Cirno MainasuK on 2021-2-26. +// + +import UIKit + +extension CALayer { + + func setupShadow( + color: UIColor = .black, + alpha: Float = 0.5, + x: CGFloat = 0, + y: CGFloat = 2, + blur: CGFloat = 4, + spread: CGFloat = 0, + roundedRect: CGRect? = nil, + byRoundingCorners corners: UIRectCorner? = nil, + cornerRadii: CGSize? = nil + ) { + // assert(roundedRect != .zero) + shadowColor = color.cgColor + shadowOpacity = alpha + shadowOffset = CGSize(width: x, height: y) + shadowRadius = blur / 2 + rasterizationScale = UIScreen.main.scale + shouldRasterize = true + masksToBounds = false + + guard let roundedRect = roundedRect, + let corners = corners, + let cornerRadii = cornerRadii else { + return + } + + if spread == 0 { + shadowPath = UIBezierPath(roundedRect: roundedRect, byRoundingCorners: corners, cornerRadii: cornerRadii).cgPath + } else { + let rect = roundedRect.insetBy(dx: -spread, dy: -spread) + shadowPath = UIBezierPath(roundedRect: rect, byRoundingCorners: corners, cornerRadii: cornerRadii).cgPath + } + } + + func removeShadow() { + shadowRadius = 0 + } + + +} diff --git a/Mastodon/Extension/CGImage.swift b/Mastodon/Extension/CGImage.swift new file mode 100644 index 000000000..cced4abee --- /dev/null +++ b/Mastodon/Extension/CGImage.swift @@ -0,0 +1,154 @@ +// +// CGImage.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-31. +// + +import CoreImage + +extension CGImage { + // Reference + // https://www.itu.int/dms_pubrec/itu-r/rec/bt/R-REC-BT.709-6-201506-I!!PDF-E.pdf + // Luma Y = 0.2126R + 0.7152G + 0.0722B + var brightness: CGFloat? { + let context = CIContext() // default with metal accelerate + let ciImage = CIImage(cgImage: self) + let rec709Image = context.createCGImage( + ciImage, + from: ciImage.extent, + format: .RGBA8, + colorSpace: CGColorSpace(name: CGColorSpace.itur_709) // BT.709 a.k.a Rec.709 + ) + guard let image = rec709Image, + image.bitsPerPixel == 32, + let data = rec709Image?.dataProvider?.data, + let pointer = CFDataGetBytePtr(data) else { return nil } + + let length = CFDataGetLength(data) + guard length > 0 else { return nil } + + var luma: CGFloat = 0.0 + for i in stride(from: 0, to: length, by: 4) { + let r = pointer[i] + let g = pointer[i + 1] + let b = pointer[i + 2] + let Y = 0.2126 * CGFloat(r) + 0.7152 * CGFloat(g) + 0.0722 * CGFloat(b) + luma += Y + } + luma /= CGFloat(width * height) + return luma + } +} + +#if canImport(SwiftUI) && DEBUG +import SwiftUI +import UIKit + +class BrightnessView: UIView { + let label = UILabel() + let imageView = UIImageView() + + override init(frame: CGRect) { + super.init(frame: frame) + + let stackView = UIStackView() + stackView.axis = .horizontal + stackView.translatesAutoresizingMaskIntoConstraints = false + addSubview(stackView) + NSLayoutConstraint.activate([ + stackView.topAnchor.constraint(equalTo: topAnchor), + stackView.leadingAnchor.constraint(equalTo: leadingAnchor), + stackView.trailingAnchor.constraint(equalTo: trailingAnchor), + stackView.bottomAnchor.constraint(equalTo: bottomAnchor), + ]) + stackView.distribution = .fillEqually + stackView.addArrangedSubview(imageView) + stackView.addArrangedSubview(label) + + imageView.contentMode = .scaleAspectFill + imageView.layer.masksToBounds = true + label.textAlignment = .center + label.numberOfLines = 0 + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func setImage(_ image: UIImage) { + imageView.image = image + + guard let brightness = image.cgImage?.brightness, + let style = image.domainLumaCoefficientsStyle else { + label.text = "" + return + } + let styleDescription: String = { + switch style { + case .light: return "Light" + case .dark: return "Dark" + case .unspecified: fallthrough + @unknown default: + return "Unknown" + } + }() + + label.text = styleDescription + "\n" + "\(brightness)" + } +} + +struct CGImage_Brightness_Previews: PreviewProvider { + + static var previews: some View { + Group { + UIViewPreview(width: 375) { + let view = BrightnessView() + view.setImage(.placeholder(color: .black)) + return view + } + .previewLayout(.fixed(width: 375, height: 44)) + UIViewPreview(width: 375) { + let view = BrightnessView() + view.setImage(.placeholder(color: .gray)) + return view + } + .previewLayout(.fixed(width: 375, height: 44)) + UIViewPreview(width: 375) { + let view = BrightnessView() + view.setImage(.placeholder(color: .separator)) + return view + } + .previewLayout(.fixed(width: 375, height: 44)) + UIViewPreview(width: 375) { + let view = BrightnessView() + view.setImage(.placeholder(color: .red)) + return view + } + .previewLayout(.fixed(width: 375, height: 44)) + UIViewPreview(width: 375) { + let view = BrightnessView() + view.setImage(.placeholder(color: .green)) + return view + } + .previewLayout(.fixed(width: 375, height: 44)) + UIViewPreview(width: 375) { + let view = BrightnessView() + view.setImage(.placeholder(color: .blue)) + return view + } + .previewLayout(.fixed(width: 375, height: 44)) + UIViewPreview(width: 375) { + let view = BrightnessView() + view.setImage(.placeholder(color: .secondarySystemGroupedBackground)) + return view + } + .previewLayout(.fixed(width: 375, height: 44)) + } + } + +} + +#endif + + diff --git a/Mastodon/Extension/CoreDataStack/Emojis.swift b/Mastodon/Extension/CoreDataStack/Emojis.swift new file mode 100644 index 000000000..87ae50171 --- /dev/null +++ b/Mastodon/Extension/CoreDataStack/Emojis.swift @@ -0,0 +1,36 @@ +// +// Emojis.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-5-7. +// + +import Foundation +import MastodonSDK + +protocol EmojiContinaer { + var emojisData: Data? { get } +} + +extension EmojiContinaer { + + 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 emojiDict: MastodonStatusContent.EmojiDict { + var dict = MastodonStatusContent.EmojiDict() + for emoji in emojis ?? [] { + guard let url = URL(string: emoji.url) else { continue } + dict[emoji.shortcode] = url + } + return dict + } + +} + diff --git a/Mastodon/Extension/CoreDataStack/MastodonUser.swift b/Mastodon/Extension/CoreDataStack/MastodonUser.swift index 7575704ba..8180b0255 100644 --- a/Mastodon/Extension/CoreDataStack/MastodonUser.swift +++ b/Mastodon/Extension/CoreDataStack/MastodonUser.swift @@ -19,6 +19,17 @@ extension MastodonUser.Property { 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 { Status.encode(emojis: $0) }, + statusesCount: entity.statusesCount, + followingCount: entity.followingCount, + followersCount: entity.followersCount, + locked: entity.locked, + bot: entity.bot, + suspended: entity.suspended, createdAt: entity.createdAt, networkDate: networkDate ) @@ -26,7 +37,67 @@ extension MastodonUser.Property { } 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? { return URL(string: avatar) } + + public func avatarImageURLWithFallback(domain: String) -> URL { + return URL(string: avatar) ?? 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 + } +} + +extension MastodonUser: EmojiContinaer { } diff --git a/Mastodon/Extension/CoreDataStack/Setting.swift b/Mastodon/Extension/CoreDataStack/Setting.swift new file mode 100644 index 000000000..b995b80e3 --- /dev/null +++ b/Mastodon/Extension/CoreDataStack/Setting.swift @@ -0,0 +1,24 @@ +// +// Setting.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-25. +// + +import Foundation +import CoreDataStack +import MastodonSDK + +extension Setting { + + var appearance: SettingsItem.AppearanceMode { + return SettingsItem.AppearanceMode(rawValue: appearanceRaw) ?? .automatic + } + + var activeSubscription: Subscription? { + return (subscriptions ?? Set()) + .sorted(by: { $0.activedAt > $1.activedAt }) + .first + } + +} diff --git a/Mastodon/Extension/CoreDataStack/Status.swift b/Mastodon/Extension/CoreDataStack/Status.swift new file mode 100644 index 000000000..0432c441b --- /dev/null +++ b/Mastodon/Extension/CoreDataStack/Status.swift @@ -0,0 +1,91 @@ +// +// Status.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021/2/4. +// + +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.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 { + // cast .media when has non audio media + if firstAttachment.type != .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 statusURL: URL { + if let urlString = self.url, + let url = URL(string: urlString) + { + return url + } else { + return URL(string: "https://\(self.domain)/web/statuses/\(self.id)")! + } + } + + var activityItems: [Any] { + var items: [Any] = [] + items.append(self.statusURL) + return items + } +} + +extension Status: EmojiContinaer { } diff --git a/Mastodon/Extension/CoreDataStack/Subscription.swift b/Mastodon/Extension/CoreDataStack/Subscription.swift new file mode 100644 index 000000000..8253264a0 --- /dev/null +++ b/Mastodon/Extension/CoreDataStack/Subscription.swift @@ -0,0 +1,20 @@ +// +// Subscription.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-25. +// + +import Foundation +import CoreDataStack +import MastodonSDK + +typealias NotificationSubscription = Subscription + +extension Subscription { + + var policy: Mastodon.API.Subscriptions.Policy { + return Mastodon.API.Subscriptions.Policy(rawValue: policyRaw) ?? .all + } + +} diff --git a/Mastodon/Extension/CoreDataStack/SubscriptionAlerts.swift b/Mastodon/Extension/CoreDataStack/SubscriptionAlerts.swift new file mode 100644 index 000000000..edf2df0c9 --- /dev/null +++ b/Mastodon/Extension/CoreDataStack/SubscriptionAlerts.swift @@ -0,0 +1,28 @@ +// +// SubscriptionAlerts.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-25. +// + + +import Foundation +import CoreDataStack +import MastodonSDK + +extension SubscriptionAlerts.Property { + + init(policy: Mastodon.API.Subscriptions.Policy) { + switch policy { + case .all: + self.init(favourite: true, follow: true, followRequest: true, mention: true, poll: true, reblog: true) + case .follower: + self.init(favourite: true, follow: nil, followRequest: nil, mention: true, poll: true, reblog: true) + case .followed: + self.init(favourite: true, follow: true, followRequest: true, mention: true, poll: true, reblog: true) + case .none, ._other: + self.init(favourite: nil, follow: nil, followRequest: nil, mention: nil, poll: nil, reblog: nil) + } + } + +} diff --git a/Mastodon/Extension/CoreDataStack/Toot.swift b/Mastodon/Extension/CoreDataStack/Toot.swift deleted file mode 100644 index 2fab537e6..000000000 --- a/Mastodon/Extension/CoreDataStack/Toot.swift +++ /dev/null @@ -1,34 +0,0 @@ -// -// Toot.swift -// Mastodon -// -// Created by MainasuK Cirno on 2021/2/4. -// - -import Foundation -import CoreDataStack -import MastodonSDK - -extension Toot.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, - reblogsCount: NSNumber(value: entity.reblogsCount), - favouritesCount: NSNumber(value: entity.favouritesCount), - repliesCount: entity.repliesCount.flatMap { NSNumber(value: $0) }, - url: entity.uri, - inReplyToID: entity.inReplyToID, - inReplyToAccountID: entity.inReplyToAccountID, - language: entity.language, - text: entity.text, - networkDate: networkDate - ) - } -} diff --git a/Mastodon/Extension/Double.swift b/Mastodon/Extension/Double.swift new file mode 100644 index 000000000..f485ec2d9 --- /dev/null +++ b/Mastodon/Extension/Double.swift @@ -0,0 +1,19 @@ +// +// Double.swift +// Mastodon +// +// Created by sxiaojian on 2021/3/8. +// + +import Foundation + +extension Double { + func asString(style: DateComponentsFormatter.UnitsStyle) -> String { + let formatter = DateComponentsFormatter() + formatter.allowedUnits = [.minute, .second] + formatter.unitsStyle = style + formatter.zeroFormattingBehavior = .pad + guard let formattedString = formatter.string(from: self) else { return "" } + return formattedString + } +} diff --git a/Mastodon/Extension/MastodonSDK/Mastodon+API+Subscriptions+Policy.swift b/Mastodon/Extension/MastodonSDK/Mastodon+API+Subscriptions+Policy.swift new file mode 100644 index 000000000..24bbfdace --- /dev/null +++ b/Mastodon/Extension/MastodonSDK/Mastodon+API+Subscriptions+Policy.swift @@ -0,0 +1,20 @@ +// +// Mastodon+API+Subscriptions+Policy.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-26. +// + +import Foundation +import MastodonSDK + +extension Mastodon.API.Subscriptions.Policy { + var title: String { + switch self { + case .all: return L10n.Scene.Settings.Section.Notifications.Trigger.anyone + case .follower: return L10n.Scene.Settings.Section.Notifications.Trigger.follower + case .followed: return L10n.Scene.Settings.Section.Notifications.Trigger.follow + case .none, ._other: return L10n.Scene.Settings.Section.Notifications.Trigger.noone + } + } +} diff --git a/Mastodon/Extension/MastodonSDK/Mastodon+Entity+Account.swift b/Mastodon/Extension/MastodonSDK/Mastodon+Entity+Account.swift new file mode 100644 index 000000000..8fd6bd67a --- /dev/null +++ b/Mastodon/Extension/MastodonSDK/Mastodon+Entity+Account.swift @@ -0,0 +1,18 @@ +// +// Mastodon+Entity+Account.swift +// Mastodon +// +// Created by xiaojian sun on 2021/4/2. +// + +import MastodonSDK + +extension Mastodon.Entity.Account: Hashable { + public func hash(into hasher: inout Hasher) { + hasher.combine(id) + } + + public static func == (lhs: Mastodon.Entity.Account, rhs: Mastodon.Entity.Account) -> Bool { + return lhs.id == rhs.id + } +} diff --git a/Mastodon/Extension/MastodonSDK/Mastodon+Entity+Error+Detail.swift b/Mastodon/Extension/MastodonSDK/Mastodon+Entity+Error+Detail.swift new file mode 100644 index 000000000..312e4e3f0 --- /dev/null +++ b/Mastodon/Extension/MastodonSDK/Mastodon+Entity+Error+Detail.swift @@ -0,0 +1,112 @@ +// +// Mastodon+Entity+ErrorDetailReason.swift +// Mastodon +// +// Created by sxiaojian on 2021/3/1. +// + +import Foundation +import MastodonSDK + +extension Mastodon.Entity.Error.Detail: LocalizedError { + + public var failureReason: String? { + let reasons: [[String]] = [ + usernameErrorDescriptions, + emailErrorDescriptions, + passwordErrorDescriptions, + agreementErrorDescriptions, + localeErrorDescriptions, + reasonErrorDescriptions, + ] + + guard !reasons.isEmpty else { + return nil + } + + return reasons + .flatMap { $0 } + .joined(separator: "; ") + + } + +} + +extension Mastodon.Entity.Error.Detail { + + enum Item: String { + case username + case email + case password + case agreement + case locale + case reason + + var localized: String { + switch self { + case .username: return L10n.Scene.Register.Error.Item.username + case .email: return L10n.Scene.Register.Error.Item.email + case .password: return L10n.Scene.Register.Error.Item.password + case .agreement: return L10n.Scene.Register.Error.Item.agreement + case .locale: return L10n.Scene.Register.Error.Item.locale + case .reason: return L10n.Scene.Register.Error.Item.reason + } + } + } + + private static func localizeError(item: Item, for reason: Reason) -> String { + switch (item, reason.error) { + case (.username, .ERR_INVALID): + return L10n.Scene.Register.Error.Special.usernameInvalid + case (.username, .ERR_TOO_LONG): + return L10n.Scene.Register.Error.Special.usernameTooLong + case (.email, .ERR_INVALID): + return L10n.Scene.Register.Error.Special.emailInvalid + case (.password, .ERR_TOO_SHORT): + return L10n.Scene.Register.Error.Special.passwordTooShort + case (_, .ERR_BLOCKED): return L10n.Scene.Register.Error.Reason.blocked(item.localized) + case (_, .ERR_UNREACHABLE): return L10n.Scene.Register.Error.Reason.unreachable(item.localized) + case (_, .ERR_TAKEN): return L10n.Scene.Register.Error.Reason.taken(item.localized) + case (_, .ERR_RESERVED): return L10n.Scene.Register.Error.Reason.reserved(item.localized) + case (_, .ERR_ACCEPTED): return L10n.Scene.Register.Error.Reason.accepted(item.localized) + case (_, .ERR_BLANK): return L10n.Scene.Register.Error.Reason.blank(item.localized) + case (_, .ERR_INVALID): return L10n.Scene.Register.Error.Reason.invalid(item.localized) + case (_, .ERR_TOO_LONG): return L10n.Scene.Register.Error.Reason.tooLong(item.localized) + case (_, .ERR_TOO_SHORT): return L10n.Scene.Register.Error.Reason.tooShort(item.localized) + case (_, .ERR_INCLUSION): return L10n.Scene.Register.Error.Reason.inclusion(item.localized) + case (_, ._other(let reason)): + assertionFailure("Needs handle new error description here") + return item.rawValue + " " + reason.description + } + } + + var usernameErrorDescriptions: [String] { + guard let username = username, !username.isEmpty else { return [] } + return username.map { Mastodon.Entity.Error.Detail.localizeError(item: .username, for: $0) } + } + + var emailErrorDescriptions: [String] { + guard let email = email, !email.isEmpty else { return [] } + return email.map { Mastodon.Entity.Error.Detail.localizeError(item: .email, for: $0) } + } + + var passwordErrorDescriptions: [String] { + guard let password = password, !password.isEmpty else { return [] } + return password.map { Mastodon.Entity.Error.Detail.localizeError(item: .password, for: $0) } + } + + var agreementErrorDescriptions: [String] { + guard let agreement = agreement, !agreement.isEmpty else { return [] } + return agreement.map { Mastodon.Entity.Error.Detail.localizeError(item: .agreement, for: $0) } + } + + var localeErrorDescriptions: [String] { + guard let locale = locale, !locale.isEmpty else { return [] } + return locale.map { Mastodon.Entity.Error.Detail.localizeError(item: .locale, for: $0) } + } + + var reasonErrorDescriptions: [String] { + guard let reason = reason, !reason.isEmpty else { return [] } + return reason.map { Mastodon.Entity.Error.Detail.localizeError(item: .reason, for: $0) } + } +} diff --git a/Mastodon/Extension/MastodonSDK/Mastodon+Entity+Error.swift b/Mastodon/Extension/MastodonSDK/Mastodon+Entity+Error.swift new file mode 100644 index 000000000..de3fd2f32 --- /dev/null +++ b/Mastodon/Extension/MastodonSDK/Mastodon+Entity+Error.swift @@ -0,0 +1,41 @@ +// +// Mastodon+Entity+Error.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-4. +// + +import Foundation +import MastodonSDK + +extension Mastodon.API.Error: LocalizedError { + + public var errorDescription: String? { + guard let mastodonError = mastodonError else { + return "HTTP \(httpResponseStatus.code)" + } + switch mastodonError { + case .generic(let error): + if let _ = error.details { + return nil // Duplicated with the details + } else { + return error.error + } + } + } + + public var failureReason: String? { + guard let mastodonError = mastodonError else { + return httpResponseStatus.reasonPhrase + } + switch mastodonError { + case .generic(let error): + if let details = error.details { + return details.failureReason + } else { + return error.errorDescription + } + } + } + +} diff --git a/Mastodon/Extension/MastodonSDK/Mastodon+Entity+History.swift b/Mastodon/Extension/MastodonSDK/Mastodon+Entity+History.swift new file mode 100644 index 000000000..b116889b8 --- /dev/null +++ b/Mastodon/Extension/MastodonSDK/Mastodon+Entity+History.swift @@ -0,0 +1,20 @@ +// +// Mastodon+Entity+History.swift +// Mastodon +// +// Created by xiaojian sun on 2021/4/2. +// + +import MastodonSDK + +extension Mastodon.Entity.History: Hashable { + public func hash(into hasher: inout Hasher) { + hasher.combine(uses) + hasher.combine(accounts) + hasher.combine(day) + } + + public static func == (lhs: Mastodon.Entity.History, rhs: Mastodon.Entity.History) -> Bool { + return lhs.uses == rhs.uses && lhs.uses == rhs.uses && lhs.day == rhs.day + } +} diff --git a/Mastodon/Extension/MastodonSDK/Mastodon+Entity+Notification+Type.swift b/Mastodon/Extension/MastodonSDK/Mastodon+Entity+Notification+Type.swift new file mode 100644 index 000000000..2037f54a2 --- /dev/null +++ b/Mastodon/Extension/MastodonSDK/Mastodon+Entity+Notification+Type.swift @@ -0,0 +1,81 @@ +// +// Mastodon+Entity+Notification+Type.swift +// Mastodon +// +// Created by sxiaojian on 2021/4/19. +// + +import Foundation +import MastodonSDK +import UIKit + +extension Mastodon.Entity.Notification.NotificationType { + public var color: UIColor { + get { + var color: UIColor + switch self { + case .follow: + color = Asset.Colors.brandBlue.color + case .favourite: + color = Asset.Colors.Notification.favourite.color + case .reblog: + color = Asset.Colors.Notification.reblog.color + case .mention: + color = Asset.Colors.Notification.mention.color + case .poll: + color = Asset.Colors.brandBlue.color + case .followRequest: + color = Asset.Colors.brandBlue.color + default: + color = .clear + } + return color + } + } + + public var actionText: String { + get { + var actionText: String + switch self { + case .follow: + actionText = L10n.Scene.Notification.Action.follow + case .favourite: + actionText = L10n.Scene.Notification.Action.favourite + case .reblog: + actionText = L10n.Scene.Notification.Action.reblog + case .mention: + actionText = L10n.Scene.Notification.Action.mention + case .poll: + actionText = L10n.Scene.Notification.Action.poll + case .followRequest: + actionText = L10n.Scene.Notification.Action.followRequest + default: + actionText = "" + } + return actionText + } + } + + public var actionImageName: String { + get { + var actionImageName: String + switch self { + case .follow: + actionImageName = "person.crop.circle.badge.checkmark" + case .favourite: + actionImageName = "star.fill" + case .reblog: + actionImageName = "arrow.2.squarepath" + case .mention: + actionImageName = "at" + case .poll: + actionImageName = "list.bullet" + case .followRequest: + actionImageName = "person.crop.circle" + default: + actionImageName = "" + } + return actionImageName + } + } +} diff --git a/Mastodon/Extension/MastodonSDK/Mastodon+Entity+Tag.swift b/Mastodon/Extension/MastodonSDK/Mastodon+Entity+Tag.swift new file mode 100644 index 000000000..caf819b38 --- /dev/null +++ b/Mastodon/Extension/MastodonSDK/Mastodon+Entity+Tag.swift @@ -0,0 +1,18 @@ +// +// Mastodon+Entity+Tag.swift +// Mastodon +// +// Created by xiaojian sun on 2021/4/2. +// + +import MastodonSDK + +extension Mastodon.Entity.Tag: Hashable { + public func hash(into hasher: inout Hasher) { + hasher.combine(name) + } + + public static func == (lhs: Mastodon.Entity.Tag, rhs: Mastodon.Entity.Tag) -> Bool { + return lhs.name == rhs.name + } +} diff --git a/Mastodon/Extension/NSLayoutConstraint.swift b/Mastodon/Extension/NSLayoutConstraint.swift index cae353187..eea697e2b 100644 --- a/Mastodon/Extension/NSLayoutConstraint.swift +++ b/Mastodon/Extension/NSLayoutConstraint.swift @@ -12,4 +12,9 @@ extension NSLayoutConstraint { self.priority = priority return self } + + func identifier(_ identifier: String?) -> Self { + self.identifier = identifier + return self + } } diff --git a/Mastodon/Extension/NSManagedObjectContext.swift b/Mastodon/Extension/NSManagedObjectContext.swift new file mode 100644 index 000000000..9c569a8f4 --- /dev/null +++ b/Mastodon/Extension/NSManagedObjectContext.swift @@ -0,0 +1,20 @@ +// +// NSManagedObjectContext.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-11. +// + +import Foundation +import CoreData + +extension NSManagedObjectContext { + func safeFetch(_ request: NSFetchRequest) -> [T] where T : NSFetchRequestResult { + do { + return try fetch(request) + } catch { + assertionFailure(error.localizedDescription) + return [] + } + } +} diff --git a/Mastodon/Extension/String.swift b/Mastodon/Extension/String.swift new file mode 100644 index 000000000..bf70c8937 --- /dev/null +++ b/Mastodon/Extension/String.swift @@ -0,0 +1,40 @@ +// +// String.swift +// Mastodon +// +// Created by sxiaojian on 2021/3/2. +// + +import Foundation + +extension String { + func capitalizingFirstLetter() -> String { + return prefix(1).capitalized + dropFirst() + } + + mutating func capitalizeFirstLetter() { + self = self.capitalizingFirstLetter() + } +} + +extension String { + static func normalize(base64String: String) -> String { + let base64 = base64String + .replacingOccurrences(of: "-", with: "+") + .replacingOccurrences(of: "_", with: "/") + .padding() + return base64 + } + + private func padding() -> String { + let remainder = self.count % 4 + if remainder > 0 { + return self.padding( + toLength: self.count + 4 - remainder, + withPad: "=", + startingAt: 0 + ) + } + return self + } +} diff --git a/Mastodon/Extension/UIAlertController.swift b/Mastodon/Extension/UIAlertController.swift index 83c0ff555..2b598f2a9 100644 --- a/Mastodon/Extension/UIAlertController.swift +++ b/Mastodon/Extension/UIAlertController.swift @@ -4,7 +4,7 @@ // import UIKit - +import MastodonSDK // Reference: // https://nshipster.com/swift-foundation-error-protocols/ extension UIAlertController { @@ -42,4 +42,3 @@ extension UIAlertController { ) } } - diff --git a/Mastodon/Extension/UIBarButtonItem.swift b/Mastodon/Extension/UIBarButtonItem.swift index 8a0630f03..cf1f84e97 100644 --- a/Mastodon/Extension/UIBarButtonItem.swift +++ b/Mastodon/Extension/UIBarButtonItem.swift @@ -17,4 +17,3 @@ extension UIBarButtonItem { } } - diff --git a/Mastodon/Extension/UIButton.swift b/Mastodon/Extension/UIButton.swift index 916ad222d..31043157a 100644 --- a/Mastodon/Extension/UIButton.swift +++ b/Mastodon/Extension/UIButton.swift @@ -43,3 +43,11 @@ extension UIButton { } } +extension UIButton { + func setBackgroundColor(_ color: UIColor, for state: UIControl.State) { + self.setBackgroundImage( + UIImage.placeholder(color: color), + for: state + ) + } +} diff --git a/Mastodon/Extension/UIControl.swift b/Mastodon/Extension/UIControl.swift new file mode 100644 index 000000000..792e82508 --- /dev/null +++ b/Mastodon/Extension/UIControl.swift @@ -0,0 +1,64 @@ +// +// UIControl.swift +// Mastodon +// +// Created by sxiaojian on 2021/3/8. +// + +import Foundation +import UIKit +import Combine + +/// A custom subscription to capture UIControl target events. +final class UIControlSubscription: Subscription where SubscriberType.Input == Control { + private var subscriber: SubscriberType? + private let control: Control + + init(subscriber: SubscriberType, control: Control, event: UIControl.Event) { + self.subscriber = subscriber + self.control = control + control.addTarget(self, action: #selector(eventHandler), for: event) + } + + func request(_ demand: Subscribers.Demand) { + // We do nothing here as we only want to send events when they occur. + // See, for more info: https://developer.apple.com/documentation/combine/subscribers/demand + } + + func cancel() { + subscriber = nil + } + + @objc private func eventHandler() { + _ = subscriber?.receive(control) + } +} + +/// A custom `Publisher` to work with our custom `UIControlSubscription`. +struct UIControlPublisher: Publisher { + + typealias Output = Control + typealias Failure = Never + + let control: Control + let controlEvents: UIControl.Event + + init(control: Control, events: UIControl.Event) { + self.control = control + self.controlEvents = events + } + + func receive(subscriber: S) where S : Subscriber, S.Failure == UIControlPublisher.Failure, S.Input == UIControlPublisher.Output { + let subscription = UIControlSubscription(subscriber: subscriber, control: control, event: controlEvents) + subscriber.receive(subscription: subscription) + } +} + +/// Extending the `UIControl` types to be able to produce a `UIControl.Event` publisher. +protocol CombineCompatible { } +extension UIControl: CombineCompatible { } +extension CombineCompatible where Self: UIControl { + func publisher(for events: UIControl.Event) -> UIControlPublisher { + return UIControlPublisher(control: self, events: events) + } +} diff --git a/Mastodon/Extension/UIIamge.swift b/Mastodon/Extension/UIImage.swift similarity index 56% rename from Mastodon/Extension/UIIamge.swift rename to Mastodon/Extension/UIImage.swift index 4f4b350c3..35766c0bc 100644 --- a/Mastodon/Extension/UIIamge.swift +++ b/Mastodon/Extension/UIImage.swift @@ -1,25 +1,23 @@ // -// UIIamge.swift +// UIImage.swift // Mastodon // -// Created by sxiaojian on 2021/1/28. +// Created by sxiaojian on 2021/3/8. // -import UIKit import CoreImage import CoreImage.CIFilterBuiltins +import UIKit extension UIImage { - static func placeholder(size: CGSize = CGSize(width: 1, height: 1), color: UIColor) -> UIImage { let render = UIGraphicsImageRenderer(size: size) - + return render.image { (context: UIGraphicsImageRendererContext) in context.cgContext.setFillColor(color.cgColor) context.fill(CGRect(origin: .zero, size: size)) } } - } // refs: https://www.hackingwithswift.com/example-code/media/how-to-read-the-average-color-of-a-uiimage-using-ciareaaverage @@ -27,20 +25,27 @@ extension UIImage { @available(iOS 14.0, *) var dominantColor: UIColor? { guard let inputImage = CIImage(image: self) else { return nil } - + let filter = CIFilter.areaAverage() filter.inputImage = inputImage filter.extent = inputImage.extent guard let outputImage = filter.outputImage else { return nil } - + var bitmap = [UInt8](repeating: 0, count: 4) let context = CIContext(options: [.workingColorSpace: kCFNull]) context.render(outputImage, toBitmap: &bitmap, rowBytes: 4, bounds: CGRect(x: 0, y: 0, width: 1, height: 1), format: .RGBA8, colorSpace: nil) - + return UIColor(red: CGFloat(bitmap[0]) / 255, green: CGFloat(bitmap[1]) / 255, blue: CGFloat(bitmap[2]) / 255, alpha: CGFloat(bitmap[3]) / 255) } } +extension UIImage { + var domainLumaCoefficientsStyle: UIUserInterfaceStyle? { + guard let brightness = cgImage?.brightness else { return nil } + return brightness > 100 ? .light : .dark // 0 ~ 255 + } +} + extension UIImage { func blur(radius: CGFloat) -> UIImage? { guard let inputImage = CIImage(image: self) else { return nil } @@ -53,3 +58,35 @@ extension UIImage { return image } } + +extension UIImage { + func withRoundedCorners(radius: CGFloat? = nil) -> UIImage? { + let maxRadius = min(size.width, size.height) / 2 + let cornerRadius: CGFloat = { + guard let radius = radius, radius > 0 else { return maxRadius } + return min(radius, maxRadius) + }() + + let render = UIGraphicsImageRenderer(size: size) + return render.image { (_: UIGraphicsImageRendererContext) in + let rect = CGRect(origin: .zero, size: size) + UIBezierPath(roundedRect: rect, cornerRadius: cornerRadius).addClip() + draw(in: rect) + } + } +} + +extension UIImage { + static func adaptiveUserInterfaceStyleImage(lightImage: UIImage, darkImage: UIImage) -> UIImage { + let imageAsset = UIImageAsset() + imageAsset.register(lightImage, with: UITraitCollection(traitsFrom: [ + UITraitCollection(displayScale: 1.0), + UITraitCollection(userInterfaceStyle: .light) + ])) + imageAsset.register(darkImage, with: UITraitCollection(traitsFrom: [ + UITraitCollection(displayScale: 1.0), + UITraitCollection(userInterfaceStyle: .dark) + ])) + return imageAsset.image(with: UITraitCollection.current) + } +} diff --git a/Mastodon/Extension/UIInterpolatingMotionEffect.swift b/Mastodon/Extension/UIInterpolatingMotionEffect.swift new file mode 100644 index 000000000..5ab4cb2f5 --- /dev/null +++ b/Mastodon/Extension/UIInterpolatingMotionEffect.swift @@ -0,0 +1,30 @@ +// +// UIInterpolatingMotionEffect.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-2. +// + +import UIKit + +extension UIInterpolatingMotionEffect { + static func motionEffect( + minX: CGFloat, + maxX: CGFloat, + minY: CGFloat, + maxY: CGFloat + ) -> UIMotionEffectGroup { + let motionEffectX = UIInterpolatingMotionEffect(keyPath: "layer.transform.translation.x", type: .tiltAlongHorizontalAxis) + motionEffectX.minimumRelativeValue = minX + motionEffectX.maximumRelativeValue = maxX + + let motionEffectY = UIInterpolatingMotionEffect(keyPath: "layer.transform.translation.y", type: .tiltAlongVerticalAxis) + motionEffectY.minimumRelativeValue = minY + motionEffectY.maximumRelativeValue = maxY + + let motionEffectGroup = UIMotionEffectGroup() + motionEffectGroup.motionEffects = [motionEffectX, motionEffectY] + + return motionEffectGroup + } +} diff --git a/Mastodon/Extension/UINavigationController.swift b/Mastodon/Extension/UINavigationController.swift new file mode 100644 index 000000000..9a9c44ab3 --- /dev/null +++ b/Mastodon/Extension/UINavigationController.swift @@ -0,0 +1,16 @@ +// +// UINavigationController.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-31. +// + +import UIKit + +// This not works! +// SeeAlso: `AdaptiveStatusBarStyleNavigationController` +extension UINavigationController { + open override var childForStatusBarStyle: UIViewController? { + return visibleViewController + } +} diff --git a/Mastodon/Extension/UIScrollView.swift b/Mastodon/Extension/UIScrollView.swift new file mode 100644 index 000000000..8999d255c --- /dev/null +++ b/Mastodon/Extension/UIScrollView.swift @@ -0,0 +1,32 @@ +// +// UIScrollView.swift +// Mastodon +// +// Created by sxiaojian on 2021/3/15. +// + +import UIKit + +extension UIScrollView { + public enum ScrollDirection { + case top + case bottom + case left + case right + } + + public func scroll(to direction: ScrollDirection, animated: Bool) { + let offset: CGPoint + switch direction { + case .top: + offset = CGPoint(x: contentOffset.x, y: -adjustedContentInset.top) + case .bottom: + offset = CGPoint(x: contentOffset.x, y: max(-adjustedContentInset.top, contentSize.height - frame.height + adjustedContentInset.bottom)) + case .left: + offset = CGPoint(x: -adjustedContentInset.left, y: contentOffset.y) + case .right: + offset = CGPoint(x: max(-adjustedContentInset.left, contentSize.width - frame.width + adjustedContentInset.right), y: contentOffset.y) + } + setContentOffset(offset, animated: animated) + } +} diff --git a/Mastodon/Extension/UITabBarController.swift b/Mastodon/Extension/UITabBarController.swift new file mode 100644 index 000000000..9b1d91292 --- /dev/null +++ b/Mastodon/Extension/UITabBarController.swift @@ -0,0 +1,14 @@ +// +// UITabBarController.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-31. +// + +import UIKit + +extension UITabBarController { + open override var childForStatusBarStyle: UIViewController? { + return selectedViewController + } +} diff --git a/Mastodon/Extension/UITableView.swift b/Mastodon/Extension/UITableView.swift new file mode 100644 index 000000000..22ae6c0b5 --- /dev/null +++ b/Mastodon/Extension/UITableView.swift @@ -0,0 +1,55 @@ +// +// UITableView.swift +// Mastodon +// +// Created by Cirno MainasuK on 2021-3-2. +// + +import UIKit + +extension UITableView { + + // static let groupedTableViewPaddingHeaderViewHeight: CGFloat = 16 + // static var groupedTableViewPaddingHeaderView: UIView { + // return UIView(frame: CGRect(x: 0, y: 0, width: 100, height: groupedTableViewPaddingHeaderViewHeight)) + // } + +} + +extension UITableView { + + func deselectRow(with transitionCoordinator: UIViewControllerTransitionCoordinator?, animated: Bool) { + guard let indexPathForSelectedRow = indexPathForSelectedRow else { return } + + guard let transitionCoordinator = transitionCoordinator else { + deselectRow(at: indexPathForSelectedRow, animated: animated) + return + } + + transitionCoordinator.animate(alongsideTransition: { _ in + self.deselectRow(at: indexPathForSelectedRow, animated: animated) + }, completion: { context in + if context.isCancelled { + self.selectRow(at: indexPathForSelectedRow, animated: animated, scrollPosition: .none) + } + }) + } + + func blinkRow(at indexPath: IndexPath) { + DispatchQueue.main.asyncAfter(wallDeadline: .now() + 1) { [weak self] in + guard let self = self else { return } + guard let cell = self.cellForRow(at: indexPath) else { return } + let backgroundColor = cell.backgroundColor + + UIView.animate(withDuration: 0.3) { + cell.backgroundColor = Asset.Colors.Label.highlight.color.withAlphaComponent(0.5) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + UIView.animate(withDuration: 0.3) { + cell.backgroundColor = backgroundColor + } + } + } + } + } + +} diff --git a/Mastodon/Extension/UIView+Gesture.swift b/Mastodon/Extension/UIView+Gesture.swift new file mode 100644 index 000000000..a76843d89 --- /dev/null +++ b/Mastodon/Extension/UIView+Gesture.swift @@ -0,0 +1,93 @@ +// +// UIView+Gesture.swift +// Mastodon +// +// Created by sxiaojian on 2021/4/14. +// + +import Combine +import Foundation +import UIKit + +struct GesturePublisher: Publisher { + typealias Output = GestureType + typealias Failure = Never + private let view: UIView + private let gestureType: GestureType + init(view: UIView, gestureType: GestureType) { + self.view = view + self.gestureType = gestureType + } + + func receive(subscriber: S) where S: Subscriber, + GesturePublisher.Failure == S.Failure, GesturePublisher.Output + == S.Input + { + let subscription = GestureSubscription( + subscriber: subscriber, + view: view, + gestureType: gestureType + ) + subscriber.receive(subscription: subscription) + } +} + +enum GestureType { + case tap(UITapGestureRecognizer = .init()) + case swipe(UISwipeGestureRecognizer = .init()) + case longPress(UILongPressGestureRecognizer = .init()) + case pan(UIPanGestureRecognizer = .init()) + case pinch(UIPinchGestureRecognizer = .init()) + case edge(UIScreenEdgePanGestureRecognizer = .init()) + func get() -> UIGestureRecognizer { + switch self { + case let .tap(tapGesture): + return tapGesture + case let .swipe(swipeGesture): + return swipeGesture + case let .longPress(longPressGesture): + return longPressGesture + case let .pan(panGesture): + return panGesture + case let .pinch(pinchGesture): + return pinchGesture + case let .edge(edgePanGesture): + return edgePanGesture + } + } +} + +class GestureSubscription: Subscription where S.Input == GestureType, S.Failure == Never { + private var subscriber: S? + private var gestureType: GestureType + private var view: UIView + init(subscriber: S, view: UIView, gestureType: GestureType) { + self.subscriber = subscriber + self.view = view + self.gestureType = gestureType + configureGesture(gestureType) + } + + private func configureGesture(_ gestureType: GestureType) { + let gesture = gestureType.get() + gesture.addTarget(self, action: #selector(handler)) + view.addGestureRecognizer(gesture) + } + + func request(_ demand: Subscribers.Demand) {} + func cancel() { + subscriber = nil + } + + @objc + private func handler() { + _ = subscriber?.receive(gestureType) + } +} + +extension UIView { + func gesture(_ gestureType: GestureType = .tap()) -> GesturePublisher { + isUserInteractionEnabled = true + return GesturePublisher(view: self, gestureType: gestureType) + } +} diff --git a/Mastodon/Extension/UIView+Remove.swift b/Mastodon/Extension/UIView+Remove.swift new file mode 100644 index 000000000..473b3c348 --- /dev/null +++ b/Mastodon/Extension/UIView+Remove.swift @@ -0,0 +1,18 @@ +// +// UIView+Remove.swift +// Mastodon +// +// Created by xiaojian sun on 2021/4/16. +// + +import Foundation +import UIKit + +extension UIView { + func removeFromStackView() { + if let stackView = self.superview as? UIStackView { + stackView.removeArrangedSubview(self) + } + self.removeFromSuperview() + } +} diff --git a/Mastodon/Extension/UIViewController.swift b/Mastodon/Extension/UIViewController.swift index c3782fa14..9ebb3a0a8 100644 --- a/Mastodon/Extension/UIViewController.swift +++ b/Mastodon/Extension/UIViewController.swift @@ -46,6 +46,53 @@ extension UIViewController { } +extension UIViewController { + + func viewController(of type: T.Type) -> T? { + if let viewController = self as? T { + return viewController + } + + // UITabBarController + if let tabBarController = self as? UITabBarController { + for tab in tabBarController.viewControllers ?? [] { + if let viewController = tab.viewController(of: type) { + return viewController + } + } + } + + // UINavigationController + if let navigationController = self as? UINavigationController { + for page in navigationController.viewControllers { + if let viewController = page.viewController(of: type) { + return viewController + } + } + } + + // UIPageController + if let pageViewController = self as? UIPageViewController { + for page in pageViewController.viewControllers ?? [] { + if let viewController = page.viewController(of: type) { + return viewController + } + } + } + + // child view controller + for subview in self.view?.subviews ?? [] { + if let childViewController = subview.next as? UIViewController, + let viewController = childViewController.viewController(of: type) { + return viewController + } + } + + return nil + } + +} + extension UIViewController { /// https://bluelemonbits.com/2018/08/26/inserting-cells-at-the-top-of-a-uitableview-with-no-scrolling/ diff --git a/Mastodon/Extension/UserDefaults.swift b/Mastodon/Extension/UserDefaults.swift new file mode 100644 index 000000000..619d6c250 --- /dev/null +++ b/Mastodon/Extension/UserDefaults.swift @@ -0,0 +1,28 @@ +// +// UserDefaults.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-26. +// + +import Foundation +import AppShared + +extension UserDefaults { + + subscript(key: String) -> T? { + get { + if let rawValue = value(forKey: key) as? T.RawValue { + return T(rawValue: rawValue) + } + return nil + } + set { set(newValue?.rawValue, forKey: key) } + } + + subscript(key: String) -> T? { + get { return value(forKey: key) as? T } + set { set(newValue, forKey: key) } + } + +} diff --git a/Mastodon/Generated/Assets.swift b/Mastodon/Generated/Assets.swift index 08507ed9d..043360043 100644 --- a/Mastodon/Generated/Assets.swift +++ b/Mastodon/Generated/Assets.swift @@ -22,55 +22,118 @@ internal typealias AssetImageTypeAlias = ImageAsset.Image // swiftlint:disable identifier_name line_length nesting type_body_length type_name internal enum Asset { internal static let accentColor = ColorAsset(name: "AccentColor") - internal enum Arrows { - internal static let arrowTriangle2Circlepath = ImageAsset(name: "Arrows/arrow.triangle.2.circlepath") - } internal enum Asset { + internal static let email = ImageAsset(name: "Asset/email") 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 Background { + internal enum AudioPlayer { + internal static let highlight = ColorAsset(name: "Colors/Background/AudioPlayer/highlight") + } + internal enum Poll { + internal static let disabled = ColorAsset(name: "Colors/Background/Poll/disabled") + internal static let highlight = ColorAsset(name: "Colors/Background/Poll/highlight") + } + internal static let alertYellow = ColorAsset(name: "Colors/Background/alert.yellow") + internal static let dangerBorder = ColorAsset(name: "Colors/Background/danger.border") + internal static let danger = ColorAsset(name: "Colors/Background/danger") + internal static let mediaTypeIndicotor = ColorAsset(name: "Colors/Background/media.type.indicotor") + internal static let navigationBar = ColorAsset(name: "Colors/Background/navigationBar") internal static let onboardingBackground = ColorAsset(name: "Colors/Background/onboarding.background") internal static let secondaryGroupedSystemBackground = ColorAsset(name: "Colors/Background/secondary.grouped.system.background") internal static let secondarySystemBackground = ColorAsset(name: "Colors/Background/secondary.system.background") internal static let systemBackground = ColorAsset(name: "Colors/Background/system.background") + internal static let systemElevatedBackground = ColorAsset(name: "Colors/Background/system.elevated.background") internal static let systemGroupedBackground = ColorAsset(name: "Colors/Background/system.grouped.background") internal static let tertiarySystemBackground = ColorAsset(name: "Colors/Background/tertiary.system.background") + internal static let tertiarySystemGroupedBackground = ColorAsset(name: "Colors/Background/tertiary.system.grouped.background") + } + internal enum Border { + internal static let notification = ColorAsset(name: "Colors/Border/notification") + internal static let searchCard = ColorAsset(name: "Colors/Border/searchCard") } 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 highlight = ColorAsset(name: "Colors/Button/highlight") + internal static let inactive = ColorAsset(name: "Colors/Button/inactive") + internal static let normal = ColorAsset(name: "Colors/Button/normal") } internal enum Icon { - internal static let photo = ColorAsset(name: "Colors/Icon/photo") internal static let plus = ColorAsset(name: "Colors/Icon/plus") } internal enum Label { internal static let highlight = ColorAsset(name: "Colors/Label/highlight") internal static let primary = ColorAsset(name: "Colors/Label/primary") internal static let secondary = ColorAsset(name: "Colors/Label/secondary") + internal static let tertiary = ColorAsset(name: "Colors/Label/tertiary") + } + internal enum Notification { + internal static let favourite = ColorAsset(name: "Colors/Notification/favourite") + internal static let mention = ColorAsset(name: "Colors/Notification/mention") + internal static let reblog = ColorAsset(name: "Colors/Notification/reblog") + } + internal enum Shadow { + internal static let searchCard = ColorAsset(name: "Colors/Shadow/SearchCard") + } + internal enum Slider { + internal static let bar = ColorAsset(name: "Colors/Slider/bar") } internal enum TextField { + internal static let background = ColorAsset(name: "Colors/TextField/background") internal static let highlight = ColorAsset(name: "Colors/TextField/highlight") internal static let invalid = ColorAsset(name: "Colors/TextField/invalid") internal static let valid = ColorAsset(name: "Colors/TextField/valid") } - internal static let lightAlertYellow = ColorAsset(name: "Colors/lightAlertYellow") - internal static let lightBackground = ColorAsset(name: "Colors/lightBackground") - internal static let lightBrandBlue = ColorAsset(name: "Colors/lightBrandBlue") - internal static let lightDangerRed = ColorAsset(name: "Colors/lightDangerRed") - internal static let lightDarkGray = ColorAsset(name: "Colors/lightDarkGray") - internal static let lightDisabled = ColorAsset(name: "Colors/lightDisabled") - internal static let lightInactive = ColorAsset(name: "Colors/lightInactive") - internal static let lightSecondaryText = ColorAsset(name: "Colors/lightSecondaryText") - internal static let lightSuccessGreen = ColorAsset(name: "Colors/lightSuccessGreen") - internal static let lightWhite = ColorAsset(name: "Colors/lightWhite") + internal static let battleshipGrey = ColorAsset(name: "Colors/battleshipGrey") + internal static let brandBlue = ColorAsset(name: "Colors/brand.blue") + 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 successGreen = ColorAsset(name: "Colors/success.green") internal static let systemOrange = ColorAsset(name: "Colors/system.orange") } - internal enum Welcome { - internal static let mastodonLogo = ImageAsset(name: "Welcome/mastodon.logo") - internal static let mastodonLogoLarge = ImageAsset(name: "Welcome/mastodon.logo.large") + 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 Compose { + internal static let background = ColorAsset(name: "Scene/Compose/background") + internal static let toolbarBackground = ColorAsset(name: "Scene/Compose/toolbar.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 Welcome { + internal enum Illustration { + internal static let backgroundCyan = ColorAsset(name: "Scene/Welcome/illustration/background.cyan") + internal static let cloudBase = ImageAsset(name: "Scene/Welcome/illustration/cloud.base") + internal static let elephantOnAirplaneWithContrail = ImageAsset(name: "Scene/Welcome/illustration/elephant.on.airplane.with.contrail") + internal static let elephantThreeOnGrass = ImageAsset(name: "Scene/Welcome/illustration/elephant.three.on.grass") + internal static let elephantThreeOnGrassWithTreeThree = ImageAsset(name: "Scene/Welcome/illustration/elephant.three.on.grass.with.tree.three") + internal static let elephantThreeOnGrassWithTreeTwo = ImageAsset(name: "Scene/Welcome/illustration/elephant.three.on.grass.with.tree.two") + } + internal static let mastodonLogoBlack = ImageAsset(name: "Scene/Welcome/mastodon.logo.black") + internal static let mastodonLogoBlackLarge = ImageAsset(name: "Scene/Welcome/mastodon.logo.black.large") + internal static let mastodonLogo = ImageAsset(name: "Scene/Welcome/mastodon.logo") + internal static let mastodonLogoLarge = ImageAsset(name: "Scene/Welcome/mastodon.logo.large") + } + } + internal enum Settings { + internal static let appearanceAutomatic = ImageAsset(name: "Settings/appearance.automatic") + internal static let appearanceDark = ImageAsset(name: "Settings/appearance.dark") + internal static let appearanceLight = ImageAsset(name: "Settings/appearance.light") } } // swiftlint:enable identifier_name line_length nesting type_body_length type_name diff --git a/Mastodon/Generated/Strings.swift b/Mastodon/Generated/Strings.swift index 47cbabab8..8f6c13f9e 100644 --- a/Mastodon/Generated/Strings.swift +++ b/Mastodon/Generated/Strings.swift @@ -13,14 +13,66 @@ internal enum L10n { internal enum Common { internal enum Alerts { + internal enum BlockDomain { + /// Block entire 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 in any public timelines or your notifications. 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 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 discard composed post content. + internal static let message = L10n.tr("Localizable", "Common.Alerts.DiscardPostContent.Message") + /// Discard Publish + internal static let title = L10n.tr("Localizable", "Common.Alerts.DiscardPostContent.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 SavePhotoFailure { + /// Please enable photo libaray access permission to save 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 expired + internal static let pollExpired = L10n.tr("Localizable", "Common.Alerts.VoteFailure.PollExpired") + /// Vote Failure + internal static let title = L10n.tr("Localizable", "Common.Alerts.VoteFailure.Title") + } } internal enum Controls { internal enum Actions { @@ -28,14 +80,28 @@ internal enum L10n { 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") /// Confirm internal static let confirm = L10n.tr("Localizable", "Common.Controls.Actions.Confirm") /// Continue internal static let `continue` = L10n.tr("Localizable", "Common.Controls.Actions.Continue") + /// 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") /// OK internal static let ok = L10n.tr("Localizable", "Common.Controls.Actions.Ok") /// Open in Safari @@ -44,34 +110,212 @@ internal enum L10n { internal static let preview = L10n.tr("Localizable", "Common.Controls.Actions.Preview") /// Remove internal static let remove = L10n.tr("Localizable", "Common.Controls.Actions.Remove") + /// 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 Firendship { + /// Block + internal static let block = L10n.tr("Localizable", "Common.Controls.Firendship.Block") + /// Block %@ + internal static func blockDomain(_ p1: Any) -> String { + return L10n.tr("Localizable", "Common.Controls.Firendship.BlockDomain", String(describing: p1)) + } + /// Blocked + internal static let blocked = L10n.tr("Localizable", "Common.Controls.Firendship.Blocked") + /// Block %@ + internal static func blockUser(_ p1: Any) -> String { + return L10n.tr("Localizable", "Common.Controls.Firendship.BlockUser", String(describing: p1)) + } + /// Edit info + internal static let editInfo = L10n.tr("Localizable", "Common.Controls.Firendship.EditInfo") + /// Follow + internal static let follow = L10n.tr("Localizable", "Common.Controls.Firendship.Follow") + /// Following + internal static let following = L10n.tr("Localizable", "Common.Controls.Firendship.Following") + /// Mute + internal static let mute = L10n.tr("Localizable", "Common.Controls.Firendship.Mute") + /// Muted + internal static let muted = L10n.tr("Localizable", "Common.Controls.Firendship.Muted") + /// Mute %@ + internal static func muteUser(_ p1: Any) -> String { + return L10n.tr("Localizable", "Common.Controls.Firendship.MuteUser", String(describing: p1)) + } + /// Pending + internal static let pending = L10n.tr("Localizable", "Common.Controls.Firendship.Pending") + /// Request + internal static let request = L10n.tr("Localizable", "Common.Controls.Firendship.Request") + /// Unblock + internal static let unblock = L10n.tr("Localizable", "Common.Controls.Firendship.Unblock") + /// Unblock %@ + internal static func unblockUser(_ p1: Any) -> String { + return L10n.tr("Localizable", "Common.Controls.Firendship.UnblockUser", String(describing: p1)) + } + /// Unmute + internal static let unmute = L10n.tr("Localizable", "Common.Controls.Firendship.Unmute") + /// Unmute %@ + internal static func unmuteUser(_ p1: Any) -> String { + return L10n.tr("Localizable", "Common.Controls.Firendship.UnmuteUser", String(describing: p1)) + } } internal enum Status { + /// content warning + internal static let contentWarning = L10n.tr("Localizable", "Common.Controls.Status.ContentWarning") + /// cw: %@ + internal static func contentWarningText(_ p1: Any) -> String { + return L10n.tr("Localizable", "Common.Controls.Status.ContentWarningText", String(describing: p1)) + } /// Tap to reveal that may be sensitive internal static let mediaContentWarning = L10n.tr("Localizable", "Common.Controls.Status.MediaContentWarning") /// Show Post internal static let showPost = L10n.tr("Localizable", "Common.Controls.Status.ShowPost") - /// content warning - internal static let statusContentWarning = L10n.tr("Localizable", "Common.Controls.Status.StatusContentWarning") - /// %@ boosted - internal static func userBoosted(_ p1: Any) -> String { - return L10n.tr("Localizable", "Common.Controls.Status.UserBoosted", String(describing: p1)) + /// 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") + /// Unreblog + 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") + /// %@ left + internal static func timeLeft(_ p1: Any) -> String { + return L10n.tr("Localizable", "Common.Controls.Status.Poll.TimeLeft", String(describing: p1)) + } + /// Vote + internal static let vote = L10n.tr("Localizable", "Common.Controls.Status.Poll.Vote") + internal enum VoteCount { + /// %d votes + internal static func multiple(_ p1: Int) -> String { + return L10n.tr("Localizable", "Common.Controls.Status.Poll.VoteCount.Multiple", p1) + } + /// %d vote + internal static func single(_ p1: Int) -> String { + return L10n.tr("Localizable", "Common.Controls.Status.Poll.VoteCount.Single", p1) + } + } + internal enum VoterCount { + /// %d voters + internal static func multiple(_ p1: Int) -> String { + return L10n.tr("Localizable", "Common.Controls.Status.Poll.VoterCount.Multiple", p1) + } + /// %d voter + internal static func single(_ p1: Int) -> String { + return L10n.tr("Localizable", "Common.Controls.Status.Poll.VoterCount.Single", p1) + } + } + } + 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 { - /// Load More - internal static let loadMore = L10n.tr("Localizable", "Common.Controls.Timeline.LoadMore") + internal enum Accessibility { + /// %@ favorites + internal static func countFavorites(_ p1: Any) -> String { + return L10n.tr("Localizable", "Common.Controls.Timeline.Accessibility.CountFavorites", String(describing: p1)) + } + /// %@ reblogs + internal static func countReblogs(_ p1: Any) -> String { + return L10n.tr("Localizable", "Common.Controls.Timeline.Accessibility.CountReblogs", String(describing: p1)) + } + /// %@ replies + internal static func countReplies(_ p1: Any) -> String { + return L10n.tr("Localizable", "Common.Controls.Timeline.Accessibility.CountReplies", String(describing: p1)) + } + } + internal enum Header { + /// You can’t view Artbot’s profile\n until they unblock you. + internal static let blockedWarning = L10n.tr("Localizable", "Common.Controls.Timeline.Header.BlockedWarning") + /// You can’t view Artbot’s profile\n until you unblock them.\nYour account looks like this to them. + internal static let blockingWarning = L10n.tr("Localizable", "Common.Controls.Timeline.Header.BlockingWarning") + /// No Status Found + internal static let noStatusFound = L10n.tr("Localizable", "Common.Controls.Timeline.Header.NoStatusFound") + /// This account has been suspended. + internal static let suspendedWarning = L10n.tr("Localizable", "Common.Controls.Timeline.Header.SuspendedWarning") + /// %@'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 Countable { @@ -85,6 +329,104 @@ internal enum L10n { } internal enum Scene { + 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 { + /// Append attachment + internal static let appendAttachment = L10n.tr("Localizable", "Scene.Compose.Accessibility.AppendAttachment") + /// Append 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") + /// Input limit exceeds %ld + internal static func inputLimitExceedsCount(_ p1: Int) -> String { + return L10n.tr("Localizable", "Scene.Compose.Accessibility.InputLimitExceedsCount", p1) + } + /// Input limit remains %ld + internal static func inputLimitRemainsCount(_ p1: Int) -> String { + return L10n.tr("Localizable", "Scene.Compose.Accessibility.InputLimitRemainsCount", p1) + } + /// 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 photo for low vision people... + internal static let descriptionPhoto = L10n.tr("Localizable", "Scene.Compose.Attachment.DescriptionPhoto") + /// Describe what’s happening for low vision people... + 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 ContentWarning { + /// Write an accurate warning here... + internal static let placeholder = L10n.tr("Localizable", "Scene.Compose.ContentWarning.Placeholder") + } + 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 { @@ -117,22 +459,186 @@ internal enum L10n { 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 Hashtag { + /// %@ people talking + internal static func prompt(_ p1: Any) -> String { + return L10n.tr("Localizable", "Scene.Hashtag.Prompt", String(describing: p1)) + } + } 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 { + internal enum Action { + /// favorited your post + internal static let favourite = L10n.tr("Localizable", "Scene.Notification.Action.Favourite") + /// followed you + internal static let follow = L10n.tr("Localizable", "Scene.Notification.Action.Follow") + /// request to follow you + internal static let followRequest = L10n.tr("Localizable", "Scene.Notification.Action.FollowRequest") + /// mentioned you + internal static let mention = L10n.tr("Localizable", "Scene.Notification.Action.Mention") + /// Your poll has ended + internal static let poll = L10n.tr("Localizable", "Scene.Notification.Action.Poll") + /// rebloged your post + internal static let reblog = L10n.tr("Localizable", "Scene.Notification.Action.Reblog") + } + 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 Profile { + /// %@ posts + internal static func subtitle(_ p1: Any) -> String { + return L10n.tr("Localizable", "Scene.Profile.Subtitle", String(describing: p1)) + } + 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 Accessibility { + /// %ld followers + internal static func countFollowers(_ p1: Int) -> String { + return L10n.tr("Localizable", "Scene.Profile.Dashboard.Accessibility.CountFollowers", p1) + } + /// %ld following + internal static func countFollowing(_ p1: Int) -> String { + return L10n.tr("Localizable", "Scene.Profile.Dashboard.Accessibility.CountFollowing", p1) + } + /// %ld posts + internal static func countPosts(_ p1: Int) -> String { + return L10n.tr("Localizable", "Scene.Profile.Dashboard.Accessibility.CountPosts", p1) + } + } + } + internal enum RelationshipActionAlert { + internal enum ConfirmUnblockUsre { + /// Confirm 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 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 PublicTimeline { /// Public internal static let title = L10n.tr("Localizable", "Scene.PublicTimeline.Title") } internal enum Register { - /// Regsiter request sent. Please check your email. - internal static let checkEmail = L10n.tr("Localizable", "Scene.Register.CheckEmail") - /// Success - internal static let success = L10n.tr("Localizable", "Scene.Register.Success") /// 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 e-mail 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 e-mail 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") @@ -146,12 +652,10 @@ internal enum L10n { 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") - /// Your password needs at least: - internal static let prompt = L10n.tr("Localizable", "Scene.Register.Input.Password.Prompt") - /// Eight characters - internal static let promptEightCharacters = L10n.tr("Localizable", "Scene.Register.Input.Password.PromptEightCharacters") } internal enum Username { /// This username is taken. @@ -161,19 +665,117 @@ internal enum L10n { } } } + 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 { + 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 among people you follow + 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 in your timeline + 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 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") + } + } + } 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") + 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 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 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") @@ -188,6 +790,8 @@ internal enum L10n { } } 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)) @@ -196,6 +800,8 @@ internal enum L10n { 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 { @@ -203,6 +809,96 @@ internal enum L10n { 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 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 { + /// 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 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 Favorite { + /// %@ favorites + internal static func multiple(_ p1: Any) -> String { + return L10n.tr("Localizable", "Scene.Thread.Favorite.Multiple", String(describing: p1)) + } + /// %@ favorite + internal static func single(_ p1: Any) -> String { + return L10n.tr("Localizable", "Scene.Thread.Favorite.Single", String(describing: p1)) + } + } + internal enum Reblog { + /// %@ reblogs + internal static func multiple(_ p1: Any) -> String { + return L10n.tr("Localizable", "Scene.Thread.Reblog.Multiple", String(describing: p1)) + } + /// %@ reblog + internal static func single(_ p1: Any) -> String { + return L10n.tr("Localizable", "Scene.Thread.Reblog.Single", String(describing: p1)) + } + } + } internal enum Welcome { /// Social networking\nback in your hands. internal static let slogan = L10n.tr("Localizable", "Scene.Welcome.Slogan") diff --git a/Mastodon/Helper/MastodonField.swift b/Mastodon/Helper/MastodonField.swift new file mode 100644 index 000000000..5f652b32c --- /dev/null +++ b/Mastodon/Helper/MastodonField.swift @@ -0,0 +1,48 @@ +// +// MastodonField.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-30. +// + +import Foundation +import ActiveLabel + +enum MastodonField { + + static func parse(field string: String) -> ParseResult { + let mentionMatches = string.matches(pattern: "(?:@([a-zA-Z0-9_]+)(@[a-zA-Z0-9_.-]+)?)") + let hashtagMatches = string.matches(pattern: "(?:#([^\\s.]+))") + let urlMatches = string.matches(pattern: "(?i)https?://\\S+(?:/|\\b)") + + var entities: [ActiveEntity] = [] + + for match in mentionMatches { + guard let text = string.substring(with: match, at: 0) else { continue } + let entity = ActiveEntity(range: match.range, type: .mention(text, userInfo: nil)) + entities.append(entity) + } + + for match in hashtagMatches { + guard let text = string.substring(with: match, at: 0) else { continue } + let entity = ActiveEntity(range: match.range, type: .hashtag(text, userInfo: nil)) + entities.append(entity) + } + + for match in urlMatches { + guard let text = string.substring(with: match, at: 0) else { continue } + let entity = ActiveEntity(range: match.range, type: .url(text, trimmed: text, url: text, userInfo: nil)) + entities.append(entity) + } + + return ParseResult(value: string, activeEntities: entities) + } + +} + +extension MastodonField { + struct ParseResult { + let value: String + let activeEntities: [ActiveEntity] + } +} diff --git a/Mastodon/Helper/MastodonMetricFormatter.swift b/Mastodon/Helper/MastodonMetricFormatter.swift new file mode 100644 index 000000000..0711669fb --- /dev/null +++ b/Mastodon/Helper/MastodonMetricFormatter.swift @@ -0,0 +1,41 @@ +// +// MastodonMetricFormatter.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-2. +// + +import Foundation + +final class MastodonMetricFormatter: Formatter { + + func string(from number: Int) -> String? { + let isPositive = number >= 0 + let symbol = isPositive ? "" : "-" + + let numberFormatter = NumberFormatter() + + let value = abs(number) + let metric: String + + switch value { + case 0..<1000: // 0 ~ 1K + metric = String(value) + case 1000..<10000: // 1K ~ 10K + numberFormatter.maximumFractionDigits = 1 + let string = numberFormatter.string(from: NSNumber(value: Double(value) / 1000.0)) ?? String(value / 1000) + metric = string + "K" + case 10000..<1000000: // 10K ~ 1M + numberFormatter.maximumFractionDigits = 0 + let string = numberFormatter.string(from: NSNumber(value: Double(value) / 1000.0)) ?? String(value / 1000) + metric = string + "K" + default: + numberFormatter.maximumFractionDigits = 0 + let string = numberFormatter.string(from: NSNumber(value: Double(value) / 1000000.0)) ?? String(value / 1000000) + metric = string + "M" + } + + return symbol + metric + } + +} diff --git a/Mastodon/Extension/MastodonContent.swift b/Mastodon/Helper/MastodonStatusContent.swift similarity index 72% rename from Mastodon/Extension/MastodonContent.swift rename to Mastodon/Helper/MastodonStatusContent.swift index b1b1a635f..0f9dbc6c0 100755 --- a/Mastodon/Extension/MastodonContent.swift +++ b/Mastodon/Helper/MastodonStatusContent.swift @@ -1,5 +1,5 @@ // -// MastodonContent.swift +// MastodonStatusContent.swift // Mastodon // // Created by MainasuK Cirno on 2021/2/1. @@ -9,15 +9,27 @@ import Foundation import Kanna import ActiveLabel -enum TootContent { +enum MastodonStatusContent { - static func parse(toot: String) throws -> TootContent.ParseResult { - let toot = toot.replacingOccurrences(of: "
", with: "\n") - let rootNode = try Node.parse(document: toot) + typealias EmojiShortcode = String + typealias EmojiDict = [EmojiShortcode: URL] + + static func parse(content: String, emojiDict: EmojiDict) throws -> MastodonStatusContent.ParseResult { + let document: String = { + var content = content + content = content.replacingOccurrences(of: "
", with: "\n") + for (shortcode, url) in emojiDict { + let emojiNode = "\(shortcode)" + let pattern = ":\(shortcode):" + content = content.replacingOccurrences(of: pattern, with: emojiNode) + } + return content + }() + let rootNode = try Node.parse(document: document) let text = String(rootNode.text) var activeEntities: [ActiveEntity] = [] - let entities = TootContent.Node.entities(in: rootNode) + let entities = MastodonStatusContent.Node.entities(in: rootNode) for entity in entities { let range = NSRange(entity.text.startIndex.. Bool { - for activeEntity in activeEntities { - let count = text.utf16.count - let endIndex = activeEntity.range.location + activeEntity.range.length - guard endIndex <= count else { - assertionFailure("Please file issue") - return false - } - } - - return true - } } @@ -97,7 +113,7 @@ extension String { } } -extension TootContent { +extension MastodonStatusContent { struct ParseResult { let document: String let original: String @@ -107,7 +123,7 @@ extension TootContent { } -extension TootContent { +extension MastodonStatusContent { class Node { @@ -155,6 +171,10 @@ extension TootContent { } } + if _classNames.contains("emoji") { + return .emoji + } + return nil }() self.level = level @@ -167,12 +187,12 @@ extension TootContent { self.children = children } - static func parse(document: String) throws -> TootContent.Node { + static func parse(document: String) throws -> MastodonStatusContent.Node { let html = try HTML(html: document, encoding: .utf8) let body = html.body ?? nil let text = body?.text ?? "" let level = 0 - let children: [TootContent.Node] = body.flatMap { body in + let children: [MastodonStatusContent.Node] = body.flatMap { body in return Node.parse(element: body, parentText: text[...], parentLevel: level + 1) } ?? [] let node = Node( @@ -253,32 +273,33 @@ extension TootContent { } -extension TootContent.Node { +extension MastodonStatusContent.Node { enum `Type` { case url case mention case hashtag + case emoji } - static func entities(in node: TootContent.Node) -> [TootContent.Node] { - return TootContent.Node.collect(node: node) { node in node.type != nil } + static func entities(in node: MastodonStatusContent.Node) -> [MastodonStatusContent.Node] { + return MastodonStatusContent.Node.collect(node: node) { node in node.type != nil } } - static func hashtags(in node: TootContent.Node) -> [TootContent.Node] { - return TootContent.Node.collect(node: node) { node in node.type == .hashtag } + static func hashtags(in node: MastodonStatusContent.Node) -> [MastodonStatusContent.Node] { + return MastodonStatusContent.Node.collect(node: node) { node in node.type == .hashtag } } - static func mentions(in node: TootContent.Node) -> [TootContent.Node] { - return TootContent.Node.collect(node: node) { node in node.type == .mention } + static func mentions(in node: MastodonStatusContent.Node) -> [MastodonStatusContent.Node] { + return MastodonStatusContent.Node.collect(node: node) { node in node.type == .mention } } - static func urls(in node: TootContent.Node) -> [TootContent.Node] { - return TootContent.Node.collect(node: node) { node in node.type == .url } + static func urls(in node: MastodonStatusContent.Node) -> [MastodonStatusContent.Node] { + return MastodonStatusContent.Node.collect(node: node) { node in node.type == .url } } } -extension TootContent.Node: CustomDebugStringConvertible { +extension MastodonStatusContent.Node: CustomDebugStringConvertible { var debugDescription: String { let linkInfo: String = { switch (href, hrefEllipsis) { diff --git a/Mastodon/Info.plist b/Mastodon/Info.plist index e3d8d4a91..38651b0ef 100644 --- a/Mastodon/Info.plist +++ b/Mastodon/Info.plist @@ -2,6 +2,8 @@ + ITSAppUsesNonExemptEncryption + CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) CFBundleExecutable @@ -73,7 +75,5 @@ UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight - UIViewControllerBasedStatusBarAppearance - diff --git a/Mastodon/Mastodon.entitlements b/Mastodon/Mastodon.entitlements index d334a5e6d..8917adbf4 100644 --- a/Mastodon/Mastodon.entitlements +++ b/Mastodon/Mastodon.entitlements @@ -2,6 +2,8 @@ + aps-environment + development com.apple.security.application-groups group.org.joinmastodon.mastodon-temp diff --git a/Mastodon/Preference/AppearancePreference.swift b/Mastodon/Preference/AppearancePreference.swift new file mode 100644 index 000000000..78cf3d332 --- /dev/null +++ b/Mastodon/Preference/AppearancePreference.swift @@ -0,0 +1,20 @@ +// +// AppearancePreference.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-26. +// + +import UIKit + +extension UserDefaults { + + @objc dynamic var customUserInterfaceStyle: UIUserInterfaceStyle { + get { + register(defaults: [#function: UIUserInterfaceStyle.unspecified.rawValue]) + return UIUserInterfaceStyle(rawValue: integer(forKey: #function)) ?? .unspecified + } + set { self[#function] = newValue.rawValue } + } + +} diff --git a/Mastodon/Preference/NotificationPreference.swift b/Mastodon/Preference/NotificationPreference.swift new file mode 100644 index 000000000..289cd1fdf --- /dev/null +++ b/Mastodon/Preference/NotificationPreference.swift @@ -0,0 +1,20 @@ +// +// NotificationPreference.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-26. +// + +import UIKit + +extension UserDefaults { + + @objc dynamic var notificationBadgeCount: Int { + get { + register(defaults: [#function: 0]) + return integer(forKey: #function) + } + set { self[#function] = newValue } + } + +} diff --git a/Mastodon/Protocol/AvatarConfigurableView.swift b/Mastodon/Protocol/AvatarConfigurableView.swift index 6c51d576c..40ef91153 100644 --- a/Mastodon/Protocol/AvatarConfigurableView.swift +++ b/Mastodon/Protocol/AvatarConfigurableView.swift @@ -23,7 +23,13 @@ extension AvatarConfigurableView { public func configure(with configuration: AvatarConfigurableViewConfiguration) { let placeholderImage: UIImage = { let placeholderImage = configuration.placeholderImage ?? UIImage.placeholder(size: Self.configurableAvatarImageSize, color: .systemFill) - return placeholderImage.af.imageRoundedIntoCircle() + 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() + } }() // cancel previous task @@ -41,14 +47,30 @@ extension AvatarConfigurableView { configurableAvatarButton?.layer.cornerRadius = 0 configurableAvatarButton?.layer.cornerCurve = .circular + // accessibility + configurableAvatarImageView?.accessibilityIgnoresInvertColors = true + configurableAvatarButton?.accessibilityIgnoresInvertColors = true + defer { avatarConfigurableView(self, didFinishConfiguration: configuration) } - + + let filter = ScaledToSizeWithRoundedCornersFilter( + size: Self.configurableAvatarImageSize, + radius: configuration.keepImageCorner ? 0 : Self.configurableAvatarImageCornerRadius + ) + // set placeholder if no asset guard let avatarImageURL = configuration.avatarImageURL else { configurableAvatarImageView?.image = placeholderImage + configurableAvatarImageView?.layer.masksToBounds = true + configurableAvatarImageView?.layer.cornerRadius = Self.configurableAvatarImageCornerRadius + configurableAvatarImageView?.layer.cornerCurve = Self.configurableAvatarImageCornerRadius < Self.configurableAvatarImageSize.width * 0.5 ? .continuous :.circular + configurableAvatarButton?.setImage(placeholderImage, for: .normal) + configurableAvatarButton?.layer.masksToBounds = true + configurableAvatarButton?.layer.cornerRadius = Self.configurableAvatarImageCornerRadius + configurableAvatarButton?.layer.cornerCurve = Self.configurableAvatarImageCornerRadius < Self.configurableAvatarImageSize.width * 0.5 ? .continuous :.circular return } @@ -65,9 +87,9 @@ extension AvatarConfigurableView { ) avatarImageView.layer.masksToBounds = true avatarImageView.layer.cornerRadius = Self.configurableAvatarImageCornerRadius - avatarImageView.layer.cornerCurve = .circular + avatarImageView.layer.cornerCurve = Self.configurableAvatarImageCornerRadius < Self.configurableAvatarImageSize.width * 0.5 ? .continuous :.circular + default: - let filter = ScaledToSizeWithRoundedCornersFilter(size: Self.configurableAvatarImageSize, radius: Self.configurableAvatarImageCornerRadius) avatarImageView.af.setImage( withURL: avatarImageURL, placeholderImage: placeholderImage, @@ -76,7 +98,15 @@ extension AvatarConfigurableView { runImageTransitionIfCached: false, completion: nil ) + + if Self.configurableAvatarImageCornerRadius > 0, configuration.keepImageCorner { + configurableAvatarImageView?.layer.masksToBounds = true + configurableAvatarImageView?.layer.cornerRadius = Self.configurableAvatarImageCornerRadius + configurableAvatarImageView?.layer.cornerCurve = Self.configurableAvatarImageCornerRadius < Self.configurableAvatarImageSize.width * 0.5 ? .continuous :.circular + } } + + configureLayerBorder(view: avatarImageView, configuration: configuration) } if let avatarButton = configurableAvatarButton { @@ -92,9 +122,8 @@ extension AvatarConfigurableView { ) avatarButton.layer.masksToBounds = true avatarButton.layer.cornerRadius = Self.configurableAvatarImageCornerRadius - avatarButton.layer.cornerCurve = .continuous + avatarButton.layer.cornerCurve = Self.configurableAvatarImageCornerRadius < Self.configurableAvatarImageSize.width * 0.5 ? .continuous : .circular default: - let filter = ScaledToSizeWithRoundedCornersFilter(size: Self.configurableAvatarImageSize, radius: Self.configurableAvatarImageCornerRadius) avatarButton.af.setImage( for: .normal, url: avatarImageURL, @@ -103,9 +132,24 @@ extension AvatarConfigurableView { completion: nil ) } + + configureLayerBorder(view: avatarButton, configuration: configuration) } } + 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) { } } @@ -114,10 +158,23 @@ struct AvatarConfigurableViewConfiguration { let avatarImageURL: URL? let placeholderImage: UIImage? + let borderColor: UIColor? + let borderWidth: CGFloat? - init(avatarImageURL: URL?, placeholderImage: UIImage? = nil) { + 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/ContentOffsetAdjustableTimelineViewControllerDelegate.swift b/Mastodon/Protocol/ContentOffsetAdjustableTimelineViewControllerDelegate.swift index dbe22c52e..98160eb42 100644 --- a/Mastodon/Protocol/ContentOffsetAdjustableTimelineViewControllerDelegate.swift +++ b/Mastodon/Protocol/ContentOffsetAdjustableTimelineViewControllerDelegate.swift @@ -7,7 +7,7 @@ import UIKit -protocol ContentOffsetAdjustableTimelineViewControllerDelegate: class { +protocol ContentOffsetAdjustableTimelineViewControllerDelegate: AnyObject { func navigationBar() -> UINavigationBar? } diff --git a/Mastodon/Protocol/DisposeBagCollectable.swift b/Mastodon/Protocol/DisposeBagCollectable.swift index a8afde9d4..58bfa8576 100644 --- a/Mastodon/Protocol/DisposeBagCollectable.swift +++ b/Mastodon/Protocol/DisposeBagCollectable.swift @@ -8,6 +8,6 @@ import Foundation import Combine -protocol DisposeBagCollectable: class { +protocol DisposeBagCollectable: AnyObject { var disposeBag: Set { get set } } diff --git a/Mastodon/Protocol/NeedsDependency+AVPlayerViewControllerDelegate.swift b/Mastodon/Protocol/NeedsDependency+AVPlayerViewControllerDelegate.swift new file mode 100644 index 000000000..e52fdc059 --- /dev/null +++ b/Mastodon/Protocol/NeedsDependency+AVPlayerViewControllerDelegate.swift @@ -0,0 +1,21 @@ +// +// NeedsDependency+AVPlayerViewControllerDelegate.swift +// Mastodon +// +// Created by xiaojian sun on 2021/3/10. +// + +import Foundation +import AVKit + +extension NeedsDependency where Self: AVPlayerViewControllerDelegate { + + func handlePlayerViewController(_ playerViewController: AVPlayerViewController, willBeginFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator) { + context.videoPlaybackService.playerViewModel(for: playerViewController)?.isFullScreenPresentationing = true + } + + func handlePlayerViewController(_ playerViewController: AVPlayerViewController, willEndFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator) { + context.videoPlaybackService.playerViewModel(for: playerViewController)?.isFullScreenPresentationing = false + } + +} diff --git a/Mastodon/Protocol/StatusProvider/StatusProvider+StatusTableViewCellDelegate.swift b/Mastodon/Protocol/StatusProvider/StatusProvider+StatusTableViewCellDelegate.swift new file mode 100644 index 000000000..3b96299d2 --- /dev/null +++ b/Mastodon/Protocol/StatusProvider/StatusProvider+StatusTableViewCellDelegate.swift @@ -0,0 +1,193 @@ +// +// 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 ActiveLabel + +// 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, avatarButtonDidPressed button: UIButton) { + StatusProviderFacade.coordinateToStatusAuthorProfileScene(for: .primary, provider: self, cell: cell) + } + + func statusTableViewCell(_ cell: StatusTableViewCell, statusView: StatusView, activeLabel: ActiveLabel, didSelectActiveEntity entity: ActiveEntity) { + StatusProviderFacade.responseToStatusActiveLabelAction(provider: self, cell: cell, activeLabel: activeLabel, didTapEntity: entity) + } + + 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 .opion(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+TimelinePostTableViewCellDelegate.swift b/Mastodon/Protocol/StatusProvider/StatusProvider+TimelinePostTableViewCellDelegate.swift deleted file mode 100644 index 336434ff0..000000000 --- a/Mastodon/Protocol/StatusProvider/StatusProvider+TimelinePostTableViewCellDelegate.swift +++ /dev/null @@ -1,81 +0,0 @@ -// -// StatusProvider+TimelinePostTableViewCellDelegate.swift -// Mastodon -// -// Created by sxiaojian on 2021/2/8. -// - -import os.log -import UIKit -import Combine -import CoreData -import CoreDataStack -import MastodonSDK -import ActiveLabel - -// MARK: - ActionToolbarContainerDelegate -extension StatusTableViewCellDelegate where Self: StatusProvider { - - func statusTableViewCell(_ cell: StatusTableViewCell, actionToolbarContainer: ActionToolbarContainer, likeButtonDidPressed sender: UIButton) { - StatusProviderFacade.responseToStatusLikeAction(provider: self, cell: cell) - } - - func statusTableViewCell(_ cell: StatusTableViewCell, statusView: StatusView, contentWarningActionButtonPressed button: UIButton) { - guard let diffableDataSource = self.tableViewDiffableDataSource else { return } - item(for: cell, indexPath: nil) - .receive(on: DispatchQueue.main) - .sink { [weak self] item in - guard let _ = self else { return } - guard let item = item else { return } - switch item { - case .homeTimelineIndex(_, let attribute): - attribute.isStatusTextSensitive = false - case .toot(_, let attribute): - attribute.isStatusTextSensitive = false - default: - return - } - var snapshot = diffableDataSource.snapshot() - snapshot.reloadItems([item]) - diffableDataSource.apply(snapshot) - } - .store(in: &cell.disposeBag) - } - -} - -extension StatusTableViewCellDelegate where Self: StatusProvider { - - func statusTableViewCell(_ cell: StatusTableViewCell, mosaicImageViewContainer: MosaicImageViewContainer, didTapImageView imageView: UIImageView, atIndex index: Int) { - - } - - func statusTableViewCell(_ cell: StatusTableViewCell, mosaicImageViewContainer: MosaicImageViewContainer, didTapContentWarningVisualEffectView visualEffectView: UIVisualEffectView) { - guard let diffableDataSource = self.tableViewDiffableDataSource else { return } - item(for: cell, indexPath: nil) - .receive(on: DispatchQueue.main) - .sink { [weak self] item in - guard let _ = self else { return } - guard let item = item else { return } - switch item { - case .homeTimelineIndex(_, let attribute): - attribute.isStatusSensitive = false - case .toot(_, let attribute): - attribute.isStatusSensitive = false - default: - return - } - - var snapshot = diffableDataSource.snapshot() - snapshot.reloadItems([item]) - UIView.animate(withDuration: 0.33) { - cell.statusView.statusMosaicImageView.blurVisualEffectView.effect = nil - cell.statusView.statusMosaicImageView.vibrancyVisualEffectView.alpha = 0.0 - } completion: { _ in - diffableDataSource.apply(snapshot, animatingDifferences: false, completion: nil) - } - } - .store(in: &cell.disposeBag) - } - -} diff --git a/Mastodon/Protocol/StatusProvider/StatusProvider+UITableViewDataSourcePrefetching.swift b/Mastodon/Protocol/StatusProvider/StatusProvider+UITableViewDataSourcePrefetching.swift new file mode 100644 index 000000000..a0aaa543e --- /dev/null +++ b/Mastodon/Protocol/StatusProvider/StatusProvider+UITableViewDataSourcePrefetching.swift @@ -0,0 +1,50 @@ +// +// 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]) { + // prefetch reply status + guard let activeMastodonAuthenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { return } + let domain = activeMastodonAuthenticationBox.domain + + var statusObjectIDs: [NSManagedObjectID] = [] + for item in items(indexPaths: indexPaths) { + switch item { + case .homeTimelineIndex(let objectID, _): + let homeTimelineIndex = managedObjectContext.object(with: objectID) as! HomeTimelineIndex + statusObjectIDs.append(homeTimelineIndex.status.objectID) + case .status(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/Protocol/StatusProvider/StatusProvider+UITableViewDelegate.swift b/Mastodon/Protocol/StatusProvider/StatusProvider+UITableViewDelegate.swift new file mode 100644 index 000000000..98fa2d2cd --- /dev/null +++ b/Mastodon/Protocol/StatusProvider/StatusProvider+UITableViewDelegate.swift @@ -0,0 +1,367 @@ +// +// 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) + .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.saveImage(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 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, 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 index 781ccc9f3..8e27a2207 100644 --- a/Mastodon/Protocol/StatusProvider/StatusProvider.swift +++ b/Mastodon/Protocol/StatusProvider/StatusProvider.swift @@ -7,13 +7,18 @@ import UIKit import Combine +import CoreData import CoreDataStack protocol StatusProvider: NeedsDependency & DisposeBagCollectable & UIViewController { - func toot() -> Future - func toot(for cell: UITableViewCell, indexPath: IndexPath?) -> Future - func toot(for cell: UICollectionViewCell) -> Future + // async + func status() -> Future + func status(for cell: UITableViewCell?, indexPath: IndexPath?) -> Future + func status(for cell: UICollectionViewCell) -> Future + // sync + var managedObjectContext: NSManagedObjectContext { get } var tableViewDiffableDataSource: UITableViewDiffableDataSource? { get } - func item(for cell: UITableViewCell, indexPath: IndexPath?) -> Future + func item(for cell: UITableViewCell?, indexPath: IndexPath?) -> Item? + func items(indexPaths: [IndexPath]) -> [Item] } diff --git a/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift b/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift index 894461566..56e9d4746 100644 --- a/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift +++ b/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift @@ -13,26 +13,183 @@ import CoreDataStack import MastodonSDK import ActiveLabel -enum StatusProviderFacade { +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, 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 responseToStatusActiveLabelAction(provider: StatusProvider, cell: UITableViewCell, activeLabel: ActiveLabel, didTapEntity entity: ActiveEntity) { + switch entity.type { + case .hashtag(let text, _): + let hashtagTimelienViewModel = HashtagTimelineViewModel(context: provider.context, hashtag: text) + provider.coordinator.present(scene: .hashtagTimeline(viewModel: hashtagTimelienViewModel), from: provider, transition: .show) + case .mention(let text, _): + coordinateToStatusMentionProfileScene(for: .primary, provider: provider, cell: cell, mention: text) + case .url(_, _, let url, _): + guard let url = URL(string: url) else { return } + provider.coordinator.present(scene: .safari(url: url), from: nil, transition: .safariPresent(animated: true, completion: nil)) + default: + break + } + } + + private static func coordinateToStatusMentionProfileScene(for target: Target, provider: StatusProvider, cell: UITableViewCell, mention: String) { + guard let activeMastodonAuthenticationBox = provider.context.authenticationService.activeMastodonAuthenticationBox.value else { return } + let domain = activeMastodonAuthenticationBox.domain + + provider.status(for: cell, indexPath: nil) + .sink { [weak provider] status in + guard let provider = provider else { return } + let _status: Status? = { + switch target { + case .primary: return status?.reblog ?? status + case .secondary: return status + } + }() + guard let status = _status else { return } + + // cannot continue without meta + guard let mentionMeta = (status.mentions ?? Set()).first(where: { $0.username == mention }) else { 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) + } + } + .store(in: &provider.disposeBag) + } +} + extension StatusProviderFacade { static func responseToStatusLikeAction(provider: StatusProvider) { _responseToStatusLikeAction( provider: provider, - toot: provider.toot() + status: provider.status() ) } static func responseToStatusLikeAction(provider: StatusProvider, cell: UITableViewCell) { _responseToStatusLikeAction( provider: provider, - toot: provider.toot(for: cell, indexPath: nil) + status: provider.status(for: cell, indexPath: nil) ) } - private static func _responseToStatusLikeAction(provider: StatusProvider, toot: Future) { + private static func _responseToStatusLikeAction(provider: StatusProvider, status: Future) { // prepare authentication guard let activeMastodonAuthenticationBox = provider.context.authenticationService.activeMastodonAuthenticationBox.value else { assertionFailure() @@ -54,23 +211,22 @@ extension StatusProviderFacade { let generator = UIImpactFeedbackGenerator(style: .light) let responseFeedbackGenerator = UIImpactFeedbackGenerator(style: .medium) - toot - .compactMap { toot -> (NSManagedObjectID, Mastodon.API.Favorites.FavoriteKind)? in - guard let toot = toot else { return nil } + 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 targetToot = (toot.reblog ?? toot) - let isLiked = targetToot.favouritedBy.flatMap { $0.contains(where: { $0.id == mastodonUserID }) } ?? false + let isLiked = status.favouritedBy.flatMap { $0.contains(where: { $0.id == mastodonUserID }) } ?? false return isLiked ? .destroy : .create }() - return (toot.objectID, favoriteKind) + return (status.objectID, favoriteKind) } - .map { tootObjectID, favoriteKind -> AnyPublisher<(Toot.ID, Mastodon.API.Favorites.FavoriteKind), Error> in - return context.apiService.like( - tootObjectID: tootObjectID, + .map { statusObjectID, favoriteKind -> AnyPublisher<(Status.ID, Mastodon.API.Favorites.FavoriteKind), Error> in + return context.apiService.favorite( + statusObjectID: statusObjectID, mastodonUserObjectID: mastodonUserObjectID, favoriteKind: favoriteKind ) - .map { tootID in (tootID, favoriteKind) } + .map { statusID in (statusID, favoriteKind) } .eraseToAnyPublisher() } .setFailureType(to: Error.self) @@ -82,7 +238,7 @@ extension StatusProviderFacade { responseFeedbackGenerator.prepare() } receiveOutput: { _, favoriteKind in generator.impactOccurred() - os_log("%{public}s[%{public}ld], %{public}s: [Like] update local toot like status to: %s", ((#file as NSString).lastPathComponent), #line, #function, favoriteKind == .create ? "like" : "unlike") + 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 switch completion { case .failure: @@ -92,9 +248,9 @@ extension StatusProviderFacade { break } } - .map { tootID, favoriteKind in - return context.apiService.like( - statusID: tootID, + .map { statusID, favoriteKind in + return context.apiService.favorite( + statusID: statusID, favoriteKind: favoriteKind, mastodonAuthenticationBox: activeMastodonAuthenticationBox ) @@ -120,10 +276,320 @@ extension StatusProviderFacade { } +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) + ) + } + + 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 = UIImpactFeedbackGenerator(style: .light) + let responseFeedbackGenerator = UIImpactFeedbackGenerator(style: .medium) + + 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 { _ in + generator.prepare() + responseFeedbackGenerator.prepare() + } receiveOutput: { _, reblogKind in + generator.impactOccurred() + 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 + 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 = provider else { return } + if provider.view.window != nil { + responseFeedbackGenerator.impactOccurred() + } + switch completion { + case .failure(let 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: + 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) + ) + } + + 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 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(dependency: NotificationViewController, 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 + } + + switch item { + case .notification(let objectID, _): + dependency.viewModel.fetchedResultsController.managedObjectContext.perform { + let notification = dependency.viewModel.fetchedResultsController.managedObjectContext.object(with: objectID) as! MastodonNotification + promise(.success(notification.status)) + } + default: + promise(.success(nil)) + } + } + + _responseToStatusContentWarningRevealAction( + dependency: dependency, + status: status + ) + } + + static func responseToStatusContentWarningRevealAction(provider: StatusProvider, cell: UITableViewCell) { + _responseToStatusContentWarningRevealAction( + dependency: provider, + status: provider.status(for: cell, indexPath: nil) + ) + } + + 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 } + return dependency.context.managedObjectContext.performChanges { + guard let status = dependency.context.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) + status.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 toot - case reblog + 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 new file mode 100644 index 000000000..e418569c1 --- /dev/null +++ b/Mastodon/Protocol/StatusTableViewControllerAspect.swift @@ -0,0 +1,165 @@ +// +// StatusTableViewControllerAspect.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-7. +// + +import UIKit +import AVKit + +// Check List Last Updated +// - HomeViewController: 2021/4/30 +// - FavoriteViewController: 2021/4/30 +// - HashtagTimelineViewController: 2021/4/30 +// - UserTimelineViewController: 2021/4/30 +// - ThreadViewController: 2021/4/30 +// * StatusTableViewControllerAspect: 2021/4/30 + +// (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) { + tableView.deselectRow(with: transitionCoordinator, animated: animated) + } +} + +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 & StatusProvider { + /// [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: StatusTableViewCellDelegate & TableViewCellHeightCacheableContainer & StatusProvider { + /// [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 reply to info for status + func aspectTableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) { + handleTableView(tableView, prefetchRowsAt: 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/TableViewCellHeightCacheableContainer.swift b/Mastodon/Protocol/TableViewCellHeightCacheableContainer.swift new file mode 100644 index 000000000..0907db56f --- /dev/null +++ b/Mastodon/Protocol/TableViewCellHeightCacheableContainer.swift @@ -0,0 +1,36 @@ +// +// TableViewCellHeightCacheableContainer.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-3. +// + +import UIKit + +protocol TableViewCellHeightCacheableContainer: StatusProvider { + var cellFrameCache: NSCache { get } +} + +extension TableViewCellHeightCacheableContainer { + + 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/UserProvider/UserProvider.swift b/Mastodon/Protocol/UserProvider/UserProvider.swift new file mode 100644 index 000000000..f9939c740 --- /dev/null +++ b/Mastodon/Protocol/UserProvider/UserProvider.swift @@ -0,0 +1,37 @@ +// +// 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.swift b/Mastodon/Protocol/UserProvider/UserProviderFacade.swift new file mode 100644 index 000000000..1f9215a76 --- /dev/null +++ b/Mastodon/Protocol/UserProvider/UserProviderFacade.swift @@ -0,0 +1,350 @@ +// +// 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() + ) + } + + private static func _toggleUserFollowRelationship( + context: AppContext, + activeMastodonAuthenticationBox: AuthenticationService.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, + 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: AuthenticationService.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, + 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: AuthenticationService.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 !isMyself { + // mute + let muteAction = UIAction( + title: isMuting ? L10n.Common.Controls.Firendship.unmuteUser(name) : L10n.Common.Controls.Firendship.mute, + image: isMuting ? UIImage(systemName: "speaker") : UIImage(systemName: "speaker.slash"), + discoverabilityTitle: isMuting ? nil : L10n.Common.Controls.Firendship.muteUser(name), + attributes: isMuting ? [] : .destructive, + state: .off + ) { [weak provider] _ 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.Firendship.muteUser(name), image: UIImage(systemName: "speaker.slash"), options: [], children: [muteAction]) + children.append(muteMenu) + } + } + + if !isMyself { + // block + let blockAction = UIAction( + title: isBlocking ? L10n.Common.Controls.Firendship.unblockUser(name) : L10n.Common.Controls.Firendship.block, + image: isBlocking ? UIImage(systemName: "hand.raised.slash") : UIImage(systemName: "hand.raised"), + discoverabilityTitle: isBlocking ? nil : L10n.Common.Controls.Firendship.blockUser(name), + attributes: isBlocking ? [] : .destructive, + state: .off + ) { [weak provider] _ 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.Firendship.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] _ in + guard let provider = provider else { return } + provider.context.blockDomainService.unblockDomain(userProvider: provider, cell: cell) + } + children.append(unblockDomainAction) + } else { + let blockDomainAction = UIAction(title: L10n.Common.Controls.Actions.blockDomain(mastodonUser.domainFromAcct), image: UIImage(systemName: "nosign"), identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off) { [weak provider] _ in + 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) { _ in + provider.context.blockDomainService.blockDomain(userProvider: provider, cell: cell) + } + alertController.addAction(blockDomainAction) + provider.present(alertController, animated: true, completion: nil) + } + children.append(blockDomainAction) + } + } + + if let shareUser = shareUser { + let shareAction = UIAction(title: L10n.Common.Controls.Actions.shareUser(name), image: UIImage(systemName: "square.and.arrow.up"), identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off) { [weak provider] _ in + 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] _ 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 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) { _ in + 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 + } +} diff --git a/Mastodon/Resources/Assets.xcassets/Arrows/arrow.triangle.2.circlepath.imageset/arrow.triangle.2.circlepath.pdf b/Mastodon/Resources/Assets.xcassets/Arrows/arrow.triangle.2.circlepath.imageset/arrow.triangle.2.circlepath.pdf deleted file mode 100644 index b864ab380..000000000 --- a/Mastodon/Resources/Assets.xcassets/Arrows/arrow.triangle.2.circlepath.imageset/arrow.triangle.2.circlepath.pdf +++ /dev/null @@ -1,193 +0,0 @@ -%PDF-1.7 - -1 0 obj - << >> -endobj - -2 0 obj - << /Length 3 0 R >> -stream -/DeviceRGB CS -/DeviceRGB cs -q -1.000000 0.000000 -0.000000 1.000000 4.000000 10.752930 cm -0.000000 0.000000 0.000000 scn -15.009519 2.109471 m -15.085540 1.562444 15.590621 1.180617 16.137648 1.256639 c -16.684677 1.332660 17.066502 1.837741 16.990480 2.384768 c -15.009519 2.109471 l -h --0.423099 4.631682 m --0.635487 4.121869 -0.394376 3.536408 0.115438 3.324021 c -0.625251 3.111633 1.210711 3.352744 1.423099 3.862558 c --0.423099 4.631682 l -h -1.000000 8.247120 m -1.000000 8.799404 0.552285 9.247120 0.000000 9.247120 c --0.552285 9.247120 -1.000000 8.799404 -1.000000 8.247120 c -1.000000 8.247120 l -h -0.000000 4.247120 m --1.000000 4.247120 l --1.000000 3.694835 -0.552285 3.247120 0.000000 3.247120 c -0.000000 4.247120 l -h -4.000000 3.247120 m -4.552285 3.247120 5.000000 3.694835 5.000000 4.247120 c -5.000000 4.799405 4.552285 5.247120 4.000000 5.247120 c -4.000000 3.247120 l -h -16.990480 2.384768 m -16.715729 4.361807 15.798570 6.193669 14.380284 7.598174 c -12.972991 6.177073 l -14.079566 5.081251 14.795152 3.651996 15.009519 2.109471 c -16.990480 2.384768 l -h -14.380284 7.598174 m -12.961998 9.002679 11.121269 9.901910 9.141643 10.157345 c -8.885699 8.173789 l -10.430243 7.974494 11.866417 7.272897 12.972991 6.177073 c -14.380284 7.598174 l -h -9.141643 10.157345 m -7.162015 10.412781 5.153316 10.010252 3.424967 9.011765 c -4.425436 7.279984 l -5.773929 8.059025 7.341156 8.373085 8.885699 8.173789 c -9.141643 10.157345 l -h -3.424967 9.011765 m -1.696617 8.013276 0.344502 6.474223 -0.423099 4.631682 c -1.423099 3.862558 l -2.021996 5.300145 3.076944 6.500945 4.425436 7.279984 c -3.424967 9.011765 l -h --1.000000 8.247120 m --1.000000 4.247120 l -1.000000 4.247120 l -1.000000 8.247120 l --1.000000 8.247120 l -h -0.000000 3.247120 m -4.000000 3.247120 l -4.000000 5.247120 l -0.000000 5.247120 l -0.000000 3.247120 l -h -f -n -Q -q -1.000000 0.000000 -0.000000 1.000000 4.000000 1.767822 cm -0.000000 0.000000 0.000000 scn -0.990481 9.369826 m -0.914460 9.916854 0.409379 10.298680 -0.137649 10.222659 c --0.684676 10.146638 -1.066502 9.641557 -0.990481 9.094529 c -0.990481 9.369826 l -h -16.423100 6.847616 m -16.635487 7.357429 16.394375 7.942889 15.884562 8.155277 c -15.374748 8.367664 14.789289 8.126554 14.576900 7.616740 c -16.423100 6.847616 l -h -15.000000 3.232178 m -15.000000 2.679893 15.447715 2.232178 16.000000 2.232178 c -16.552284 2.232178 17.000000 2.679893 17.000000 3.232178 c -15.000000 3.232178 l -h -16.000000 7.232178 m -17.000000 7.232178 l -17.000000 7.784462 16.552284 8.232178 16.000000 8.232178 c -16.000000 7.232178 l -h -12.000000 8.232178 m -11.447715 8.232178 11.000000 7.784462 11.000000 7.232178 c -11.000000 6.679893 11.447715 6.232178 12.000000 6.232178 c -12.000000 8.232178 l -h --0.990481 9.094529 m --0.715729 7.117491 0.201429 5.285628 1.619715 3.881123 c -3.027008 5.302223 l -1.920433 6.398046 1.204848 7.827302 0.990481 9.369826 c --0.990481 9.094529 l -h -1.619715 3.881123 m -3.038001 2.476617 4.878731 1.577388 6.858358 1.321952 c -7.114300 3.305508 l -5.569757 3.504804 4.133582 4.206400 3.027008 5.302223 c -1.619715 3.881123 l -h -6.858358 1.321952 m -8.837985 1.066517 10.846684 1.469046 12.575033 2.467534 c -11.574564 4.199314 l -10.226071 3.420273 8.658844 3.106212 7.114300 3.305508 c -6.858358 1.321952 l -h -12.575033 2.467534 m -14.303383 3.466022 15.655499 5.005074 16.423100 6.847616 c -14.576900 7.616740 l -13.978004 6.179152 12.923057 4.978354 11.574564 4.199314 c -12.575033 2.467534 l -h -17.000000 3.232178 m -17.000000 7.232178 l -15.000000 7.232178 l -15.000000 3.232178 l -17.000000 3.232178 l -h -16.000000 8.232178 m -12.000000 8.232178 l -12.000000 6.232178 l -16.000000 6.232178 l -16.000000 8.232178 l -h -f -n -Q - -endstream -endobj - -3 0 obj - 3597 -endobj - -4 0 obj - << /Annots [] - /Type /Page - /MediaBox [ 0.000000 0.000000 24.000000 24.000000 ] - /Resources 1 0 R - /Contents 2 0 R - /Parent 5 0 R - >> -endobj - -5 0 obj - << /Kids [ 4 0 R ] - /Count 1 - /Type /Pages - >> -endobj - -6 0 obj - << /Type /Catalog - /Pages 5 0 R - >> -endobj - -xref -0 7 -0000000000 65535 f -0000000010 00000 n -0000000034 00000 n -0000003687 00000 n -0000003710 00000 n -0000003883 00000 n -0000003957 00000 n -trailer -<< /ID [ (some) (id) ] - /Root 6 0 R - /Size 7 ->> -startxref -4016 -%%EOF \ No newline at end of file diff --git a/Mastodon/Resources/Assets.xcassets/Asset/email.imageset/Contents.json b/Mastodon/Resources/Assets.xcassets/Asset/email.imageset/Contents.json new file mode 100644 index 000000000..3febbcacb --- /dev/null +++ b/Mastodon/Resources/Assets.xcassets/Asset/email.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "filename" : "c1.svg", + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "c1-1.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Mastodon/Resources/Assets.xcassets/Asset/email.imageset/c1-1.svg b/Mastodon/Resources/Assets.xcassets/Asset/email.imageset/c1-1.svg new file mode 100644 index 000000000..a316721b0 --- /dev/null +++ b/Mastodon/Resources/Assets.xcassets/Asset/email.imageset/c1-1.svg @@ -0,0 +1,342 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Mastodon/Resources/Assets.xcassets/Asset/email.imageset/c1.svg b/Mastodon/Resources/Assets.xcassets/Asset/email.imageset/c1.svg new file mode 100644 index 000000000..ce59284c0 --- /dev/null +++ b/Mastodon/Resources/Assets.xcassets/Asset/email.imageset/c1.svg @@ -0,0 +1,342 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Mastodon/Resources/Assets.xcassets/Arrows/Contents.json b/Mastodon/Resources/Assets.xcassets/Circles/Contents.json similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Arrows/Contents.json rename to Mastodon/Resources/Assets.xcassets/Circles/Contents.json diff --git a/Mastodon/Resources/Assets.xcassets/Circles/plus.circle.fill.imageset/Contents.json b/Mastodon/Resources/Assets.xcassets/Circles/plus.circle.fill.imageset/Contents.json new file mode 100644 index 000000000..40480a161 --- /dev/null +++ b/Mastodon/Resources/Assets.xcassets/Circles/plus.circle.fill.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "plus.circle.fill.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/Mastodon/Resources/Assets.xcassets/Circles/plus.circle.fill.imageset/plus.circle.fill.pdf b/Mastodon/Resources/Assets.xcassets/Circles/plus.circle.fill.imageset/plus.circle.fill.pdf new file mode 100644 index 000000000..efe6b69dc --- /dev/null +++ b/Mastodon/Resources/Assets.xcassets/Circles/plus.circle.fill.imageset/plus.circle.fill.pdf @@ -0,0 +1,89 @@ +%PDF-1.7 + +1 0 obj + << >> +endobj + +2 0 obj + << /Length 3 0 R >> +stream +/DeviceRGB CS +/DeviceRGB cs +q +1.000000 0.000000 -0.000000 1.000000 0.000000 0.000000 cm +0.000000 0.000000 0.000000 scn +12.007203 -0.000002 m +18.586674 -0.000002 24.000000 5.413311 24.000000 12.007201 c +24.000000 18.586702 18.586674 24.000000 11.992814 24.000000 c +5.413314 24.000000 0.000000 18.586702 0.000000 12.007201 c +0.000000 5.413311 5.413314 -0.000002 12.007203 -0.000002 c +h +6.478707 12.007201 m +6.478707 12.827837 7.068974 13.432522 7.875220 13.432522 c +10.567494 13.432522 l +10.567494 16.124798 l +10.567494 16.931015 11.172179 17.535698 11.992814 17.535698 c +12.813449 17.535698 13.418134 16.931015 13.418134 16.124798 c +13.418134 13.432522 l +16.110380 13.432522 l +16.931015 13.432522 17.521311 12.827837 17.521311 12.007201 c +17.521311 11.186566 16.931015 10.581882 16.110380 10.581882 c +13.418134 10.581882 l +13.418134 7.889637 l +13.418134 7.083389 12.813449 6.478704 11.992814 6.478704 c +11.172179 6.478704 10.567494 7.083389 10.567494 7.889637 c +10.567494 10.581882 l +7.875220 10.581882 l +7.068974 10.581882 6.478707 11.186566 6.478707 12.007201 c +h +f +n +Q + +endstream +endobj + +3 0 obj + 1071 +endobj + +4 0 obj + << /Annots [] + /Type /Page + /MediaBox [ 0.000000 0.000000 24.000000 24.000000 ] + /Resources 1 0 R + /Contents 2 0 R + /Parent 5 0 R + >> +endobj + +5 0 obj + << /Kids [ 4 0 R ] + /Count 1 + /Type /Pages + >> +endobj + +6 0 obj + << /Type /Catalog + /Pages 5 0 R + >> +endobj + +xref +0 7 +0000000000 65535 f +0000000010 00000 n +0000000034 00000 n +0000001161 00000 n +0000001184 00000 n +0000001357 00000 n +0000001431 00000 n +trailer +<< /ID [ (some) (id) ] + /Root 6 0 R + /Size 7 +>> +startxref +1490 +%%EOF \ No newline at end of file diff --git a/Mastodon/Resources/Assets.xcassets/Circles/plus.circle.imageset/Contents.json b/Mastodon/Resources/Assets.xcassets/Circles/plus.circle.imageset/Contents.json new file mode 100644 index 000000000..30eea7b43 --- /dev/null +++ b/Mastodon/Resources/Assets.xcassets/Circles/plus.circle.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "plus.circle.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/Mastodon/Resources/Assets.xcassets/Circles/plus.circle.imageset/plus.circle.pdf b/Mastodon/Resources/Assets.xcassets/Circles/plus.circle.imageset/plus.circle.pdf new file mode 100644 index 000000000..65d55fe27 --- /dev/null +++ b/Mastodon/Resources/Assets.xcassets/Circles/plus.circle.imageset/plus.circle.pdf @@ -0,0 +1,95 @@ +%PDF-1.7 + +1 0 obj + << >> +endobj + +2 0 obj + << /Length 3 0 R >> +stream +/DeviceRGB CS +/DeviceRGB cs +q +1.000000 0.000000 -0.000000 1.000000 0.000000 0.000000 cm +0.000000 0.000000 0.000000 scn +21.999905 0.000160 m +34.035152 0.000160 44.000000 9.986404 44.000000 22.000080 c +44.000000 34.035332 34.013599 44.000000 21.978350 44.000000 c +9.964656 44.000000 0.000000 34.035332 0.000000 22.000080 c +0.000000 9.986404 9.986255 0.000160 21.999905 0.000160 c +h +21.999905 3.666824 m +11.819542 3.666824 3.688203 11.819717 3.688203 22.000080 c +3.688203 32.180443 11.797986 40.333336 21.978350 40.333336 c +32.158710 40.333336 40.311611 32.180443 40.333256 22.000080 c +40.354897 11.819717 32.180267 3.666824 21.999905 3.666824 c +h +13.782296 20.188307 m +20.166574 20.188307 l +20.166574 13.760918 l +20.166574 12.682493 20.899923 11.949142 21.956793 11.949142 c +23.035217 11.949142 23.790121 12.682493 23.790121 13.760918 c +23.790121 20.188307 l +30.217514 20.188307 l +31.295938 20.188307 32.029289 20.921658 32.029289 21.978525 c +32.029289 23.056950 31.295938 23.811855 30.217514 23.811855 c +23.790121 23.811855 l +23.790121 30.196133 l +23.790121 31.317715 23.035217 32.051018 21.956793 32.051018 c +20.899923 32.051018 20.166574 31.296114 20.166574 30.196133 c +20.166574 23.811855 l +13.782296 23.811855 l +12.660716 23.811855 11.927410 23.056950 11.927410 21.978525 c +11.927410 20.921658 12.682316 20.188307 13.782296 20.188307 c +h +f +n +Q + +endstream +endobj + +3 0 obj + 1347 +endobj + +4 0 obj + << /Annots [] + /Type /Page + /MediaBox [ 0.000000 0.000000 44.000000 44.000000 ] + /Resources 1 0 R + /Contents 2 0 R + /Parent 5 0 R + >> +endobj + +5 0 obj + << /Kids [ 4 0 R ] + /Count 1 + /Type /Pages + >> +endobj + +6 0 obj + << /Type /Catalog + /Pages 5 0 R + >> +endobj + +xref +0 7 +0000000000 65535 f +0000000010 00000 n +0000000034 00000 n +0000001437 00000 n +0000001460 00000 n +0000001633 00000 n +0000001707 00000 n +trailer +<< /ID [ (some) (id) ] + /Root 6 0 R + /Size 7 +>> +startxref +1766 +%%EOF \ No newline at end of file diff --git a/Mastodon/Resources/Assets.xcassets/Welcome/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/Background/AudioPlayer/Contents.json similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Welcome/Contents.json rename to Mastodon/Resources/Assets.xcassets/Colors/Background/AudioPlayer/Contents.json diff --git a/Mastodon/Resources/Assets.xcassets/Colors/lightDangerRed.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/Background/AudioPlayer/highlight.colorset/Contents.json similarity index 74% rename from Mastodon/Resources/Assets.xcassets/Colors/lightDangerRed.colorset/Contents.json rename to Mastodon/Resources/Assets.xcassets/Colors/Background/AudioPlayer/highlight.colorset/Contents.json index dabccc33e..2e1ce5f3a 100644 --- a/Mastodon/Resources/Assets.xcassets/Colors/lightDangerRed.colorset/Contents.json +++ b/Mastodon/Resources/Assets.xcassets/Colors/Background/AudioPlayer/highlight.colorset/Contents.json @@ -5,9 +5,9 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "0.353", - "green" : "0.251", - "red" : "0.875" + "blue" : "0.851", + "green" : "0.565", + "red" : "0.169" } }, "idiom" : "universal" diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Background/Poll/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/Background/Poll/Contents.json new file mode 100644 index 000000000..6e965652d --- /dev/null +++ b/Mastodon/Resources/Assets.xcassets/Colors/Background/Poll/Contents.json @@ -0,0 +1,9 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "provides-namespace" : true + } +} diff --git a/Mastodon/Resources/Assets.xcassets/Colors/lightDisabled.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/Background/Poll/disabled.colorset/Contents.json similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Colors/lightDisabled.colorset/Contents.json rename to Mastodon/Resources/Assets.xcassets/Colors/Background/Poll/disabled.colorset/Contents.json diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Button/highlight.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/Background/Poll/highlight.colorset/Contents.json similarity index 74% rename from Mastodon/Resources/Assets.xcassets/Colors/Button/highlight.colorset/Contents.json rename to Mastodon/Resources/Assets.xcassets/Colors/Background/Poll/highlight.colorset/Contents.json index 03a422b00..2e1ce5f3a 100644 --- a/Mastodon/Resources/Assets.xcassets/Colors/Button/highlight.colorset/Contents.json +++ b/Mastodon/Resources/Assets.xcassets/Colors/Background/Poll/highlight.colorset/Contents.json @@ -5,9 +5,9 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "0.839", - "green" : "0.573", - "red" : "0.204" + "blue" : "0.851", + "green" : "0.565", + "red" : "0.169" } }, "idiom" : "universal" diff --git a/Mastodon/Resources/Assets.xcassets/Colors/lightAlertYellow.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/Background/alert.yellow.colorset/Contents.json similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Colors/lightAlertYellow.colorset/Contents.json rename to Mastodon/Resources/Assets.xcassets/Colors/Background/alert.yellow.colorset/Contents.json diff --git a/Mastodon/Resources/Assets.xcassets/Colors/lightBrandBlue.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/Background/danger.border.colorset/Contents.json similarity index 76% rename from Mastodon/Resources/Assets.xcassets/Colors/lightBrandBlue.colorset/Contents.json rename to Mastodon/Resources/Assets.xcassets/Colors/Background/danger.border.colorset/Contents.json index d853a71aa..bc9f94fcc 100644 --- a/Mastodon/Resources/Assets.xcassets/Colors/lightBrandBlue.colorset/Contents.json +++ b/Mastodon/Resources/Assets.xcassets/Colors/Background/danger.border.colorset/Contents.json @@ -5,9 +5,9 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "217", - "green" : "144", - "red" : "43" + "blue" : "66", + "green" : "46", + "red" : "163" } }, "idiom" : "universal" diff --git a/Mastodon/Resources/Assets.xcassets/Colors/lightBackground.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/Background/danger.colorset/Contents.json similarity index 74% rename from Mastodon/Resources/Assets.xcassets/Colors/lightBackground.colorset/Contents.json rename to Mastodon/Resources/Assets.xcassets/Colors/Background/danger.colorset/Contents.json index 0e4687fb4..b77cb3c75 100644 --- a/Mastodon/Resources/Assets.xcassets/Colors/lightBackground.colorset/Contents.json +++ b/Mastodon/Resources/Assets.xcassets/Colors/Background/danger.colorset/Contents.json @@ -5,9 +5,9 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "0.910", - "green" : "0.882", - "red" : "0.851" + "blue" : "90", + "green" : "64", + "red" : "223" } }, "idiom" : "universal" diff --git a/Mastodon/Resources/Assets.xcassets/Colors/lightSecondaryText.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/Background/media.type.indicotor.colorset/Contents.json similarity index 74% rename from Mastodon/Resources/Assets.xcassets/Colors/lightSecondaryText.colorset/Contents.json rename to Mastodon/Resources/Assets.xcassets/Colors/Background/media.type.indicotor.colorset/Contents.json index 5fb782c4f..e9c583c0d 100644 --- a/Mastodon/Resources/Assets.xcassets/Colors/lightSecondaryText.colorset/Contents.json +++ b/Mastodon/Resources/Assets.xcassets/Colors/Background/media.type.indicotor.colorset/Contents.json @@ -5,9 +5,9 @@ "color-space" : "srgb", "components" : { "alpha" : "0.600", - "blue" : "0.263", - "green" : "0.235", - "red" : "0.235" + "blue" : "0", + "green" : "0", + "red" : "0" } }, "idiom" : "universal" diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Background/navigationBar.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/Background/navigationBar.colorset/Contents.json new file mode 100644 index 000000000..7f9578a7a --- /dev/null +++ b/Mastodon/Resources/Assets.xcassets/Colors/Background/navigationBar.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "0.940", + "blue" : "249", + "green" : "249", + "red" : "249" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "0.940", + "blue" : "29", + "green" : "29", + "red" : "29" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Background/secondary.grouped.system.background.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/Background/secondary.grouped.system.background.colorset/Contents.json index abe46b9aa..5e7067405 100644 --- a/Mastodon/Resources/Assets.xcassets/Colors/Background/secondary.grouped.system.background.colorset/Contents.json +++ b/Mastodon/Resources/Assets.xcassets/Colors/Background/secondary.grouped.system.background.colorset/Contents.json @@ -5,9 +5,9 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "255", + "blue" : "254", "green" : "255", - "red" : "255" + "red" : "254" } }, "idiom" : "universal" @@ -23,9 +23,9 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "0x37", - "green" : "0x2D", - "red" : "0x29" + "blue" : "46", + "green" : "44", + "red" : "44" } }, "idiom" : "universal" diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Background/secondary.system.background.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/Background/secondary.system.background.colorset/Contents.json index 7e0375939..82abbf254 100644 --- a/Mastodon/Resources/Assets.xcassets/Colors/Background/secondary.system.background.colorset/Contents.json +++ b/Mastodon/Resources/Assets.xcassets/Colors/Background/secondary.system.background.colorset/Contents.json @@ -5,9 +5,27 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "0x37", - "green" : "0x2D", - "red" : "0x29" + "blue" : "232", + "green" : "225", + "red" : "217" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "46", + "green" : "44", + "red" : "44" } }, "idiom" : "universal" diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Background/system.background.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/Background/system.background.colorset/Contents.json index b10e249b2..23d03492f 100644 --- a/Mastodon/Resources/Assets.xcassets/Colors/Background/system.background.colorset/Contents.json +++ b/Mastodon/Resources/Assets.xcassets/Colors/Background/system.background.colorset/Contents.json @@ -5,9 +5,9 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "255", + "blue" : "254", "green" : "255", - "red" : "255" + "red" : "254" } }, "idiom" : "universal" @@ -23,9 +23,9 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "0.169", - "green" : "0.141", - "red" : "0.125" + "blue" : "0x00", + "green" : "0x00", + "red" : "0x00" } }, "idiom" : "universal" diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Background/system.elevated.background.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/Background/system.elevated.background.colorset/Contents.json new file mode 100644 index 000000000..e13fb4690 --- /dev/null +++ b/Mastodon/Resources/Assets.xcassets/Colors/Background/system.elevated.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" : "1.000", + "blue" : "0x1E", + "green" : "0x1C", + "red" : "0x1C" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Background/system.grouped.background.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/Background/system.grouped.background.colorset/Contents.json index edc0dce9a..6bce2b697 100644 --- a/Mastodon/Resources/Assets.xcassets/Colors/Background/system.grouped.background.colorset/Contents.json +++ b/Mastodon/Resources/Assets.xcassets/Colors/Background/system.grouped.background.colorset/Contents.json @@ -5,9 +5,9 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "232", - "green" : "225", - "red" : "217" + "blue" : "0xE8", + "green" : "0xE1", + "red" : "0xD9" } }, "idiom" : "universal" @@ -23,9 +23,9 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "0.169", - "green" : "0.141", - "red" : "0.125" + "blue" : "0x00", + "green" : "0x00", + "red" : "0x00" } }, "idiom" : "universal" diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Background/tertiary.system.background.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/Background/tertiary.system.background.colorset/Contents.json index 2388399df..9fa2b261b 100644 --- a/Mastodon/Resources/Assets.xcassets/Colors/Background/tertiary.system.background.colorset/Contents.json +++ b/Mastodon/Resources/Assets.xcassets/Colors/Background/tertiary.system.background.colorset/Contents.json @@ -5,9 +5,27 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "0x43", - "green" : "0x36", - "red" : "0x32" + "blue" : "0xFE", + "green" : "0xFF", + "red" : "0xFE" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x3C", + "green" : "0x3A", + "red" : "0x3A" } }, "idiom" : "universal" diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Background/tertiary.system.grouped.background.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/Background/tertiary.system.grouped.background.colorset/Contents.json new file mode 100644 index 000000000..5da572b1d --- /dev/null +++ b/Mastodon/Resources/Assets.xcassets/Colors/Background/tertiary.system.grouped.background.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xE8", + "green" : "0xE1", + "red" : "0xD9" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x3C", + "green" : "0x3A", + "red" : "0x3A" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Border/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/Border/Contents.json new file mode 100644 index 000000000..6e965652d --- /dev/null +++ b/Mastodon/Resources/Assets.xcassets/Colors/Border/Contents.json @@ -0,0 +1,9 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "provides-namespace" : true + } +} diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Border/notification.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/Border/notification.colorset/Contents.json new file mode 100644 index 000000000..afc18df10 --- /dev/null +++ b/Mastodon/Resources/Assets.xcassets/Colors/Border/notification.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xE8", + "green" : "0xE1", + "red" : "0xD9" + } + }, + "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/Colors/Border/searchCard.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/Border/searchCard.colorset/Contents.json new file mode 100644 index 000000000..a0ce2efb8 --- /dev/null +++ b/Mastodon/Resources/Assets.xcassets/Colors/Border/searchCard.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "0.000", + "blue" : "1.000", + "green" : "1.000", + "red" : "1.000" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "0.300", + "blue" : "213", + "green" : "213", + "red" : "213" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Button/disabled.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/Button/disabled.colorset/Contents.json index 78cde95fb..f2e6f489e 100644 --- a/Mastodon/Resources/Assets.xcassets/Colors/Button/disabled.colorset/Contents.json +++ b/Mastodon/Resources/Assets.xcassets/Colors/Button/disabled.colorset/Contents.json @@ -11,6 +11,24 @@ } }, "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" : { diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Button/inactive.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/Button/inactive.colorset/Contents.json new file mode 100644 index 000000000..9fbab2202 --- /dev/null +++ b/Mastodon/Resources/Assets.xcassets/Colors/Button/inactive.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.549", + "green" : "0.510", + "red" : "0.431" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.392", + "green" : "0.365", + "red" : "0.310" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Button/normal.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/Button/normal.colorset/Contents.json new file mode 100644 index 000000000..869ed278a --- /dev/null +++ b/Mastodon/Resources/Assets.xcassets/Colors/Button/normal.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "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" : "0xFF", + "green" : "0x84", + "red" : "0x0A" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Label/highlight.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/Label/highlight.colorset/Contents.json index 2e1ce5f3a..d853a71aa 100644 --- a/Mastodon/Resources/Assets.xcassets/Colors/Label/highlight.colorset/Contents.json +++ b/Mastodon/Resources/Assets.xcassets/Colors/Label/highlight.colorset/Contents.json @@ -5,9 +5,9 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "0.851", - "green" : "0.565", - "red" : "0.169" + "blue" : "217", + "green" : "144", + "red" : "43" } }, "idiom" : "universal" diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Icon/photo.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/Label/tertiary.colorset/Contents.json similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Colors/Icon/photo.colorset/Contents.json rename to Mastodon/Resources/Assets.xcassets/Colors/Label/tertiary.colorset/Contents.json diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Notification/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/Notification/Contents.json new file mode 100644 index 000000000..6e965652d --- /dev/null +++ b/Mastodon/Resources/Assets.xcassets/Colors/Notification/Contents.json @@ -0,0 +1,9 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "provides-namespace" : true + } +} diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Notification/favourite.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/Notification/favourite.colorset/Contents.json new file mode 100644 index 000000000..36de20274 --- /dev/null +++ b/Mastodon/Resources/Assets.xcassets/Colors/Notification/favourite.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0", + "green" : "204", + "red" : "255" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Notification/mention.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/Notification/mention.colorset/Contents.json new file mode 100644 index 000000000..9dff2f59b --- /dev/null +++ b/Mastodon/Resources/Assets.xcassets/Colors/Notification/mention.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "222", + "green" : "82", + "red" : "175" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "242", + "green" : "90", + "red" : "191" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Notification/reblog.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/Notification/reblog.colorset/Contents.json new file mode 100644 index 000000000..ec427ccaa --- /dev/null +++ b/Mastodon/Resources/Assets.xcassets/Colors/Notification/reblog.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "89", + "green" : "199", + "red" : "52" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "75", + "green" : "215", + "red" : "20" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Shadow/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/Shadow/Contents.json new file mode 100644 index 000000000..6e965652d --- /dev/null +++ b/Mastodon/Resources/Assets.xcassets/Colors/Shadow/Contents.json @@ -0,0 +1,9 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "provides-namespace" : true + } +} diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Shadow/SearchCard.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/Shadow/SearchCard.colorset/Contents.json new file mode 100644 index 000000000..a28cf0793 --- /dev/null +++ b/Mastodon/Resources/Assets.xcassets/Colors/Shadow/SearchCard.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0", + "green" : "0", + "red" : "0" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "0.000", + "blue" : "1.000", + "green" : "1.000", + "red" : "1.000" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Slider/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/Slider/Contents.json new file mode 100644 index 000000000..6e965652d --- /dev/null +++ b/Mastodon/Resources/Assets.xcassets/Colors/Slider/Contents.json @@ -0,0 +1,9 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "provides-namespace" : true + } +} diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Slider/bar.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/Slider/bar.colorset/Contents.json new file mode 100644 index 000000000..dc91052f7 --- /dev/null +++ b/Mastodon/Resources/Assets.xcassets/Colors/Slider/bar.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "147", + "green" : "106", + "red" : "51" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Mastodon/Resources/Assets.xcassets/Colors/TextField/background.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/TextField/background.colorset/Contents.json new file mode 100644 index 000000000..cde0cdf00 --- /dev/null +++ b/Mastodon/Resources/Assets.xcassets/Colors/TextField/background.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "0.600", + "blue" : "213", + "green" : "212", + "red" : "212" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "0.240", + "blue" : "128", + "green" : "118", + "red" : "118" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Mastodon/Resources/Assets.xcassets/Colors/battleshipGrey.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/battleshipGrey.colorset/Contents.json new file mode 100644 index 000000000..37df8107f --- /dev/null +++ b/Mastodon/Resources/Assets.xcassets/Colors/battleshipGrey.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "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/Colors/brand.blue.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/brand.blue.colorset/Contents.json new file mode 100644 index 000000000..a85c0e379 --- /dev/null +++ b/Mastodon/Resources/Assets.xcassets/Colors/brand.blue.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "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/danger.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/danger.colorset/Contents.json new file mode 100644 index 000000000..8ea3105e6 --- /dev/null +++ b/Mastodon/Resources/Assets.xcassets/Colors/danger.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "info" : { + "version" : 1, + "author" : "xcode" + }, + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "red" : "0.875", + "blue" : "0.353", + "green" : "0.251" + } + }, + "idiom" : "universal" + } + ] +} \ No newline at end of file diff --git a/Mastodon/Resources/Assets.xcassets/Colors/disabled.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/disabled.colorset/Contents.json new file mode 100644 index 000000000..303021b9f --- /dev/null +++ b/Mastodon/Resources/Assets.xcassets/Colors/disabled.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "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 new file mode 100644 index 000000000..ea5d9760a --- /dev/null +++ b/Mastodon/Resources/Assets.xcassets/Colors/inactive.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "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/lightDarkGray.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/lightDarkGray.colorset/Contents.json deleted file mode 100644 index 8d54c84c2..000000000 --- a/Mastodon/Resources/Assets.xcassets/Colors/lightDarkGray.colorset/Contents.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "colors" : [ - { - "color" : { - "color-space" : "srgb", - "components" : { - "alpha" : "1.000", - "blue" : "0.169", - "green" : "0.137", - "red" : "0.122" - } - }, - "idiom" : "universal" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/Mastodon/Resources/Assets.xcassets/Colors/lightInactive.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/lightInactive.colorset/Contents.json deleted file mode 100644 index 69dc63851..000000000 --- a/Mastodon/Resources/Assets.xcassets/Colors/lightInactive.colorset/Contents.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "colors" : [ - { - "color" : { - "color-space" : "srgb", - "components" : { - "alpha" : "1.000", - "blue" : "0.549", - "green" : "0.510", - "red" : "0.431" - } - }, - "idiom" : "universal" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/Mastodon/Resources/Assets.xcassets/Colors/lightWhite.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/lightWhite.colorset/Contents.json deleted file mode 100644 index a5291a593..000000000 --- a/Mastodon/Resources/Assets.xcassets/Colors/lightWhite.colorset/Contents.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "colors" : [ - { - "color" : { - "color-space" : "srgb", - "components" : { - "alpha" : "1.000", - "blue" : "0.996", - "green" : "1.000", - "red" : "0.996" - } - }, - "idiom" : "universal" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/Mastodon/Resources/Assets.xcassets/Colors/lightSuccessGreen.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/success.green.colorset/Contents.json similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Colors/lightSuccessGreen.colorset/Contents.json rename to Mastodon/Resources/Assets.xcassets/Colors/success.green.colorset/Contents.json diff --git a/Mastodon/Resources/Assets.xcassets/Connectivity/Contents.json b/Mastodon/Resources/Assets.xcassets/Connectivity/Contents.json new file mode 100644 index 000000000..6e965652d --- /dev/null +++ b/Mastodon/Resources/Assets.xcassets/Connectivity/Contents.json @@ -0,0 +1,9 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "provides-namespace" : true + } +} diff --git a/Mastodon/Resources/Assets.xcassets/Connectivity/photo.fill.split.imageset/Contents.json b/Mastodon/Resources/Assets.xcassets/Connectivity/photo.fill.split.imageset/Contents.json new file mode 100644 index 000000000..9c640adf5 --- /dev/null +++ b/Mastodon/Resources/Assets.xcassets/Connectivity/photo.fill.split.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "Frame 2.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/Mastodon/Resources/Assets.xcassets/Connectivity/photo.fill.split.imageset/Frame 2.pdf b/Mastodon/Resources/Assets.xcassets/Connectivity/photo.fill.split.imageset/Frame 2.pdf new file mode 100644 index 000000000..4ce898753 --- /dev/null +++ b/Mastodon/Resources/Assets.xcassets/Connectivity/photo.fill.split.imageset/Frame 2.pdf @@ -0,0 +1,114 @@ +%PDF-1.7 + +1 0 obj + << >> +endobj + +2 0 obj + << /Length 3 0 R >> +stream +/DeviceRGB CS +/DeviceRGB cs +q +0.992546 -0.121869 0.121869 0.992546 42.624641 7.462139 cm +0.000000 0.000000 0.000000 scn +29.841717 4.404652 m +10.813622 4.404652 l +5.729721 9.441498 l +29.810324 9.441498 l +32.782593 9.441498 34.628483 11.256016 34.628483 14.259354 c +34.628483 19.077240 l +20.237179 32.404518 l +18.766808 33.781090 16.983574 34.438072 15.262939 34.438072 c +13.481962 34.438072 11.763362 33.813934 10.231857 32.441067 c +0.000000 39.493633 l +11.853184 50.706104 l +1.586006 62.000000 l +29.841717 62.000000 l +36.411587 62.000000 39.665127 58.746330 39.665127 52.301659 c +39.665127 14.102936 l +39.665127 7.658268 36.411587 4.404652 29.841717 4.404652 c +h +f* +n +Q +q +1.000000 0.000000 -0.000000 1.000000 6.000000 11.404663 cm +0.000000 0.000000 0.000000 scn +35.690556 57.595337 m +9.823408 57.595337 l +3.284870 57.595337 0.000000 54.372997 0.000000 47.896996 c +0.000000 9.698273 l +0.000000 3.222317 3.284870 -0.000011 9.823408 -0.000011 c +44.918179 -0.000011 l +39.834278 5.036835 l +9.886006 5.036835 l +6.851334 5.036835 5.036836 6.851357 5.036836 9.917267 c +5.036836 11.825638 l +14.641250 20.209938 l +16.017820 21.430046 17.519461 22.055767 18.896032 22.055767 c +20.428938 22.055767 22.024504 21.430050 23.401012 20.147408 c +29.376427 14.766380 l +44.330532 28.031185 l +44.332489 28.032942 44.334446 28.034697 44.336403 28.036451 c +34.104435 35.089096 l +45.957619 46.301567 l +35.690556 57.595337 l +h +15.736227 35.758499 m +15.736227 31.503782 19.208826 28.031185 23.463608 28.031185 c +27.687059 28.031185 31.159658 31.503782 31.159658 35.758499 c +31.159658 39.981949 27.687059 43.485878 23.463608 43.485878 c +19.208826 43.485878 15.736227 39.981949 15.736227 35.758499 c +h +f* +n +Q + +endstream +endobj + +3 0 obj + 1681 +endobj + +4 0 obj + << /Annots [] + /Type /Page + /MediaBox [ 0.000000 0.000000 92.000000 76.000000 ] + /Resources 1 0 R + /Contents 2 0 R + /Parent 5 0 R + >> +endobj + +5 0 obj + << /Kids [ 4 0 R ] + /Count 1 + /Type /Pages + >> +endobj + +6 0 obj + << /Type /Catalog + /Pages 5 0 R + >> +endobj + +xref +0 7 +0000000000 65535 f +0000000010 00000 n +0000000034 00000 n +0000001771 00000 n +0000001794 00000 n +0000001967 00000 n +0000002041 00000 n +trailer +<< /ID [ (some) (id) ] + /Root 6 0 R + /Size 7 +>> +startxref +2100 +%%EOF \ No newline at end of file diff --git a/Mastodon/Resources/Assets.xcassets/Human/Contents.json b/Mastodon/Resources/Assets.xcassets/Human/Contents.json new file mode 100644 index 000000000..6e965652d --- /dev/null +++ b/Mastodon/Resources/Assets.xcassets/Human/Contents.json @@ -0,0 +1,9 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "provides-namespace" : true + } +} diff --git a/Mastodon/Resources/Assets.xcassets/Human/face.smiling.adaptive.imageset/Contents.json b/Mastodon/Resources/Assets.xcassets/Human/face.smiling.adaptive.imageset/Contents.json new file mode 100644 index 000000000..df869a35c --- /dev/null +++ b/Mastodon/Resources/Assets.xcassets/Human/face.smiling.adaptive.imageset/Contents.json @@ -0,0 +1,25 @@ +{ + "images" : [ + { + "filename" : "emojiIconLight.pdf", + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "emojiIconDark.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/Mastodon/Resources/Assets.xcassets/Human/face.smiling.adaptive.imageset/emojiIconDark.pdf b/Mastodon/Resources/Assets.xcassets/Human/face.smiling.adaptive.imageset/emojiIconDark.pdf new file mode 100644 index 000000000..77c6c2d32 --- /dev/null +++ b/Mastodon/Resources/Assets.xcassets/Human/face.smiling.adaptive.imageset/emojiIconDark.pdf @@ -0,0 +1,97 @@ +%PDF-1.7 + +1 0 obj + << >> +endobj + +2 0 obj + << /Length 3 0 R >> +stream +/DeviceRGB CS +/DeviceRGB cs +q +1.000000 0.000000 -0.000000 1.000000 0.000000 0.000000 cm +0.225600 0.613812 0.894400 scn +48.000000 0.000000 m +74.509674 0.000000 96.000000 21.490326 96.000000 48.000000 c +96.000000 74.509666 74.509674 96.000000 48.000000 96.000000 c +21.490332 96.000000 0.000000 74.509666 0.000000 48.000000 c +0.000000 21.490326 21.490332 0.000000 48.000000 0.000000 c +h +48.000023 39.999962 m +38.338688 39.999962 31.928020 41.125294 24.000021 42.666630 c +22.189354 43.015961 18.666687 42.666630 18.666687 37.333294 c +18.666687 26.666626 30.920021 13.333298 48.000023 13.333298 c +65.077354 13.333298 77.333359 26.666626 77.333359 37.333294 c +77.333359 42.666630 73.810692 43.018627 72.000023 42.666630 c +64.072021 41.125294 57.661354 39.999962 48.000023 39.999962 c +h +38.666645 59.999981 m +38.666645 54.845322 35.681877 50.666649 31.999979 50.666649 c +28.318081 50.666649 25.333313 54.845322 25.333313 59.999981 c +25.333313 65.154640 28.318081 69.333313 31.999979 69.333313 c +35.681877 69.333313 38.666645 65.154640 38.666645 59.999981 c +h +63.999977 50.666649 m +67.681877 50.666649 70.666641 54.845322 70.666641 59.999981 c +70.666641 65.154640 67.681877 69.333313 63.999977 69.333313 c +60.318081 69.333313 57.333313 65.154640 57.333313 59.999981 c +57.333313 54.845322 60.318081 50.666649 63.999977 50.666649 c +h +48.000000 34.666645 m +32.000000 34.666645 24.000000 37.333313 24.000000 37.333313 c +24.000000 37.333313 29.333334 26.666649 48.000000 26.666649 c +66.666672 26.666649 72.000000 37.333313 72.000000 37.333313 c +72.000000 37.333313 64.000000 34.666645 48.000000 34.666645 c +h +f* +n +Q + +endstream +endobj + +3 0 obj + 1603 +endobj + +4 0 obj + << /Annots [] + /Type /Page + /MediaBox [ 0.000000 0.000000 96.000000 96.000000 ] + /Resources 1 0 R + /Contents 2 0 R + /Parent 5 0 R + >> +endobj + +5 0 obj + << /Kids [ 4 0 R ] + /Count 1 + /Type /Pages + >> +endobj + +6 0 obj + << /Type /Catalog + /Pages 5 0 R + >> +endobj + +xref +0 7 +0000000000 65535 f +0000000010 00000 n +0000000034 00000 n +0000001693 00000 n +0000001716 00000 n +0000001889 00000 n +0000001963 00000 n +trailer +<< /ID [ (some) (id) ] + /Root 6 0 R + /Size 7 +>> +startxref +2022 +%%EOF \ No newline at end of file diff --git a/Mastodon/Resources/Assets.xcassets/Human/face.smiling.adaptive.imageset/emojiIconLight.pdf b/Mastodon/Resources/Assets.xcassets/Human/face.smiling.adaptive.imageset/emojiIconLight.pdf new file mode 100644 index 000000000..61f471d6d --- /dev/null +++ b/Mastodon/Resources/Assets.xcassets/Human/face.smiling.adaptive.imageset/emojiIconLight.pdf @@ -0,0 +1,103 @@ +%PDF-1.7 + +1 0 obj + << >> +endobj + +2 0 obj + << /Length 3 0 R >> +stream +/DeviceRGB CS +/DeviceRGB cs +q +1.000000 0.000000 -0.000000 1.000000 0.000000 0.000000 cm +0.168627 0.564706 0.850980 scn +90.000000 48.000000 m +90.000000 24.804031 71.195969 6.000000 48.000000 6.000000 c +24.804039 6.000000 6.000000 24.804031 6.000000 48.000000 c +6.000000 71.195961 24.804041 90.000000 48.000000 90.000000 c +71.195969 90.000000 90.000000 71.195961 90.000000 48.000000 c +h +48.000000 0.000000 m +74.509674 0.000000 96.000000 21.490326 96.000000 48.000000 c +96.000000 74.509666 74.509674 96.000000 48.000000 96.000000 c +21.490332 96.000000 0.000000 74.509666 0.000000 48.000000 c +0.000000 21.490326 21.490332 0.000000 48.000000 0.000000 c +h +38.666645 59.999981 m +38.666645 54.845322 35.681877 50.666649 31.999979 50.666649 c +28.318081 50.666649 25.333313 54.845322 25.333313 59.999981 c +25.333313 65.154640 28.318081 69.333313 31.999979 69.333313 c +35.681877 69.333313 38.666645 65.154640 38.666645 59.999981 c +h +63.999977 50.666649 m +67.681877 50.666649 70.666641 54.845322 70.666641 59.999981 c +70.666641 65.154640 67.681877 69.333313 63.999977 69.333313 c +60.318081 69.333313 57.333313 65.154640 57.333313 59.999981 c +57.333313 54.845322 60.318081 50.666649 63.999977 50.666649 c +h +48.000023 39.999962 m +38.338688 39.999962 31.928020 41.125294 24.000021 42.666630 c +22.189354 43.015961 18.666687 42.666630 18.666687 37.333294 c +18.666687 26.666626 30.920021 13.333298 48.000023 13.333298 c +65.077354 13.333298 77.333359 26.666626 77.333359 37.333294 c +77.333359 42.666630 73.810684 43.018627 72.000023 42.666630 c +64.072021 41.125294 57.661354 39.999962 48.000023 39.999962 c +h +24.000000 37.333313 m +24.000000 37.333313 32.000000 34.666645 48.000000 34.666645 c +64.000000 34.666645 72.000000 37.333313 72.000000 37.333313 c +72.000000 37.333313 66.666672 26.666649 48.000000 26.666649 c +29.333334 26.666649 24.000000 37.333313 24.000000 37.333313 c +h +f* +n +Q + +endstream +endobj + +3 0 obj + 1869 +endobj + +4 0 obj + << /Annots [] + /Type /Page + /MediaBox [ 0.000000 0.000000 96.000000 96.000000 ] + /Resources 1 0 R + /Contents 2 0 R + /Parent 5 0 R + >> +endobj + +5 0 obj + << /Kids [ 4 0 R ] + /Count 1 + /Type /Pages + >> +endobj + +6 0 obj + << /Type /Catalog + /Pages 5 0 R + >> +endobj + +xref +0 7 +0000000000 65535 f +0000000010 00000 n +0000000034 00000 n +0000001959 00000 n +0000001982 00000 n +0000002155 00000 n +0000002229 00000 n +trailer +<< /ID [ (some) (id) ] + /Root 6 0 R + /Size 7 +>> +startxref +2288 +%%EOF \ No newline at end of file diff --git a/Mastodon/Resources/Assets.xcassets/Scene/Compose/Contents.json b/Mastodon/Resources/Assets.xcassets/Scene/Compose/Contents.json new file mode 100644 index 000000000..6e965652d --- /dev/null +++ b/Mastodon/Resources/Assets.xcassets/Scene/Compose/Contents.json @@ -0,0 +1,9 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "provides-namespace" : true + } +} diff --git a/Mastodon/Resources/Assets.xcassets/Scene/Compose/background.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Scene/Compose/background.colorset/Contents.json new file mode 100644 index 000000000..82edd034b --- /dev/null +++ b/Mastodon/Resources/Assets.xcassets/Scene/Compose/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" : "1.000", + "blue" : "30", + "green" : "28", + "red" : "28" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Mastodon/Resources/Assets.xcassets/Scene/Compose/toolbar.background.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Scene/Compose/toolbar.background.colorset/Contents.json new file mode 100644 index 000000000..4ef70f635 --- /dev/null +++ b/Mastodon/Resources/Assets.xcassets/Scene/Compose/toolbar.background.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "222", + "green" : "216", + "red" : "214" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "43", + "green" : "43", + "red" : "43" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Mastodon/Resources/Assets.xcassets/Scene/Contents.json b/Mastodon/Resources/Assets.xcassets/Scene/Contents.json new file mode 100644 index 000000000..6e965652d --- /dev/null +++ b/Mastodon/Resources/Assets.xcassets/Scene/Contents.json @@ -0,0 +1,9 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "provides-namespace" : true + } +} diff --git a/Mastodon/Resources/Assets.xcassets/Scene/Profile/Banner/Contents.json b/Mastodon/Resources/Assets.xcassets/Scene/Profile/Banner/Contents.json new file mode 100644 index 000000000..6e965652d --- /dev/null +++ b/Mastodon/Resources/Assets.xcassets/Scene/Profile/Banner/Contents.json @@ -0,0 +1,9 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "provides-namespace" : true + } +} diff --git a/Mastodon/Resources/Assets.xcassets/Scene/Profile/Banner/bio.edit.background.gray.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Scene/Profile/Banner/bio.edit.background.gray.colorset/Contents.json new file mode 100644 index 000000000..aa5323a21 --- /dev/null +++ b/Mastodon/Resources/Assets.xcassets/Scene/Profile/Banner/bio.edit.background.gray.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "0.200", + "blue" : "128", + "green" : "120", + "red" : "120" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "0.360", + "blue" : "128", + "green" : "120", + "red" : "120" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Mastodon/Resources/Assets.xcassets/Scene/Profile/Banner/name.edit.background.gray.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Scene/Profile/Banner/name.edit.background.gray.colorset/Contents.json new file mode 100644 index 000000000..b4ce9fd5b --- /dev/null +++ b/Mastodon/Resources/Assets.xcassets/Scene/Profile/Banner/name.edit.background.gray.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "0.360", + "blue" : "128", + "green" : "120", + "red" : "120" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Mastodon/Resources/Assets.xcassets/Scene/Profile/Banner/username.gray.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Scene/Profile/Banner/username.gray.colorset/Contents.json new file mode 100644 index 000000000..473d42adc --- /dev/null +++ b/Mastodon/Resources/Assets.xcassets/Scene/Profile/Banner/username.gray.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "0.600", + "blue" : "0.961", + "green" : "0.922", + "red" : "0.922" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Mastodon/Resources/Assets.xcassets/Scene/Profile/Contents.json b/Mastodon/Resources/Assets.xcassets/Scene/Profile/Contents.json new file mode 100644 index 000000000..6e965652d --- /dev/null +++ b/Mastodon/Resources/Assets.xcassets/Scene/Profile/Contents.json @@ -0,0 +1,9 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "provides-namespace" : true + } +} diff --git a/Mastodon/Resources/Assets.xcassets/Scene/Welcome/Contents.json b/Mastodon/Resources/Assets.xcassets/Scene/Welcome/Contents.json new file mode 100644 index 000000000..6e965652d --- /dev/null +++ b/Mastodon/Resources/Assets.xcassets/Scene/Welcome/Contents.json @@ -0,0 +1,9 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "provides-namespace" : true + } +} diff --git a/Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/Contents.json b/Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/Contents.json new file mode 100644 index 000000000..6e965652d --- /dev/null +++ b/Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/Contents.json @@ -0,0 +1,9 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "provides-namespace" : true + } +} 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 new file mode 100644 index 000000000..cd6391d81 --- /dev/null +++ b/Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/background.cyan.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "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/illustration/cloud.base.imageset/Contents.json b/Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/cloud.base.imageset/Contents.json new file mode 100644 index 000000000..16dc17794 --- /dev/null +++ b/Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/cloud.base.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "untitled10007Group61.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "untitled10007Group61@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "untitled10007Group61@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/cloud.base.imageset/untitled10007Group61.png b/Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/cloud.base.imageset/untitled10007Group61.png new file mode 100644 index 000000000..fa4dba30a Binary files /dev/null and b/Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/cloud.base.imageset/untitled10007Group61.png differ diff --git a/Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/cloud.base.imageset/untitled10007Group61@2x.png b/Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/cloud.base.imageset/untitled10007Group61@2x.png new file mode 100644 index 000000000..c96857b07 Binary files /dev/null and b/Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/cloud.base.imageset/untitled10007Group61@2x.png differ diff --git a/Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/cloud.base.imageset/untitled10007Group61@3x.png b/Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/cloud.base.imageset/untitled10007Group61@3x.png new file mode 100644 index 000000000..90ac897c4 Binary files /dev/null and b/Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/cloud.base.imageset/untitled10007Group61@3x.png differ diff --git a/Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/elephant.on.airplane.with.contrail.imageset/Contents.json b/Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/elephant.on.airplane.with.contrail.imageset/Contents.json new file mode 100644 index 000000000..5e4683b34 --- /dev/null +++ b/Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/elephant.on.airplane.with.contrail.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "untitled10006Group21.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "untitled10006Group21@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "untitled10006Group21@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/elephant.on.airplane.with.contrail.imageset/untitled10006Group21.png b/Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/elephant.on.airplane.with.contrail.imageset/untitled10006Group21.png new file mode 100644 index 000000000..08190922e Binary files /dev/null and b/Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/elephant.on.airplane.with.contrail.imageset/untitled10006Group21.png differ diff --git a/Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/elephant.on.airplane.with.contrail.imageset/untitled10006Group21@2x.png b/Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/elephant.on.airplane.with.contrail.imageset/untitled10006Group21@2x.png new file mode 100644 index 000000000..19f422051 Binary files /dev/null and b/Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/elephant.on.airplane.with.contrail.imageset/untitled10006Group21@2x.png differ diff --git a/Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/elephant.on.airplane.with.contrail.imageset/untitled10006Group21@3x.png b/Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/elephant.on.airplane.with.contrail.imageset/untitled10006Group21@3x.png new file mode 100644 index 000000000..8c2c3774c Binary files /dev/null and b/Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/elephant.on.airplane.with.contrail.imageset/untitled10006Group21@3x.png differ diff --git a/Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/elephant.three.on.grass.imageset/Contents.json b/Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/elephant.three.on.grass.imageset/Contents.json new file mode 100644 index 000000000..e3c65df44 --- /dev/null +++ b/Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/elephant.three.on.grass.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "untitled10003Group11.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "untitled10003Group11@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "untitled10003Group11@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/elephant.three.on.grass.imageset/untitled10003Group11.png b/Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/elephant.three.on.grass.imageset/untitled10003Group11.png new file mode 100644 index 000000000..f18ac92ed Binary files /dev/null and b/Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/elephant.three.on.grass.imageset/untitled10003Group11.png differ diff --git a/Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/elephant.three.on.grass.imageset/untitled10003Group11@2x.png b/Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/elephant.three.on.grass.imageset/untitled10003Group11@2x.png new file mode 100644 index 000000000..d738223a2 Binary files /dev/null and b/Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/elephant.three.on.grass.imageset/untitled10003Group11@2x.png differ diff --git a/Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/elephant.three.on.grass.imageset/untitled10003Group11@3x.png b/Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/elephant.three.on.grass.imageset/untitled10003Group11@3x.png new file mode 100644 index 000000000..f3688b8f5 Binary files /dev/null and b/Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/elephant.three.on.grass.imageset/untitled10003Group11@3x.png differ diff --git a/Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/elephant.three.on.grass.with.tree.three.imageset/Contents.json b/Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/elephant.three.on.grass.with.tree.three.imageset/Contents.json new file mode 100644 index 000000000..4895bc2a8 --- /dev/null +++ b/Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/elephant.three.on.grass.with.tree.three.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "untitled10005Group101.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "untitled10005Group101@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "untitled10005Group101@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/elephant.three.on.grass.with.tree.three.imageset/untitled10005Group101.png b/Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/elephant.three.on.grass.with.tree.three.imageset/untitled10005Group101.png new file mode 100644 index 000000000..d0b216570 Binary files /dev/null and b/Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/elephant.three.on.grass.with.tree.three.imageset/untitled10005Group101.png differ diff --git a/Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/elephant.three.on.grass.with.tree.three.imageset/untitled10005Group101@2x.png b/Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/elephant.three.on.grass.with.tree.three.imageset/untitled10005Group101@2x.png new file mode 100644 index 000000000..a2beeca6d Binary files /dev/null and b/Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/elephant.three.on.grass.with.tree.three.imageset/untitled10005Group101@2x.png differ diff --git a/Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/elephant.three.on.grass.with.tree.three.imageset/untitled10005Group101@3x.png b/Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/elephant.three.on.grass.with.tree.three.imageset/untitled10005Group101@3x.png new file mode 100644 index 000000000..a65f103e3 Binary files /dev/null and b/Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/elephant.three.on.grass.with.tree.three.imageset/untitled10005Group101@3x.png differ diff --git a/Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/elephant.three.on.grass.with.tree.two.imageset/Contents.json b/Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/elephant.three.on.grass.with.tree.two.imageset/Contents.json new file mode 100644 index 000000000..acbad6203 --- /dev/null +++ b/Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/elephant.three.on.grass.with.tree.two.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "untitled10004Group111.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "untitled10004Group111@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "untitled10004Group111@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/elephant.three.on.grass.with.tree.two.imageset/untitled10004Group111.png b/Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/elephant.three.on.grass.with.tree.two.imageset/untitled10004Group111.png new file mode 100644 index 000000000..9cdf48308 Binary files /dev/null and b/Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/elephant.three.on.grass.with.tree.two.imageset/untitled10004Group111.png differ diff --git a/Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/elephant.three.on.grass.with.tree.two.imageset/untitled10004Group111@2x.png b/Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/elephant.three.on.grass.with.tree.two.imageset/untitled10004Group111@2x.png new file mode 100644 index 000000000..6c2b9bb9c Binary files /dev/null and b/Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/elephant.three.on.grass.with.tree.two.imageset/untitled10004Group111@2x.png differ diff --git a/Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/elephant.three.on.grass.with.tree.two.imageset/untitled10004Group111@3x.png b/Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/elephant.three.on.grass.with.tree.two.imageset/untitled10004Group111@3x.png new file mode 100644 index 000000000..31cc970d8 Binary files /dev/null and b/Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/elephant.three.on.grass.with.tree.two.imageset/untitled10004Group111@3x.png differ diff --git a/Mastodon/Resources/Assets.xcassets/Welcome/mastodon.logo.large.imageset/Contents.json b/Mastodon/Resources/Assets.xcassets/Scene/Welcome/mastodon.logo.black.imageset/Contents.json similarity index 72% rename from Mastodon/Resources/Assets.xcassets/Welcome/mastodon.logo.large.imageset/Contents.json rename to Mastodon/Resources/Assets.xcassets/Scene/Welcome/mastodon.logo.black.imageset/Contents.json index f41e77ffa..3018f4b9a 100644 --- a/Mastodon/Resources/Assets.xcassets/Welcome/mastodon.logo.large.imageset/Contents.json +++ b/Mastodon/Resources/Assets.xcassets/Scene/Welcome/mastodon.logo.black.imageset/Contents.json @@ -1,7 +1,7 @@ { "images" : [ { - "filename" : "mastodon.large.pdf", + "filename" : "mastodon.logo.black.pdf", "idiom" : "universal" } ], diff --git a/Mastodon/Resources/Assets.xcassets/Welcome/mastodon.logo.imageset/mastodon.logo.pdf b/Mastodon/Resources/Assets.xcassets/Scene/Welcome/mastodon.logo.black.imageset/mastodon.logo.black.pdf similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Welcome/mastodon.logo.imageset/mastodon.logo.pdf rename to Mastodon/Resources/Assets.xcassets/Scene/Welcome/mastodon.logo.black.imageset/mastodon.logo.black.pdf diff --git a/Mastodon/Resources/Assets.xcassets/Arrows/arrow.triangle.2.circlepath.imageset/Contents.json b/Mastodon/Resources/Assets.xcassets/Scene/Welcome/mastodon.logo.black.large.imageset/Contents.json similarity index 69% rename from Mastodon/Resources/Assets.xcassets/Arrows/arrow.triangle.2.circlepath.imageset/Contents.json rename to Mastodon/Resources/Assets.xcassets/Scene/Welcome/mastodon.logo.black.large.imageset/Contents.json index c59347e9e..fefc19832 100644 --- a/Mastodon/Resources/Assets.xcassets/Arrows/arrow.triangle.2.circlepath.imageset/Contents.json +++ b/Mastodon/Resources/Assets.xcassets/Scene/Welcome/mastodon.logo.black.large.imageset/Contents.json @@ -1,7 +1,7 @@ { "images" : [ { - "filename" : "arrow.triangle.2.circlepath.pdf", + "filename" : "mastodon.logo.black.large.pdf", "idiom" : "universal" } ], diff --git a/Mastodon/Resources/Assets.xcassets/Welcome/mastodon.logo.large.imageset/mastodon.large.pdf b/Mastodon/Resources/Assets.xcassets/Scene/Welcome/mastodon.logo.black.large.imageset/mastodon.logo.black.large.pdf similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Welcome/mastodon.logo.large.imageset/mastodon.large.pdf rename to Mastodon/Resources/Assets.xcassets/Scene/Welcome/mastodon.logo.black.large.imageset/mastodon.logo.black.large.pdf diff --git a/Mastodon/Resources/Assets.xcassets/Welcome/mastodon.logo.imageset/Contents.json b/Mastodon/Resources/Assets.xcassets/Scene/Welcome/mastodon.logo.imageset/Contents.json similarity index 75% rename from Mastodon/Resources/Assets.xcassets/Welcome/mastodon.logo.imageset/Contents.json rename to Mastodon/Resources/Assets.xcassets/Scene/Welcome/mastodon.logo.imageset/Contents.json index 31a9c2c27..6a0bfc87a 100644 --- a/Mastodon/Resources/Assets.xcassets/Welcome/mastodon.logo.imageset/Contents.json +++ b/Mastodon/Resources/Assets.xcassets/Scene/Welcome/mastodon.logo.imageset/Contents.json @@ -1,7 +1,7 @@ { "images" : [ { - "filename" : "mastodon.logo.pdf", + "filename" : "logotypeFull1.pdf", "idiom" : "universal" } ], diff --git a/Mastodon/Resources/Assets.xcassets/Scene/Welcome/mastodon.logo.imageset/logotypeFull1.pdf b/Mastodon/Resources/Assets.xcassets/Scene/Welcome/mastodon.logo.imageset/logotypeFull1.pdf new file mode 100644 index 000000000..1420a5d7f --- /dev/null +++ b/Mastodon/Resources/Assets.xcassets/Scene/Welcome/mastodon.logo.imageset/logotypeFull1.pdf @@ -0,0 +1,513 @@ +%PDF-1.7 + +1 0 obj + << /BBox [ 0.000000 0.000000 261.000000 67.000000 ] + /Resources << >> + /Subtype /Form + /Length 2 0 R + /Group << /Type /Group + /S /Transparency + >> + /Type /XObject + >> +stream +/DeviceRGB CS +/DeviceRGB cs +q +1.000000 0.000000 -0.000000 1.000000 2.000000 2.000000 cm +0.000000 0.000000 0.000000 scn +0.000000 63.000000 m +257.000000 63.000000 l +257.000000 0.000000 l +0.000000 0.000000 l +0.000000 63.000000 l +h +f +n +Q + +endstream +endobj + +2 0 obj + 234 +endobj + +3 0 obj + << /BBox [ 0.000000 0.000000 261.000000 67.000000 ] + /Resources << >> + /Subtype /Form + /Length 4 0 R + /Group << /Type /Group + /S /Transparency + >> + /Type /XObject + >> +stream +/DeviceRGB CS +/DeviceRGB cs +q +1.000000 0.000000 -0.000000 1.000000 2.000000 1.844727 cm +0.168627 0.564706 0.850980 scn +57.842663 25.387310 m +56.973518 20.943096 50.061783 16.079765 42.122841 15.137188 c +37.982918 14.645527 33.907391 14.194614 29.561213 14.392532 c +22.453136 14.716278 16.844669 16.079762 16.844669 16.079762 c +16.844669 15.391525 16.887453 14.736427 16.972567 14.123863 c +17.896652 7.149250 23.928431 6.731026 29.641823 6.536243 c +35.408352 6.340115 40.542614 7.950329 40.542614 7.950329 c +40.779942 2.765934 l +40.779942 2.765934 36.746300 0.612568 29.561213 0.216728 c +25.598721 0.000004 20.679268 0.315689 14.948763 1.823364 c +2.521337 5.094391 0.384049 18.266270 0.057106 31.631596 c +-0.042868 35.599815 0.018828 39.341911 0.018828 42.470993 c +0.018828 56.138123 9.024163 60.143955 9.024163 60.143955 c +13.564885 62.217625 21.356571 63.089451 29.456736 63.155273 c +29.655783 63.155273 l +37.755947 63.089451 45.552582 62.217625 50.093304 60.143955 c +50.093304 60.143955 59.098644 56.138123 59.098644 42.470993 c +59.098644 42.470993 59.211227 32.387894 57.842663 25.387310 c +h +f +n +Q +q +1.000000 0.000000 -0.000000 1.000000 14.485992 26.452484 cm +0.996078 1.000000 0.996078 scn +0.000000 21.175804 m +0.000000 23.165289 1.622104 24.777744 3.622489 24.777744 c +5.623325 24.777744 7.244979 23.165289 7.244979 21.175804 c +7.244979 19.186768 5.623325 17.573866 3.622489 17.573866 c +1.622104 17.573866 0.000000 19.186768 0.000000 21.175804 c +h +51.953178 16.803417 m +51.953178 0.255280 l +45.359833 0.255280 l +45.359833 16.317129 l +45.359833 19.703238 43.926872 21.421368 41.060944 21.421368 c +37.892841 21.421368 36.304512 19.382627 36.304512 15.351715 c +36.304512 6.560461 l +29.749895 6.560461 l +29.749895 15.351715 l +29.749895 19.382627 28.161566 21.421368 24.993464 21.421368 c +22.127537 21.421368 20.694574 19.703238 20.694574 16.317129 c +20.694574 0.255280 l +14.101229 0.255280 l +14.101229 16.803417 l +14.101229 20.185495 14.967223 22.873068 16.706865 24.861656 c +18.500998 26.849798 20.850389 27.868944 23.766304 27.868944 c +27.140659 27.868944 29.695858 26.579786 31.384611 24.000576 c +33.027431 21.262854 l +34.669796 24.000576 l +36.359001 26.579786 38.913750 27.868944 42.288555 27.868944 c +45.204472 27.868944 47.553417 26.849798 49.347549 24.861656 c +51.087193 22.873068 51.953178 20.185495 51.953178 16.803417 c +51.953178 16.803417 l +h +74.667320 8.577215 m +76.027779 10.006529 76.683022 11.806602 76.683022 13.977438 c +76.683022 16.148273 76.027779 17.948345 74.667320 19.324818 c +73.357300 20.754578 71.693764 21.442368 69.678070 21.442368 c +67.661919 21.442368 65.998833 20.754578 64.688812 19.324818 c +63.378338 17.948345 62.723106 16.148273 62.723106 13.977438 c +62.723106 11.806602 63.378338 10.006529 64.688812 8.577215 c +65.998833 7.200743 67.661919 6.512505 69.678070 6.512505 c +71.693764 6.512505 73.357300 7.200743 74.667320 8.577215 c +h +76.683022 27.213799 m +83.184502 27.213799 l +83.184502 0.741074 l +76.683022 0.741074 l +76.683022 3.865234 l +74.717758 1.270798 71.995941 0.000000 68.468475 0.000000 c +65.091866 0.000000 62.219185 1.323635 59.799988 4.024193 c +57.431683 6.724304 56.222076 10.059816 56.222076 13.977438 c +56.222076 17.842222 57.431683 21.178179 59.799988 23.878288 c +62.219185 26.578400 65.091866 27.954872 68.468475 27.954872 c +71.995941 27.954872 74.717758 26.684074 76.683022 24.090088 c +76.683022 27.213799 l +76.683022 27.213799 l +h +105.058311 14.453876 m +106.973137 13.024565 107.931007 11.012690 107.880569 8.471540 c +107.880569 5.770981 106.922699 3.653431 104.957443 2.170834 c +102.991730 0.741074 100.623421 0.000000 97.750740 0.000000 c +92.559738 0.000000 89.031815 2.118000 87.166977 6.300707 c +92.811928 9.635767 l +93.567589 7.359705 95.230667 6.194584 97.750740 6.194584 c +100.068611 6.194584 101.228233 6.936104 101.228233 8.471540 c +101.228233 9.583378 99.716003 10.589090 96.642021 11.383003 c +95.482407 11.700928 94.524994 12.018402 93.769333 12.283487 c +92.711052 12.706638 91.804077 13.183523 91.047966 13.765636 c +89.183136 15.194948 88.225716 17.101593 88.225716 19.536619 c +88.225716 22.131054 89.132698 24.195765 90.947098 25.678362 c +92.811928 27.213799 95.079361 27.954872 97.801178 27.954872 c +102.135201 27.954872 105.310501 26.101961 107.376183 22.342852 c +101.833023 19.166304 l +101.026474 20.965929 99.666016 21.865967 97.801178 21.865967 c +95.835472 21.865967 94.878067 21.124891 94.878067 19.695580 c +94.878067 18.583744 96.389832 17.578032 99.464264 16.783670 c +101.833023 16.254395 103.697403 15.460037 105.058311 14.453876 c +105.058311 14.453876 l +h +125.722229 20.648455 m +120.027290 20.648455 l +120.027290 9.635767 l +120.027290 8.312132 120.531677 7.518219 121.489082 7.147905 c +122.194763 6.882818 123.605659 6.829983 125.722229 6.936106 c +125.722229 0.741074 l +121.338226 0.211800 118.162918 0.635399 116.298080 2.065159 c +114.433701 3.441635 113.526268 5.982782 113.526268 9.635767 c +113.526268 20.648455 l +109.141808 20.648455 l +109.141808 27.213799 l +113.526268 27.213799 l +113.526268 32.561180 l +120.027290 34.625893 l +120.027290 27.213799 l +125.722229 27.213799 l +125.722229 20.648455 l +125.722229 20.648455 l +h +146.436966 8.735956 m +147.747437 10.112877 148.402237 11.860113 148.402237 13.977663 c +148.402237 16.095211 147.747437 17.842445 146.436966 19.218920 c +145.126495 20.595840 143.513840 21.283630 141.548141 21.283630 c +139.582886 21.283630 137.970245 20.595840 136.659760 19.218920 c +135.399734 17.789608 134.744492 16.042374 134.744492 13.977663 c +134.744492 11.912504 135.399734 10.165268 136.659760 8.735956 c +137.970245 7.359482 139.582886 6.671242 141.548141 6.671242 c +143.513840 6.671242 145.126495 7.359482 146.436966 8.735956 c +146.436966 8.735956 l +h +132.073563 4.023972 m +129.503494 6.724081 128.243469 10.006754 128.243469 13.977663 c +128.243469 17.895733 129.503494 21.177954 132.073563 23.878063 c +134.643616 26.578175 137.818924 27.954649 141.548141 27.954649 c +145.277802 27.954649 148.452667 26.578175 151.023178 23.878063 c +153.593689 21.177954 154.903702 17.842447 154.903702 13.977663 c +154.903702 10.059591 153.593689 6.724081 151.023178 4.023972 c +148.452667 1.323414 145.328247 0.000225 141.548141 0.000225 c +137.768478 0.000225 134.643616 1.323414 132.073563 4.023972 c +h +176.626083 8.577215 m +177.936554 10.006529 178.591339 11.806602 178.591339 13.977438 c +178.591339 16.148273 177.936554 17.948345 176.626083 19.324818 c +175.316055 20.754578 173.652527 21.442368 171.636826 21.442368 c +169.620682 21.442368 167.957596 20.754578 166.597137 19.324818 c +165.287109 17.948345 164.631424 16.148273 164.631424 13.977438 c +164.631424 11.806602 165.287109 10.006529 166.597137 8.577215 c +167.957596 7.200743 169.671112 6.512505 171.636826 6.512505 c +173.652527 6.512505 175.316055 7.200743 176.626083 8.577215 c +h +178.591339 37.802887 m +185.092789 37.802887 l +185.092789 0.741074 l +178.591339 0.741074 l +178.591339 3.865234 l +176.676529 1.270798 173.954697 0.000000 170.427216 0.000000 c +167.050613 0.000000 164.127945 1.323635 161.708755 4.024193 c +159.339996 6.724304 158.130402 10.059816 158.130402 13.977438 c +158.130402 17.842222 159.339996 21.178179 161.708755 23.878288 c +164.127945 26.578400 167.050613 27.954872 170.427216 27.954872 c +173.954697 27.954872 176.676529 26.684074 178.591339 24.090088 c +178.591339 37.802887 l +178.591339 37.802887 l +h +207.924301 8.735956 m +209.234329 10.112877 209.889572 11.860113 209.889572 13.977663 c +209.889572 16.095211 209.234329 17.842445 207.924301 19.218920 c +206.613831 20.595840 205.001190 21.283630 203.035477 21.283630 c +201.070221 21.283630 199.457123 20.595840 198.147110 19.218920 c +196.886612 17.789608 196.231812 16.042374 196.231812 13.977663 c +196.231812 11.912504 196.886612 10.165268 198.147110 8.735956 c +199.457123 7.359482 201.070221 6.671242 203.035477 6.671242 c +205.001190 6.671242 206.613831 7.359482 207.924301 8.735956 c +207.924301 8.735956 l +h +193.560883 4.023972 m +190.990372 6.724081 189.730804 10.006754 189.730804 13.977663 c +189.730804 17.895733 190.990372 21.177954 193.560883 23.878063 c +196.131393 26.578175 199.306259 27.954649 203.035477 27.954649 c +206.765137 27.954649 209.940002 26.578175 212.510498 23.878063 c +215.081009 21.177954 216.391037 17.842447 216.391037 13.977663 c +216.391037 10.059591 215.081009 6.724081 212.510498 4.023972 c +209.940002 1.323414 206.815582 0.000225 203.035477 0.000225 c +199.255814 0.000225 196.131393 1.323414 193.560883 4.023972 c +h +244.513977 16.995380 m +244.513977 0.741432 l +238.012482 0.741432 l +238.012482 16.148182 l +238.012482 17.895418 237.559006 19.219053 236.652023 20.224766 c +235.795044 21.124802 234.585434 21.601686 233.023239 21.601686 c +229.343994 21.601686 227.479614 19.430855 227.479614 15.036346 c +227.479614 0.741432 l +220.978149 0.741432 l +220.978149 27.213709 l +227.479614 27.213709 l +227.479614 24.248959 l +229.041824 26.737270 231.511002 27.954782 234.988937 27.954782 c +237.760742 27.954782 240.028625 27.001907 241.792587 25.042873 c +243.606537 23.083838 244.513977 20.436565 244.513977 16.995380 c +f +n +Q + +endstream +endobj + +4 0 obj + 8970 +endobj + +5 0 obj + << /Filter [ /FlateDecode ] + /ColorSpace /DeviceGray + /Width 522 + /Length 6 0 R + /Height 134 + /BitsPerComponent 8 + /Subtype /Image + /Type /XObject + >> +stream +xí½…_TÝ6ìÌÀ 1tw‡”€Hˆ„…€  ¢Hˆ€-¡"Ý­ÒÝÃàó§}×½Öž <ç¼ïïû=Çç¼\³÷Zk¯Ž{Ýë^±÷9ò‘b±X‚[, ¶p>Äÿ:Ð®®T*Ó700”ÉŒMMMMLŒär¹¡¾¾žL +  ÐÄ!Qüç@e¡ÂGþèjCÏ—HÐü†rSsKk;'W7wO/o___o/Ow77'G{[kKsSc#C}™®ŽœâÿÔÁt¤*èJ$bXþ‹utõ ŒLÌ­lݽýŽ…žˆ:Ÿxæ̹óÉÉ/¦¤$'Ÿ?w6))!.6:òDhp Ÿ‡«£µ¹©‘LzHÿb}# k;KS}Ý?D }¹©•½³‡O@Hxôé3É—®ÝJ¿ÿ '7¿ðyQQqqIiiÙë²²ÒWÅEE/žæ?Éy™qëÚ¥‹gâc"Bz8ÛY™Êõ1\RÃï ’è[¸ø…œˆ p·6Ôý³j Ì@ÏÐÔÚÁýhpÄ©3)×îd>*(*}û±âË·º†Æ–¶öÎήîîÞÞÞ¾ÞÞžîöÖ榆ÚêÏß•<κ{-õl\dˆŸ‡“µ©\OGògïÏ®™[Ä…[÷³2³²²2ï¤Åz[êýAU…¡K&7³uõ ‰JL¹™ùäEÙ‡ªo -=ßû‡FFÇ''§gçæ…Å…¥¥ÅÅ……ù¹ÙÙÙ™©ÉñÑáÁþïÝ-5Ÿ?¾.Ê˺–sÜ×ÕÆL.Õùó¸Þ‘ží±Kyï«kj¾}ûVýéù­gÃ?§¢D:2c+g¿°¸‹·² Ë>U7uôü›š™[XZY[ßØØÜR(ÛÛÊm¥ +ÛxT(¶676Ö×V–æf&džûû:šk*^?˹“ÂÏÙÊXvH û!2p:™UÞ5<ÆÐWŸèiüÇT“D*·v;›šþäUymkïÀØÔÜâÊÚÆ–b[¹ü½óë×/º¹¢ ¸alm¬¯,ÎM öµÕU–åe¤Å{ØÉþì1³¸¿="C׸ü†ñUt°µ™ÎW}LþJéè›»K¸ö¨¤ª©{`|fqucS@+Hßùõ)»!<“F7HB¹½µ¾º8;>ØÓTUúøzb°›¥ÁËD"šËÉtÿná”ð´uFAUúK¹Ô÷:Í×Tûÿ>ÄR¹­oÌÕÜ·µÝCS «ë[Û¼]q£q¡@ÃMªðH`&þL%˜VÛŠ•…©ÁîºwO®ÅùÛIÿþn÷ï2ÐÕ“›[Y™ééàùo„ÈÐ-þiÛÌ6«7åÒ÷7©GMÿˆÞ"’š8ŸË,­íŸ[٠ᤠ¼@Pü¥yਸØÍ]Á6WçÇúêÞd'w5ûãFˆÇºúFævî~A¾vÆRXýà»ËÃ5¹IÞfÆÖ«ØØûBiÏc‚¿”  „¿Ÿ'09Ñ"ã iGÍþÞì…ØÀáÄ­w=ó›Jj0j5\L!Yq@Ú W#TH‚ \LúÀ+¶~|J?é$ÿ#¦’œJ žð÷Ë Œˆ'  „Tß¿9{ ’ZM~Ö<¹Î mÆnR€ ðÄ/vÿj'ò®73U¹>Ý^œd%ûÏ)A$ã÷ŸûׂˆNÛ0mJØÙá<á?àÍ"Š÷ÿ2K( Â" +•œ€ |îð¯(eA ¸Uü¡)åFFÎDP˜—sïfjbd€›µñÛ8XD·pñät,ÀÛÑÜ@ßÈÊ=(úÜ•tÊ@aþ£û7.ž>îcoŠÐÚÁEúQ÷?©Y¯´nN0\ ~ n\*p£*4¦P”¿”sýõeÙ—bœ('ÈÀ~ˆ¥†–nÁñW³_¼«ªmlmmki¬­z÷üáµÄ7Kcs—à„K7nݾ}ûÖ­ëÉ1¾ÖúêÚC÷Ó3s ˆNÍ((«øÖÐÜÚÖÚÒø­êͳ¬Ë§¹ZÍ`Êì’xéæí;™…å]S”)ämcº»âiÖ[Àµ‹1Gm Ô‘"ˆX*·r;v*-#¯äã׺¦ÖÖÖ–¦úêO¥÷/ŇzØIOÓjãšxíaÑûϵM-mBY\¥ Yy%iIŒ4:ì¥p84cp\ZFAɧ¯õ,‚æúêò²ÂÌ˧Cè0ôÞ:É,="Î^¹IÕsëÖå¤ãî6ÖÎA§¯=|ñþK]S ²ÞP]^’{ûl˜'Bk–˜x}Ö:³ÅÚ‰Z #µ%éö¹ª ÁFÐÈ–¢~b€ÕöúÂHWuéÔO+Ö4{N`âwýÉ›šöïCãS3³33SãC?ÚkÞ=¹çïꜜýêSee®ÏnǸkÆV‘®ÜÆ;*åÁ«ÏÍ=cIGmÇûÚª_?¾zÊÏÞ˜J.³H~XR^YUÝØ3¾¼…\¶–'z›ª«*)Òçwbݵ¦s`·fT—J?7u÷LP¼3Óã£=-__çÝL +qµÐ?¨ F"3—c 7óßÕvü¦²P††¾·U¿yt%ÖÏ#ðÜs6: –HNÀÜA]äD=„?}ãq’˜¦¦'GzZ«ËroÄs1ÓÛ“4-¢_É{ÚÊË¥EøùG]zü¦¦ãÏúôÄHwç§wìt5\A$µ¾ùñç² 8áÞ N§ö¬º÷Y2E <)l®Ì‚^Þ÷³9€Pïîá©9oê{Gg—Ö6èüìöÖÆÚÒìh_ý›ì䓉™ïZ~†zkž'5ÓE0‚Xj옔^üµchš¼CHÅÖÆêÒÌHomYÖù`'lz ¦"Òßµþ™˜]Ùd…@¾”ÈÕÄèÐàÐÐ`_í‹T U¤`ï†ÖÞ'/?zSÛ54µ°²Îs¤Ø\_žŸé­ÿäZŒ¯íîÑD¬â[yŸ¼’û¾¡olv™ŽƒT–™QÊÐ…è˜ËÅíD ÀA<%f|Wß3<½¸²¾‰àŠí­ÍõÕJú]îå“>6eáU ‰‘ûéœÊÎdhp ãÓã´„ów‹kzÆæ–×6·Û +ä|e~¢¿ùý£‹¡N&šsd"ç˜Guã“ä@íÐtÒÉFeñ~{@`¤E 8 ØQn­Ì ·~È>hc°«"]kßS·‹ª{Æ×·hÈbÑ`5V±>O^î\M/n^Z[][]]í}=ÈRh4‘Ž±chJî§ö¡ÙUì% Çζbm~¬«ªàò g]‰Ë©ÜºŠ`}S8‘C…n¬#ʵµ¹Þ·ŽY ‘ëÊí’2J¾õŽ-¬nb‹~©ü¨íÍÕʼnïõ¯³Îs49` i럘QRÛ7¹¨:Ơܢ Uܾ–ù¶{ŽÊ}ë "‘®‘}@bú«šÞñy*’„GŒØHzkua¼¯¶äÞ™ JZÕš€ÄØë|Qëä + +²ºº2Þþþqæ“÷­#óë +ÞÙ‘ußXžê«~z9Œú$ÆÞç‹Úçø©J–¨¼*Ð3g†Œô€¼ÓMàVŒ6Ô€.haª§êñ¹ë=²‚HÇÐÆ?)ëmË0²Ï<8 tÑ—úw/^~ý>¦N6«ƒ•·ƒ­x£!¨}hjaõ÷é•-Vh +K7ü)ksýuE×Âuå®ñ…-ØDŲ8ØOðI†_kÃUwCU”€]ZÇà >¶S]’Oæ…@F¥bcq´«2ïÒ WS™ªJUéÈíŽ]ÈùØ>º°AÍÿt¡UdèÍ‹WÕ?Õ»Òo±©Õ3ÀâC’s>uŒ"iV"î‘ߨùÑŽò'iaÎ x 1ñ½ˆEtJ¤Xjüôák×$í( Y `h}~ þùåã†*¶¬ktýÃMß Þ–¦&ú˜ªz ™žàG3° ÌDš`¸‰<#'³?>ç$ùX¨i’A¬oíw6§œ²Ž>Ì7tåæòhWC]çè + +(׆@ B£‰õ¬/Ô,l(UyÇã/Hª‹Ã /¯…9»%ðTàþ9v@ é¡Ö|Ç”ñ™´ü/}Ó«˜\±èhauögí‹kQn¦R¡JUÚ_ÌûÜ7µ +bà/24ÒQW×5¶Â³¢\Þ3:€#8†¥PÒ,<Ý,£àG̼£X™ùYóüj„‹©)ˆM|SJ±LB°ÿ;ý³£«f”ÈqÀ¨ÜZ®šd£/Ÿž}øýêÑu8ábÊž¸É†² Odp•4\‚%S‚AÈ)Ü¿_Ûs}å÷c\wmR‹e–G“r*ûfÐ à€O‚JÛZž›_™àQ¹6Xy+ØŠÇ cê™ðèëÀâ¦R;³j èHË´`{K÷ø:ZŽ8wù£IÙYe”ÀÉ‹áÒÓšyœ``©C£[¹±0Üøêf¤Ëž•S±¾M`rþ·Ÿsl—*ÌdP¢,Ccs¬¤;‚œ 4 AÇÈñø¥gµH#  6y,¤‚É/ 5¼¼áb¢SˆÊ8% Í噉©yhTfŸrsiè[îsVdè—×4f»B’áIÅ P´¡²€F½‘4mš\'tANÜÚØ€¸NŒ¾aµ¹ÐûþÖ OߌTb ȃºü .'ˆ$èÖp—„96âÈ@eÁè‰íöÑÆ——Bì Ôí²˜¸ŸÊªø1O²8ó PY BcZ¶¶¶1x‚ÖÊq”‹…µCKDÙ¸Ø •@JºéÅ¥`$@&'€àav”ôâ" â8Hå6Sý-Ù°&1ó¿ô¶o™…Óy§["¢GÒ¸ŽÖÌ ̪gT€Á‚«LÃÍ,qïl/þøx븚)ˆ lC¯•´O­ñ‘@þU"ýÆ–P +ä:Ýè¾D Œ'ˆd6¡·?õ %l,ÏMŽOÎ.o@ä ;åÖÂÏŠûÑ>~ |tÈ^¥ª¡¡©¹OÒãjpÔåß¡ äææÖ"àØQ, ×å_ð³ÔŒ×Cìô¿íwƒ\Pˆ6×!Я®a~‹ÐdE*ÍÀ„É̽Ïä"iH' 6¸Ã³r{kKHO°@ +Hú¼Ÿ¥J`%¤¨7Öàΰ­ØX[Y\XÀL”… \þú Ú…1]«à›kj7f@:¤’.Xp€ÉFAS·¼ +‡'\ ¬$š_ì†í/lB4æ%¸Ë)#€H×Ì÷\AýØ*ï%P@« ScÃC˜ü/¯±9"BP¸ÃHr§±¡K\nýõ s“™Á®¦Úoßš:û§–7AZ;$æ·–Ý ÷:ŸÞA3sÖPÑ°ÂŒk©¿ŒF,ÁÈ%ú^ù÷y64ªÑ•ÅÙé©É©Y¬,`bkÂÎÖbÿçìÓî&*Q+})Ï[&×((•±o®ÎO ¡,óK*AHâ JwŒ¹Wñ}LSVf§€ÙEöz·¦¤V=ˆsWí¨ctÀÜQ‚ªY”˜rNÿèíîîí™YÚ`(ÃÖlç«T?ZÉlÃïUnÀ’\yPn‰ +€J »4óK<²›;ÁH<“FÕÀtìX °ß^ì}{5Pµ 1tŒºWÙ¿´Wè`yf¸§¹¦ª¢¼âK}Ûñ…5°iTED<átL|’_uͳB’Ì<ÙýõÕ“¬ûY¹Å-C˜…!®©ï e÷Ž:yÄ<¨ê›˜žž™_Þ„ +0Šå…¹™i`¸­ìJ ¥1èà+%Ó*¡“øõÅÉÁž¶¦Æú¦Öž‰…5HŒ"€Ø8×óîV˜°ò-’»ÆfÂä Ù%”þ`wÓ·ªòO•_ê;~¢, ¸° 9A½ž Öw,혡¡…:X[@Ò­ õÍmÝý™Õ][¹9ÛýæÆqUÒD eŒw°ÇùÑîÆÏïË^•”}¨níÇ\añ¬}¾nOoüè9žÌ©›ÜDA¨z› +ðu`…K[a.ˆ gܸTàF3 ¯"(#¨=©…jq;펨ðsCm_Jó³îܸ~+#çŇº¾qto +Œ‹À(KŒ:–A×>üd»'ˆws¶§üQZ\dxĩ䌢oßgV–g[Ëó¯Ÿòµµp ½ú¬²¡¥¥½ghvMÕ­·±VÑÛÞÒÒÜÜ\ó³[3Œôž‰OêFX·ИKß›*Ëžåæää–”7`­‰äz†íµ‰¦§çÕ‚¸Ì:øjY7ã&ä½rf µêU^æí×o¦ç<ÿÐÐ7¾‡Â;'ðæÔ1õ:“W?FIÃ…’ÆVSEéÓ'9rŸ–”×÷h’&y«±ðœõl@bâs‘$F¸vmæ{Ý›'éWS.\H½žù¼²cŒRFÁqsª©0ÑC.Æö“ó)>uà±T&AG^UFqíÊBpƒF +ÌÌÒèæØši-Lò³Ú:Eg}ÂØÀÈ ~²çëËÌÔøÈР à°è³7rß5 Òœ +®´²$ŒR›ãw?ÓlP®ŽÖ<>ÌÕÎÖÎÙ72íIU×à@û—wφºYXxÆ\ÎÌÍËþ¶öç,zÅ·³1÷³îÝóü¼¼¼'9w/„Ò ;}›ã·ÞƒAÃqBÒŸÃmþÝÔÄØèèØÄ‹·Ÿ¼­ÿ9³FÒ+¹+–*ïE:ðž‰%߸ǵ£hIŠÊòx×ç÷RNG„;|<æÜÍ'hZ<¼@ ŒÇ‹õíÃï|ü±„å ¸™Íö7¾Ï¿“’x*{ío?~ƒ¤17„3.Ƴ uÒÊz±ÙŽp¸w°¤ÐS‘->,Ð×ËÛ7(òÂý²¦Ä g¸+z^§5×9"’³“t*ñ‰9ÿäÆ}@Z†*@Û,€Ù€äÓnhYÑ+ûÊÒŽšQñEºæGSŠ;fid”Še¬”>Lòsµ·¶´°´qô +Ž¿õÓsA#…äAb”Ú…g~ãÌí/åÒO·Âìô¤z¦X"|øösEqÖÅ/c™DÇÀÊ#82öTÜùÛÏÇ„]éÕÑÆ·Ïǧ¢Oø;cÅPÇÄ#© q¤B!L÷}yv31쨻³ƒ£³»ïñøëù]ãXƒ3àkŒ˜;@¸Ã£]äƒÚqN ¿¶¿¿½d…åKìSÚ&ÝÎÌHñ³7¡;ìl˜ÑéW¿SÂdí0Pq/ÎÏÅÅÅÙÙÑÎÒeV!×?|_¢^‚ò+×g0à$‡yØšÊõõôô MmÜCÎd¾mWµÇöÊ@esÁÞb²õ:¸ %—G›Kïb/ÔÌPOWG¢+Õ7¶ö8‘VPÝ¿ÀËB 9<(æA·>aWÖ¿½6ÝSž“æ)$mdjírîÁ‡.6É‚ÄEIWÜ ·£¤HLÁ˜œ Ó/ `)×ÏÆH¦#‹%ºænQwÞt«:ܯ±o"íeGDFžçŠ;çLpx¹`ÂE€¥`R=‚”¡r€"Ì‚ºUÐ2 !ucäËý¶ô¶ºÄÈ#©°ez“¹@›éz—ãeaÀ^TDf├÷f÷ðB 9ï;ˆdöY5œÐlËýé‘NF „3´õ ;îïl.Ä…úééëËm‚ÒÞö¡Ê(60§·W‚l ôé#£:bs>õ¨v­IPnÎ}¯x˜`g¢ÇÏ>ÑÁßSw_wL¨dÍ©f uF`p:¦¾É/;æ8 oŸj-»uÒö®Y°Ø gâ’‚å‚eÁR žÀ×Är׸Ü6‚lMI—g'ùÛË@ÇjÂÈÖ/>ëcæ¨ _›“͉ìµNðaî€à4=k~~ÑßJ½½#’šxœ~ømDµ¿°9Õøø”³ KJºø™ +¸‚ it£ь̤‚úˆ`RÏü˜Êã"“@9›u£ôE]‹À+o¿/!+ä XøY•çi&ÓÚfÓݱ”gàèè +ŒC$'C–Ú…Ýû: +~JQï@|{–êdf ‹Ž khnçHçÌ´¢k€h•Œ­$°Sî¾&ì?ü‹¾•\‚Ébƒ»by¨úqÒQ+4&ÜD=s˜ô÷=³h2*ÿöbß»«AØÎÆN?út?copÚœíýíºk_B¤+w¹\Ô2¹.”E%'Pcê˜ù¥–t/ iÂöÒÐ×Ü3~ÖZIÑÑ·ôI|XÕ¿È iGA„hA¹ñ²Ç½>q7Üž‹ "©Uà岞y¤ ÷_ +Hj nrP‚7jD–•ÎAm"`ç¯}«Ê •À÷`h¹þᙢ†$ù$ÆY £g~ÿëÈ:¬a¯„8\pÎ×\»òPì6¿ñºÓ Ä@N ž@ k|»r #ä°³µÐ_]pùäQ'Kc}™Ò‚žpvMêD €S‚öûþCoW ²Éœ×'[_¦Z«vk8D2S¯Ó¿ ,S­"嵑êLµHdà“S7!(—G¾=Nð4%¥HÁ1üÖû^0îmg‘Q% µ=~I“ªb}²åEj 6mᢆXÏÊïBa›]§µáêûöôöã ª¹ÃŽb¾óe²)Õ +¹[|^Ó$r‡ø)æÚ_œõ$JàS²C8Ü9ùÇ…¡³‹ÝjR<¨ÉDæ²Ü‹‰9+fZ +N»€°2ô¤iŠÓø/Å|ßû¡¶êÕGtŒ\OeWC˜€ø$JÄ ó€KoÀRÈ5³>÷³öÕ½”˜c^NÖfr:JÚ ô‚ èyF£ÅÞ²‹ÞšÓO[£²Ö¤·—úËïF8hi#ˆdþ‹Ú¦ÁŠ¨ü[Ó­‰nr±ÝóvÕùD¬Þ”\:à]„ܺ±5d—@ïe$ƒ•`Ê;Ø\ã± P™ÎD…üÒ;pU Á•)\ÓÜŠ¹¶§ n(zèù—s´¼ˆ¿1Ñ”—è¡Z6Ók—J»ÏF1w`+Kb#ϤgØX‚-9ca¸ãsqöõs1¡GÝì-ŒöOKJ(íæU†IïJ ï;Är¤gì´)Ÿ9Õúìœ÷®®Å 6pŒ¼W5¸BV¼ØSšâk&‘€»—‘BP®Ô<Œå+º» Ò³>výý÷E$AØY¦ÕfšF‰ Æ?Sey=?¤÷är•[<U©Aqm/t—$û%cTcMJ]š†Ï/éa6$‡i kyìÆÇ~žçdÌ *F‘Á7 ¨h¨Ðq ýWÌ~ ‘üQ€¶YÁ–ŽJH.A[“g,lôî~k^Ç<ðꇟ+<2jÍ+XÙ‘ëi,ÍaΟ¾Ü=n#•Ù„e|V¹08´=?ëI3Š}9DdUsÑMÎF’Tt,XÒ”1P4äJ“÷ïÌû@úèþTwCmPèÜŒ„˜GšæGŒF ð=*¢4d“‚) Q‚ÀǨ4P? õÃn/ ¬]FJjÜ„—ä•.Ü*ì€'¼À"£øˆ®Íñô/¬VàNKp÷õ€ú­ÀňùaßQz˜stf%ÍÐá v°O°4;1ØÛüåMAúù[#Í^!*2ëÛmÜã549׳|P3™øµ9Ùð8fÏH-@×êØ ¡WÃ+5£~ë;Uƒk°³DH$ Éêt^ã±#$³1V“a. +9í,03ŒW4ödO.å(“W¢b%lt€5(72rtºÉ¬2iõMöd kðŒ 7ý8T„ ZAcÍ%‰ ÆDÜGíÁ ؘlxí¤\ïƒØ˜­€ð)`Œ\ „,œ lfs¼.;ÊþÀ¤!(€ï@!·&ŸÄ:£ó Iѹ9%@ ëx Ž²›¨ ¼êægÉ‚0:@8¨’n‚Jg L\UC°=pBÆð#]fÒ(f‚œJ@VõN>¬›%Àig}´úwýHdàrº yZA±[À(W3=~ùÙ·Ót\Ù`2ÃT}izœæãoT!ÄF™ —{(8<:EÀ(3’Xï~èXÝ E$xCi¦›ó°`gàû¤QÅþòAóȱ‘ÇÙ—àoä “äŒ"6¸P§ ð£5™ŒëUð­*™(“[”4 Bà 4:À + „gÜ15Ó†/–‘,¦Œ'€ 12ƒÂb„Êô£K ŠÙR 0ã"=I%]a ¬‚'ˆèÙG¸Í(¯AïD´¸|Õá3t8•ÄˆtŽ§>)ožhAŒ …Eìhežö6WÉ +˜·BNPUÈbÖvSBv-Q&ÂþZÃøps`7œx“[ ß4år1À:5ç »³:ðñúo(3ß)\¿Ó#XƒÆj2ÃNìè-@Áˆp +”@Kt˜y𹵉ag<öP‚£Ã«.Úb”˜¼^ò'ž –'ZŒµÒŠ’vkð ýÌÌ°ÛPÜpCe7©Û³ŒDÔ²U;H;Tó¿é4:¶ÎnQhHŒê]i‚HjìpìLFñ×Ρ™¥5:¦Oä‘@ßìúã¦:‚Ìx_O€+ç ꡫ׸܂õ™ñºQئ9º,!ÞñD¶¦ó´G”P~3ø7”€5à Ñ9A  ETEVí¸0:lŒ×fGþf`² +½ EHzª)—-!'hÉ ˜šQõ·TpŒÌ£’9%œ€`­3Óø ÍH`FzfO¤ÐÝÈŠn®¨À=ÀŠtü˜hŒ¬ŠQóê$Hh“õ9Ñ$þìÖïÎa +aQ ÂZ”RÛøD¥>xUÕò}”^ˆ1Àb¤öô:Ë®‡ª÷ BJ€#(+3Ü… µ¥} ˆc(?òêÖûyâÞ·‘ .^Ñ<’.›7`fIiƒm}¾{œm—î$FÒ9%OPË ÷¿1‰×ÖDC.[Žß™Ý‰L~Þ ØÄb"«2°y5%0žä~%«(ð´@ ©˜;PÛ ÀL`„J 7.Ú n®T€k¬B8X,¸Ø-(ÌŽ h‚œ@”@spÈêÀÎŽbº¥ žÖ ÷Cb†÷,rJ 9A3:tôÍœbRî=}WÝÒ;49·BÄÀüÂ÷öÚXCáY¬×Á#È“)šEÂù/&'ìú2§.¶x‡Cž·fèZ XÐÂÊ0ú"ŸàèÀ2}‡“9$óØû7ð4Ñ~`ÓM½p E ºÖt膒Fø­™6,¸8‹ÔwŠyÔÀTÌÆj²#í0 ‚'Ðù)ª÷ù™ßPª­E<ø±º pWÕ`La*êFP˜†ø8`‹[¨ ¨hÕùT¹@¥'…0wÀl,äV9qY8`Û¡«8Ù[«‡j71'_ž€Õf-J ·Ô-œü"’®Þ/(«lèœZ\ßbÔJ—bé移a‡ºEFà  ă +aë Z)B¼®^'ÄV_±PØïØÈë ¶ø"7ä›/o°™¦ÕY§šóãÝúW‘ÌöD†ªÉ‰^ƒ†-3ô£ˆä°émªÿAIKŒ½Î¾ ¯Ò|\¹>,,+ƒ„•%Ô/Ä°(äðŠÉbt@ü{(jl?!ÏçY昢5î¾Z€3ó*ƒÊÁ,@´\NÀš^À•wß—a‹ø•Ë??^Ö v!úaí‰Sä#v x Ò‘ÊÍí=#ÒîäW6}ŸXTO+Áæó±Ëš…º†0: B°è +990HL¦òù5R®V¥c2³»F RË «|/PB&¸Õf)–†8?AyóÝ%© ¢}aEbC×ÓOù¦ ~à \NÀ¶EÚ&¼!†•ÁÊtˆŒûƒSÒï~,SÙC,t#iÔ$F¬U 9Âèð£"Õœ(îÈ3§ÆhmVÖy¯p©@ÍÍžqã"…R=Ã'32€ŽKPxEtA:ž!'¼`'*±4Æ$yX"¶õ±ÚœØþK¤cæ{¡¨C¹œ°›bÄbú(–±…½»ßñSïä¿kø9½*ôYÖÃT›*„F–iPx‚%ˆåžg‹Ú±#€ÅO6ç'íߣã,1¿û‰ˆ„¼D×"à2§jÅ +5¥ œhA¤kîŸVÒ-,’Ð$ŒHd›q„$]äa‚6Þ $›]3*$ÅDlPhâ >Q,$ŽåšýÚŠ'ÀòŒdi=ÁvQnŠž_Ì ¶üA <ª` 1qhXtµ#3€}'`=Í Z0 'ìlÍu–¤Xîë ìý¥W °*„§¹ƒj‘A$ÖÕ7251”éÒÿ¸Ø8û†%Þ,¬ìž\Å'üÿú…^«N 'h ‘]” ÒwŽÍm@‡¥p;ŠÅïoß·KNo5\z¤Òcc[ 4¦KLДꦜj~zŽÎJ#€ˆŠØ ^ø1:<K&qO!q²êR,}GÒüdšÄz–A—JºæXŸ€`9Óö ÃBS“bÁŒ ,—xÂî°Ô°ž°ØQp$›æg®5:€÷#YüØ…*~*•t¦3…«ðKQ¬º ¶° ¡G² +á}äçAÍ(m2Ê•áêœ8w“][F(ƒÌ: åEëó…„HbÔš;ˆD:ú¦öî^v&`ä& Ll=BÏgìá/ U!¿e…óù u=¾è + k¼æû?0ˆ‡eTaG2p^£ƒä¾æ2vœI€ˆËù: :cžÖÇksbõYS2*BÄ(âÒÏò{']Œö‚Ž¾Í1¢"48óÇxëÕØË¿‡=X#qåúDCáy_ Õ;N"™™W£êaFGHzc¬6;šM6Á\µ$FÃöQÂ^ž@£ö³°Ú,cqjÚK&Àu¢òBP=’ΠÒÕ@1Ha Ä ¶¤ À@ž@Y•bÙrm‹ÂÃÆ\ÏÛÛNFÚ›¹"‰ÌÂ'ñÑ×AvÊÞÀHbTQšÞÈÆëDüÙÓÇœx½#„±ÿ™G_‡ÀE1ä7¬G;rJ0ò%ÌS‚ˆkéçûÝ[~àÝ—^c’ÂÊžù³23ÎÃTkßâýBä·Ýtz ØQ®ôºE G¤gw"½JÝÛקÚJ®wØS}+¿sOjGè1ü@¡&a<2Sà•·}HšA±ø³’äÓ5$2S·“éúøwÒ 0B°ºAIÉNª0V'Tï#–ÔÔËìe}Œ— [`µ€ìÐ0 +|\‡Î 6 + ˜à ˜¨Ÿ И`¦ d ŒT‚ /;è œÐ 'Z^^ s6U­‚ñëXúÄg~êÛÄŠ…¦•%Zcdí‡ÁÀÂ5äÌ'…Ù)!Â˪"‰Mȵ7ªI;–âce€Ü@ g^va…QýÅ^ŠpГˆÑëÉÎn ùØoF)P~á„­§%}ôÜ‘}s·ˆë¯Z&°ÀHÀÖþò;÷¤kî—VÖ³ÀÊ*Z©–êd¢!#”Åúhb6{ÝyB“ÐWøØ™%l«zžÁHÉ߆’ŽõÒNÚÀÜ=êVYÛÔº*éYl–z39†F’ö/^½û)äÇ,2ÕÏ\,ÆLªDŸÉ+‘†Jå–drM.v `F²‡Šµ…ƒ=Ðqí ­È*IŒGÄX™Ã*ãµ2.Åòpý‹k‘žÖÆ2‰D,Ñ‘ê›:$f½ïšbg÷@ ÂèÀÇ€¸ëùê«ò/ÙòCJ"]3¿t}N ØÚç‘Ü-é… b-oªñé…[cC}}==$‰UO»•ì #e{{u¢íõÝÓþŽær=:t/“›9øÆÞ,naÇ“q)×Fª³±®„¸År×ø'ªáû‰‹µO¯„{XÉeºÖ™cЙìOêÃÉ›;ðoª ép: Ų†æ\h“žàï`n@þue†fö~q·K›Ç # ê7#ß²£Ù Qç ñPªW D à ¨hø Ñ£»aç_CŠ5¹1×A`Ö¤ÐÍB0ГZO*#Ü dÅo”ë Ø"a£ƒHj|íÝ÷%Ž9o.׿º›pÌÃŽ^Ù01·vòH¡¯­¨•S êÑA¬kdã™úðMýññï_ž\8æH/èJeFöÇo¼í#^ÿ´d÷ˆ§Q]Oª_š&œ}.ÔÛÍÅÉÑ–½ï€iJòËv’ ;[Ëãmï^Œòw³·±²¶vpó‹¼õ¦y„vÁÉËÎÖ|×ëË–4@È8~ûÓÏžSôêź—wâ¹SYŒXY¢Rs+zfXYxÅ€'ÄH”pD×ìèÅ—!àˆà;Š•‰ŽS"ý(i+;—£ÉYoÛF‰©Ëí(æ{^_ dI%ÐÂ%ëñÿÉj3(£ƒ˜½ï€ÍDÇÀ[á5óD®hÏüŸÈ# ¤³Gn ì€Ù’ + PQ‚U‰±Gb^ã„Šá)7FZ>ä^Kˆ8æçãí“œþ¢ºoš*¡@ ´ž€‘Y×È>()ýeuÏØâúÚÜÏy—èå)k{·àóOj†°ÇÂ`).+’KŒGôœhv@kt¡ã­Mu•çݼ{ò„¿“™žXlàx’}ÎxG±:Ùûµè~ZBTXHHXT|Ú½U]ãüß’p)WGjrN¹°¿Eº¦^çžµ°¡ÿR‚¬›ß=¾Jeñööõ?“œQTý}†Ž5 r¸P‚ 'ŒÊü2Hg¦É¤À’N§¤GžNÉxñ¹{b…’ôhÍ£87–4£„]<r‹T PB2F¶9<Áß\‚}Ñ3/;©Ãp!7¦à¦'h°&º#ŠJÅMPé°ŒÔû™‘1Ãpkæ —%ˆôlCo¾ëFW¸n-OöÕ½-¸#-99åꜗ­Csë  + Mú¦ +ñ±¾UÀùœmÃskŠí™ß^ÞO9vüxdüåÜŠ^u÷é¨vDѳªG阀†žê«ÿXü,ïÉÃÛBä‘ÔÜïbQëô:{›u¦X›êø\Vø0ãÎíô삲ª¶Á$'G¢¯. +_±}xzy¿°(Ž°[K½µo 3o\º˜œråΣ—UmÃóìÐ¥PÖ9ÁX£ÑRCJæH Â%Ýö¹´ ;ãöíôy%U­³k +º•›3¥W‚¬…¤±²$P‚°ž°—hWšñ* äžgh?¦hAx¦> ÷`MªqÃ¿Ø …ÿ¤ªÌj`a\ŽÑ1öHÈ­Y±2(ÿüX_sõ§7¥%eï+ë:¦—¹pë‰'ˆåN'3>tNñ¯®moÌ ·V•ä=¸—žñ  ¬¦wŠ1äå~w5@8ˆýý›å®.œQÛc?»ÛZ¿½}HïJ£9"Ó?}_@šÞâïm©ûú¹êKmKÏÐôm~#<È_±Ð_™yÒY8 éÄûlaã8¨îðÁbge)+EYê;¦Tea +"%¨x±¡cDƧBÒðƒ¤§‡{ZjYÒMÝ‘º¶¶¾±‰þÊlvg{u²íÕÕ[Íš²Ž±[܃ªþEa–…>Ž³¾²¼°¸ˆ²;¤ts”9Ï!iÌàÄAá7ÖÖ×Ö×·(l”të«+¡ê¤1'J BÁèÀå^^%Ðè€Ò€@€ ¬ñÆ«ÿÙ˜7*mh?ÃL1¤¨nét« e¦>NtT:äÆx)DRSïDþ!=ò@7©h %UžéBc¡/0ì³Y$º/–}Ó^¶©?BnÔdëët`τ͹®’˖ر#@0 ÃÒ5 P2k#ÂWøD:†Žá7ËÚ5Ó& dâ÷Îö:¦üw¢œø°CÉ,Žž/¨¥/ÀºüJe!3ž”( +}€Î©E GtäŽa7JÚ&ÖÐ6äHL˜”o:ÞPÒª€Â²1Ÿ%ü ž°d%¸œ.h™Q°`žT\ZàOð.<îsbÊnÝ Ì€ò0 «ZìKÁ/¹ –Î'«:G 1%"!F#äþ4ß^C´ô?³÷ÞµO =á¢*BàVn­Lv}ÊJô±à»‘ŽÜéÄõWM£Kšo$h—Ýyevblj„ì%@NP7vÜcÒ߶ažÊ<À ~tQpD±<Ññî~œ·¹fW”€Ñaþ¢Ì´ó…bÓ¾–àŽÜS²à GôÙA«ƒ)žè™n‚JWƒ;ÄüXÃ.¨É@731…F O`Õvåym?Ÿ-ê4¨ô©m-½ã˜i“5ÖˆJ€ˆfå>÷ëÏYêc䈬@…Nù¶7—ÆZJ®ŸpÄ{äÃ>u· Ô5ž ¦Œ×^qeÍà ÑàeÈ;„ÈÅÑÖ÷™‰¾Vª‘Z€Hjìq³¸ŽÝÃ#yWL(ËD_[K퓈À4qˆeæ1wËZ´j€ã*øÂöÆÂhËÛŒ8oÍmF lÉ +C”€ÁWU½Ä˜œ€H%`î ÉN>¬çBÝ*°ÊãPñ +S °a€…PÓ¤à‰À4(¸ØÍA#u7Ø^¤VVQ}Îô±â^6!„o\LYI•ÞVõúMíÏyZ‚ñšECÛ  Oªz& R! '€›JnE»3ßb}› ”Âoý3«Ú‰0:Þ$zž17‹¾}ŸF¼ä‡JKÀŒ`e¦¿áÕÝ8ß=oÔ"lE\}þí»=¦›€V\i©xý¶~`‘õCìJÓn¨%€Ì<¢o¾¬ý1½²ES–%M±ÑW¼úë‹ïÄzi½Ä ƒ'%ÀR%Èhe‰"+¢ÑÝ8Æ)ሎ¡]йìw̓3ümtQ@\Ûôýž¯Ï®G¹kT‚ŽÜ!$5¯²sxò0A$1´ô¿pÿeUSÏàØÔìÜ‚γ ô÷?×NØï&„Ñ•Û¾”õâýç†ÖŽî «écN’ê4A¬khí™’U\ÙÔ=06=7¿,ÌM ö6U½zvÒ‡þ +÷ƒ‚zŸ¼”SÆI…œŸ™îk®|‘q6ØÓ'òjá§Z¤¡¾þóKL÷ˆùtlß;29óeŠ4> R­ÎÏMô4Ug§žô±•ïIZdèy«¨²õS__Wþôêq;ÚÕ0OÜ|QQW߀Të>ßÁÒ¨ˆÎÑÞª\£=-¶‹/0U— + j[p±[{žµa¦í„Ÿmµ555¸¿},¼*6VA$Á³lZæ³·ŸÚ:{z{{:Ûê?¿-ÌHŽô±3·öˆL¹›ýèQ®¬›gƒìÕµ'’è™Øºx7¿¬¢¦±½ !{»;[¾¼{þàÊé`7 õ¿Zh@¥æpòܵŒG…EÅ¥¥%%/ 3/†jÉ•ð#Ñ3sð‹¾x7¯´â[3‹·§«£¹¶¼4?=%:ÀÑLÿÈ e1s +a§Sn?@Ò¨ŒÒâçyÙ·RãOEÒZÿè%Ó/Ï 'kttÔ‰ w+m¢&€éZ{…“;%{Ì‹ý‹¨VÊfdPA0‘Æ›X‚4 + h Dôƒ‰°„¹ÞOÙ)q‘á'BýÝ,ô5œX±ÔÐÜÞÃ?,&áì…ää gbNxØšHÅb}cK;GGG; ´/ü«€Š30µqõ ‰8E!/^¼pætLX §ùÛ[iD©±…­“«»§7àéîdm²wؽšÚºøGÆ%»p19ù|R\TÈQW[3ÃßÆ+@$‘É-<OÄ$œK¾ÈÊæçnk¦¯#–èê›XÙ9 $ŽŽööÖªÏîhÉÐÌÖõhH䩤óɈá\Ò©¨Ð£n¨ ~gPfVöˆñÚÛY™Âlµ¦>Þ^.Ž6Hcßÿ*îÅ©o`hdllld$7í«<@ŒxM­ì]ˆ^|@/ÎöV¦†úÜ ‘¾±¹“«—7räéêd«.‹DGªÇ CY褼ïr'“›YÛ;»S>,i3úoX¸±DGD HÅû½¥¨=¸ã‡dYVŒ=ϾìÀÜ’ƒÚŠ‘t¦â¸Ê eÔ<ì¢â³jÒ„[€rcªýeZ%ôdRdù8"T€TÏÐÈÄÄÄXNjÝí=0e/D"ôr™¾¡ÜØ-+7@Ywý Db‰@süÆ7i ¹«@MFmZ0 +&‚–Qn)€¨XªÎMòÚõ¹ÒCüгÊV}·€1s\ û’Ù¨\z$ µ,3Óˆš˜AÖD¶–‡ëŸ¦ìýÔé!þHmŽ³/Æ©[4íqF\WµÀ,˜B7h¨°ëiggsi¤±èJ¨½á®Qòèë0êïíkGP ½˜½ªgòA7‡`b\jìl¯ÏÖ]=á¤užã$füKph«ý@Kâ"@ÃÅšŸº?Ì ÝÙÍé€DÅÚôêgWÂwÿ÷í!þˆŒè½^ú2µ—0ª/jbº÷.P˜ªÚ=¸ÐÓŽrsi¼³"7%ÔißÒÿ!þ Л@ùMìãÁ´:  áE™éH£[ ä÷`ž!*b+½éMæ™ ‡ßíÕâ¿=‡(úҧЦèãh<\ìæ + ŒRȠ̀¶%MVçG:«žÞˆõµ–´žxˆ?RëPúJ0AÐþ¸7¨¸¦’`É @;;Û ƒÑîêWY绚ïÛ9;ÄŸsÿËÚÿ'¬Ý’ àÜŽT<@ÀŒLÑÖp±Ø¡Ï&ÏŽt×”>L‹öµÃÈpH.ÄØyxÑ>§`mŒ •FfBknm)¶éðž´ €«BÐ`M½»¼09ÐQ]šs96ÀÉÜàpdø£!2tŠ}$j@‰–dê/Åúòüìì²êðÙiCx4 °VkK³ãÝ åE. +t±Ü{¶æèûõUü›q¨Ÿ”ó£}=?G§ˆ襢m¥$ÁŸ  ~ƒ¶ŠÍÕ•å…¹éñÁ¾¶ÚOÅo_ˆp¶’ï:Epˆ?„Ïÿíz{y´­¼¤¨ä]eMc[÷Áщ©é¹ÅÅåUz5p ØÜÚÜÜÜØX[[]YZœŸžøÑÝÞTSñúùÛɧB}œ,þíiŽCü  ÿ¾zÚ:C+ +Ðø¸4ØžÿQ‘såÂÅ+·³rŸ•¾«øZÛÔÚÑÓ÷s`hdtlbbrjzrbb|ltdh ÿ{og[s}uÕ§·%Ïó²n¥% öq¶1ÙûL‡øS¡ïEG–°óK1×Yr5ÊÏÇ?$"6)ùÊ­{Ù¹…E%¯ß—W~©®©­ohllh¨¯­©þRYþáméËgy9™w®_JNŠ‹ >êîH§zþÍ™ŠCü9ZÑŽ´‡P‰(áå?s+;'woÿàQ±ñg/¤^¹q;ã~VöÃǹ¹ådgÝϸsãjÚÅs‰§¢ÃCƒü¼ù+ýÿüTÏ!þÐGßžÒG4Ì€“í(é}Mé“_úrc3+[{'Wo_ÿ àã'Â#£cbbc¢OF„Ÿ8|,ÀÏÇÓÍÙÁÎÊÂT}fìþIëÛGÞÿ2L;ÓZ ГrùÇû+ü‹ˆØ7‘õ @æ–V66t<ÖÉÉÑ‘Î[[Y˜›šÐé>=]:"yHÿ@Ð÷ÒJ»ø[§DôQ«ô0[íÂé8}’P¦§§GŸ/ÔÓ“Étuu@‡ðφXî÷¸v2#€1‚éé;“ON»ò#¨à°åÿ— ’Y_§þ‡ãÝØžï.Ió×þ\È!þw½‡ÄüÆIÕçÿ4Ø^úœåhp8ü"=Ûã·Þ_Ôþ„ ¦‘ô´³³9Ýþ2ÕßJ[R8Äÿ,tL<“òêÇùç‹šE‚@Ùù¥\ú’ï±ûÑCüB¤grýMÿ»´¿p3(7g{Þ§Ÿt7;$…ÿ 1r{X=´"ü.n5v¶×&ÚKoG{X +ÊÿWÀ‹ 0â +LRŠZ&×°ÝŒÆBƒ Û+c-e Næ†2Z8<"‘H¢#5êýî%ÞCü 1p ¿ý®{Ž}›D w¶VÆÚ>ä¤ôw±132Ñ""€U$P(@,¡÷´õ MÌm]]útÉ!þ1é˜xÄ=¨ú¹ >¼„ÉA +“½ÕÅYi§Ãü<œ°±`fl$740Ð7Ð×744Äê³…µ£›O`XL\ìq‹=_&<Ä? +b™¥ï¹¼š¡%µ=öž € HÇ,B±67ÜñµôÉÝKgcÃCz{º»¹º8;9;»¸ºyx ‰ˆI¼xíޣܬKaNÂw˜ñÏ„ÄÀ68íyý°úë° FtÑ'Eg‡»*_¿Èͼs=-ùü™Ä„¸S±§NÅÅ'ž½vývæã§%¿Ô×~x”è¥úÚ!þ™Ð‘;„]yÙ8¼DM¦Ö'‚ cŠ›+ “ƒ}í ß*?¾)).zVX÷$//¿ðyQÉ›UßÚºŽŒ·²ÿÎEt‡øÇB¤kâ|âÚ‹úþyõ_X0`úŽr{cuinjl¸ÿ{OWG{[KsScssk[gw_ÿÐØäÌüòÚÆâ÷W…MìCüc!’‚.Òßnqaa @‚>”ŠÍõUúXæÜìÌôÔÔÔÌììüâÒÊÚƦbŒcuàÓ CJø§C$5v +½ø¸¼clq]ø 6(GR™Z\¹­Pl)èÞRloÓ©w8Àqu¨üV°å!%üÓ!Ò5²HºWZ÷cjyS†Ÿ€GF"ôßwØo⟠±Ž¡µOôÕ¼­ýSKë[$/ ™qPqqØf ž•+ýå7ƒ-)áŸúP°KpÒg-?ÆçV6¶0Ðf47ˆ­Î€'• »–Û[«“í%—üå„ÿ ˆÙ?¶ŸOú¡®s`|viuC± r@ƒã¢'pUÍ`»½½¹¶8=Ôñ1[óuõCü³¶`jïsâÌGÅåuí}C³ +ë›[L,D“kÀÌ·é/ –秆zZ>¿Ê<d{xÀé"±Žž©WplòíœçoªêÛzûG&fæÙT‘f +€“¢€­­ÍõÕå…™Éá]U¯ î¥EûÙzüŸÑ‚±•“OHô™Ëwsž•~üZßÚõ}`xtbjfnnaqqq ÷üüìôäøÈàîÖú/ŠsÓ/%†û9[Êÿ“¯Z⟑HG×ÀÄÊÑÃÿøÉÄ‹×3¼|󩪺®©¥½³«›þp¢§§«³½¥¡¶ºüÝ«§2®^8æçfKG9ÂÿD"‰T_nnãèîv2á|Úõ»÷³<+*~U”ÒÞCþã7/''D‡z¹ØšÉõùÁÿ(0LèêËM,¬\< =—xæì…‹„ ØŒ‹‰ ;æïíæhkalðûM‡ø߀X¢#Õ302µ ÷c]Ü=<éÿ&||¼¼<Ýœìl,ÍŒé/ þÿÿ~þ!þDÐ5]]½kddlbjbbblldh ¯'Õ+8äÿ¯A ÝÅLe<Ä?ÿ ›ðü +endstream +endobj + +6 0 obj + 21412 +endobj + +7 0 obj + << /Filter [ /FlateDecode ] + /ColorSpace /DeviceRGB + /Width 522 + /Length 8 0 R + /Height 134 + /SMask 5 0 R + /BitsPerComponent 8 + /Subtype /Image + /Type /XObject + >> +stream +xíÝÍŽãF–†aÍL»ÝvÛmc +†/´ðÊÛ¹ÞÄ\/½çE¾¨§O)J©,g•ãY| +)’"õ“e_.Ÿ«ßÿº,˲üüö‚€Ÿþ™ªÿùŸÿ¡þøㆆ~jõÓO?Qù媶m»\.ÿû¿ÿK]w–eY–wåÿþïÿrñLJ~øáÂè믿¦Æ_ÿúW*¾zA4—eù¼üë_ÿ¢ÞT¯Ë—Á÷ç¾W¯¾ýö[*þö·¿QÁEžªÿþïÿ¦ø¯ÿú/jC'ùË_Z:37rúî»ïþùÏ–eùýýï§6ÿøÇ?ÖËó °½|S¿Ò‘W㊫7•ùÿüÏÿ´‚ 2HÀ4'ˆiAˆºêzãÏÆñ‰,Ëò|“@åÊO=À˳¾ßp.ï™ÄEÕOÞ8²Tµë³rCÔ²ÈSÓ‡ì´FkŽ| +q½^/˲¼ ®¨¡ÊÛ¹ëõzò+©ååÝ_ß|ó_åŽP±—bëMÿñÿA½)ÃjA5³j`°6cgžNSï‰Ë²Üeï +Ï 0hŠìÅÄ+Ìò¹à“B;dLJ8õX®ê@Y5WéO¨¦šnO„eYž. +à °½Bí\>wÇÇ‘ƒ.²jžº9à$ç±Fšc8x"œÌ㧉½7BËg-‡5ay¢Ÿ~ú‰7–¼ ÈÇêëqﯗw…‹¤G¶;ØÌ•–BeuÏø(="GkŽ2€U5 T7\îT^VðG¡a€¯;Ô/´—÷ãÇü¾@˜šÖ)GRA¨ÒC!lŽµ¢Gä°iÖDí©y7Gqñ„_¾ ß}÷uyk¼ˆ¸òâÌKoùLyp­Uë±i…Áº‡G+zT3ZS­³5‘žµæžï¿ÿþ×_%¬{Äg¤¬ö×Ë_½0P—7âÝd^k  …Çb}vx'êËG~¾Ëñq4©"£†8hš­ J&xl M¬{`yçÚY]ÿó,^³}ySí¥Ä‹ %s,8:ÿ|ù‡HË»ÒþøÿÕ¬ô`°ŠŒ„HÁÚŒ­'ͬʹóÀõz½,Ÿ¾)=snû ùòD|MM=P_zÜA¼;—w‚¯Ó©{<|Öý Lùµ¡@¨Æ¤“B5ö *Hˆ±gù’pñ¡6\”¨Ë'Ö^kë(¼7¿ÿþ»·Þ5ñÝ !ÇÈcgU²Á:ÅC TöPEnì´Fš ý•=Ô“êàä@PvN°©Ë{ƉíM¡>3šŽÿSÀËÃò净 +÷6ÂòøW|_ýµºÉky© À@ù¤ 6Xï5.U{È0P£5AHÀ4¬¨ûîÀåÝòÄŽG‚ÈSøöiý¥ý§çQ¨ÖïïÄô¿—8â‚6­7eê”Õ +jPÍA'#û­ÕØ3jc¸«ÞüZuùÃyå·‚ƒ‚j^ž®þk¸z¬ ŒÖÝáçgòŸæ8p ©5•=V%D®èa4ö§'aÔš6© à 9á@v”ïK¯×ëú~齩G$Ç«ñXS^4—§ãî@eƒö9j@ź;¼|”¦Ê£“ƒ%›Tª›=4Ah줂€„ †Ô 9Ò$À@=vsŒ;ªÉW¬Ë{pæî€z¬½;¬ßÞGu‡×Ü|ÿý÷õ.ïJ;p6­£ô'Œ|ÈŠÙ´6­“&HPkÊN+ V¬ª­Ùx=Áú~é³p|4—7âg‡åsñÛo¿åÿÇÁ ¾p¨"GkjÚY9ÀZM{@@Â]X +@¨Ò“0U5[?/Ÿã§˜§oóÞ;O(8ÿAæ£4Ÿ>ý¿ÒÍÖ&|Úau'Oq¨È;®åÂOET9d 0[eŒ¡Vœ Sã£éIí=D?'1jpU¡þ!¶m» ~þùgªüêwråÙ>nmþ£9íá­ íÚòDõØÕü äIyP#•ýp½^/ï[ B¶VÛÇúÇë {„fÚù,ÞîÝá×—}ËƃðÙ¹¾l?< +£œ``Y^}Ó2׊1`šb¯ÇZÙcE‚lÖ6[a”þ1c{¯½ÛñT|Sí:æ?%çÍ‹Cß^Åœ¿œmÛ6^AÔË`›uÞeû÷Ú_ÊM¿|ðfq†Oaû¸Ší%ä$‡‰Êá š ÀGåRÛË ì *¶—&\Ñ+ù=¤+ªÜ _üß ·žW/êâÛo¿Í¶=íPrŽQ+ŸWÅ>§Vôp@Æ?üàó}æþæ›oØ`B°m €Î0γäxµ§“SKÛǬj'›Gl6<ì4*¶—Ù²êOƒ#ˆ#Í„ã˜ÖcÓ4AˆÚ¬9ì´Š ÂM ¡²'•óÜ üû¥O&ÿÕ ¾ ùꫯί= rîàŸ®a¼ËpBrM èüå:Ú‹âǯV^¾À«ƒ &€ÍfÏžèz½¶õÃÆmóo>ÃÊ‘¥ÞgmÀ¼£`7*·3l*Ú¦F{‹ÎK†ZÕ^JT½þÌ™bƒ© À@Ï‚ZñÒÈÙõÎOêkä”–[KE à¤Ê-ƒ—ê³N T$ 9A4A5 áaÌžú蘭ÍØÙzŽ›M=¥ÛkÄËï³äõR_³97šºUܼ¨÷_,w©Ï='mÝZ·°VÔMÍà<ßìŠoù¨ûŸÅ™-3` Œ§¢>÷ÌÉ;Oê]¶m»¼Ï¢ņÁ}–Ù-SõQ¶–×ëõò‘{ø“©«mÕI9i_/WøœKð°ZÓ_ß¡å´ÏögW³3A!ý ^ìÿéKf/%j5^úè¡j=ÇÍhýiŽaj|Ôë6 Í„ŠN^ƒcDýô\/ªjg‘çg>o©ÇX„*ÎaÏØzå?ðý÷ßóAž¥Xi§©ÛYóIíR†¶®:çx Ç¡â)PïÕ>ødÚlRÂÔø(ï©ùø~Œ›Z±÷Ú€q·¿oѽ Ö=ÃJ©ÛCê1w¸X+"œÄ`>}pk`ïS9+ÀHê]ÆgÑžøÉ<»ñ¥õxÕ¬Úc¶Fk>†I@¨jj“ÎÑAf«ÌÖ¦u¶&ìÉ1âXƒP4ç õ15j“Õ5nŒõ$Ï[·{3Ÿ—³Ñ=ÀÌ 83„{ø~uïŽæ*Ž±[@˜ª+­Yuþ“7Ç°y Tucxá{¼¸æPŸeÛ¶ËËÝœÚÔµcÜ<{2Œ›JUÍ‹S] +¼ >Æ·=«±êTd3^ÏW=oÌ7*œ“\ +Êq¯N®±«ó$3„=^‘nªçd&LPm’A˜â¡laµw÷<‰i­AÔpF½X¡î©Ì<¥™€äq‡Ü¼œË×™Y2× +Ñš7e|]õ]Nž–Y¦™Âž“+ªxýRÑž]]¹=Zñ(Í´ó)ؘ{'g<0PA‘a ŽöúGu¤Ù­©ÚI¡J +uÂ`•™º‡o$¾šq°¨r-µ"¡šv‚~Ää 쩃\¯W?Ý{aNX†ÔQúÇ€šåçeåîÉ0DaD?çñ¼.Û¶ÕßDxî ì­e¯TGš9ü²ý^|ò¢Â'Åœ ¸Š=< +ƒUfê&Ï€ÑA¹œð^œß 94Ô¦ÎYÑ‚È ì9~ô€÷kžŽ›Ç îÍ–~‡!=¨Yµ'™‚˜ +„×`B‘E¹¢‘A›VÕt‚€Ü›ƒNÎGÒB´æ6²5á¹>lÛv¹Óx|ÇU4 aÏøhí!ƒÐä‰àB=n02!AfêÃê÷ u¥L 0Fd‘E®¸zÜÃjÛjC'HÀ½9¸AÔ{Ó½Ø`bº–©6²5‘™yÕ|o ˜„=㣵‡ B“'v¸ßéÝÜeYf–™ú°ºxË ë½‰gDu|WkâfM0ªÌVÕ ›Ví峎Z?MFµßlÖÄد'Õ¿ñxáz­ÕØsg3nަϑŃfÔfrB´6„‘ýŽ·6vÖ­‰±çæÓ1 T™­ª6­ÚËûõã?»„Q]Ù­‰±ÓÎ=ÿüøXîâ*¬ÕØs§‚ OuÞïQùÙÝ šQgKNˆÖC„‘ýµ6¼s£ú}õÚFVÎYë±6¦5£ö“A8ÐЄŠ1 f´æTÆ$`/OMÐ Øóþ>µmÛeGÞ‡pg<¡ÊTFôÃ@mj§¹V¬{ÞTë]ö6©NEá@@*ƯWwÉñª×„:gÍSÓt‚€` Š ØÀÝÁ¯e¦~ùå—ñgt1 Ô¦všk…Áº')ÿ.bŸ/Tœ®"Ü0Å­Š¯¸ ǘ„©úÙ:¢„†N‚& ÔªöŒ™*rS;É0P›ÚYsµ×u€Ù +SœõÒqpæ¦ÓŽµ§fÑBEÈ{Ú£g.AdöI~ûí·úï[ƒ¥@š0P«Ú3fªÈMíäsW°IÔ{ïëlSu€Ù +S<‚j~Œo´ª:gÍ¢„Š‘÷´G[ó$ µº9O$ì©ÆL¡É¯N1}igY@®ì±Â`} fYf*˜‘+z@8PÔÓÎ=Ó³wy ?8ì}§4N8öœ4]°v’A8ƒ;ÝôjSg¨¹ªO­©,k°ÂW\½JLGI¨ÒI@È•kÌG'W~}+Yf*˜‘+vH}¿:Uß=NwÚ8m5>:N2Ž¹Ùã$îä3ÎlÚzU;É œÁ¿ù‹ÏÞ¦¶µ´¦Òéþ±Â~«j®ÒŸedL–ªÒ™Ð¤ŸªtŽaäCV‘A* TЂ5ÒL{¨ Œöúƒ0¤VÙç'åtb*¨Uëa0P‘p€IDÖ˜©uª\µmÛåå5BÍ0IE‚2ì^uA¾á­²¹DΫX¯È‘fB•Î„&—Vn‹|ÅAÀÞÚ©ÃȧfƒA* Teý±žÍ£ŽÆK™3PAíõ«†:Ž?î!ƒ|³D=溂` "ᓈ¬º”™ +‚j†o¹s&T9íôh3œW·“,2‚ÐÐ)r¸ÁðíÍM,¨"ƒP¥Ç`E½¦ ÖΚQ›5£6É M` Š Më¤ Â È¡in©Ì Ö„çISOË6 Íf‚Zs´mÛå#VºÈ˜@ wˆµnm¸T•a|ùœk5ÎLA5ïa d0ª¸y¬ÒãHçA½œ§©³ÕŒÚl˶“ª5 œ„*2 4YJìydž¥™pRÛLg¨m‘½f]­9⬠ã\E›Ðf­KUÙÛ<X5¨æ›œí®Ep<žGa "¡I?Ad$àÞ\ýô€ÐÐ BØ´ªæ8ßYeÀñ¹´'‹ïñ°Âù½Øªfepœœ?§eû‡\¥©bä8›=V0† +~|§ÖÅ•Q{ÆgŸxU ·Áz^žøº®L›Ð¤Ÿñ`A2êÆ×̪²,j®êøcŒ!œÓªš#Û\MH•©dÓú™Á`U{š¨{XÓ'U9¡†Ì|óé;Æ»8 Ö*#ƒÉTÕã$cœy:CdAC­ª¹Jÿ¦xTä  C­ TcOss@Ãx`°Š,r3í<Àx¨òÐŒê›:O$Ô÷ìMؾü™bYWam¦ò™.h§•õRï Ù3¿ÓRorK`°ÖÕåmÛ./öFŠ™EVäsqkýôÁWd T;Ðf¾‰Z®lµÎ@¹¹¹% “ƒPg«¹ªýYQ¾–9P¬öú+ö6¾EiÜøcu-Ç×[Ÿ”sº„íÔʹŽ#utsNåhÜBœêæ°: ™Bc§5Z³ò!* TŒá^uA2 Ô“öïõWíí7Ž¯–ÎYk=”SÛ¶]Šé-é¬2[•ìIÎ<ãÆß”I‚y¨ª÷GðZã¥Áê@ÓªúQ]lLý«B_ìV&¡ÖU7£æ^CLç'€Ð8¸½<§ßZ+O„Ì9†{ÕÉ0POÚ¶í2ÓžÝTV”àÜã?ÐpfGfÁ= ïÂ{¾^x³Ã©ÊŠ TP‡GfÔ ³¼ +üXísßÃÈúqª±Š™©ªÓfçÔSu6´¦Æα禺HÍhMŒ= †T$TÓÎÓñµÓlEžºç§vȦ:¬ÎV3Z3¸NRïÅl"WéIسw_« Ö\l³—‚˜þá¨üó<Îöëõzùø’¶?ö´?Áj³©ÁÍiG×ëõ²c<=| +…!žHM½ž1]ã¶m—ÛþCÊæ¡æp\©õ³CÍpØh:çtWT¼3áüáä!WYËtZ¤?#¦¥joL&Õ·OS9I|Ž¬ÂÙ¬aÓ +†Q+>‰{wð¡ŒDÍM{Ȧ@ ¨Yô€PÙS«ÌTT3h‚ ½Üð³5j“ ÂÀ@EÂ_‘ñ¨•gŽ¸˜sáÝ{¿±Ç3‡©Û¶] +&¤ŽŽ'|/dêØx7¦VÕ|ŽWö!“€€Ô,–ºùªëõzyy_Wt›“&â™Rµ÷ýV—3¨qï…\ž*™Çid¤êaÛ¶ËË®£VmÎÖ¬¦·øf{YË'¯5Ú–G} +o­žŠÔ†NÜÔz:»µÎBL›T*z@™©0PA ÷Ê‚*2[£5U;“ TîÂ" É vh‚WS²HŒ=â-Äc—ͬŽ™EF’ÙrêM Ë" ƒ€Æ€ ®$Ôæz½^žÁçèê²Âg°«ó>“Éë‚ÎI… +Æ€Ádœ¹RMeg¦‚ ³5®×ëeÀÆP•ñ*OÁ)MEÖÈä AŒAõ·2[Ã<0P_¯Þ¾³I''çÜà !ˆ/BsS«S šãäíؽä÷¨u~ªÌµ‚Ko9Ç2^µi¦‚ š+úe¦ÆqS­³5cÚß:mZ›ÖI‘a ªæ»Œ Ö¦<—jê`µ.{/–ÔóJf꨾v°mÛåß± šfÖžhF›ü•n¾²=º9~óÈL ž&5êO$÷jûJmu6ÛJÕž#Aªre»×ø…a6™5ê¿ Õø¡{xãqïç ¿·ï½×—k±"!|EGÝíLš1=pw©Ö<Å“¥ÆɵߜöÀñ²yÔ`EB÷˜­ÇÂhì·ÇZÑÆ0uüè¤Â@} w½ösæŒpÿ½0oH¨ØÛ`úa *Ó6~[Å̼#´ÓøauÕoäxÛ¶]U¯W®ÅzŒ·— ŒÆÅíÙ;(w¹^¯—[\]ãi™mnÛ©:ÏÔËòöŠ^e 0gTdˆ9Þªš+¾ >€µP÷LåUm›‚’ Ö©ƒ‡Ä:ƒæžúè4¬qWS­³51öàfgrÂãÑ¡ŠÌ£ ã›o¾ñÊù¹¤ƒù©ÑšÇx“FU¶PÌÔF"/1ù½×ÝMuEmhÖGýöŒ¶©#fáõ¶m»|”­urk´fûéaºÍm‘Ìg®Ï£¬zŽUm½šn¡xˆm,ˆ‘ÞI¯×ëåQ¼¾¨{Úª³UÈ7;¶‡€¶Tk"#_cœ¶öÔM•w‡¶êºÈ”¨ <€AˆÖ¬êCf«È"7Ç ¨yOSsÔΚњo­‡ñ¬¨çÌø,ꣿ7¨Gðv.+_Â'±.bo{òÝZ]9Ax#~NlûŸ5Š ‚È7où¤¦·¡{å:yscb¹m›;||{Ïâ´Õ¥éNÛ3Ýò*ó€óœú6u¶Æíáæb'R™„H3aO@ š €ÆPÑ ƒ5hÂ@¹Ig $œ‘Á†ZApÏïáè´,B„6à5ø&?ߢ0-\¶:jj¦¢6Îi•Ù +ƒõ¹Üf†Á +BäÙÝüDÆMŠ*& ÞöóæŸð\$¹;°>#¸ªÈM¾ñKØÃâ 09«  ~îxLÞ 2-{x„?BñÄ m€Œ„ ¸ù|µÀL9®×ëeà†Õ‘f+=^N×½e]WÄí`üéä"S<Bcg­aÓ +ƒcz@PÍHs@ˆƒfÍ¢Œ!ì±îñQk´¦‡¦¾=à=!'?<ÚÆcìÑë_¹àªUß%¶uµfN¿¼'Üáb6šÖÙšÉ +Û¼çÕywp涸M+Ü u¿’I83XÑšõ®”¥‚Á Á§öy"Wa°yãªyPG, 0õY<Ðá*@aON¿Šm›.Õ:ëÅá1LNb#©Ú[v진hÍ „Øk¬' ÎC«È 4t‚6© ¡ª5ŸTO€ëõzy¹€Œ¯‹6sk¾éŠêi³mÛåßñr¸yË`Z@ 7ÕmÀõzåá.uu5ßk<^bN"—ô×ã-u*+5Ô}Å ‚·„†‘ „KñÔVôzu¥fª«>ÀVQ0 îð½ÛÊcêæeE0ó(à`@@³OÈfP•ÏÁ£.n$Äɪô¬"ËLÚ4×ZÙc­ZOkbì©xTfêT{(MC­ €ÀΧÞÄ`¨"»¸'ÿSL/ÁAp½7Mo.ë<‘&„ªö$'p•àfJ8É æ +€€ö^w*Ÿõ‚©@¨ÒãñzåÅj۶˿s~kÕz¸5P«6 aW°‡½;lÃJï’{7Ç‹i Ç«¾/\lÕt’ÖŒWîpüãã´ÐõÞÄÓ¤6îÉ6C¶™B5þwZŽùGG{Úül {’P¿Yr#UÇ“E>ps@´‘4A¨ì±Fmš­"ƒ€ƒPïTJˆ±'x„†NîrfÏØןüSn@­â¤¢ÞÄ÷]¹ÀÖËÞâ®Â +0Pa ž”ë\u]¼e€üLH ^‰u?<Œ;/¿;­Íºªéz³ì(“°ÍÔ׫Ga*kŒ3«v©ZGüîp½^/¯“Ž¶ßêzkf½þc:Ô\[<\Ö +YýbἶT&„Ùª¶“¹¹xwÈFÖÁS7ŒXDdÕ¼§Ž©­ zD` Â@AdPÑ)òœÔ>P8¡Þ«€ÚxRÕ—Ìž›ÏnT×h¶6cg^ŒMnRà´wA+žˆ9Eë¥>o 8 +™–u Tx¦±êLJ,‹ŒDͬ‚úF\Q­SíÎâ'óÙå‰SŸÈ­µŽXµë_´RïÂâTÝõòÉŽbÿÔIêv’AÈoâî@eq* B“΄‘Y:Ah줂71 „fì¬={9ì´NZÖ“¬„jìá},õX}mæUsÌYdÀÁ´Ù3žóî´,H¡ioÀ¦|î\¬¨ s‚и¼ä³U¯7®¨öø|5ýŒé`ë”Ûü,¬QYñ†„ÎCú.ÜSÚ ™ÉÇ‘íiž‘I@ ØË°iã¦èA5#Í„êL'M*zD*0PEáŒ3#¹xzÈêI²· #½Ò~{ÛpF»>ðc.ç0íâSŸ5VÚbžé믹ožY–ãEUÛ$›ÖxýñâR*Ú¾R[ÚJiræ*çÏH. í8Þ‹cD=6®÷ƒA@B88ßøÜôÊ?ØžqÕ »ŽlXIPkž|ë¨ééÚ&<‰-¤fÙ„ð˜,k°†M*ö*SãCӨњ¢Sfªj®ÒŸô€ð–¡á7½*¾…º\a¦oäFž´Û¶]^~ª¦6^`y"Ôék0ë5PA@½\Њ„§Ë̾â|šðâöÓO?î¬+\iÅÍÅÞ—c8"krý†ð¬”©óDjØlª±ç)ØT± +¨b<Ù~ü×â]=uºœ¡Ö§pKöøh¶Ç}[7’l ÈA„& ¦cì´î9x´>TsL;Mi4Aˆ4šôj=àË¿a)‘‘<”àÝõ5˜ „©ºê¬ôØôq}ÛÛT^SÔªmOÝØlcFíWiª REœ|vÚœbfêÓ×9dºÒcnRÓæaÌëïhÔ›“´õŠk¬.µ_ÕYd$¨5ùøp}ëx«£ªMÞšQ·<˜„Ñ8Ï]ŸöÔiÍV6ƒ©½èhÒI[Ü0j‹L›ÖQëoMÐ#²jVzªiç,(3Ud¨7å ÔS· 7Åù@òY¤‚7Ïþ2{F}¦#?SðÖÅ”ê›UqSƒmù$ïl³S±,aÓú\mN7 öî’S٘dÕ,ÆP1}çŸGÿ(uƒk~ú†œä¾ù‡Þ×Ø$à¦RA¨8ͨ_ÿùÎmêç¡‚=¬ÎàœTÔ>Md#]¼ Së´iª%@Pͨ͛95ƒ&Uz•=T‘AЙ,{¬ªyÄ£ á..E­èÑÞ y†mOÔiÉ ¨æØ»;lÛvÙw¼©\̽GTY{ Vä)4¼ãg\Ÿ&2Ì­ûó¼zåÉ„T3¼à¨})=â)PUï›`Zªôðdy.,’ûx{‚²j®cäTç±ò|ëÌA§ÈǶmûî»ï˜§)´îÉœÇÇ–ú%Þ¸ÐVš1ìpêž ›âQ^Í šHpu7¢ÝÀxÔ 3„° M: 0P«±tÂZ¥ÇP«jnÚCµ¹—cÚygl®Z{ÚÌ6­ò8r”¹NªŸÐÅ<™v SãK€‹Ïï¿ÿÞ®˜M®/./êŽÕ¸'#ÛiÈ7‰ëm^žÁ÷HAñ¦†óTé!€pkÏ–D–MÀ8ì¹XWÐktNqæPGYœUÓ ÙÏÔmÛ.µûÑLNìUjÃÙ’·åÁ" 4d…Á:ªýd‚&È¡9‰Ahèù朜^|ÔñÎpÃêRS¬…ªé`;­‘×i^¤UÌ6PQCp¬dY™©Ï’ÙD„º%‘Ü#Sõ¸ÜÅ7ÿ`ÃØHG;ø†¯bäQƃ0Uª9¦{Ú`š ŒèaŠ‡@ÑB¤i¨õ F‚ªùŒ:žÛ3j¿KŽê$Õ^Ó†ÑAdª±GÛ¶]^üüóÏn-wêM™p ÍÉ~v&õÀô´§‚ÂT}(Ë>¬Îš ŒèaŠ‡@q¼æÛ>»›X € šÏ¨ãÉ ` h·à›ê²Ú0š ˆ B5öˆóv¸?™M/Ñ£L8†×ã`«@ŽÌŸӻñLRÑ `®õXCj¤IᤃÁy(¡¢„°iUr‚ZóÇ„¾ ©·½EX/a³V‚&bìi² ¼¨SŒ¡bfªô$è¸yÀ¶Çm·mjºR:³xýÃ_7üã+×rŒy¨Aj¤Iá¤3¯q·¹a- „M«’Ôš B´&¸ÄQý€pŒîàp¶ZAšHˆ±§É€„“øì0Þì2I‚rŽÝ‹ßê£MkÓ:å Ž´6­3Í„3Ú`›T ›V‚æ]\Ä +ƒUd‘;­aÓªš#* €õPNq j'U›At‚P=J?„»LOÝmÛ.ñRe ÈUÖ•PÕΚƒN¨È*øàpæz¶NemZgšYém°“PA@‚lZAšw¹ùG•Ü~ÿýw>ô‘Wg ›VÕé4PA„H3¡áî·µ8Ú‘ÅÞœé'€p—¶«¦òö›z,/ävV¨N;ð˜:-ZÓVÿf >#1^äÑq¿uOMØÓÐ ›T%@¨è¡j=­‰±'x„ÑØO ©aÓú£§M„\Ñ­ zÂ&5Ò$€4ApÄ‹úfa4ö·ž4Ý÷Ì?^þªÿ$³‰<Úëçý*õø +ì†ao’hh†Mª’ T¼®o¾ßüa «a4öÓCjØ´¸9 ùõ×_¹2ó¬yîÙ᧢'lR#MAªÚêîÂ< ŒêÉùFꪹg±½û—‡ìIª›‘‘ 7µ‘­9jZSµÓ\ë1Æ€ rØ´‚0ªýf«j®è‡ªšAê‹maÊ“aÊ¥¬`¤¯‹»î.n=à€ZE!Ø *[Â…ÚŸ<Žù¥—ªËZ3mÂM¬”zž+ŠéŠj§¹ÖcÌ_7‰×8 +'¹gäj­¸pQŸ…ùA˜ºù켊ž|[Â7KTwï×âüž«÷ªK9›Ìµº–q'û¼à0kc§ ¨9Z'ÍŠ@ÙZ=²Ÿ +Ud¬ Té1XAyTûÍÖhMÐUdàq¹—AÏ®,ž Q³öz¬•Zá€ZOòHdÂÆ9­{|ÔzÀ3™¯[©ç¹áN×b§ Èâ¸hð:eóìZÀ$ Èl­ÆÙO!Ó‚ù2çÖ@½ _1ñœïpÈ#×%³5ZôÀ@[ *[À`j5®ÅÉ­•=Vj=ÖNû›Ü°ã™9 Ôƒuç+J''uÜuÊ°= ‚€@@BE'a“ +‚È hh‚€1ÀlÕ˜­aÓÚÐ)²ÌV¬wáB½©¾» Ï1¹j*'ÕDÈT 'ùÛ–«È´•3[§|ÈúJ\©Ít«*WMY„NÛ¶]öñun;¾.ËT ˆ Â6€y@€/pp?¢>Œuúó kYf+ Ö»¸H­ñ³W¹éöï×HÃÔÉ[ƒŸg¶NùRz@NLžPåä‘×Þ…R›ºxÍ  ²È0XAaTûÍVÕcçØí¡4 À@Ad*z@@Âhúдó1þÕ<¬9ªººš5ö4Îy0Ìg´ÆeYJ&€ð\þR0ý&ªnÛÁªæÎ?ƒkÂt]¨«0[UsŒ¾Ã|½zëdƒÇǪA@Âhú{ŒzË‚ñÜ[Ûùç ¶ŸªL®â€¬SÓ‹çIãñbE (ÌÈ»îtMæAß\=prXåü#úA8ÆšÚ™\ªù@†%€  œ×Æï5Ôšà +à @x¯*3Pcz(ÇõÊÁÌÀ6Ô³*𰼡rS«qf7ÒŠ„ª.Å6S廇թNª[òöðt†ú¬“X1 õ‘MÒ^³n-ê9&€ 2#?¹j=µYs5í·“*3õ2PóƒaÉÖ°iÕƒ[³è!®×ëåœúO9¸Ì õ@ð,­WïšÃÁ+ß[ð¿ç×(§µFš U]<ߢŒûä<#ÔùÕ6£6y ROº¾ba¿±"ò¸®†u!dÔ™ú¦Ø`j])Üë¨ö·Á9æˆ:þõnž¬†TÕãSà—¼ù¹ _ERå´mv¢í¢Çd¶›xFî·mÛ./\ÖÍóBgm¦ò¡ZG{ý¨Õ ›T¨Ñš‰\µžÚ4[«ÚSse¿ SîðO€c +BeÛ‚ï.n^^r–@€W ¾3¡¼¯ó|ež)¶Sfê_¡{k¹É-÷W?÷ƒÏ«qwMùEýÃê~hÏÔ&„¶ Óí|ºú¡lonž SuÂQÝžœùõÜÊ9Ù“ ¸©ÖÙ*¾äyÖîÔ=l'{Û1÷þè ËmˆÙ¨Už‘¹“ýºµÊ^âšr´&ZM¨Q›5c$ ­ŽÒŸôÀ@mì´6­³5Ϩ‹¸cÛQ@BÅ]›ú€œÿüôI'µqcÄ&± ÓaÎ6n —M¨âeâEÕëó½x¡QÙêMuë¥>‹/%žW­ö/ÂÜÂÊWkpg¼^¯—'á9rhV™uäËl6õ¨[ËÓ¡få¤bWS›z^!3Ÿ¥î%+'¿›×¸ýîÿŠEÜáí y ~:§ž|¦¾0¿@k{8¼ƒpbµµpìØ!ÜÈS¹Ü@@È pUfª’ši: M¢5eg* ©Í´säδ6õLðdý„y¬þxÇ)Íñ%¸^¯—^$aض³…€ëÇ1ã›d^éíŒ:éúqNÔÝÒÜÜþ·Æ«† +ðž(žþ¿^öØ]ê"YÅÞ~˜î„‹:Õ›â™Rork§×¢º©œ9T\Ë™Ðxfä[à5B…› 5yǶm—'©O*‡r´½bÓãuý÷½}½^ÙÉ{ka'Œ7ÊQ½0NsjŽi§ÚC­{ý1°‡*²’ ˜†©ÇíÿÄr‰«„óBØ´M¢5«ôo!ö¾=^–eùŒpw Nqé ¨­Ùø(5hªfС{d?ªÈ ˆ BÕzÒ4X+{¬{¸5ðsCýáeY–ÏŽ¿ý}5û»÷c¹B@8ci„hÍf|”0†¨=c®czD†À­ŠüͲ,Ëgíà³Ãž\ A"M#ûǪšœæ+‚D¡¢„æÇ\·†eY¾¯ù£Ö= Ad:AˆÖDzPóIÓEj'êTò#7Öƒ½·,Ëò%áBÐá.h•™*3µj=4aHEB3í¯5O ð¾ùjn۶˲,Ë—ÂÿΆêuïàò8åø±ž·7Þ~* Ô3)3U{6­#÷µáGœü³šeY–/ßP§×½ñRY{ö2Ò$€p#AÙ:•‡¢öahµ™vºg¬Ë²,_<ÿñ¸ÿµ¥vé«Éä$D¾ÉaÖjìiê2 T@š M*{jEBÃ=”EX–eù³Ù»0¢>4Í ¨9¦:xè@[ª5•N#û© ÔeÍÊAÚ{ë²,Ë,«×OšÚi¶Šk,@¨jO2!h>foYúA@62ãç~e .˲üIä’ÈÕ* œÇxÂù­Uëa)¢5A ©ª¹ñ¡¬Î`™¯@nüc¬û²,*üô@ ¯¢aÓú.¹T/¹VÑ_ÑsŒ ™Ú´Nç´VcO¶jü˜°,Ëò'7½0¶ëíȵªæ×ãzÂu¤Ù +‚7ë²,Ër×OjS/õ5 4éäjüÕW_å—‰½ÿO¢¦·ª)7˜Ê*­‚àlÖ¦}hŠõ Ò², ÎÿgEsÁ' †hÍjü?ñýøòÿˆüöÛoë½£â¶R4¯¹šö³,µ:¾+­?CZ–eiŽ/›7qGˆ4 1^¨G×ëõòñ¡ë]#¸wàx#}” õضm—u/X–e9á»ï¾Ëœ/j¨{¸ìƒ äi¨SqyøVžÿöéõz½¼øåaY–eùd¸°S+z@ˆ4T›5ÿío£.˲,Ÿ#ÉE½°Ÿä"c]–eY>_~Õ?þmO»Â·æ¨¨yœyY–eù,ðÓ5êµ=Ò™3„Æ_üT¯Æÿú°,˲üê›üzµ'ƒ s­8˲,Ëç¨þÿk|Ÿ¯ñò~³'Íø Bßÿýú±,Ëò9òÁ%=Wø„óê"LEUýGÊŸòßäA×[á²,Ëržw‡ð:OaÏÞ£µ¿Íìþt۶˛ñ_ØUŸò–´,ËòÈ»ëà=½¶|ÔZ=j¯ÿÚÂûÑúø°,Ër—íãÛøü<Ýnfk´f¤ŸÂIü6A=ãÇTÆç܆ÜüO0ù\Ì˲,Ë[ÈEž€Daj|È€ƒ0âR‚j®ò©ó8yõõ×_S—eY–O€k> TÕŒ4‚'TÔ‹yÍàš/òž¶2s3~‡¶,˲ܴ}ü~iŠË;JNz@›Tä5ÜÓ&Õ\íõ/˲,¯Ôþw \oEŽ\áT›fkEjh\W­¨*‚iA>\ä ¨eY–å1¿ýöÛ?ü0~Ïß.¹ÕøÐÍž4 V$(Í„&ý ¢ ¸;P±mÛeY–eyÉÍ¥õ¦\+:AˆÚ¬Y7{j“\Ñ3ÅSX¹´,ËòFòN½›© Lå¡„=uÀ^Žt&Tt‚ uwX–eyŠú/‹ÿú׿ֿâ6¼Se¦"aÔjÍhýÇMŒ=ÙNÔ±,˲<ìæ¿,æÚÛ.Èi&Dz Td¶6vRA@BŒ=ÍúUzY–åéøš:Å ‚:ª—ë3¹jý4A¨ì±VcøøC]–eYÞÈßÿþwêyãåºõ´f“G"= â†ø=š +ÿËKõë²eY–å|šŸz¹øƒp/o |§”;ÚÍïÊ–eY–xuݶíRì}i“7ð £Ç.ûáÌT¦ü1ºý+¿eY–å-x›¨üǼQ?ó¢]Ìi‚0Úë¯øŒÂ²,Ëò~ä?mçU[íÅ_þò—ñî•Ÿ +Â1Æ€0Å]‰5¶ß½>/,˲¼C?üðƒßêÀë6×p*.@¨¸}P£ÝJ|”Zäv@…¿8Ƕm—eY–åÛv.Ô¹žs×ÈE>ÓÌ—Wü¾ì²ß¿ü‚ü?¾aÛYѲ,ËòþñTû¶§ýߟù¸AE‚r¸^¯—eY–åÏÇ’`]–eYÞÔÿP@ô“ +endstream +endobj + +8 0 obj + 14846 +endobj + +9 0 obj + << /ExtGState << /E1 << /SMask << /Type /Mask + /G 1 0 R + /S /Alpha + >> + /Type /ExtGState + >> >> + /XObject << /X2 3 0 R + /X1 7 0 R + >> + >> +endobj + +10 0 obj + << /Length 11 0 R >> +stream +/DeviceRGB CS +/DeviceRGB cs +q +261.000000 0.000000 -0.000000 67.000000 0.000000 0.000000 cm +/X1 Do +Q +q +/E1 gs +/X2 Do +Q + +endstream +endobj + +11 0 obj + 118 +endobj + +12 0 obj + << /Annots [] + /Type /Page + /MediaBox [ 0.000000 0.000000 261.000000 67.000000 ] + /Resources 9 0 R + /Contents 10 0 R + /Parent 13 0 R + >> +endobj + +13 0 obj + << /Kids [ 12 0 R ] + /Count 1 + /Type /Pages + >> +endobj + +14 0 obj + << /Type /Catalog + /Pages 13 0 R + >> +endobj + +xref +0 15 +0000000000 65535 f +0000000010 00000 n +0000000493 00000 n +0000000515 00000 n +0000009734 00000 n +0000009757 00000 n +0000031385 00000 n +0000031409 00000 n +0000046488 00000 n +0000046512 00000 n +0000046851 00000 n +0000047027 00000 n +0000047050 00000 n +0000047227 00000 n +0000047303 00000 n +trailer +<< /ID [ (some) (id) ] + /Root 14 0 R + /Size 15 +>> +startxref +47364 +%%EOF \ No newline at end of file diff --git a/Mastodon/Resources/Assets.xcassets/Scene/Welcome/mastodon.logo.large.imageset/Contents.json b/Mastodon/Resources/Assets.xcassets/Scene/Welcome/mastodon.logo.large.imageset/Contents.json new file mode 100644 index 000000000..e26db2fac --- /dev/null +++ b/Mastodon/Resources/Assets.xcassets/Scene/Welcome/mastodon.logo.large.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "logotypeFull1.large.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Mastodon/Resources/Assets.xcassets/Scene/Welcome/mastodon.logo.large.imageset/logotypeFull1.large.pdf b/Mastodon/Resources/Assets.xcassets/Scene/Welcome/mastodon.logo.large.imageset/logotypeFull1.large.pdf new file mode 100644 index 000000000..d4d478ef6 Binary files /dev/null and b/Mastodon/Resources/Assets.xcassets/Scene/Welcome/mastodon.logo.large.imageset/logotypeFull1.large.pdf differ diff --git a/Mastodon/Resources/Assets.xcassets/Settings/Contents.json b/Mastodon/Resources/Assets.xcassets/Settings/Contents.json new file mode 100644 index 000000000..6e965652d --- /dev/null +++ b/Mastodon/Resources/Assets.xcassets/Settings/Contents.json @@ -0,0 +1,9 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "provides-namespace" : true + } +} diff --git a/Mastodon/Resources/Assets.xcassets/Settings/appearance.automatic.imageset/Contents.json b/Mastodon/Resources/Assets.xcassets/Settings/appearance.automatic.imageset/Contents.json new file mode 100644 index 000000000..75da4a571 --- /dev/null +++ b/Mastodon/Resources/Assets.xcassets/Settings/appearance.automatic.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "iPhone 11 Pro _ X - 1.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Mastodon/Resources/Assets.xcassets/Settings/appearance.automatic.imageset/iPhone 11 Pro _ X - 1.pdf b/Mastodon/Resources/Assets.xcassets/Settings/appearance.automatic.imageset/iPhone 11 Pro _ X - 1.pdf new file mode 100644 index 000000000..868d8d8b9 Binary files /dev/null and b/Mastodon/Resources/Assets.xcassets/Settings/appearance.automatic.imageset/iPhone 11 Pro _ X - 1.pdf differ diff --git a/Mastodon/Resources/Assets.xcassets/Settings/appearance.dark.imageset/Contents.json b/Mastodon/Resources/Assets.xcassets/Settings/appearance.dark.imageset/Contents.json new file mode 100644 index 000000000..6ca47e403 --- /dev/null +++ b/Mastodon/Resources/Assets.xcassets/Settings/appearance.dark.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "iPhone 11 Pro _ X - 1 (2).pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Mastodon/Resources/Assets.xcassets/Settings/appearance.dark.imageset/iPhone 11 Pro _ X - 1 (2).pdf b/Mastodon/Resources/Assets.xcassets/Settings/appearance.dark.imageset/iPhone 11 Pro _ X - 1 (2).pdf new file mode 100644 index 000000000..a214d2853 Binary files /dev/null and b/Mastodon/Resources/Assets.xcassets/Settings/appearance.dark.imageset/iPhone 11 Pro _ X - 1 (2).pdf differ diff --git a/Mastodon/Resources/Assets.xcassets/Settings/appearance.light.imageset/Contents.json b/Mastodon/Resources/Assets.xcassets/Settings/appearance.light.imageset/Contents.json new file mode 100644 index 000000000..86e635c39 --- /dev/null +++ b/Mastodon/Resources/Assets.xcassets/Settings/appearance.light.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "iPhone 11 Pro _ X - 1 (1).pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Mastodon/Resources/Assets.xcassets/Settings/appearance.light.imageset/iPhone 11 Pro _ X - 1 (1).pdf b/Mastodon/Resources/Assets.xcassets/Settings/appearance.light.imageset/iPhone 11 Pro _ X - 1 (1).pdf new file mode 100644 index 000000000..2b8b869b0 Binary files /dev/null and b/Mastodon/Resources/Assets.xcassets/Settings/appearance.light.imageset/iPhone 11 Pro _ X - 1 (1).pdf differ diff --git a/Mastodon/Resources/ar.lproj/InfoPlist.strings b/Mastodon/Resources/ar.lproj/InfoPlist.strings new file mode 100644 index 000000000..48566ae36 --- /dev/null +++ b/Mastodon/Resources/ar.lproj/InfoPlist.strings @@ -0,0 +1,2 @@ +"NSCameraUsageDescription" = "Used to take photo for post status"; +"NSPhotoLibraryAddUsageDescription" = "Used to save photo into the Photo Library"; \ No newline at end of file diff --git a/Mastodon/Resources/ar.lproj/Localizable.strings b/Mastodon/Resources/ar.lproj/Localizable.strings new file mode 100644 index 000000000..c9ed556c3 --- /dev/null +++ b/Mastodon/Resources/ar.lproj/Localizable.strings @@ -0,0 +1,303 @@ +"Common.Alerts.BlockDomain.BlockEntireDomain" = "Block entire 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 in any public timelines or your notifications. Your followers from that domain will be removed."; +"Common.Alerts.Common.PleaseTryAgain" = "Please try again."; +"Common.Alerts.Common.PleaseTryAgainLater" = "Please try again later."; +"Common.Alerts.DeletePost.Delete" = "Delete"; +"Common.Alerts.DeletePost.Title" = "Are you sure you want to delete this post?"; +"Common.Alerts.DiscardPostContent.Message" = "Confirm discard composed post content."; +"Common.Alerts.DiscardPostContent.Title" = "Discard Publish"; +"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 photo libaray access permission to save photo."; +"Common.Alerts.SavePhotoFailure.Title" = "Save Photo Failure"; +"Common.Alerts.ServerError.Title" = "Server Error"; +"Common.Alerts.SignOut.Confirm" = "Sign Out"; +"Common.Alerts.SignOut.Message" = "Are you sure you want to sign out?"; +"Common.Alerts.SignOut.Title" = "Sign out"; +"Common.Alerts.SignUpFailure.Title" = "Sign Up Failure"; +"Common.Alerts.VoteFailure.PollExpired" = "The poll has expired"; +"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" = "Cancel"; +"Common.Controls.Actions.Confirm" = "Confirm"; +"Common.Controls.Actions.Continue" = "Continue"; +"Common.Controls.Actions.Delete" = "Delete"; +"Common.Controls.Actions.Discard" = "Discard"; +"Common.Controls.Actions.Done" = "Done"; +"Common.Controls.Actions.Edit" = "Edit"; +"Common.Controls.Actions.FindPeople" = "Find people to follow"; +"Common.Controls.Actions.ManuallySearch" = "Manually search instead"; +"Common.Controls.Actions.Ok" = "OK"; +"Common.Controls.Actions.OpenInSafari" = "Open in Safari"; +"Common.Controls.Actions.Preview" = "Preview"; +"Common.Controls.Actions.Remove" = "Remove"; +"Common.Controls.Actions.ReportUser" = "Report %@"; +"Common.Controls.Actions.Save" = "Save"; +"Common.Controls.Actions.SavePhoto" = "Save photo"; +"Common.Controls.Actions.SeeMore" = "See More"; +"Common.Controls.Actions.Settings" = "Settings"; +"Common.Controls.Actions.Share" = "Share"; +"Common.Controls.Actions.SharePost" = "Share post"; +"Common.Controls.Actions.ShareUser" = "Share %@"; +"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" = "Try Again"; +"Common.Controls.Actions.UnblockDomain" = "Unblock %@"; +"Common.Controls.Firendship.Block" = "Block"; +"Common.Controls.Firendship.BlockDomain" = "Block %@"; +"Common.Controls.Firendship.BlockUser" = "Block %@"; +"Common.Controls.Firendship.Blocked" = "Blocked"; +"Common.Controls.Firendship.EditInfo" = "Edit info"; +"Common.Controls.Firendship.Follow" = "Follow"; +"Common.Controls.Firendship.Following" = "Following"; +"Common.Controls.Firendship.Mute" = "Mute"; +"Common.Controls.Firendship.MuteUser" = "Mute %@"; +"Common.Controls.Firendship.Muted" = "Muted"; +"Common.Controls.Firendship.Pending" = "Pending"; +"Common.Controls.Firendship.Request" = "Request"; +"Common.Controls.Firendship.Unblock" = "Unblock"; +"Common.Controls.Firendship.UnblockUser" = "Unblock %@"; +"Common.Controls.Firendship.Unmute" = "Unmute"; +"Common.Controls.Firendship.UnmuteUser" = "Unmute %@"; +"Common.Controls.Status.Actions.Favorite" = "Favorite"; +"Common.Controls.Status.Actions.Menu" = "Menu"; +"Common.Controls.Status.Actions.Reblog" = "Reblog"; +"Common.Controls.Status.Actions.Reply" = "Reply"; +"Common.Controls.Status.Actions.Unfavorite" = "Unfavorite"; +"Common.Controls.Status.Actions.Unreblog" = "Unreblog"; +"Common.Controls.Status.ContentWarning" = "content warning"; +"Common.Controls.Status.ContentWarningText" = "cw: %@"; +"Common.Controls.Status.MediaContentWarning" = "Tap to reveal that may be sensitive"; +"Common.Controls.Status.Poll.Closed" = "Closed"; +"Common.Controls.Status.Poll.TimeLeft" = "%@ left"; +"Common.Controls.Status.Poll.Vote" = "Vote"; +"Common.Controls.Status.Poll.VoteCount.Multiple" = "%d votes"; +"Common.Controls.Status.Poll.VoteCount.Single" = "%d vote"; +"Common.Controls.Status.Poll.VoterCount.Multiple" = "%d voters"; +"Common.Controls.Status.Poll.VoterCount.Single" = "%d voter"; +"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" = "Profile"; +"Common.Controls.Tabs.Search" = "Search"; +"Common.Controls.Timeline.Accessibility.CountFavorites" = "%@ favorites"; +"Common.Controls.Timeline.Accessibility.CountReblogs" = "%@ reblogs"; +"Common.Controls.Timeline.Accessibility.CountReplies" = "%@ replies"; +"Common.Controls.Timeline.Header.BlockedWarning" = "You can’t view Artbot’s profile + until they unblock you."; +"Common.Controls.Timeline.Header.BlockingWarning" = "You can’t view Artbot’s profile + until you unblock them. +Your account looks like this to them."; +"Common.Controls.Timeline.Header.NoStatusFound" = "No Status Found"; +"Common.Controls.Timeline.Header.SuspendedWarning" = "This account has been suspended."; +"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" = "Show more replies"; +"Common.Countable.Photo.Multiple" = "photos"; +"Common.Countable.Photo.Single" = "photo"; +"Scene.Compose.Accessibility.AppendAttachment" = "Append attachment"; +"Scene.Compose.Accessibility.AppendPoll" = "Append 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.InputLimitExceedsCount" = "Input limit exceeds %ld"; +"Scene.Compose.Accessibility.InputLimitRemainsCount" = "Input limit remains %ld"; +"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 photo for low vision people..."; +"Scene.Compose.Attachment.DescriptionVideo" = "Describe what’s happening for low vision people..."; +"Scene.Compose.Attachment.Photo" = "photo"; +"Scene.Compose.Attachment.Video" = "video"; +"Scene.Compose.ComposeAction" = "Publish"; +"Scene.Compose.ContentInputPlaceholder" = "Type or paste what's on your mind"; +"Scene.Compose.ContentWarning.Placeholder" = "Write an accurate warning here..."; +"Scene.Compose.MediaSelection.Browse" = "Browse"; +"Scene.Compose.MediaSelection.Camera" = "Take Photo"; +"Scene.Compose.MediaSelection.PhotoLibrary" = "Photo Library"; +"Scene.Compose.Poll.DurationTime" = "Duration: %@"; +"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 minutes"; +"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.Hashtag.Prompt" = "%@ people talking"; +"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.Action.Favourite" = "favorited your post"; +"Scene.Notification.Action.Follow" = "followed you"; +"Scene.Notification.Action.FollowRequest" = "request to follow you"; +"Scene.Notification.Action.Mention" = "mentioned you"; +"Scene.Notification.Action.Poll" = "Your poll has ended"; +"Scene.Notification.Action.Reblog" = "rebloged your post"; +"Scene.Notification.Title.Everything" = "Everything"; +"Scene.Notification.Title.Mentions" = "Mentions"; +"Scene.Profile.Dashboard.Accessibility.CountFollowers" = "%ld followers"; +"Scene.Profile.Dashboard.Accessibility.CountFollowing" = "%ld following"; +"Scene.Profile.Dashboard.Accessibility.CountPosts" = "%ld posts"; +"Scene.Profile.Dashboard.Followers" = "followers"; +"Scene.Profile.Dashboard.Following" = "following"; +"Scene.Profile.Dashboard.Posts" = "posts"; +"Scene.Profile.RelationshipActionAlert.ConfirmUnblockUsre.Message" = "Confirm unblock %@"; +"Scene.Profile.RelationshipActionAlert.ConfirmUnblockUsre.Title" = "Unblock Account"; +"Scene.Profile.RelationshipActionAlert.ConfirmUnmuteUser.Message" = "Confirm unmute %@"; +"Scene.Profile.RelationshipActionAlert.ConfirmUnmuteUser.Title" = "Unmute Account"; +"Scene.Profile.SegmentedControl.Media" = "Media"; +"Scene.Profile.SegmentedControl.Posts" = "Posts"; +"Scene.Profile.SegmentedControl.Replies" = "Replies"; +"Scene.Profile.Subtitle" = "%@ posts"; +"Scene.PublicTimeline.Title" = "Public"; +"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" = "Username"; +"Scene.Register.Error.Reason.Accepted" = "%@ must be accepted"; +"Scene.Register.Error.Reason.Blank" = "%@ is required"; +"Scene.Register.Error.Reason.Blocked" = "%@ contains a disallowed e-mail 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 e-mail 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" = "Delete"; +"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" = "Step 1 of 2"; +"Scene.Report.Step2" = "Step 2 of 2"; +"Scene.Report.TextPlaceholder" = "Type or paste additional comments"; +"Scene.Report.Title" = "Report %@"; +"Scene.Search.Recommend.Accounts.Description" = "You may like to follow these accounts"; +"Scene.Search.Recommend.Accounts.Follow" = "Follow"; +"Scene.Search.Recommend.Accounts.Title" = "Accounts you might like"; +"Scene.Search.Recommend.ButtonText" = "See All"; +"Scene.Search.Recommend.HashTag.Description" = "Hashtags that are getting quite a bit of attention among people you follow"; +"Scene.Search.Recommend.HashTag.PeopleTalking" = "%@ people are talking"; +"Scene.Search.Recommend.HashTag.Title" = "Trending in your timeline"; +"Scene.Search.Searchbar.Cancel" = "Cancel"; +"Scene.Search.Searchbar.Placeholder" = "Search hashtags and users"; +"Scene.Search.Searching.Clear" = "clear"; +"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.ServerPicker.Button.Category.Academia" = "academia"; +"Scene.ServerPicker.Button.Category.Activism" = "activism"; +"Scene.ServerPicker.Button.Category.All" = "All"; +"Scene.ServerPicker.Button.Category.AllAccessiblityDescription" = "Category: All"; +"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 data. Check your internet connection."; +"Scene.ServerPicker.EmptyState.FindingServers" = "Finding available servers..."; +"Scene.ServerPicker.EmptyState.NoResults" = "No results"; +"Scene.ServerPicker.Input.Placeholder" = "Find a server or join your own..."; +"Scene.ServerPicker.Label.Category" = "CATEGORY"; +"Scene.ServerPicker.Label.Language" = "LANGUAGE"; +"Scene.ServerPicker.Label.Users" = "USERS"; +"Scene.ServerPicker.Title" = "Pick a Server, +any server."; +"Scene.ServerRules.Button.Confirm" = "I Agree"; +"Scene.ServerRules.PrivacyPolicy" = "privacy policy"; +"Scene.ServerRules.Prompt" = "By continuing, you're subject to the terms of service and privacy policy for %@."; +"Scene.ServerRules.Subtitle" = "These rules are set by the admins of %@."; +"Scene.ServerRules.TermsOfService" = "terms of service"; +"Scene.ServerRules.Title" = "Some ground rules."; +"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.Privacy" = "Privacy Policy"; +"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.Spicyzone.Clear" = "Clear Media Cache"; +"Scene.Settings.Section.Spicyzone.Signout" = "Sign Out"; +"Scene.Settings.Section.Spicyzone.Title" = "The spicy zone"; +"Scene.Settings.Title" = "Settings"; +"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.Favorite.Multiple" = "%@ favorites"; +"Scene.Thread.Favorite.Single" = "%@ favorite"; +"Scene.Thread.Reblog.Multiple" = "%@ reblogs"; +"Scene.Thread.Reblog.Single" = "%@ reblog"; +"Scene.Thread.Title" = "Post from %@"; +"Scene.Welcome.Slogan" = "Social networking +back in your hands."; \ No newline at end of file diff --git a/Mastodon/Resources/en.lproj/InfoPlist.strings b/Mastodon/Resources/en.lproj/InfoPlist.strings index 972e1a7a2..48566ae36 100644 --- a/Mastodon/Resources/en.lproj/InfoPlist.strings +++ b/Mastodon/Resources/en.lproj/InfoPlist.strings @@ -1,2 +1,2 @@ -"NSCameraUsageDescription" = "Used to take photo for toot"; +"NSCameraUsageDescription" = "Used to take photo for post status"; "NSPhotoLibraryAddUsageDescription" = "Used to save photo into the Photo Library"; \ No newline at end of file diff --git a/Mastodon/Resources/en.lproj/Localizable.strings b/Mastodon/Resources/en.lproj/Localizable.strings index b3df9a77f..c9ed556c3 100644 --- a/Mastodon/Resources/en.lproj/Localizable.strings +++ b/Mastodon/Resources/en.lproj/Localizable.strings @@ -1,28 +1,151 @@ +"Common.Alerts.BlockDomain.BlockEntireDomain" = "Block entire 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 in any public timelines or your notifications. Your followers from that domain will be removed."; +"Common.Alerts.Common.PleaseTryAgain" = "Please try again."; +"Common.Alerts.Common.PleaseTryAgainLater" = "Please try again later."; +"Common.Alerts.DeletePost.Delete" = "Delete"; +"Common.Alerts.DeletePost.Title" = "Are you sure you want to delete this post?"; +"Common.Alerts.DiscardPostContent.Message" = "Confirm discard composed post content."; +"Common.Alerts.DiscardPostContent.Title" = "Discard Publish"; +"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 photo libaray access permission to save photo."; +"Common.Alerts.SavePhotoFailure.Title" = "Save Photo Failure"; "Common.Alerts.ServerError.Title" = "Server Error"; +"Common.Alerts.SignOut.Confirm" = "Sign Out"; +"Common.Alerts.SignOut.Message" = "Are you sure you want to sign out?"; +"Common.Alerts.SignOut.Title" = "Sign out"; "Common.Alerts.SignUpFailure.Title" = "Sign Up Failure"; +"Common.Alerts.VoteFailure.PollExpired" = "The poll has expired"; +"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" = "Cancel"; "Common.Controls.Actions.Confirm" = "Confirm"; "Common.Controls.Actions.Continue" = "Continue"; +"Common.Controls.Actions.Delete" = "Delete"; +"Common.Controls.Actions.Discard" = "Discard"; +"Common.Controls.Actions.Done" = "Done"; "Common.Controls.Actions.Edit" = "Edit"; +"Common.Controls.Actions.FindPeople" = "Find people to follow"; +"Common.Controls.Actions.ManuallySearch" = "Manually search instead"; "Common.Controls.Actions.Ok" = "OK"; "Common.Controls.Actions.OpenInSafari" = "Open in Safari"; "Common.Controls.Actions.Preview" = "Preview"; "Common.Controls.Actions.Remove" = "Remove"; +"Common.Controls.Actions.ReportUser" = "Report %@"; "Common.Controls.Actions.Save" = "Save"; "Common.Controls.Actions.SavePhoto" = "Save photo"; "Common.Controls.Actions.SeeMore" = "See More"; +"Common.Controls.Actions.Settings" = "Settings"; +"Common.Controls.Actions.Share" = "Share"; +"Common.Controls.Actions.SharePost" = "Share post"; +"Common.Controls.Actions.ShareUser" = "Share %@"; "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" = "Try Again"; +"Common.Controls.Actions.UnblockDomain" = "Unblock %@"; +"Common.Controls.Firendship.Block" = "Block"; +"Common.Controls.Firendship.BlockDomain" = "Block %@"; +"Common.Controls.Firendship.BlockUser" = "Block %@"; +"Common.Controls.Firendship.Blocked" = "Blocked"; +"Common.Controls.Firendship.EditInfo" = "Edit info"; +"Common.Controls.Firendship.Follow" = "Follow"; +"Common.Controls.Firendship.Following" = "Following"; +"Common.Controls.Firendship.Mute" = "Mute"; +"Common.Controls.Firendship.MuteUser" = "Mute %@"; +"Common.Controls.Firendship.Muted" = "Muted"; +"Common.Controls.Firendship.Pending" = "Pending"; +"Common.Controls.Firendship.Request" = "Request"; +"Common.Controls.Firendship.Unblock" = "Unblock"; +"Common.Controls.Firendship.UnblockUser" = "Unblock %@"; +"Common.Controls.Firendship.Unmute" = "Unmute"; +"Common.Controls.Firendship.UnmuteUser" = "Unmute %@"; +"Common.Controls.Status.Actions.Favorite" = "Favorite"; +"Common.Controls.Status.Actions.Menu" = "Menu"; +"Common.Controls.Status.Actions.Reblog" = "Reblog"; +"Common.Controls.Status.Actions.Reply" = "Reply"; +"Common.Controls.Status.Actions.Unfavorite" = "Unfavorite"; +"Common.Controls.Status.Actions.Unreblog" = "Unreblog"; +"Common.Controls.Status.ContentWarning" = "content warning"; +"Common.Controls.Status.ContentWarningText" = "cw: %@"; "Common.Controls.Status.MediaContentWarning" = "Tap to reveal that may be sensitive"; +"Common.Controls.Status.Poll.Closed" = "Closed"; +"Common.Controls.Status.Poll.TimeLeft" = "%@ left"; +"Common.Controls.Status.Poll.Vote" = "Vote"; +"Common.Controls.Status.Poll.VoteCount.Multiple" = "%d votes"; +"Common.Controls.Status.Poll.VoteCount.Single" = "%d vote"; +"Common.Controls.Status.Poll.VoterCount.Multiple" = "%d voters"; +"Common.Controls.Status.Poll.VoterCount.Single" = "%d voter"; "Common.Controls.Status.ShowPost" = "Show Post"; -"Common.Controls.Status.StatusContentWarning" = "content warning"; -"Common.Controls.Status.UserBoosted" = "%@ boosted"; -"Common.Controls.Timeline.LoadMore" = "Load More"; +"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" = "Profile"; +"Common.Controls.Tabs.Search" = "Search"; +"Common.Controls.Timeline.Accessibility.CountFavorites" = "%@ favorites"; +"Common.Controls.Timeline.Accessibility.CountReblogs" = "%@ reblogs"; +"Common.Controls.Timeline.Accessibility.CountReplies" = "%@ replies"; +"Common.Controls.Timeline.Header.BlockedWarning" = "You can’t view Artbot’s profile + until they unblock you."; +"Common.Controls.Timeline.Header.BlockingWarning" = "You can’t view Artbot’s profile + until you unblock them. +Your account looks like this to them."; +"Common.Controls.Timeline.Header.NoStatusFound" = "No Status Found"; +"Common.Controls.Timeline.Header.SuspendedWarning" = "This account has been suspended."; +"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" = "Show more replies"; "Common.Countable.Photo.Multiple" = "photos"; "Common.Countable.Photo.Single" = "photo"; +"Scene.Compose.Accessibility.AppendAttachment" = "Append attachment"; +"Scene.Compose.Accessibility.AppendPoll" = "Append 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.InputLimitExceedsCount" = "Input limit exceeds %ld"; +"Scene.Compose.Accessibility.InputLimitRemainsCount" = "Input limit remains %ld"; +"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 photo for low vision people..."; +"Scene.Compose.Attachment.DescriptionVideo" = "Describe what’s happening for low vision people..."; +"Scene.Compose.Attachment.Photo" = "photo"; +"Scene.Compose.Attachment.Video" = "video"; +"Scene.Compose.ComposeAction" = "Publish"; +"Scene.Compose.ContentInputPlaceholder" = "Type or paste what's on your mind"; +"Scene.Compose.ContentWarning.Placeholder" = "Write an accurate warning here..."; +"Scene.Compose.MediaSelection.Browse" = "Browse"; +"Scene.Compose.MediaSelection.Camera" = "Take Photo"; +"Scene.Compose.MediaSelection.PhotoLibrary" = "Photo Library"; +"Scene.Compose.Poll.DurationTime" = "Duration: %@"; +"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 minutes"; +"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."; @@ -35,22 +158,106 @@ "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.Hashtag.Prompt" = "%@ people talking"; +"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.Action.Favourite" = "favorited your post"; +"Scene.Notification.Action.Follow" = "followed you"; +"Scene.Notification.Action.FollowRequest" = "request to follow you"; +"Scene.Notification.Action.Mention" = "mentioned you"; +"Scene.Notification.Action.Poll" = "Your poll has ended"; +"Scene.Notification.Action.Reblog" = "rebloged your post"; +"Scene.Notification.Title.Everything" = "Everything"; +"Scene.Notification.Title.Mentions" = "Mentions"; +"Scene.Profile.Dashboard.Accessibility.CountFollowers" = "%ld followers"; +"Scene.Profile.Dashboard.Accessibility.CountFollowing" = "%ld following"; +"Scene.Profile.Dashboard.Accessibility.CountPosts" = "%ld posts"; +"Scene.Profile.Dashboard.Followers" = "followers"; +"Scene.Profile.Dashboard.Following" = "following"; +"Scene.Profile.Dashboard.Posts" = "posts"; +"Scene.Profile.RelationshipActionAlert.ConfirmUnblockUsre.Message" = "Confirm unblock %@"; +"Scene.Profile.RelationshipActionAlert.ConfirmUnblockUsre.Title" = "Unblock Account"; +"Scene.Profile.RelationshipActionAlert.ConfirmUnmuteUser.Message" = "Confirm unmute %@"; +"Scene.Profile.RelationshipActionAlert.ConfirmUnmuteUser.Title" = "Unmute Account"; +"Scene.Profile.SegmentedControl.Media" = "Media"; +"Scene.Profile.SegmentedControl.Posts" = "Posts"; +"Scene.Profile.SegmentedControl.Replies" = "Replies"; +"Scene.Profile.Subtitle" = "%@ posts"; "Scene.PublicTimeline.Title" = "Public"; -"Scene.Register.CheckEmail" = "Regsiter request sent. Please check your email."; +"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" = "Username"; +"Scene.Register.Error.Reason.Accepted" = "%@ must be accepted"; +"Scene.Register.Error.Reason.Blank" = "%@ is required"; +"Scene.Register.Error.Reason.Blocked" = "%@ contains a disallowed e-mail 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 e-mail 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" = "Delete"; "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.Password.Prompt" = "Your password needs at least:"; -"Scene.Register.Input.Password.PromptEightCharacters" = "Eight characters"; "Scene.Register.Input.Username.DuplicatePrompt" = "This username is taken."; "Scene.Register.Input.Username.Placeholder" = "username"; -"Scene.Register.Success" = "Success"; "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" = "Step 1 of 2"; +"Scene.Report.Step2" = "Step 2 of 2"; +"Scene.Report.TextPlaceholder" = "Type or paste additional comments"; +"Scene.Report.Title" = "Report %@"; +"Scene.Search.Recommend.Accounts.Description" = "You may like to follow these accounts"; +"Scene.Search.Recommend.Accounts.Follow" = "Follow"; +"Scene.Search.Recommend.Accounts.Title" = "Accounts you might like"; +"Scene.Search.Recommend.ButtonText" = "See All"; +"Scene.Search.Recommend.HashTag.Description" = "Hashtags that are getting quite a bit of attention among people you follow"; +"Scene.Search.Recommend.HashTag.PeopleTalking" = "%@ people are talking"; +"Scene.Search.Recommend.HashTag.Title" = "Trending in your timeline"; +"Scene.Search.Searchbar.Cancel" = "Cancel"; +"Scene.Search.Searchbar.Placeholder" = "Search hashtags and users"; +"Scene.Search.Searching.Clear" = "clear"; +"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.ServerPicker.Button.Category.Academia" = "academia"; +"Scene.ServerPicker.Button.Category.Activism" = "activism"; "Scene.ServerPicker.Button.Category.All" = "All"; -"Scene.ServerPicker.Button.Seeless" = "See Less"; -"Scene.ServerPicker.Button.Seemore" = "See More"; +"Scene.ServerPicker.Button.Category.AllAccessiblityDescription" = "Category: All"; +"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 data. Check your internet connection."; +"Scene.ServerPicker.EmptyState.FindingServers" = "Finding available servers..."; +"Scene.ServerPicker.EmptyState.NoResults" = "No results"; "Scene.ServerPicker.Input.Placeholder" = "Find a server or join your own..."; "Scene.ServerPicker.Label.Category" = "CATEGORY"; "Scene.ServerPicker.Label.Language" = "LANGUAGE"; @@ -58,8 +265,39 @@ tap the link to confirm your account."; "Scene.ServerPicker.Title" = "Pick a Server, any server."; "Scene.ServerRules.Button.Confirm" = "I Agree"; +"Scene.ServerRules.PrivacyPolicy" = "privacy policy"; "Scene.ServerRules.Prompt" = "By continuing, you're subject to the terms of service and privacy policy for %@."; "Scene.ServerRules.Subtitle" = "These rules are set by the admins of %@."; +"Scene.ServerRules.TermsOfService" = "terms of service"; "Scene.ServerRules.Title" = "Some ground rules."; +"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.Privacy" = "Privacy Policy"; +"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.Spicyzone.Clear" = "Clear Media Cache"; +"Scene.Settings.Section.Spicyzone.Signout" = "Sign Out"; +"Scene.Settings.Section.Spicyzone.Title" = "The spicy zone"; +"Scene.Settings.Title" = "Settings"; +"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.Favorite.Multiple" = "%@ favorites"; +"Scene.Thread.Favorite.Single" = "%@ favorite"; +"Scene.Thread.Reblog.Multiple" = "%@ reblogs"; +"Scene.Thread.Reblog.Single" = "%@ reblog"; +"Scene.Thread.Title" = "Post from %@"; "Scene.Welcome.Slogan" = "Social networking -back in your hands."; +back in your hands."; \ No newline at end of file diff --git a/Mastodon/Resources/en.lproj/infoPlist.strings b/Mastodon/Resources/en.lproj/infoPlist.strings new file mode 100644 index 000000000..48566ae36 --- /dev/null +++ b/Mastodon/Resources/en.lproj/infoPlist.strings @@ -0,0 +1,2 @@ +"NSCameraUsageDescription" = "Used to take photo for post status"; +"NSPhotoLibraryAddUsageDescription" = "Used to save photo into the Photo Library"; \ No newline at end of file diff --git a/Mastodon/Scene/Compose/CollectionViewCell/ComposeRepliedToStatusContentCollectionViewCell.swift b/Mastodon/Scene/Compose/CollectionViewCell/ComposeRepliedToStatusContentCollectionViewCell.swift new file mode 100644 index 000000000..8da4c0729 --- /dev/null +++ b/Mastodon/Scene/Compose/CollectionViewCell/ComposeRepliedToStatusContentCollectionViewCell.swift @@ -0,0 +1,62 @@ +// +// ComposeRepliedToStatusContentCollectionViewCell.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-11. +// + +import UIKit +import Combine + +final class ComposeRepliedToStatusContentCollectionViewCell: UICollectionViewCell { + + var disposeBag = Set() + + let statusView = StatusView() + + let framePublisher = PassthroughSubject() + + override func prepareForReuse() { + super.prepareForReuse() + + statusView.updateContentWarningDisplay(isHidden: true, animated: false) + disposeBag.removeAll() + } + + override init(frame: CGRect) { + super.init(frame: frame) + _init() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + _init() + } + + override func layoutSubviews() { + super.layoutSubviews() + framePublisher.send(bounds) + } + +} + +extension ComposeRepliedToStatusContentCollectionViewCell { + + private func _init() { + backgroundColor = .clear + + statusView.actionToolbarContainer.isHidden = true + statusView.revealContentWarningButton.isHidden = true + + statusView.translatesAutoresizingMaskIntoConstraints = false + 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.readableContentGuide.leadingAnchor), + contentView.readableContentGuide.trailingAnchor.constraint(equalTo: statusView.trailingAnchor), + contentView.bottomAnchor.constraint(equalTo: statusView.bottomAnchor, constant: 10).identifier("ComposeRepliedToStatusContentCollectionViewCell.contentView.bottom to statusView.bottom"), + ]) + } + +} + diff --git a/Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusAttachmentCollectionViewCell.swift b/Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusAttachmentCollectionViewCell.swift new file mode 100644 index 000000000..87fe0efaf --- /dev/null +++ b/Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusAttachmentCollectionViewCell.swift @@ -0,0 +1,99 @@ +// +// ComposeStatusAttachmentCollectionViewCell.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-17. +// + +import os.log +import UIKit +import Combine + +protocol ComposeStatusAttachmentCollectionViewCellDelegate: AnyObject { + func composeStatusAttachmentCollectionViewCell(_ cell: ComposeStatusAttachmentCollectionViewCell, removeButtonDidPressed button: UIButton) +} + +final class ComposeStatusAttachmentCollectionViewCell: UICollectionViewCell { + + var disposeBag = Set() + + static let verticalMarginHeight: CGFloat = ComposeStatusAttachmentCollectionViewCell.removeButtonSize.height * 0.5 + static let removeButtonSize = CGSize(width: 22, height: 22) + + weak var delegate: ComposeStatusAttachmentCollectionViewCellDelegate? + + let attachmentContainerView = AttachmentContainerView() + let removeButton: UIButton = { + let button = HighlightDimmableButton() + button.expandEdgeInsets = UIEdgeInsets(top: -10, left: -10, bottom: -10, right: -10) + let image = UIImage(systemName: "minus")!.withConfiguration(UIImage.SymbolConfiguration(pointSize: 14, weight: .bold)) + button.tintColor = .white + button.setImage(image, for: .normal) + button.setBackgroundImage(.placeholder(color: Asset.Colors.Background.danger.color), for: .normal) + button.layer.masksToBounds = true + button.layer.cornerRadius = ComposeStatusAttachmentCollectionViewCell.removeButtonSize.width * 0.5 + button.layer.borderColor = Asset.Colors.Background.dangerBorder.color.cgColor + button.layer.borderWidth = 1 + return button + }() + + override func prepareForReuse() { + super.prepareForReuse() + + attachmentContainerView.activityIndicatorView.startAnimating() + attachmentContainerView.previewImageView.af.cancelImageRequest() + attachmentContainerView.previewImageView.image = .placeholder(color: .systemFill) + delegate = nil + disposeBag.removeAll() + } + + override init(frame: CGRect) { + super.init(frame: frame) + _init() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + _init() + } + +} + +extension ComposeStatusAttachmentCollectionViewCell { + + private func _init() { + // selectionStyle = .none + + attachmentContainerView.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(attachmentContainerView) + NSLayoutConstraint.activate([ + attachmentContainerView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: ComposeStatusAttachmentCollectionViewCell.verticalMarginHeight), + attachmentContainerView.leadingAnchor.constraint(equalTo: contentView.readableContentGuide.leadingAnchor), + attachmentContainerView.trailingAnchor.constraint(equalTo: contentView.readableContentGuide.trailingAnchor), + contentView.bottomAnchor.constraint(equalTo: attachmentContainerView.bottomAnchor, constant: ComposeStatusAttachmentCollectionViewCell.verticalMarginHeight), + attachmentContainerView.heightAnchor.constraint(equalToConstant: 205).priority(.defaultHigh), + ]) + + removeButton.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(removeButton) + NSLayoutConstraint.activate([ + removeButton.centerXAnchor.constraint(equalTo: attachmentContainerView.trailingAnchor), + removeButton.centerYAnchor.constraint(equalTo: attachmentContainerView.topAnchor), + removeButton.widthAnchor.constraint(equalToConstant: ComposeStatusAttachmentCollectionViewCell.removeButtonSize.width).priority(.defaultHigh), + removeButton.heightAnchor.constraint(equalToConstant: ComposeStatusAttachmentCollectionViewCell.removeButtonSize.height).priority(.defaultHigh), + ]) + + removeButton.addTarget(self, action: #selector(ComposeStatusAttachmentCollectionViewCell.removeButtonDidPressed(_:)), for: .touchUpInside) + } + +} + + +extension ComposeStatusAttachmentCollectionViewCell { + + @objc private func removeButtonDidPressed(_ sender: UIButton) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + delegate?.composeStatusAttachmentCollectionViewCell(self, removeButtonDidPressed: sender) + } + +} diff --git a/Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusContentCollectionViewCell.swift b/Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusContentCollectionViewCell.swift new file mode 100644 index 000000000..5ec2a9eeb --- /dev/null +++ b/Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusContentCollectionViewCell.swift @@ -0,0 +1,124 @@ +// +// ComposeStatusContentCollectionViewCell.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-11. +// + +import os.log +import UIKit +import Combine +import TwitterTextEditor + +final class ComposeStatusContentCollectionViewCell: UICollectionViewCell { + + var disposeBag = Set() + + let statusView = StatusView() + + let statusContentWarningEditorView = StatusContentWarningEditorView() + + let textEditorView: TextEditorView = { + let textEditorView = TextEditorView() + textEditorView.font = .preferredFont(forTextStyle: .body) + textEditorView.scrollView.isScrollEnabled = false + textEditorView.isScrollEnabled = false + textEditorView.placeholderText = L10n.Scene.Compose.contentInputPlaceholder + textEditorView.keyboardType = .twitter + return textEditorView + }() + + // output + let composeContent = PassthroughSubject() + let contentWarningContent = PassthroughSubject() + + override init(frame: CGRect) { + super.init(frame: frame) + _init() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + _init() + } + +} + +extension ComposeStatusContentCollectionViewCell { + + private func _init() { + // selectionStyle = .none + preservesSuperviewLayoutMargins = true + + statusContentWarningEditorView.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(statusContentWarningEditorView) + NSLayoutConstraint.activate([ + statusContentWarningEditorView.topAnchor.constraint(equalTo: contentView.topAnchor), + statusContentWarningEditorView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + statusContentWarningEditorView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + ]) + statusContentWarningEditorView.preservesSuperviewLayoutMargins = true + statusContentWarningEditorView.containerBackgroundView.isHidden = false + + statusView.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(statusView) + NSLayoutConstraint.activate([ + statusView.topAnchor.constraint(equalTo: statusContentWarningEditorView.bottomAnchor, constant: 20), + statusView.leadingAnchor.constraint(equalTo: contentView.readableContentGuide.leadingAnchor), + statusView.trailingAnchor.constraint(equalTo: contentView.readableContentGuide.trailingAnchor), + ]) + statusView.statusContainerStackView.isHidden = true + statusView.actionToolbarContainer.isHidden = true + statusView.nameTrialingDotLabel.isHidden = true + statusView.dateLabel.isHidden = true + + statusView.setContentHuggingPriority(.defaultHigh, for: .vertical) + statusView.setContentCompressionResistancePriority(.required - 1, for: .vertical) + + textEditorView.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(textEditorView) + NSLayoutConstraint.activate([ + textEditorView.topAnchor.constraint(equalTo: statusView.bottomAnchor, constant: 10), + textEditorView.leadingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.leadingAnchor), + textEditorView.trailingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.trailingAnchor), + contentView.bottomAnchor.constraint(equalTo: textEditorView.bottomAnchor, constant: 10), + textEditorView.heightAnchor.constraint(greaterThanOrEqualToConstant: 44).priority(.defaultHigh), + ]) + textEditorView.setContentCompressionResistancePriority(.required - 2, for: .vertical) + + statusContentWarningEditorView.textView.delegate = self + textEditorView.changeObserver = self + + statusContentWarningEditorView.containerView.isHidden = true + statusView.revealContentWarningButton.isHidden = true + } + +} + +// MARK: - TextEditorViewChangeObserver +extension ComposeStatusContentCollectionViewCell: TextEditorViewChangeObserver { + func textEditorView(_ textEditorView: TextEditorView, didChangeWithChangeResult changeResult: TextEditorViewChangeResult) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: text: %s", ((#file as NSString).lastPathComponent), #line, #function, textEditorView.text) + guard changeResult.isTextChanged else { return } + composeContent.send(textEditorView.text) + } +} + +// MARK: - UITextViewDelegate +extension ComposeStatusContentCollectionViewCell: UITextViewDelegate { + + func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool { + // disable input line break + guard text != "\n" else { return false } + return true + } + + func textViewDidChange(_ textView: UITextView) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: text: %s", ((#file as NSString).lastPathComponent), #line, #function, textView.text) + guard textView === statusContentWarningEditorView.textView else { return } + // replace line break with space + textView.text = textView.text.replacingOccurrences(of: "\n", with: " ") + contentWarningContent.send(textView.text) + } + +} diff --git a/Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusPollExpiresOptionCollectionViewCell.swift b/Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusPollExpiresOptionCollectionViewCell.swift new file mode 100644 index 000000000..8347f5641 --- /dev/null +++ b/Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusPollExpiresOptionCollectionViewCell.swift @@ -0,0 +1,74 @@ +// +// ComposeStatusPollExpiresOptionCollectionViewCell.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-24. +// + +import os.log +import UIKit +import Combine + +protocol ComposeStatusPollExpiresOptionCollectionViewCellDelegate: AnyObject { + func composeStatusPollExpiresOptionCollectionViewCell(_ cell: ComposeStatusPollExpiresOptionCollectionViewCell, didSelectExpiresOption expiresOption: ComposeStatusItem.ComposePollExpiresOptionAttribute.ExpiresOption) +} + +final class ComposeStatusPollExpiresOptionCollectionViewCell: UICollectionViewCell { + + var disposeBag = Set() + weak var delegate: ComposeStatusPollExpiresOptionCollectionViewCellDelegate? + + let durationButton: UIButton = { + let button = HighlightDimmableButton() + button.titleLabel?.font = UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 12)) + button.expandEdgeInsets = UIEdgeInsets(top: 0, left: -10, bottom: -20, right: -20) + button.setTitle(L10n.Scene.Compose.Poll.durationTime(L10n.Scene.Compose.Poll.thirtyMinutes), for: .normal) + button.setTitleColor(Asset.Colors.Button.normal.color, for: .normal) + return button + }() + + override init(frame: CGRect) { + super.init(frame: frame) + _init() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + _init() + } + +} + +extension ComposeStatusPollExpiresOptionCollectionViewCell { + + private typealias ExpiresOption = ComposeStatusItem.ComposePollExpiresOptionAttribute.ExpiresOption + + private func _init() { + durationButton.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(durationButton) + NSLayoutConstraint.activate([ + durationButton.topAnchor.constraint(equalTo: contentView.topAnchor), + durationButton.leadingAnchor.constraint(equalTo: contentView.readableContentGuide.leadingAnchor, constant: PollOptionView.checkmarkBackgroundLeadingMargin), + durationButton.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), + ]) + + let children = ExpiresOption.allCases.map { expiresOption -> UIAction in + UIAction(title: expiresOption.title, image: nil, identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off) { [weak self] action in + guard let self = self else { return } + self.expiresOptionActionHandler(action, expiresOption: expiresOption) + } + } + durationButton.menu = UIMenu(title: "", image: nil, identifier: nil, options: .displayInline, children: children) + durationButton.showsMenuAsPrimaryAction = true + } + +} + +extension ComposeStatusPollExpiresOptionCollectionViewCell { + + private func expiresOptionActionHandler(_ sender: UIAction, expiresOption: ExpiresOption) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: select %s", ((#file as NSString).lastPathComponent), #line, #function, expiresOption.title) + delegate?.composeStatusPollExpiresOptionCollectionViewCell(self, didSelectExpiresOption: expiresOption) + } + +} diff --git a/Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusPollOptionAppendEntryCollectionViewCell.swift b/Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusPollOptionAppendEntryCollectionViewCell.swift new file mode 100644 index 000000000..dbe9ef4ad --- /dev/null +++ b/Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusPollOptionAppendEntryCollectionViewCell.swift @@ -0,0 +1,141 @@ +// +// ComposeStatusPollOptionAppendEntryCollectionViewCell.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-23. +// + +import os.log +import UIKit + +protocol ComposeStatusPollOptionAppendEntryCollectionViewCellDelegate: AnyObject { + func composeStatusPollOptionAppendEntryCollectionViewCellDidPressed(_ cell: ComposeStatusPollOptionAppendEntryCollectionViewCell) +} + +final class ComposeStatusPollOptionAppendEntryCollectionViewCell: UICollectionViewCell { + + let pollOptionView = PollOptionView() + 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 + }() + + let singleTagGestureRecognizer = UITapGestureRecognizer.singleTapGestureRecognizer + + weak var delegate: ComposeStatusPollOptionAppendEntryCollectionViewCellDelegate? + + override var isHighlighted: Bool { + didSet { + pollOptionView.roundedBackgroundView.backgroundColor = isHighlighted ? Asset.Colors.Background.tertiarySystemBackground.color : Asset.Colors.Background.secondarySystemBackground.color + pollOptionView.plusCircleImageView.tintColor = isHighlighted ? Asset.Colors.Button.normal.color.withAlphaComponent(0.5) : Asset.Colors.Button.normal.color + } + } + + override func point(inside point: CGPoint, with event: UIEvent?) -> Bool { + return pollOptionView.frame.contains(point) + } + + override func prepareForReuse() { + super.prepareForReuse() + + delegate = nil + } + + override init(frame: CGRect) { + super.init(frame: frame) + _init() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + _init() + } + +} + +extension ComposeStatusPollOptionAppendEntryCollectionViewCell { + + private func _init() { + pollOptionView.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(pollOptionView) + NSLayoutConstraint.activate([ + pollOptionView.topAnchor.constraint(equalTo: contentView.topAnchor), + pollOptionView.leadingAnchor.constraint(equalTo: contentView.readableContentGuide.leadingAnchor), + pollOptionView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), + ]) + + reorderBarImageView.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(reorderBarImageView) + NSLayoutConstraint.activate([ + reorderBarImageView.topAnchor.constraint(equalTo: contentView.topAnchor), + reorderBarImageView.leadingAnchor.constraint(equalTo: pollOptionView.trailingAnchor, constant: ComposeStatusPollOptionCollectionViewCell.reorderHandlerImageLeadingMargin), + reorderBarImageView.trailingAnchor.constraint(equalTo: contentView.readableContentGuide.trailingAnchor), + reorderBarImageView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), + ]) + + pollOptionView.checkmarkImageView.isHidden = true + pollOptionView.checkmarkBackgroundView.isHidden = true + pollOptionView.optionPercentageLabel.isHidden = true + pollOptionView.optionTextField.isHidden = true + pollOptionView.plusCircleImageView.isHidden = false + + pollOptionView.roundedBackgroundView.backgroundColor = Asset.Colors.Background.secondarySystemBackground.color + setupBorderColor() + + pollOptionView.addGestureRecognizer(singleTagGestureRecognizer) + singleTagGestureRecognizer.addTarget(self, action: #selector(ComposeStatusPollOptionAppendEntryCollectionViewCell.singleTagGestureRecognizerHandler(_:))) + + reorderBarImageView.isHidden = true + } + + private func setupBorderColor() { + pollOptionView.roundedBackgroundView.layer.borderWidth = 1 + pollOptionView.roundedBackgroundView.layer.borderColor = Asset.Colors.Background.secondarySystemBackground.color.cgColor + } + + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + + setupBorderColor() + } + +} + +extension ComposeStatusPollOptionAppendEntryCollectionViewCell { + + @objc private func singleTagGestureRecognizerHandler(_ sender: UITapGestureRecognizer) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + delegate?.composeStatusPollOptionAppendEntryCollectionViewCellDidPressed(self) + } + +} + +#if canImport(SwiftUI) && DEBUG +import SwiftUI + +struct ComposeStatusNewPollOptionCollectionViewCell_Previews: PreviewProvider { + + static var controls: some View { + Group { + UIViewPreview() { + let cell = ComposeStatusPollOptionAppendEntryCollectionViewCell() + return cell + } + .previewLayout(.fixed(width: 375, height: 44 + 10)) + } + } + + static var previews: some View { + Group { + controls.colorScheme(.light) + controls.colorScheme(.dark) + } + .background(Color.gray) + } + +} + +#endif diff --git a/Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusPollOptionCollectionViewCell.swift b/Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusPollOptionCollectionViewCell.swift new file mode 100644 index 000000000..ab2117f13 --- /dev/null +++ b/Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusPollOptionCollectionViewCell.swift @@ -0,0 +1,176 @@ +// +// ComposeStatusPollOptionCollectionViewCell.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-23. +// + +import os.log +import UIKit +import Combine + +protocol ComposeStatusPollOptionCollectionViewCellDelegate: AnyObject { + func composeStatusPollOptionCollectionViewCell(_ cell: ComposeStatusPollOptionCollectionViewCell, textFieldDidBeginEditing textField: UITextField) + func composeStatusPollOptionCollectionViewCell(_ cell: ComposeStatusPollOptionCollectionViewCell, textBeforeDeleteBackward text: String?) + func composeStatusPollOptionCollectionViewCell(_ cell: ComposeStatusPollOptionCollectionViewCell, pollOptionTextFieldDidReturn: UITextField) +} + +final class ComposeStatusPollOptionCollectionViewCell: UICollectionViewCell { + + static let reorderHandlerImageLeadingMargin: CGFloat = 11 + + var disposeBag = Set() + weak var delegate: ComposeStatusPollOptionCollectionViewCellDelegate? + + let pollOptionView = PollOptionView() + 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 + }() + + let singleTagGestureRecognizer = UITapGestureRecognizer.singleTapGestureRecognizer + + private var pollOptionSubscription: AnyCancellable? + let pollOption = PassthroughSubject() + + override func point(inside point: CGPoint, with event: UIEvent?) -> Bool { + return pollOptionView.frame.contains(point) + } + + override func prepareForReuse() { + super.prepareForReuse() + + delegate = nil + disposeBag.removeAll() + } + + override init(frame: CGRect) { + super.init(frame: frame) + _init() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + _init() + } + +} + +extension ComposeStatusPollOptionCollectionViewCell { + + private func _init() { + pollOptionView.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(pollOptionView) + NSLayoutConstraint.activate([ + pollOptionView.topAnchor.constraint(equalTo: contentView.topAnchor), + pollOptionView.leadingAnchor.constraint(equalTo: contentView.readableContentGuide.leadingAnchor), + pollOptionView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), + ]) + + reorderBarImageView.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(reorderBarImageView) + NSLayoutConstraint.activate([ + reorderBarImageView.topAnchor.constraint(equalTo: contentView.topAnchor), + reorderBarImageView.leadingAnchor.constraint(equalTo: pollOptionView.trailingAnchor, constant: ComposeStatusPollOptionCollectionViewCell.reorderHandlerImageLeadingMargin), + reorderBarImageView.trailingAnchor.constraint(equalTo: contentView.readableContentGuide.trailingAnchor), + reorderBarImageView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), + ]) + reorderBarImageView.setContentCompressionResistancePriority(.defaultHigh + 10, for: .horizontal) + + pollOptionView.checkmarkImageView.isHidden = true + pollOptionView.optionPercentageLabel.isHidden = true + pollOptionView.optionTextField.text = nil + + pollOptionView.roundedBackgroundView.backgroundColor = Asset.Colors.Background.secondarySystemBackground.color + pollOptionView.checkmarkBackgroundView.backgroundColor = Asset.Colors.Background.tertiarySystemBackground.color + setupBorderColor() + + pollOptionView.addGestureRecognizer(singleTagGestureRecognizer) + singleTagGestureRecognizer.addTarget(self, action: #selector(ComposeStatusPollOptionCollectionViewCell.singleTagGestureRecognizerHandler(_:))) + + pollOptionSubscription = NotificationCenter.default.publisher(for: UITextField.textDidChangeNotification, object: pollOptionView.optionTextField) + .receive(on: DispatchQueue.main) + .sink { [weak self] notification in + guard let self = self else { return } + guard let textField = notification.object as? UITextField else { return } + self.pollOption.send(textField.text ?? "") + } + pollOptionView.optionTextField.deleteBackwardDelegate = self + pollOptionView.optionTextField.delegate = self + } + + private func setupBorderColor() { + pollOptionView.checkmarkBackgroundView.layer.borderColor = UIColor.systemGray3.cgColor + pollOptionView.checkmarkBackgroundView.layer.borderWidth = 1 + } + + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + + setupBorderColor() + } + +} + +extension ComposeStatusPollOptionCollectionViewCell { + + @objc private func singleTagGestureRecognizerHandler(_ sender: UITapGestureRecognizer) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + pollOptionView.optionTextField.becomeFirstResponder() + } + +} + +// MARK: - DeleteBackwardResponseTextFieldDelegate +extension ComposeStatusPollOptionCollectionViewCell: DeleteBackwardResponseTextFieldDelegate { + func deleteBackwardResponseTextField(_ textField: DeleteBackwardResponseTextField, textBeforeDelete: String?) { + delegate?.composeStatusPollOptionCollectionViewCell(self, textBeforeDeleteBackward: textBeforeDelete) + } +} + +// MARK: - UITextFieldDelegate +extension ComposeStatusPollOptionCollectionViewCell: UITextFieldDelegate { + + func textFieldDidBeginEditing(_ textField: UITextField) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + delegate?.composeStatusPollOptionCollectionViewCell(self, textFieldDidBeginEditing: textField) + } + + func textFieldShouldReturn(_ textField: UITextField) -> Bool { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + if textField === pollOptionView.optionTextField { + delegate?.composeStatusPollOptionCollectionViewCell(self, pollOptionTextFieldDidReturn: textField) + } + return true + } +} + +#if canImport(SwiftUI) && DEBUG +import SwiftUI + +struct ComposeStatusPollOptionCollectionViewCell_Previews: PreviewProvider { + + static var controls: some View { + Group { + UIViewPreview() { + let cell = ComposeStatusPollOptionCollectionViewCell() + return cell + } + .previewLayout(.fixed(width: 375, height: 44 + 10)) + } + } + + static var previews: some View { + Group { + controls.colorScheme(.light) + controls.colorScheme(.dark) + } + .background(Color.gray) + } + +} + +#endif diff --git a/Mastodon/Scene/Compose/CollectionViewCell/CustomEmojiPickerHeaderCollectionReusableView.swift b/Mastodon/Scene/Compose/CollectionViewCell/CustomEmojiPickerHeaderCollectionReusableView.swift new file mode 100644 index 000000000..61753a4c2 --- /dev/null +++ b/Mastodon/Scene/Compose/CollectionViewCell/CustomEmojiPickerHeaderCollectionReusableView.swift @@ -0,0 +1,42 @@ +// +// CustomEmojiPickerHeaderCollectionReusableView.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-24. +// + +import UIKit + +final class CustomEmojiPickerHeaderCollectionReusableView: UICollectionReusableView { + + let titlelabel: UILabel = { + let label = UILabel() + label.font = UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 12, weight: .bold)) + label.textColor = Asset.Colors.Label.secondary.color + return label + }() + + override init(frame: CGRect) { + super.init(frame: frame) + _init() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + _init() + } + +} + +extension CustomEmojiPickerHeaderCollectionReusableView { + private func _init() { + titlelabel.translatesAutoresizingMaskIntoConstraints = false + addSubview(titlelabel) + NSLayoutConstraint.activate([ + titlelabel.topAnchor.constraint(equalTo: topAnchor, constant: 20), + titlelabel.leadingAnchor.constraint(equalTo: readableContentGuide.leadingAnchor), + titlelabel.trailingAnchor.constraint(equalTo: readableContentGuide.trailingAnchor), + titlelabel.bottomAnchor.constraint(equalTo: bottomAnchor), + ]) + } +} diff --git a/Mastodon/Scene/Compose/CollectionViewCell/CustomEmojiPickerItemCollectionViewCell.swift b/Mastodon/Scene/Compose/CollectionViewCell/CustomEmojiPickerItemCollectionViewCell.swift new file mode 100644 index 000000000..49e6c1fe2 --- /dev/null +++ b/Mastodon/Scene/Compose/CollectionViewCell/CustomEmojiPickerItemCollectionViewCell.swift @@ -0,0 +1,56 @@ +// +// CustomEmojiPickerItemCollectionViewCell.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-24. +// + +import UIKit + +final class CustomEmojiPickerItemCollectionViewCell: UICollectionViewCell { + + static let itemSize = CGSize(width: 44, height: 44) + + let emojiImageView: UIImageView = { + let imageView = UIImageView() + imageView.contentMode = .scaleAspectFit + imageView.layer.masksToBounds = true + return imageView + }() + + override var isHighlighted: Bool { + didSet { + emojiImageView.alpha = isHighlighted ? 0.5 : 1.0 + } + } + + override init(frame: CGRect) { + super.init(frame: frame) + _init() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + _init() + } + +} + +extension CustomEmojiPickerItemCollectionViewCell { + + private func _init() { + emojiImageView.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(emojiImageView) + NSLayoutConstraint.activate([ + emojiImageView.topAnchor.constraint(equalTo: contentView.topAnchor), + emojiImageView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + emojiImageView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + emojiImageView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), + ]) + + isAccessibilityElement = true + accessibilityTraits = .button + accessibilityHint = "emoji" + } + +} diff --git a/Mastodon/Scene/Compose/ComposeViewController.swift b/Mastodon/Scene/Compose/ComposeViewController.swift new file mode 100644 index 000000000..2a0d46a16 --- /dev/null +++ b/Mastodon/Scene/Compose/ComposeViewController.swift @@ -0,0 +1,1049 @@ +// +// ComposeViewController.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-11. +// + +import os.log +import UIKit +import Combine +import PhotosUI +import Kingfisher +import MastodonSDK +import TwitterTextEditor + +final class ComposeViewController: UIViewController, NeedsDependency { + + weak var context: AppContext! { willSet { precondition(!isViewLoaded) } } + weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } } + + var disposeBag = Set() + var viewModel: ComposeViewModel! + + private var suffixedAttachmentViews: [UIView] = [] + + 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.Button.normal.color), for: .normal) + button.setBackgroundImage(.placeholder(color: Asset.Colors.Button.normal.color.withAlphaComponent(0.5)), for: .highlighted) + button.setBackgroundImage(.placeholder(color: Asset.Colors.Button.disabled.color), for: .disabled) + button.setTitleColor(.white, for: .normal) + button.contentEdgeInsets = UIEdgeInsets(top: 6, left: 16, bottom: 5, right: 16) // set 28pt height + button.adjustsImageWhenHighlighted = false + return button + }() + private(set) lazy var publishBarButtonItem: UIBarButtonItem = { + let barButtonItem = UIBarButtonItem(customView: publishButton) + return barButtonItem + }() + + let collectionView: UICollectionView = { + let collectionViewLayout = ComposeViewController.createLayout() + let collectionView = UICollectionView(frame: .zero, collectionViewLayout: collectionViewLayout) + collectionView.register(ComposeRepliedToStatusContentCollectionViewCell.self, forCellWithReuseIdentifier: String(describing: ComposeRepliedToStatusContentCollectionViewCell.self)) + collectionView.register(ComposeStatusContentCollectionViewCell.self, forCellWithReuseIdentifier: String(describing: ComposeStatusContentCollectionViewCell.self)) + collectionView.register(ComposeStatusAttachmentCollectionViewCell.self, forCellWithReuseIdentifier: String(describing: ComposeStatusAttachmentCollectionViewCell.self)) + collectionView.register(ComposeStatusPollOptionCollectionViewCell.self, forCellWithReuseIdentifier: String(describing: ComposeStatusPollOptionCollectionViewCell.self)) + collectionView.register(ComposeStatusPollOptionAppendEntryCollectionViewCell.self, forCellWithReuseIdentifier: String(describing: ComposeStatusPollOptionAppendEntryCollectionViewCell.self)) + collectionView.register(ComposeStatusPollExpiresOptionCollectionViewCell.self, forCellWithReuseIdentifier: String(describing: ComposeStatusPollExpiresOptionCollectionViewCell.self)) + collectionView.backgroundColor = Asset.Scene.Compose.background.color + collectionView.alwaysBounceVertical = true + return collectionView + }() + + var systemKeyboardHeight: CGFloat = .zero { + didSet { + // note: some system AutoLayout warning here + customEmojiPickerInputView.frame.size.height = systemKeyboardHeight != .zero ? systemKeyboardHeight : 300 + } + } + + // CustomEmojiPickerView + let customEmojiPickerInputView: CustomEmojiPickerInputView = { + let view = CustomEmojiPickerInputView(frame: CGRect(x: 0, y: 0, width: 0, height: 300), inputViewStyle: .keyboard) + return view + }() + + let composeToolbarView = ComposeToolbarView() + var composeToolbarViewBottomLayoutConstraint: NSLayoutConstraint! + let composeToolbarBackgroundView = UIView() + + private(set) lazy var imagePicker: PHPickerViewController = { + var configuration = PHPickerConfiguration() + configuration.filter = .images + configuration.selectionLimit = 4 + + let imagePicker = PHPickerViewController(configuration: configuration) + imagePicker.delegate = self + return imagePicker + }() + private(set) lazy var imagePickerController: UIImagePickerController = { + let imagePickerController = UIImagePickerController() + imagePickerController.sourceType = .camera + imagePickerController.delegate = self + return imagePickerController + }() + + private(set) lazy var documentPickerController: UIDocumentPickerViewController = { + let documentPickerController = UIDocumentPickerViewController(forOpeningContentTypes: [.image]) + documentPickerController.delegate = self + return documentPickerController + }() + + deinit { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + } + +} + +extension ComposeViewController { + private static func createLayout() -> 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 + // section.interGroupSpacing = 10 + // section.contentInsets = NSDirectionalEdgeInsets(top: 10, leading: 10, bottom: 10, trailing: 10) + return UICollectionViewCompositionalLayout(section: section) + } +} + +extension ComposeViewController { + + override func viewDidLoad() { + super.viewDidLoad() + + viewModel.title + .receive(on: DispatchQueue.main) + .sink { [weak self] title in + guard let self = self else { return } + self.title = title + } + .store(in: &disposeBag) + view.backgroundColor = Asset.Scene.Compose.background.color + navigationItem.leftBarButtonItem = UIBarButtonItem(title: L10n.Common.Controls.Actions.cancel, style: .plain, target: self, action: #selector(ComposeViewController.cancelBarButtonItemPressed(_:))) + navigationItem.rightBarButtonItem = publishBarButtonItem + publishButton.addTarget(self, action: #selector(ComposeViewController.publishBarButtonItemPressed(_:)), for: .touchUpInside) + + 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), + ]) + + composeToolbarView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(composeToolbarView) + composeToolbarViewBottomLayoutConstraint = view.bottomAnchor.constraint(equalTo: composeToolbarView.bottomAnchor) + NSLayoutConstraint.activate([ + composeToolbarView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + composeToolbarView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + composeToolbarViewBottomLayoutConstraint, + composeToolbarView.heightAnchor.constraint(equalToConstant: ComposeToolbarView.toolbarHeight), + ]) + composeToolbarView.preservesSuperviewLayoutMargins = true + composeToolbarView.delegate = self + + composeToolbarBackgroundView.translatesAutoresizingMaskIntoConstraints = false + view.insertSubview(composeToolbarBackgroundView, belowSubview: composeToolbarView) + NSLayoutConstraint.activate([ + composeToolbarBackgroundView.topAnchor.constraint(equalTo: composeToolbarView.topAnchor), + composeToolbarBackgroundView.leadingAnchor.constraint(equalTo: composeToolbarView.leadingAnchor), + composeToolbarBackgroundView.trailingAnchor.constraint(equalTo: composeToolbarView.trailingAnchor), + view.bottomAnchor.constraint(equalTo: composeToolbarBackgroundView.bottomAnchor), + ]) + + collectionView.delegate = self + viewModel.setupDiffableDataSource( + for: collectionView, + dependency: self, + customEmojiPickerInputViewModel: viewModel.customEmojiPickerInputViewModel, + textEditorViewTextAttributesDelegate: self, + composeStatusAttachmentTableViewCellDelegate: self, + composeStatusPollOptionCollectionViewCellDelegate: self, + composeStatusNewPollOptionCollectionViewCellDelegate: self, + composeStatusPollExpiresOptionCollectionViewCellDelegate: self + ) + let longPressReorderGesture = UILongPressGestureRecognizer(target: self, action: #selector(ComposeViewController.longPressReorderGestureHandler(_:))) + collectionView.addGestureRecognizer(longPressReorderGesture) + + customEmojiPickerInputView.collectionView.delegate = self + viewModel.customEmojiPickerInputViewModel.customEmojiPickerInputView = customEmojiPickerInputView + viewModel.setupCustomEmojiPickerDiffableDataSource( + for: customEmojiPickerInputView.collectionView, + dependency: self + ) + + // respond scrollView overlap change + //view.layoutIfNeeded() + // update layout when keyboard show/dismiss + Publishers.CombineLatest4( + KeyboardResponderService.shared.isShow.eraseToAnyPublisher(), + KeyboardResponderService.shared.state.eraseToAnyPublisher(), + KeyboardResponderService.shared.endFrame.eraseToAnyPublisher(), + viewModel.isCustomEmojiComposing.eraseToAnyPublisher() + ) + .sink(receiveValue: { [weak self] isShow, state, endFrame, isCustomEmojiComposing in + guard let self = self else { return } + + let extraMargin: CGFloat = { + if self.view.safeAreaInsets.bottom == .zero { + // needs extra margin for zero inset device to workaround UIKit issue + return self.composeToolbarView.frame.height + } else { + // default some magic 16 extra margin + return 16 + } + }() + + // update keyboard background color + + guard isShow, state == .dock else { + self.collectionView.contentInset.bottom = self.view.safeAreaInsets.bottom + extraMargin + self.collectionView.verticalScrollIndicatorInsets.bottom = self.view.safeAreaInsets.bottom + extraMargin + UIView.animate(withDuration: 0.3) { + self.composeToolbarViewBottomLayoutConstraint.constant = self.view.safeAreaInsets.bottom + if self.view.window != nil { + self.view.layoutIfNeeded() + } + } + self.updateKeyboardBackground(isKeyboardDisplay: isShow) + return + } + // isShow AND dock state + self.systemKeyboardHeight = endFrame.height + + let contentFrame = self.view.convert(self.collectionView.frame, to: nil) + let padding = contentFrame.maxY - endFrame.minY + guard padding > 0 else { + self.collectionView.contentInset.bottom = self.view.safeAreaInsets.bottom + extraMargin + self.collectionView.verticalScrollIndicatorInsets.bottom = self.view.safeAreaInsets.bottom + extraMargin + UIView.animate(withDuration: 0.3) { + self.composeToolbarViewBottomLayoutConstraint.constant = self.view.safeAreaInsets.bottom + self.view.layoutIfNeeded() + } + self.updateKeyboardBackground(isKeyboardDisplay: false) + return + } + + self.collectionView.contentInset.bottom = padding + extraMargin + self.collectionView.verticalScrollIndicatorInsets.bottom = padding + extraMargin + UIView.animate(withDuration: 0.3) { + self.composeToolbarViewBottomLayoutConstraint.constant = padding + self.view.layoutIfNeeded() + } + self.updateKeyboardBackground(isKeyboardDisplay: isShow) + }) + .store(in: &disposeBag) + + // bind publish bar button state + viewModel.isPublishBarButtonItemEnabled + .receive(on: DispatchQueue.main) + .assign(to: \.isEnabled, on: publishBarButtonItem) + .store(in: &disposeBag) + + // bind media button toolbar state + viewModel.isMediaToolbarButtonEnabled + .receive(on: DispatchQueue.main) + .assign(to: \.isEnabled, on: composeToolbarView.mediaButton) + .store(in: &disposeBag) + + // bind poll button toolbar state + viewModel.isPollToolbarButtonEnabled + .receive(on: DispatchQueue.main) + .assign(to: \.isEnabled, on: composeToolbarView.pollButton) + .store(in: &disposeBag) + + Publishers.CombineLatest( + viewModel.isPollComposing, + viewModel.isPollToolbarButtonEnabled + ) + .receive(on: DispatchQueue.main) + .sink { [weak self] isPollComposing, isPollToolbarButtonEnabled in + guard let self = self else { return } + guard isPollToolbarButtonEnabled else { + self.composeToolbarView.pollButton.accessibilityLabel = L10n.Scene.Compose.Accessibility.appendPoll + return + } + self.composeToolbarView.pollButton.accessibilityLabel = isPollComposing ? L10n.Scene.Compose.Accessibility.removePoll : L10n.Scene.Compose.Accessibility.appendPoll + } + .store(in: &disposeBag) + + // bind image picker toolbar state + viewModel.attachmentServices + .receive(on: DispatchQueue.main) + .sink { [weak self] attachmentServices in + guard let self = self else { return } + self.composeToolbarView.mediaButton.isEnabled = attachmentServices.count < 4 + self.resetImagePicker() + } + .store(in: &disposeBag) + + // bind content warning button state + viewModel.isContentWarningComposing + .receive(on: DispatchQueue.main) + .sink { [weak self] isContentWarningComposing in + guard let self = self else { return } + self.composeToolbarView.contentWarningButton.accessibilityLabel = isContentWarningComposing ? L10n.Scene.Compose.Accessibility.disableContentWarning : L10n.Scene.Compose.Accessibility.enableContentWarning + } + .store(in: &disposeBag) + + // bind visibility toolbar UI + Publishers.CombineLatest( + viewModel.selectedStatusVisibility, + viewModel.traitCollectionDidChangePublisher + ) + .receive(on: DispatchQueue.main) + .sink { [weak self] type, _ in + guard let self = self else { return } + let image = type.image(interfaceStyle: self.traitCollection.userInterfaceStyle) + self.composeToolbarView.visibilityButton.setImage(image, for: .normal) + } + .store(in: &disposeBag) + + viewModel.characterCount + .receive(on: DispatchQueue.main) + .sink { [weak self] characterCount in + guard let self = self else { return } + let count = ComposeViewModel.composeContentLimit - characterCount + self.composeToolbarView.characterCountLabel.text = "\(count)" + switch count { + case _ where count < 0: + self.composeToolbarView.characterCountLabel.font = .systemFont(ofSize: 24, weight: .bold) + self.composeToolbarView.characterCountLabel.textColor = Asset.Colors.danger.color + self.composeToolbarView.characterCountLabel.accessibilityLabel = L10n.Scene.Compose.Accessibility.inputLimitExceedsCount(abs(count)) + default: + self.composeToolbarView.characterCountLabel.font = .systemFont(ofSize: 15, weight: .regular) + self.composeToolbarView.characterCountLabel.textColor = Asset.Colors.Label.secondary.color + self.composeToolbarView.characterCountLabel.accessibilityLabel = L10n.Scene.Compose.Accessibility.inputLimitRemainsCount(count) + } + } + .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() + .receive(on: DispatchQueue.main) + .sink(receiveValue: { [weak self] emojis in + guard let self = self else { return } + if emojis.isEmpty { + self.customEmojiPickerInputView.activityIndicatorView.startAnimating() + } else { + self.customEmojiPickerInputView.activityIndicatorView.stopAnimating() + } + }) + .store(in: &disposeBag) + + // setup snap behavior + Publishers.CombineLatest( + viewModel.repliedToCellFrame.removeDuplicates().eraseToAnyPublisher(), + viewModel.collectionViewState.eraseToAnyPublisher() + ) + .receive(on: DispatchQueue.main) + .sink { [weak self] repliedToCellFrame, collectionViewState in + guard let self = self else { return } + guard repliedToCellFrame != .zero else { return } + switch collectionViewState { + case .fold: + self.collectionView.contentInset.top = -repliedToCellFrame.height + case .expand: + self.collectionView.contentInset.top = 0 + } + } + .store(in: &disposeBag) + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + + // Fix AutoLayout conflict issue + DispatchQueue.main.async { [weak self] in + guard let self = self else { return } + self.markTextEditorViewBecomeFirstResponser() + } + } + + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + + viewModel.traitCollectionDidChangePublisher.send() + } + +} + +extension ComposeViewController { + + private func textEditorView() -> TextEditorView? { + guard let diffableDataSource = viewModel.diffableDataSource else { return nil } + let items = diffableDataSource.snapshot().itemIdentifiers + for item in items { + switch item { + case .input: + guard let indexPath = diffableDataSource.indexPath(for: item), + let cell = collectionView.cellForItem(at: indexPath) as? ComposeStatusContentCollectionViewCell else { + continue + } + return cell.textEditorView + default: + continue + } + } + + return nil + } + + private func markTextEditorViewBecomeFirstResponser() { + textEditorView()?.isEditing = true + } + + private func contentWarningEditorTextView() -> UITextView? { + guard let diffableDataSource = viewModel.diffableDataSource else { return nil } + let items = diffableDataSource.snapshot().itemIdentifiers + for item in items { + switch item { + case .input: + guard let indexPath = diffableDataSource.indexPath(for: item), + let cell = collectionView.cellForItem(at: indexPath) as? ComposeStatusContentCollectionViewCell else { + continue + } + return cell.statusContentWarningEditorView.textView + default: + continue + } + } + + return nil + } + + private func pollOptionCollectionViewCell(of item: ComposeStatusItem) -> ComposeStatusPollOptionCollectionViewCell? { + guard case .pollOption = item else { return nil } + guard let diffableDataSource = viewModel.diffableDataSource else { return nil } + guard let indexPath = diffableDataSource.indexPath(for: item), + let cell = collectionView.cellForItem(at: indexPath) as? ComposeStatusPollOptionCollectionViewCell else { + return nil + } + + return cell + } + + private func firstPollOptionCollectionViewCell() -> ComposeStatusPollOptionCollectionViewCell? { + guard let diffableDataSource = viewModel.diffableDataSource else { return nil } + let items = diffableDataSource.snapshot().itemIdentifiers(inSection: .poll) + let firstPollItem = items.first { item -> Bool in + guard case .pollOption = item else { return false } + return true + } + + guard let item = firstPollItem else { + return nil + } + + return pollOptionCollectionViewCell(of: item) + } + + private func lastPollOptionCollectionViewCell() -> ComposeStatusPollOptionCollectionViewCell? { + guard let diffableDataSource = viewModel.diffableDataSource else { return nil } + let items = diffableDataSource.snapshot().itemIdentifiers(inSection: .poll) + let lastPollItem = items.last { item -> Bool in + guard case .pollOption = item else { return false } + return true + } + + guard let item = lastPollItem else { + return nil + } + + return pollOptionCollectionViewCell(of: item) + } + + private func markFirstPollOptionCollectionViewCellBecomeFirstResponser() { + guard let cell = firstPollOptionCollectionViewCell() else { return } + cell.pollOptionView.optionTextField.becomeFirstResponder() + } + + private func markLastPollOptionCollectionViewCellBecomeFirstResponser() { + guard let cell = lastPollOptionCollectionViewCell() else { return } + cell.pollOptionView.optionTextField.becomeFirstResponder() + } + + private func showDismissConfirmAlertController() { + let alertController = UIAlertController( + title: L10n.Common.Alerts.DiscardPostContent.title, + message: L10n.Common.Alerts.DiscardPostContent.message, + preferredStyle: .alert + ) + let discardAction = UIAlertAction(title: L10n.Common.Controls.Actions.discard, style: .destructive) { [weak self] _ in + guard let self = self else { return } + self.dismiss(animated: true, completion: nil) + } + alertController.addAction(discardAction) + let cancelAction = UIAlertAction(title: L10n.Common.Controls.Actions.cancel, style: .cancel) + alertController.addAction(cancelAction) + present(alertController, animated: true, completion: nil) + } + + private func resetImagePicker() { + var configuration = PHPickerConfiguration() + configuration.filter = .images + let selectionLimit = max(1, 4 - viewModel.attachmentServices.value.count) + configuration.selectionLimit = selectionLimit + + imagePicker = createImagePicker(configuration: configuration) + } + + private func createImagePicker(configuration: PHPickerConfiguration) -> PHPickerViewController { + let imagePicker = PHPickerViewController(configuration: configuration) + imagePicker.delegate = self + return imagePicker + } + + private func updateKeyboardBackground(isKeyboardDisplay: Bool) { + guard isKeyboardDisplay else { + composeToolbarBackgroundView.backgroundColor = Asset.Scene.Compose.toolbarBackground.color + return + } + composeToolbarBackgroundView.backgroundColor = UIColor(dynamicProvider: { traitCollection -> UIColor in + // avoid elevated color + switch traitCollection.userInterfaceStyle { + case .light: return .white + default: return .black + } + }) + } + +} + +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 { + showDismissConfirmAlertController() + return + } + dismiss(animated: true, completion: nil) + } + + @objc private func publishBarButtonItemPressed(_ sender: UIBarButtonItem) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + guard viewModel.publishStateMachine.enter(ComposeViewModel.PublishState.Publishing.self) else { + // TODO: handle error + return + } + context.statusPublishService.publish(composeViewModel: viewModel) + dismiss(animated: true, completion: nil) + } + + // seealso: ComposeViewModel.setupDiffableDataSource(…) + @objc private func longPressReorderGestureHandler(_ sender: UILongPressGestureRecognizer) { + switch(sender.state) { + case .began: + guard let selectedIndexPath = collectionView.indexPathForItem(at: sender.location(in: collectionView)), + let cell = collectionView.cellForItem(at: selectedIndexPath) as? ComposeStatusPollOptionCollectionViewCell else { + break + } + // check if pressing reorder bar no not + let locationInCell = sender.location(in: cell) + guard cell.reorderBarImageView.frame.contains(locationInCell) else { + return + } + + collectionView.beginInteractiveMovementForItem(at: selectedIndexPath) + case .changed: + guard let selectedIndexPath = collectionView.indexPathForItem(at: sender.location(in: collectionView)), + let diffableDataSource = viewModel.diffableDataSource else { + break + } + guard let item = diffableDataSource.itemIdentifier(for: selectedIndexPath), + case .pollOption = item else { + collectionView.cancelInteractiveMovement() + return + } + + var position = sender.location(in: collectionView) + position.x = collectionView.frame.width * 0.5 + collectionView.updateInteractiveMovementTargetPosition(position) + case .ended: + collectionView.endInteractiveMovement() + collectionView.reloadData() + default: + collectionView.cancelInteractiveMovement() + } + } + +} + +// MARK: - TextEditorViewTextAttributesDelegate +extension ComposeViewController: TextEditorViewTextAttributesDelegate { + + func textEditorView( + _ textEditorView: TextEditorView, + updateAttributedString attributedString: NSAttributedString, + completion: @escaping (NSAttributedString?) -> Void + ) { + // FIXME: needs O(1) update completion to fix profermance issue + DispatchQueue.global().async { + let string = attributedString.string + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: update: %s", ((#file as NSString).lastPathComponent), #line, #function, string) + + let stringRange = NSRange(location: 0, length: string.length) + let highlightMatches = string.matches(pattern: "(?:@([a-zA-Z0-9_]+)(@[a-zA-Z0-9_.-]+)?|#([^\\s.]+))") + // accept ^\B: or \s: but not accept \B: to force user input a space to make emoji take effect + // precondition :\B with following space + let emojiMatches = string.matches(pattern: "(?:(^\\B:|\\s:)([a-zA-Z0-9_]+)(:\\B(?=\\s)))") + // only accept http/https scheme + let urlMatches = string.matches(pattern: "(?i)https?://\\S+(?:/|\\b)") + + DispatchQueue.main.async { [weak self] in + guard let self = self else { + completion(nil) + return + } + let customEmojiViewModel = self.viewModel.customEmojiViewModel.value + for view in self.suffixedAttachmentViews { + view.removeFromSuperview() + } + self.suffixedAttachmentViews.removeAll() + + // set normal apperance + let attributedString = NSMutableAttributedString(attributedString: attributedString) + attributedString.removeAttribute(.suffixedAttachment, range: stringRange) + attributedString.removeAttribute(.underlineStyle, range: stringRange) + attributedString.addAttribute(.foregroundColor, value: Asset.Colors.Label.primary.color, range: stringRange) + attributedString.addAttribute(.font, value: UIFont.preferredFont(forTextStyle: .body), range: stringRange) + + // hashtag + for match in highlightMatches { + // set highlight + var attributes = [NSAttributedString.Key: Any]() + attributes[.foregroundColor] = Asset.Colors.Label.highlight.color + + // See `traitCollectionDidChange(_:)` + // set accessibility + if #available(iOS 13.0, *) { + switch self.traitCollection.accessibilityContrast { + case .high: + attributes[.underlineStyle] = NSUnderlineStyle.single.rawValue + default: + break + } + } + attributedString.addAttributes(attributes, range: match.range) + } + + // emoji + if let customEmojiViewModel = customEmojiViewModel, !customEmojiViewModel.emojiDict.value.isEmpty { + for match in emojiMatches { + guard let name = string.substring(with: match, at: 2) else { continue } + guard let emoji = customEmojiViewModel.emoji(shortcode: name) else { continue } + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: handle emoji: %s", ((#file as NSString).lastPathComponent), #line, #function, name) + + // set emoji token invisiable (without upper bounce space) + var attributes = [NSAttributedString.Key: Any]() + attributes[.font] = UIFont.systemFont(ofSize: 0.01) + attributedString.addAttributes(attributes, range: match.range) + + // append emoji attachment + let imageViewSize = CGSize(width: 20, height: 20) + let imageView = UIImageView(frame: CGRect(origin: .zero, size: imageViewSize)) + textEditorView.textContentView.addSubview(imageView) + self.suffixedAttachmentViews.append(imageView) + let processor = DownsamplingImageProcessor(size: imageViewSize) + imageView.kf.setImage( + with: URL(string: emoji.url), + placeholder: UIImage.placeholder(size: imageViewSize, color: .systemFill), + options: [ + .processor(processor), + .scaleFactor(textEditorView.traitCollection.displayScale), + ], completionHandler: nil + ) + let layoutInTextContainer = { [weak textEditorView] (view: UIView, frame: CGRect) in + // `textEditorView` retains `textStorage`, which retains this block as a part of attributes. + guard let textEditorView = textEditorView else { + return + } + let insets = textEditorView.textContentInsets + view.frame = frame.offsetBy(dx: insets.left, dy: insets.top) + } + let attachment = TextAttributes.SuffixedAttachment( + size: imageViewSize, + attachment: .view(view: imageView, layoutInTextContainer: layoutInTextContainer) + ) + let index = match.range.upperBound - 1 + attributedString.addAttribute( + .suffixedAttachment, + value: attachment, + range: NSRange(location: index, length: 1) + ) + } + } + + // url + for match in urlMatches { + guard let name = string.substring(with: match, at: 0) else { continue } + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: handle emoji: %s", ((#file as NSString).lastPathComponent), #line, #function, name) + + // set highlight + var attributes = [NSAttributedString.Key: Any]() + attributes[.foregroundColor] = Asset.Colors.Label.highlight.color + + // See `traitCollectionDidChange(_:)` + // set accessibility + if #available(iOS 13.0, *) { + switch self.traitCollection.accessibilityContrast { + case .high: + attributes[.underlineStyle] = NSUnderlineStyle.single.rawValue + default: + break + } + } + attributedString.addAttributes(attributes, range: match.range) + } + + if string.count > ComposeViewModel.composeContentLimit { + var attributes = [NSAttributedString.Key: Any]() + attributes[.foregroundColor] = Asset.Colors.danger.color + let boundStart = string.index(string.startIndex, offsetBy: ComposeViewModel.composeContentLimit) + let boundEnd = string.endIndex + let range = boundStart..) { + guard scrollView === collectionView else { return } + + let repliedToCellFrame = viewModel.repliedToCellFrame.value + guard repliedToCellFrame != .zero else { return } + let throttle = viewModel.repliedToCellFrame.value.height - scrollView.adjustedContentInset.top + // print("\(throttle) - \(scrollView.contentOffset.y)") + + switch viewModel.collectionViewState.value { + case .fold: + if scrollView.contentOffset.y < throttle { + viewModel.collectionViewState.value = .expand + } + os_log("%{public}s[%{public}ld], %{public}s: fold", ((#file as NSString).lastPathComponent), #line, #function) + + case .expand: + if scrollView.contentOffset.y > -44 { + viewModel.collectionViewState.value = .fold + os_log("%{public}s[%{public}ld], %{public}s: expand", ((#file as NSString).lastPathComponent), #line, #function) + } + } + } +} + +// MARK: - UITableViewDelegate +extension ComposeViewController: UICollectionViewDelegate { + + func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: select %s", ((#file as NSString).lastPathComponent), #line, #function, indexPath.debugDescription) + + if collectionView === customEmojiPickerInputView.collectionView { + guard let diffableDataSource = viewModel.customEmojiPickerDiffableDataSource else { return } + let item = diffableDataSource.itemIdentifier(for: indexPath) + guard case let .emoji(attribute) = item else { return } + let emoji = attribute.emoji + let textEditorView = self.textEditorView() + + // retrive active text input and insert emoji + // the leading and trailing space is REQUIRED to fix `UITextStorage` layout issue + let reference = viewModel.customEmojiPickerInputViewModel.insertText(" :\(emoji.shortcode): ") + + // workaround: non-user interactive change do not trigger value update event + if reference?.value === textEditorView { + viewModel.composeStatusAttribute.composeContent.value = textEditorView?.text + // update text storage + textEditorView?.setNeedsUpdateTextAttributes() + // collection self-size + DispatchQueue.main.async { [weak self] in + guard let self = self else { return } + self.collectionView.collectionViewLayout.invalidateLayout() + } + } + } else { + // do nothing + } + } +} + +// MARK: - UIAdaptivePresentationControllerDelegate +extension ComposeViewController: UIAdaptivePresentationControllerDelegate { + + func adaptivePresentationStyle(for controller: UIPresentationController, traitCollection: UITraitCollection) -> UIModalPresentationStyle { + return .fullScreen + } + + func presentationControllerShouldDismiss(_ presentationController: UIPresentationController) -> Bool { + return viewModel.shouldDismiss.value + } + + func presentationControllerDidAttemptToDismiss(_ presentationController: UIPresentationController) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + showDismissConfirmAlertController() + + } + + func presentationControllerDidDismiss(_ presentationController: UIPresentationController) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + } + +} + +// MARK: - PHPickerViewControllerDelegate +extension ComposeViewController: PHPickerViewControllerDelegate { + func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) { + picker.dismiss(animated: true, completion: nil) + + let attachmentServices: [MastodonAttachmentService] = results.map { result in + let service = MastodonAttachmentService( + context: context, + pickerResult: result, + initalAuthenticationBox: viewModel.activeAuthenticationBox.value + ) + return service + } + viewModel.attachmentServices.value = viewModel.attachmentServices.value + attachmentServices + } +} + +// MARK: - UIImagePickerControllerDelegate +extension ComposeViewController: UIImagePickerControllerDelegate & UINavigationControllerDelegate { + + func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey : Any]) { + picker.dismiss(animated: true, completion: nil) + + guard let image = info[.originalImage] as? UIImage else { return } + + let attachmentService = MastodonAttachmentService( + context: context, + image: image, + initalAuthenticationBox: viewModel.activeAuthenticationBox.value + ) + viewModel.attachmentServices.value = viewModel.attachmentServices.value + [attachmentService] + } + + func imagePickerControllerDidCancel(_ picker: UIImagePickerController) { + os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + picker.dismiss(animated: true, completion: nil) + } +} + +// MARK: - UIDocumentPickerDelegate +extension ComposeViewController: UIDocumentPickerDelegate { + func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) { + guard let url = urls.first else { return } + + do { + guard url.startAccessingSecurityScopedResource() else { return } + defer { url.stopAccessingSecurityScopedResource() } + let imageData = try Data(contentsOf: url) + let attachmentService = MastodonAttachmentService( + context: context, + imageData: imageData, + initalAuthenticationBox: viewModel.activeAuthenticationBox.value + ) + viewModel.attachmentServices.value = viewModel.attachmentServices.value + [attachmentService] + } catch { + os_log("%{public}s[%{public}ld], %{public}s: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) + } + } +} + +// MARK: - ComposeStatusAttachmentTableViewCellDelegate +extension ComposeViewController: ComposeStatusAttachmentCollectionViewCellDelegate { + + func composeStatusAttachmentCollectionViewCell(_ cell: ComposeStatusAttachmentCollectionViewCell, removeButtonDidPressed 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 } + guard case let .attachment(attachmentService) = item else { return } + + var attachmentServices = viewModel.attachmentServices.value + guard let index = attachmentServices.firstIndex(of: attachmentService) else { return } + attachmentServices.remove(at: index) + viewModel.attachmentServices.value = attachmentServices + } + +} + +// MARK: - ComposeStatusPollOptionCollectionViewCellDelegate +extension ComposeViewController: ComposeStatusPollOptionCollectionViewCellDelegate { + + func composeStatusPollOptionCollectionViewCell(_ cell: ComposeStatusPollOptionCollectionViewCell, textFieldDidBeginEditing textField: UITextField) { + // FIXME: make poll section visible + // DispatchQueue.main.async { + // self.collectionView.scroll(to: .bottom, animated: true) + // } + } + + + // handle delete backward event for poll option input + func composeStatusPollOptionCollectionViewCell(_ cell: ComposeStatusPollOptionCollectionViewCell, textBeforeDeleteBackward text: String?) { + guard (text ?? "").isEmpty else { return } + 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 } + guard case let .pollOption(attribute) = item else { return } + + var pollAttributes = viewModel.pollOptionAttributes.value + guard let index = pollAttributes.firstIndex(of: attribute) else { return } + + // mark previous (fallback to next) item of removed middle poll option become first responder + let pollItems = diffableDataSource.snapshot().itemIdentifiers(inSection: .poll) + if let indexOfItem = pollItems.firstIndex(of: item), index > 0 { + func cellBeforeRemoved() -> ComposeStatusPollOptionCollectionViewCell? { + guard index > 0 else { return nil } + let indexBeforeRemoved = pollItems.index(before: indexOfItem) + let itemBeforeRemoved = pollItems[indexBeforeRemoved] + return pollOptionCollectionViewCell(of: itemBeforeRemoved) + } + + func cellAfterRemoved() -> ComposeStatusPollOptionCollectionViewCell? { + guard index < pollItems.count - 1 else { return nil } + let indexAfterRemoved = pollItems.index(after: index) + let itemAfterRemoved = pollItems[indexAfterRemoved] + return pollOptionCollectionViewCell(of: itemAfterRemoved) + } + + var cell: ComposeStatusPollOptionCollectionViewCell? = cellBeforeRemoved() + if cell == nil { + cell = cellAfterRemoved() + } + cell?.pollOptionView.optionTextField.becomeFirstResponder() + } + + guard pollAttributes.count > 2 else { + return + } + pollAttributes.remove(at: index) + + // update data source + viewModel.pollOptionAttributes.value = pollAttributes + } + + // handle keyboard return event for poll option input + func composeStatusPollOptionCollectionViewCell(_ cell: ComposeStatusPollOptionCollectionViewCell, pollOptionTextFieldDidReturn: UITextField) { + guard let diffableDataSource = viewModel.diffableDataSource else { return } + guard let indexPath = collectionView.indexPath(for: cell) else { return } + let pollItems = diffableDataSource.snapshot().itemIdentifiers(inSection: .poll).filter { item in + guard case .pollOption = item else { return false } + return true + } + guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return } + guard let index = pollItems.firstIndex(of: item) else { return } + + if index == pollItems.count - 1 { + // is the last + viewModel.createNewPollOptionIfPossible() + DispatchQueue.main.async { + self.markLastPollOptionCollectionViewCellBecomeFirstResponser() + } + } else { + // not the last + let indexAfter = pollItems.index(after: index) + let itemAfter = pollItems[indexAfter] + let cell = pollOptionCollectionViewCell(of: itemAfter) + cell?.pollOptionView.optionTextField.becomeFirstResponder() + } + } + +} + +// MARK: - ComposeStatusPollOptionAppendEntryCollectionViewCellDelegate +extension ComposeViewController: ComposeStatusPollOptionAppendEntryCollectionViewCellDelegate { + func composeStatusPollOptionAppendEntryCollectionViewCellDidPressed(_ cell: ComposeStatusPollOptionAppendEntryCollectionViewCell) { + viewModel.createNewPollOptionIfPossible() + DispatchQueue.main.async { + self.markLastPollOptionCollectionViewCellBecomeFirstResponser() + } + } +} + +// MARK: - ComposeStatusPollExpiresOptionCollectionViewCellDelegate +extension ComposeViewController: ComposeStatusPollExpiresOptionCollectionViewCellDelegate { + func composeStatusPollExpiresOptionCollectionViewCell(_ cell: ComposeStatusPollExpiresOptionCollectionViewCell, didSelectExpiresOption expiresOption: ComposeStatusItem.ComposePollExpiresOptionAttribute.ExpiresOption) { + viewModel.pollExpiresOptionAttribute.expiresOption.value = expiresOption + } +} diff --git a/Mastodon/Scene/Compose/ComposeViewModel+Diffable.swift b/Mastodon/Scene/Compose/ComposeViewModel+Diffable.swift new file mode 100644 index 000000000..6581e1fb1 --- /dev/null +++ b/Mastodon/Scene/Compose/ComposeViewModel+Diffable.swift @@ -0,0 +1,117 @@ +// +// ComposeViewModel+Diffable.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-11. +// + +import UIKit +import Combine +import TwitterTextEditor +import MastodonSDK + +extension ComposeViewModel { + + func setupDiffableDataSource( + for collectionView: UICollectionView, + dependency: NeedsDependency, + customEmojiPickerInputViewModel: CustomEmojiPickerInputViewModel, + textEditorViewTextAttributesDelegate: TextEditorViewTextAttributesDelegate, + composeStatusAttachmentTableViewCellDelegate: ComposeStatusAttachmentCollectionViewCellDelegate, + composeStatusPollOptionCollectionViewCellDelegate: ComposeStatusPollOptionCollectionViewCellDelegate, + composeStatusNewPollOptionCollectionViewCellDelegate: ComposeStatusPollOptionAppendEntryCollectionViewCellDelegate, + composeStatusPollExpiresOptionCollectionViewCellDelegate: ComposeStatusPollExpiresOptionCollectionViewCellDelegate + ) { + let diffableDataSource = ComposeStatusSection.collectionViewDiffableDataSource( + for: collectionView, + dependency: dependency, + managedObjectContext: context.managedObjectContext, + composeKind: composeKind, + repliedToCellFrameSubscriber: repliedToCellFrame, + customEmojiPickerInputViewModel: customEmojiPickerInputViewModel, + textEditorViewTextAttributesDelegate: textEditorViewTextAttributesDelegate, + composeStatusAttachmentTableViewCellDelegate: composeStatusAttachmentTableViewCellDelegate, + composeStatusPollOptionCollectionViewCellDelegate: composeStatusPollOptionCollectionViewCellDelegate, + composeStatusNewPollOptionCollectionViewCellDelegate: composeStatusNewPollOptionCollectionViewCellDelegate, + composeStatusPollExpiresOptionCollectionViewCellDelegate: composeStatusPollExpiresOptionCollectionViewCellDelegate + ) + + diffableDataSource.reorderingHandlers.canReorderItem = { item in + switch item { + case .pollOption: return true + default: return false + } + } + + // update reordered data source + diffableDataSource.reorderingHandlers.didReorder = { [weak self] transaction in + guard let self = self else { return } + + let items = transaction.finalSnapshot.itemIdentifiers + var pollOptionAttributes: [ComposeStatusItem.ComposePollOptionAttribute] = [] + for item in items { + guard case let .pollOption(attribute) = item else { continue } + pollOptionAttributes.append(attribute) + } + self.pollOptionAttributes.value = pollOptionAttributes + } + + self.diffableDataSource = diffableDataSource + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([.repliedTo, .status, .attachment, .poll]) + switch composeKind { + case .reply(let statusObjectID): + snapshot.appendItems([.replyTo(statusObjectID: statusObjectID)], toSection: .repliedTo) + snapshot.appendItems([.input(replyToStatusObjectID: statusObjectID, attribute: composeStatusAttribute)], toSection: .repliedTo) + case .hashtag, .mention, .post: + snapshot.appendItems([.input(replyToStatusObjectID: nil, attribute: composeStatusAttribute)], toSection: .status) + } + diffableDataSource.apply(snapshot, animatingDifferences: false) + } + + 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 + 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) + } + } + .store(in: &disposeBag) + } + +} diff --git a/Mastodon/Scene/Compose/ComposeViewModel+PublishState.swift b/Mastodon/Scene/Compose/ComposeViewModel+PublishState.swift new file mode 100644 index 000000000..fd3f5bce0 --- /dev/null +++ b/Mastodon/Scene/Compose/ComposeViewModel+PublishState.swift @@ -0,0 +1,164 @@ +// +// ComposeViewModel+PublishState.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-18. +// + +import os.log +import Foundation +import Combine +import CoreDataStack +import GameplayKit +import MastodonSDK + +extension ComposeViewModel { + class PublishState: GKState { + weak var viewModel: ComposeViewModel? + + init(viewModel: ComposeViewModel) { + 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?.publishStateMachinePublisher.value = self + } + } +} + +extension ComposeViewModel.PublishState { + class Initial: ComposeViewModel.PublishState { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + return stateClass == Publishing.self + } + } + + class Publishing: ComposeViewModel.PublishState { + + var publishingSubscription: AnyCancellable? + + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + return stateClass == Fail.self || stateClass == Finish.self + } + + 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 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 } + }() + let pollExpiresIn: Int? = { + guard viewModel.isPollComposing.value 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 } + var id: Mastodon.Entity.Status.ID? + viewModel.context.managedObjectContext.performAndWait { + guard let replyTo = viewModel.context.managedObjectContext.object(with: repliedToStatusObjectID) as? Status else { return } + id = replyTo.id + } + return id + }() + let sensitive: Bool = viewModel.isContentWarningComposing.value + let spoilerText: String? = { + let text = viewModel.composeStatusAttribute.contentWarningContent.value.trimmingCharacters(in: .whitespacesAndNewlines) + guard !text.isEmpty else { + return nil + } + return text + }() + let visibility = viewModel.selectedStatusVisibility.value.visibility + + let updateMediaQuerySubscriptions: [AnyPublisher, Error>] = { + var subscriptions: [AnyPublisher, Error>] = [] + for attachmentService in attachmentServices { + guard let attachmentID = attachmentService.attachment.value?.id else { continue } + let description = attachmentService.description.value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard !description.isEmpty else { continue } + let query = Mastodon.API.Media.UpdateMediaQuery( + file: nil, + thumbnail: nil, + description: description, + focus: nil + ) + let subscription = viewModel.context.apiService.updateMedia( + domain: domain, + attachmentID: attachmentID, + query: query, + mastodonAuthenticationBox: mastodonAuthenticationBox + ) + subscriptions.append(subscription) + } + return subscriptions + }() + + publishingSubscription = Publishers.MergeMany(updateMediaQuerySubscriptions) + .collect() + .flatMap { attachments -> AnyPublisher, Error> in + let query = Mastodon.API.Statuses.PublishStatusQuery( + status: viewModel.composeStatusAttribute.composeContent.value, + mediaIDs: mediaIDs.isEmpty ? nil : mediaIDs, + pollOptions: pollOptions, + pollExpiresIn: pollExpiresIn, + inReplyToID: inReplyToID, + sensitive: sensitive, + spoilerText: spoilerText, + visibility: visibility + ) + return viewModel.context.apiService.publishStatus( + domain: domain, + query: query, + mastodonAuthenticationBox: mastodonAuthenticationBox + ) + } + .receive(on: DispatchQueue.main) + .sink { completion in + switch completion { + case .failure(let error): + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: publish status %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) + stateMachine.enter(Fail.self) + case .finished: + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: publish status success", ((#file as NSString).lastPathComponent), #line, #function) + stateMachine.enter(Finish.self) + } + } receiveValue: { response in + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: status %s published: %s", ((#file as NSString).lastPathComponent), #line, #function, response.value.id, response.value.uri) + } + } + } + + class Fail: ComposeViewModel.PublishState { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + // allow discard publishing + return stateClass == Publishing.self || stateClass == Discard.self + } + } + + class Discard: ComposeViewModel.PublishState { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + return false + } + } + + class Finish: ComposeViewModel.PublishState { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + return false + } + } + +} diff --git a/Mastodon/Scene/Compose/ComposeViewModel.swift b/Mastodon/Scene/Compose/ComposeViewModel.swift new file mode 100644 index 000000000..58d1ffa75 --- /dev/null +++ b/Mastodon/Scene/Compose/ComposeViewModel.swift @@ -0,0 +1,420 @@ +// +// ComposeViewModel.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-11. +// + +import os.log +import UIKit +import Combine +import CoreData +import CoreDataStack +import GameplayKit +import MastodonSDK + +final class ComposeViewModel { + + static let composeContentLimit: Int = 500 + + var disposeBag = Set() + + // 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 traitCollectionDidChangePublisher = CurrentValueSubject(Void()) // use CurrentValueSubject to make intial event emit + let repliedToCellFrame = CurrentValueSubject(.zero) + + // output + var diffableDataSource: UICollectionViewDiffableDataSource! + var customEmojiPickerDiffableDataSource: UICollectionViewDiffableDataSource! + private(set) lazy var publishStateMachine: GKStateMachine = { + // exclude timeline middle fetcher state + let stateMachine = GKStateMachine(states: [ + PublishState.Initial(viewModel: self), + PublishState.Publishing(viewModel: self), + PublishState.Fail(viewModel: self), + PublishState.Discard(viewModel: self), + PublishState.Finish(viewModel: self), + ]) + stateMachine.enter(PublishState.Initial.self) + return stateMachine + }() + private(set) lazy var publishStateMachinePublisher = CurrentValueSubject(nil) + private(set) var publishDate = Date() // update it when enter Publishing state + + // 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) + + // for hashtag: #' ' + // for mention: @' ' + private(set) var preInsertedContent: String? + + // custom emojis + var customEmojiViewModelSubscription: AnyCancellable? + let customEmojiViewModel = CurrentValueSubject(nil) + let customEmojiPickerInputViewModel = CustomEmojiPickerInputViewModel() + let isLoadingCustomEmoji = CurrentValueSubject(false) + + // attachment + let attachmentServices = CurrentValueSubject<[MastodonAttachmentService], Never>([]) + + // polls + let pollOptionAttributes = CurrentValueSubject<[ComposeStatusItem.ComposePollOptionAttribute], Never>([]) + let pollExpiresOptionAttribute = ComposeStatusItem.ComposePollExpiresOptionAttribute() + + init( + context: AppContext, + composeKind: ComposeStatusSection.ComposeKind + ) { + 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.selectedStatusVisibility = CurrentValueSubject(context.authenticationService.activeMastodonAuthentication.value?.user.locked == true ? .private : .public) + self.activeAuthentication = CurrentValueSubject(context.authenticationService.activeMastodonAuthentication.value) + self.activeAuthenticationBox = CurrentValueSubject(context.authenticationService.activeMastodonAuthenticationBox.value) + // end init + if case let .reply(repliedToStatusObjectID) = composeKind { + 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 { + mentionAccts.append("@" + mention.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 + } + + } else if case let .hashtag(text) = composeKind { + let initialComposeContent = "#" + text + UITextChecker.learnWord(initialComposeContent) + let preInsertedContent = initialComposeContent + " " + self.preInsertedContent = preInsertedContent + self.composeStatusAttribute.composeContent.value = preInsertedContent + } else if case let .mention(mastodonUserObjectID) = composeKind { + 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 + } + } else { + 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.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 = composeStatusAttribute.composeContent + .map { composeContent -> Bool in + let composeContent = composeContent ?? "" + return composeContent.count <= ComposeViewModel.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.eraseToAnyPublisher(), + isComposeContentValid.eraseToAnyPublisher(), + isMediaEmpty.eraseToAnyPublisher(), + isMediaUploadAllSuccess.eraseToAnyPublisher() + ) + .map { isComposeContentEmpty, isComposeContentValid, isMediaEmpty, isMediaUploadAllSuccess -> Bool in + if isMediaEmpty { + return isComposeContentValid && !isComposeContentEmpty + } else { + return isComposeContentValid && isMediaUploadAllSuccess + } + } + .eraseToAnyPublisher() + + let isPublishBarButtonItemEnabledPrecondition2 = Publishers.CombineLatest4( + isComposeContentEmpty.eraseToAnyPublisher(), + isComposeContentValid.eraseToAnyPublisher(), + isPollComposing.eraseToAnyPublisher(), + isPollAttributeAllValid.eraseToAnyPublisher() + ) + .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) + + // bind snapshot + Publishers.CombineLatest3( + attachmentServices.eraseToAnyPublisher(), + isPollComposing.eraseToAnyPublisher(), + pollOptionAttributes.eraseToAnyPublisher() + ) + .receive(on: DispatchQueue.main) + .sink { [weak self] attachmentServices, isPollComposing, pollAttributes in + guard let self = self else { return } + guard let diffableDataSource = self.diffableDataSource else { return } + var snapshot = diffableDataSource.snapshot() + + snapshot.deleteItems(snapshot.itemIdentifiers(inSection: .attachment)) + var attachmentItems: [ComposeStatusItem] = [] + for attachmentService in attachmentServices { + let item = ComposeStatusItem.attachment(attachmentService: attachmentService) + attachmentItems.append(item) + } + snapshot.appendItems(attachmentItems, toSection: .attachment) + + snapshot.deleteItems(snapshot.itemIdentifiers(inSection: .poll)) + if isPollComposing { + var pollItems: [ComposeStatusItem] = [] + for pollAttribute in pollAttributes { + let item = ComposeStatusItem.pollOption(attribute: pollAttribute) + pollItems.append(item) + } + snapshot.appendItems(pollItems, toSection: .poll) + if pollAttributes.count < 4 { + snapshot.appendItems([ComposeStatusItem.pollOptionAppendEntry], toSection: .poll) + } + snapshot.appendItems([ComposeStatusItem.pollExpiresOption(attribute: self.pollExpiresOptionAttribute)], toSection: .poll) + } + + diffableDataSource.apply(snapshot) + + // 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.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 >= 4 + let shouldPollDisable = attachmentServices.count > 0 + + self.isMediaToolbarButtonEnabled.value = !shouldMediaDisable + self.isPollToolbarButtonEnabled.value = !shouldPollDisable + }) + .store(in: &disposeBag) + + if let preInsertedContent = preInsertedContent { + // add a space after the injected text + composeStatusAttribute.composeContent.send(preInsertedContent + " ") + } + } + + deinit { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + } + +} + +extension ComposeViewModel { + enum CollectionViewState { + case fold // snap to input + case expand // snap to reply + } +} + +extension ComposeViewModel { + func createNewPollOptionIfPossible() { + guard pollOptionAttributes.value.count < 4 else { return } + + let attribute = ComposeStatusItem.ComposePollOptionAttribute() + pollOptionAttributes.value = pollOptionAttributes.value + [attribute] + } + + func updatePublishDate() { + publishDate = Date() + } +} + +// MARK: - MastodonAttachmentServiceDelegate +extension ComposeViewModel: MastodonAttachmentServiceDelegate { + func mastodonAttachmentService(_ service: MastodonAttachmentService, uploadStateDidChange state: MastodonAttachmentService.UploadState?) { + // trigger new output event + attachmentServices.value = attachmentServices.value + } +} + +// MARK: - ComposePollAttributeDelegate +extension ComposeViewModel: ComposePollAttributeDelegate { + func composePollAttribute(_ attribute: ComposeStatusItem.ComposePollOptionAttribute, pollOptionDidChange: String?) { + // trigger update + pollOptionAttributes.value = pollOptionAttributes.value + } +} diff --git a/Mastodon/Scene/Compose/View/AttachmentContainerView+EmptyStateView.swift b/Mastodon/Scene/Compose/View/AttachmentContainerView+EmptyStateView.swift new file mode 100644 index 000000000..353fe7497 --- /dev/null +++ b/Mastodon/Scene/Compose/View/AttachmentContainerView+EmptyStateView.swift @@ -0,0 +1,132 @@ +// +// AttachmentContainerView+EmptyStateView.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-18. +// + +import UIKit + +extension AttachmentContainerView { + final class EmptyStateView: UIView { + + static let photoFillSplitImage = Asset.Connectivity.photoFillSplit.image.withRenderingMode(.alwaysTemplate) + static let videoSplashImage: UIImage = { + let image = UIImage(systemName: "video.slash")!.withConfiguration(UIImage.SymbolConfiguration(pointSize: 64)) + return image + }() + + let imageView: UIImageView = { + let imageView = UIImageView() + imageView.tintColor = Asset.Colors.Label.secondary.color + imageView.image = AttachmentContainerView.EmptyStateView.photoFillSplitImage + return imageView + }() + let label: UILabel = { + let label = UILabel() + label.font = .preferredFont(forTextStyle: .body) + label.textColor = Asset.Colors.Label.secondary.color + label.textAlignment = .center + label.text = L10n.Scene.Compose.Attachment.attachmentBroken(L10n.Scene.Compose.Attachment.photo) + label.numberOfLines = 2 + return label + }() + + override init(frame: CGRect) { + super.init(frame: frame) + _init() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + _init() + } + + } +} + +extension AttachmentContainerView.EmptyStateView { + private func _init() { + layer.masksToBounds = true + layer.cornerRadius = AttachmentContainerView.containerViewCornerRadius + layer.cornerCurve = .continuous + backgroundColor = Asset.Colors.Background.systemGroupedBackground.color + + let stackView = UIStackView() + stackView.axis = .vertical + stackView.alignment = .center + stackView.translatesAutoresizingMaskIntoConstraints = false + addSubview(stackView) + NSLayoutConstraint.activate([ + stackView.topAnchor.constraint(equalTo: topAnchor), + stackView.leadingAnchor.constraint(equalTo: leadingAnchor), + stackView.trailingAnchor.constraint(equalTo: trailingAnchor), + stackView.bottomAnchor.constraint(equalTo: bottomAnchor), + ]) + let topPaddingView = UIView() + let middlePaddingView = UIView() + let bottomPaddingView = UIView() + + topPaddingView.translatesAutoresizingMaskIntoConstraints = false + stackView.addArrangedSubview(topPaddingView) + imageView.translatesAutoresizingMaskIntoConstraints = false + stackView.addArrangedSubview(imageView) + NSLayoutConstraint.activate([ + imageView.widthAnchor.constraint(equalToConstant: 92).priority(.defaultHigh), + imageView.heightAnchor.constraint(equalToConstant: 76).priority(.defaultHigh), + ]) + imageView.setContentHuggingPriority(.required - 1, for: .vertical) + middlePaddingView.translatesAutoresizingMaskIntoConstraints = false + stackView.addArrangedSubview(middlePaddingView) + stackView.addArrangedSubview(label) + bottomPaddingView.translatesAutoresizingMaskIntoConstraints = false + stackView.addArrangedSubview(bottomPaddingView) + NSLayoutConstraint.activate([ + topPaddingView.heightAnchor.constraint(equalTo: middlePaddingView.heightAnchor, multiplier: 1.5), + bottomPaddingView.heightAnchor.constraint(equalTo: middlePaddingView.heightAnchor, multiplier: 1.5), + ]) + } +} + +#if canImport(SwiftUI) && DEBUG +import SwiftUI + +struct AttachmentContainerView_EmptyStateView_Previews: PreviewProvider { + + static var previews: some View { + Group { + UIViewPreview(width: 375) { + let emptyStateView = AttachmentContainerView.EmptyStateView() + NSLayoutConstraint.activate([ + emptyStateView.heightAnchor.constraint(equalToConstant: 205) + ]) + return emptyStateView + } + .previewLayout(.fixed(width: 375, height: 205)) + UIViewPreview(width: 375) { + let emptyStateView = AttachmentContainerView.EmptyStateView() + NSLayoutConstraint.activate([ + emptyStateView.heightAnchor.constraint(equalToConstant: 205) + ]) + return emptyStateView + } + .preferredColorScheme(.dark) + .previewLayout(.fixed(width: 375, height: 205)) + UIViewPreview(width: 375) { + let emptyStateView = AttachmentContainerView.EmptyStateView() + emptyStateView.imageView.image = AttachmentContainerView.EmptyStateView.videoSplashImage + emptyStateView.label.text = L10n.Scene.Compose.Attachment.attachmentBroken(L10n.Scene.Compose.Attachment.video) + + NSLayoutConstraint.activate([ + emptyStateView.heightAnchor.constraint(equalToConstant: 205) + ]) + return emptyStateView + } + .previewLayout(.fixed(width: 375, height: 205)) + } + } + +} + +#endif + diff --git a/Mastodon/Scene/Compose/View/AttachmentContainerView.swift b/Mastodon/Scene/Compose/View/AttachmentContainerView.swift new file mode 100644 index 000000000..eb5f01f41 --- /dev/null +++ b/Mastodon/Scene/Compose/View/AttachmentContainerView.swift @@ -0,0 +1,140 @@ +// +// AttachmentContainerView.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-17. +// + +import UIKit +import UITextView_Placeholder + +final class AttachmentContainerView: UIView { + + static let containerViewCornerRadius: CGFloat = 4 + + var descriptionBackgroundViewFrameObservation: NSKeyValueObservation? + + let activityIndicatorView = UIActivityIndicatorView(style: .large) + + let previewImageView: UIImageView = { + let imageView = UIImageView() + imageView.contentMode = .scaleAspectFill + imageView.layer.masksToBounds = true + return imageView + }() + + let emptyStateView = AttachmentContainerView.EmptyStateView() + let descriptionBackgroundView: UIView = { + let view = UIView() + view.layer.masksToBounds = true + view.layer.cornerRadius = AttachmentContainerView.containerViewCornerRadius + view.layer.cornerCurve = .continuous + view.layer.maskedCorners = [.layerMinXMaxYCorner, .layerMaxXMaxYCorner] + view.layoutMargins = UIEdgeInsets(top: 0, left: 8, bottom: 5, right: 8) + return view + }() + let descriptionBackgroundGradientLayer: CAGradientLayer = { + let gradientLayer = CAGradientLayer() + gradientLayer.colors = [UIColor.black.withAlphaComponent(0.0).cgColor, UIColor.black.withAlphaComponent(0.69).cgColor] + gradientLayer.locations = [0.0, 1.0] + gradientLayer.startPoint = CGPoint(x: 0.5, y: 0) + gradientLayer.endPoint = CGPoint(x: 0.5, y: 1) + gradientLayer.frame = CGRect(x: 0, y: 0, width: 100, height: 100) + return gradientLayer + }() + let descriptionTextView: UITextView = { + let textView = UITextView() + textView.showsVerticalScrollIndicator = false + textView.backgroundColor = .clear + textView.textColor = .white + textView.font = UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 15), maximumPointSize: 20) + textView.placeholder = L10n.Scene.Compose.Attachment.descriptionPhoto + textView.placeholderColor = UIColor.white.withAlphaComponent(0.6) // force white with alpha for Light/Dark mode + textView.returnKeyType = .done + return textView + }() + + override init(frame: CGRect) { + super.init(frame: frame) + _init() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + _init() + } + +} + +extension AttachmentContainerView { + + private func _init() { + previewImageView.translatesAutoresizingMaskIntoConstraints = false + addSubview(previewImageView) + NSLayoutConstraint.activate([ + previewImageView.topAnchor.constraint(equalTo: topAnchor), + previewImageView.leadingAnchor.constraint(equalTo: leadingAnchor), + previewImageView.trailingAnchor.constraint(equalTo: trailingAnchor), + previewImageView.bottomAnchor.constraint(equalTo: bottomAnchor), + ]) + + descriptionBackgroundView.translatesAutoresizingMaskIntoConstraints = false + addSubview(descriptionBackgroundView) + NSLayoutConstraint.activate([ + descriptionBackgroundView.leadingAnchor.constraint(equalTo: leadingAnchor), + descriptionBackgroundView.trailingAnchor.constraint(equalTo: trailingAnchor), + descriptionBackgroundView.bottomAnchor.constraint(equalTo: bottomAnchor), + descriptionBackgroundView.heightAnchor.constraint(equalTo: heightAnchor, multiplier: 0.3), + ]) + descriptionBackgroundView.layer.addSublayer(descriptionBackgroundGradientLayer) + descriptionBackgroundViewFrameObservation = descriptionBackgroundView.observe(\.bounds, options: [.initial, .new]) { [weak self] _, _ in + guard let self = self else { return } + self.descriptionBackgroundGradientLayer.frame = self.descriptionBackgroundView.bounds + } + + descriptionTextView.translatesAutoresizingMaskIntoConstraints = false + descriptionBackgroundView.addSubview(descriptionTextView) + NSLayoutConstraint.activate([ + descriptionTextView.leadingAnchor.constraint(equalTo: descriptionBackgroundView.layoutMarginsGuide.leadingAnchor), + descriptionTextView.trailingAnchor.constraint(equalTo: descriptionBackgroundView.layoutMarginsGuide.trailingAnchor), + descriptionBackgroundView.layoutMarginsGuide.bottomAnchor.constraint(equalTo: descriptionTextView.bottomAnchor), + descriptionTextView.heightAnchor.constraint(lessThanOrEqualToConstant: 36), + ]) + + emptyStateView.translatesAutoresizingMaskIntoConstraints = false + addSubview(emptyStateView) + NSLayoutConstraint.activate([ + emptyStateView.topAnchor.constraint(equalTo: topAnchor), + emptyStateView.leadingAnchor.constraint(equalTo: leadingAnchor), + emptyStateView.trailingAnchor.constraint(equalTo: trailingAnchor), + emptyStateView.bottomAnchor.constraint(equalTo: bottomAnchor), + ]) + + activityIndicatorView.translatesAutoresizingMaskIntoConstraints = false + addSubview(activityIndicatorView) + NSLayoutConstraint.activate([ + activityIndicatorView.centerXAnchor.constraint(equalTo: previewImageView.centerXAnchor), + activityIndicatorView.centerYAnchor.constraint(equalTo: previewImageView.centerYAnchor), + ]) + + emptyStateView.isHidden = true + activityIndicatorView.hidesWhenStopped = true + activityIndicatorView.startAnimating() + + descriptionTextView.delegate = self + } + +} + +// MARK: - UITextViewDelegate +extension AttachmentContainerView: UITextViewDelegate { + func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool { + // let keyboard dismiss when input description with "done" type return key + if textView === descriptionTextView, text == "\n" { + textView.resignFirstResponder() + return false + } + + return true + } +} diff --git a/Mastodon/Scene/Compose/View/ComposeToolbarView.swift b/Mastodon/Scene/Compose/View/ComposeToolbarView.swift new file mode 100644 index 000000000..68f5eed06 --- /dev/null +++ b/Mastodon/Scene/Compose/View/ComposeToolbarView.swift @@ -0,0 +1,316 @@ +// +// ComposeToolbarView.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-12. +// + +import os.log +import UIKit +import MastodonSDK + +protocol ComposeToolbarViewDelegate: AnyObject { + func composeToolbarView(_ composeToolbarView: ComposeToolbarView, cameraButtonDidPressed sender: UIButton, mediaSelectionType type: ComposeToolbarView.MediaSelectionType) + func composeToolbarView(_ composeToolbarView: ComposeToolbarView, pollButtonDidPressed sender: UIButton) + func composeToolbarView(_ composeToolbarView: ComposeToolbarView, emojiButtonDidPressed sender: UIButton) + func composeToolbarView(_ composeToolbarView: ComposeToolbarView, contentWarningButtonDidPressed sender: UIButton) + func composeToolbarView(_ composeToolbarView: ComposeToolbarView, visibilityButtonDidPressed sender: UIButton, visibilitySelectionType type: ComposeToolbarView.VisibilitySelectionType) +} + +final class ComposeToolbarView: UIView { + + static let toolbarButtonSize: CGSize = CGSize(width: 44, height: 44) + static let toolbarHeight: CGFloat = 44 + + weak var delegate: ComposeToolbarViewDelegate? + + let mediaButton: UIButton = { + let button = HighlightDimmableButton() + ComposeToolbarView.configureToolbarButtonAppearance(button: button) + button.setImage(UIImage(systemName: "photo", withConfiguration: UIImage.SymbolConfiguration(pointSize: 20, weight: .regular)), for: .normal) + button.accessibilityLabel = L10n.Scene.Compose.Accessibility.appendAttachment + return button + }() + + let pollButton: UIButton = { + let button = HighlightDimmableButton(type: .custom) + ComposeToolbarView.configureToolbarButtonAppearance(button: button) + button.setImage(UIImage(systemName: "list.bullet", withConfiguration: UIImage.SymbolConfiguration(pointSize: 20, weight: .medium)), for: .normal) + button.accessibilityLabel = L10n.Scene.Compose.Accessibility.appendPoll + return button + }() + + let emojiButton: UIButton = { + let button = HighlightDimmableButton() + ComposeToolbarView.configureToolbarButtonAppearance(button: button) + let image = Asset.Human.faceSmilingAdaptive.image + .af.imageScaled(to: CGSize(width: 20, height: 20)) + .withRenderingMode(.alwaysTemplate) + button.setImage(image, for: .normal) + button.accessibilityLabel = L10n.Scene.Compose.Accessibility.customEmojiPicker + return button + }() + + let contentWarningButton: UIButton = { + let button = HighlightDimmableButton() + ComposeToolbarView.configureToolbarButtonAppearance(button: button) + button.setImage(UIImage(systemName: "exclamationmark.shield", withConfiguration: UIImage.SymbolConfiguration(pointSize: 20, weight: .regular)), for: .normal) + button.accessibilityLabel = L10n.Scene.Compose.Accessibility.enableContentWarning + return button + }() + + let visibilityButton: UIButton = { + let button = HighlightDimmableButton() + ComposeToolbarView.configureToolbarButtonAppearance(button: button) + button.setImage(UIImage(systemName: "person.3", withConfiguration: UIImage.SymbolConfiguration(pointSize: 15, weight: .medium)), for: .normal) + button.accessibilityLabel = L10n.Scene.Compose.Accessibility.postVisibilityMenu + return button + }() + + let characterCountLabel: UILabel = { + let label = UILabel() + label.font = .systemFont(ofSize: 15, weight: .regular) + label.text = "500" + label.textColor = Asset.Colors.Label.secondary.color + label.accessibilityLabel = L10n.Scene.Compose.Accessibility.inputLimitRemainsCount(500) + return label + }() + + override init(frame: CGRect) { + super.init(frame: frame) + _init() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + _init() + } + +} + +extension ComposeToolbarView { + + private func _init() { + // magic keyboard color (iOS 14): + // light with white background: RGB 214 216 222 + // dark with black background: RGB 43 43 43 + backgroundColor = Asset.Scene.Compose.toolbarBackground.color + + let stackView = UIStackView() + stackView.axis = .horizontal + stackView.spacing = 0 + stackView.distribution = .fillEqually + stackView.translatesAutoresizingMaskIntoConstraints = false + addSubview(stackView) + NSLayoutConstraint.activate([ + stackView.centerYAnchor.constraint(equalTo: centerYAnchor), + layoutMarginsGuide.leadingAnchor.constraint(equalTo: stackView.leadingAnchor, constant: 8), // tweak button margin offset + ]) + + let buttons = [ + mediaButton, + pollButton, + emojiButton, + contentWarningButton, + visibilityButton, + ] + buttons.forEach { button in + button.translatesAutoresizingMaskIntoConstraints = false + stackView.addArrangedSubview(button) + NSLayoutConstraint.activate([ + button.widthAnchor.constraint(equalToConstant: 44), + button.heightAnchor.constraint(equalToConstant: 44), + ]) + } + + characterCountLabel.translatesAutoresizingMaskIntoConstraints = false + addSubview(characterCountLabel) + NSLayoutConstraint.activate([ + characterCountLabel.topAnchor.constraint(equalTo: topAnchor), + characterCountLabel.leadingAnchor.constraint(greaterThanOrEqualTo: stackView.trailingAnchor, constant: 8), + characterCountLabel.trailingAnchor.constraint(equalTo: layoutMarginsGuide.trailingAnchor), + characterCountLabel.bottomAnchor.constraint(equalTo: bottomAnchor), + ]) + characterCountLabel.setContentHuggingPriority(.defaultHigh, for: .horizontal) + + mediaButton.menu = createMediaContextMenu() + mediaButton.showsMenuAsPrimaryAction = true + pollButton.addTarget(self, action: #selector(ComposeToolbarView.pollButtonDidPressed(_:)), for: .touchUpInside) + emojiButton.addTarget(self, action: #selector(ComposeToolbarView.emojiButtonDidPressed(_:)), for: .touchUpInside) + contentWarningButton.addTarget(self, action: #selector(ComposeToolbarView.contentWarningButtonDidPressed(_:)), for: .touchUpInside) + visibilityButton.menu = createVisibilityContextMenu(interfaceStyle: traitCollection.userInterfaceStyle) + visibilityButton.showsMenuAsPrimaryAction = true + + updateToolbarButtonUserInterfaceStyle() + } + + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + + updateToolbarButtonUserInterfaceStyle() + } + +} + +extension ComposeToolbarView { + enum MediaSelectionType: String { + case camera + case photoLibrary + case browse + } + + enum VisibilitySelectionType: String, CaseIterable { + case `public` + case unlisted + case `private` + case direct + + var title: String { + switch self { + case .public: return L10n.Scene.Compose.Visibility.public + case .unlisted: return L10n.Scene.Compose.Visibility.unlisted + case .private: return L10n.Scene.Compose.Visibility.private + case .direct: return L10n.Scene.Compose.Visibility.direct + } + } + + func image(interfaceStyle: UIUserInterfaceStyle) -> UIImage { + switch self { + case .public: + switch interfaceStyle { + case .light: return UIImage(systemName: "person.3", withConfiguration: UIImage.SymbolConfiguration(pointSize: 15, weight: .medium))! + default: return UIImage(systemName: "person.3.fill", withConfiguration: UIImage.SymbolConfiguration(pointSize: 15, weight: .medium))! + } + case .unlisted: return UIImage(systemName: "eye.slash", withConfiguration: UIImage.SymbolConfiguration(pointSize: 18, weight: .regular))! + case .private: return UIImage(systemName: "person.crop.circle.badge.plus", withConfiguration: UIImage.SymbolConfiguration(pointSize: 18, weight: .regular))! + case .direct: return UIImage(systemName: "at", withConfiguration: UIImage.SymbolConfiguration(pointSize: 19, weight: .regular))! + } + } + + func imageNameForTimeline() -> String { + switch self { + case .public: return "person.3" + case .unlisted: return "eye.slash" + case .private: return "person.crop.circle.badge.plus" + case .direct: return "at" + } + } + + var visibility: Mastodon.Entity.Status.Visibility { + switch self { + case .public: return .public + case .unlisted: return .unlisted + case .private: return .private + case .direct: return .direct + } + } + } +} + +extension ComposeToolbarView { + + private static func configureToolbarButtonAppearance(button: UIButton) { + button.tintColor = Asset.Colors.Button.normal.color + button.setBackgroundImage(.placeholder(size: ComposeToolbarView.toolbarButtonSize, color: .systemFill), for: .highlighted) + button.layer.masksToBounds = true + button.layer.cornerRadius = 5 + button.layer.cornerCurve = .continuous + } + + private func updateToolbarButtonUserInterfaceStyle() { + switch traitCollection.userInterfaceStyle { + case .light: + mediaButton.setImage(UIImage(systemName: "photo", withConfiguration: UIImage.SymbolConfiguration(pointSize: 20, weight: .regular))!, for: .normal) + contentWarningButton.setImage(UIImage(systemName: "exclamationmark.shield", withConfiguration: UIImage.SymbolConfiguration(pointSize: 20, weight: .regular))!, for: .normal) + + case .dark: + mediaButton.setImage(UIImage(systemName: "photo.fill", withConfiguration: UIImage.SymbolConfiguration(pointSize: 20, weight: .regular))!, for: .normal) + contentWarningButton.setImage(UIImage(systemName: "exclamationmark.shield.fill", withConfiguration: UIImage.SymbolConfiguration(pointSize: 20, weight: .regular))!, for: .normal) + + default: + assertionFailure() + } + + visibilityButton.menu = createVisibilityContextMenu(interfaceStyle: traitCollection.userInterfaceStyle) + } + + private func createMediaContextMenu() -> UIMenu { + var children: [UIMenuElement] = [] + let photoLibraryAction = UIAction(title: L10n.Scene.Compose.MediaSelection.photoLibrary, image: UIImage(systemName: "rectangle.on.rectangle"), identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off) { [weak self] _ in + guard let self = self else { return } + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: mediaSelectionType: .photoLibaray", ((#file as NSString).lastPathComponent), #line, #function) + self.delegate?.composeToolbarView(self, cameraButtonDidPressed: self.mediaButton, mediaSelectionType: .photoLibrary) + } + children.append(photoLibraryAction) + if UIImagePickerController.isSourceTypeAvailable(.camera) { + let cameraAction = UIAction(title: L10n.Scene.Compose.MediaSelection.camera, image: UIImage(systemName: "camera"), identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off, handler: { [weak self] _ in + guard let self = self else { return } + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: mediaSelectionType: .camera", ((#file as NSString).lastPathComponent), #line, #function) + self.delegate?.composeToolbarView(self, cameraButtonDidPressed: self.mediaButton, mediaSelectionType: .camera) + }) + children.append(cameraAction) + } + let browseAction = UIAction(title: L10n.Scene.Compose.MediaSelection.browse, image: UIImage(systemName: "ellipsis"), identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off) { [weak self] _ in + guard let self = self else { return } + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: mediaSelectionType: .browse", ((#file as NSString).lastPathComponent), #line, #function) + self.delegate?.composeToolbarView(self, cameraButtonDidPressed: self.mediaButton, mediaSelectionType: .browse) + } + children.append(browseAction) + + return UIMenu(title: "", image: nil, identifier: nil, options: .displayInline, children: children) + } + + private func createVisibilityContextMenu(interfaceStyle: UIUserInterfaceStyle) -> UIMenu { + let children: [UIMenuElement] = VisibilitySelectionType.allCases.map { type in + UIAction(title: type.title, image: type.image(interfaceStyle: interfaceStyle), identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off) { [weak self] action in + guard let self = self else { return } + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: visibilitySelectionType: %s", ((#file as NSString).lastPathComponent), #line, #function, type.rawValue) + self.delegate?.composeToolbarView(self, visibilityButtonDidPressed: self.visibilityButton, visibilitySelectionType: type) + } + } + return UIMenu(title: "", image: nil, identifier: nil, options: .displayInline, children: children) + } + +} + +extension ComposeToolbarView { + + @objc private func pollButtonDidPressed(_ sender: UIButton) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + delegate?.composeToolbarView(self, pollButtonDidPressed: sender) + } + + @objc private func emojiButtonDidPressed(_ sender: UIButton) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + delegate?.composeToolbarView(self, emojiButtonDidPressed: sender) + } + + @objc private func contentWarningButtonDidPressed(_ sender: UIButton) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + delegate?.composeToolbarView(self, contentWarningButtonDidPressed: sender) + } + +} + +#if canImport(SwiftUI) && DEBUG +import SwiftUI + +struct ComposeToolbarView_Previews: PreviewProvider { + + static var previews: some View { + UIViewPreview(width: 375) { + let toolbarView = ComposeToolbarView() + toolbarView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + toolbarView.widthAnchor.constraint(equalToConstant: 375).priority(.defaultHigh), + toolbarView.heightAnchor.constraint(equalToConstant: 64).priority(.defaultHigh), + ]) + return toolbarView + } + .previewLayout(.fixed(width: 375, height: 100)) + } + +} + +#endif + diff --git a/Mastodon/Scene/Compose/View/CustomEmojiPickerInputView.swift b/Mastodon/Scene/Compose/View/CustomEmojiPickerInputView.swift new file mode 100644 index 000000000..6bfe31d34 --- /dev/null +++ b/Mastodon/Scene/Compose/View/CustomEmojiPickerInputView.swift @@ -0,0 +1,86 @@ +// +// CustomEmojiPickerInputView.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-24. +// + +import UIKit + +final class CustomEmojiPickerInputView: UIInputView { + + private(set) lazy var collectionView: UICollectionView = { + let collectionView = UICollectionView(frame: .zero, collectionViewLayout: createLayout()) + collectionView.register(CustomEmojiPickerItemCollectionViewCell.self, forCellWithReuseIdentifier: String(describing: CustomEmojiPickerItemCollectionViewCell.self)) + collectionView.register(CustomEmojiPickerHeaderCollectionReusableView.self, forSupplementaryViewOfKind: String(describing: CustomEmojiPickerHeaderCollectionReusableView.self), withReuseIdentifier: String(describing: CustomEmojiPickerHeaderCollectionReusableView.self)) + collectionView.backgroundColor = .clear + return collectionView + }() + + let activityIndicatorView = UIActivityIndicatorView(style: .large) + + override init(frame: CGRect, inputViewStyle: UIInputView.Style) { + super.init(frame: frame, inputViewStyle: inputViewStyle) + _init() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + _init() + } + +} + +extension CustomEmojiPickerInputView { + private func _init() { + allowsSelfSizing = true + + activityIndicatorView.translatesAutoresizingMaskIntoConstraints = false + addSubview(activityIndicatorView) + NSLayoutConstraint.activate([ + activityIndicatorView.centerXAnchor.constraint(equalTo: centerXAnchor), + activityIndicatorView.centerYAnchor.constraint(equalTo: centerYAnchor), + ]) + + collectionView.translatesAutoresizingMaskIntoConstraints = false + addSubview(collectionView) + NSLayoutConstraint.activate([ + collectionView.topAnchor.constraint(equalTo: topAnchor), + collectionView.leadingAnchor.constraint(equalTo: leadingAnchor), + collectionView.trailingAnchor.constraint(equalTo: trailingAnchor), + collectionView.bottomAnchor.constraint(equalTo: bottomAnchor), + ]) + + activityIndicatorView.hidesWhenStopped = true + activityIndicatorView.startAnimating() + } +} + +extension CustomEmojiPickerInputView { + func createLayout() -> UICollectionViewLayout { + let itemSize = NSCollectionLayoutSize(widthDimension: .absolute(CustomEmojiPickerItemCollectionViewCell.itemSize.width), + heightDimension: .fractionalHeight(1.0)) + let item = NSCollectionLayoutItem(layoutSize: itemSize) + item.edgeSpacing = NSCollectionLayoutEdgeSpacing(leading: .flexible(4), top: .flexible(4), trailing: .flexible(0), bottom: .flexible(0)) + let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), + heightDimension: .absolute(CustomEmojiPickerItemCollectionViewCell.itemSize.height)) + let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item]) + + let section = NSCollectionLayoutSection(group: group) + section.interGroupSpacing = 5 + section.contentInsetsReference = .readableContent + section.contentInsets = NSDirectionalEdgeInsets(top: 10, leading: 0, bottom: 10, trailing: 0) + + let sectionHeader = NSCollectionLayoutBoundarySupplementaryItem( + layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), + heightDimension: .estimated(44)), + elementKind: String(describing: CustomEmojiPickerHeaderCollectionReusableView.self), + alignment: .top) + // sectionHeader.pinToVisibleBounds = true + sectionHeader.zIndex = 2 + section.boundarySupplementaryItems = [sectionHeader] + + let layout = UICollectionViewCompositionalLayout(section: section) + return layout + } +} diff --git a/Mastodon/Scene/Compose/View/CustomEmojiPickerInputViewModel.swift b/Mastodon/Scene/Compose/View/CustomEmojiPickerInputViewModel.swift new file mode 100644 index 000000000..02a45d922 --- /dev/null +++ b/Mastodon/Scene/Compose/View/CustomEmojiPickerInputViewModel.swift @@ -0,0 +1,58 @@ +// +// CustomEmojiPickerInputViewModel.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-25. +// + +import UIKit +import Combine + +final class CustomEmojiPickerInputViewModel { + + var disposeBag = Set() + + private var customEmojiReplacableTextInputReferences: [CustomEmojiReplacableTextInputReference] = [] + + // input + weak var customEmojiPickerInputView: CustomEmojiPickerInputView? + + // output + let isCustomEmojiComposing = CurrentValueSubject(false) + +} + +extension CustomEmojiPickerInputViewModel { + + private func removeEmptyReferences() { + customEmojiReplacableTextInputReferences.removeAll(where: { element in + element.value == nil + }) + } + + func append(customEmojiReplacableTextInput textInput: CustomEmojiReplacableTextInput) { + removeEmptyReferences() + + let isContains = customEmojiReplacableTextInputReferences.contains(where: { element in + element.value === textInput + }) + guard !isContains else { + return + } + customEmojiReplacableTextInputReferences.append(CustomEmojiReplacableTextInputReference(value: textInput)) + } + + func insertText(_ text: String) -> CustomEmojiReplacableTextInputReference? { + removeEmptyReferences() + + for reference in customEmojiReplacableTextInputReferences { + guard reference.value?.isFirstResponder == true else { continue } + reference.value?.insertText(text) + return reference + } + + return nil + } + +} + diff --git a/Mastodon/Scene/Compose/View/StatusContentWarningEditorView.swift b/Mastodon/Scene/Compose/View/StatusContentWarningEditorView.swift new file mode 100644 index 000000000..510edd464 --- /dev/null +++ b/Mastodon/Scene/Compose/View/StatusContentWarningEditorView.swift @@ -0,0 +1,114 @@ +// +// StatusContentWarningEditorView.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-25. +// + +import UIKit + +final class StatusContentWarningEditorView: UIView { + + let containerView: UIView = { + let view = UIView() + view.backgroundColor = Asset.Colors.Background.secondarySystemBackground.color + return view + }() + + // due to section following readable inset. We overlap the bleeding to make backgorund fill + // default hidden + let containerBackgroundView: UIView = { + let view = UIView() + view.backgroundColor = Asset.Colors.Background.secondarySystemBackground.color + view.isHidden = true + return view + }() + + let iconImageView: UIImageView = { + let imageView = UIImageView() + imageView.image = UIImage(systemName: "exclamationmark.shield")!.withConfiguration(UIImage.SymbolConfiguration(pointSize: 30, weight: .regular)).withRenderingMode(.alwaysTemplate) + imageView.tintColor = Asset.Colors.Label.primary.color + imageView.contentMode = .center + return imageView + }() + + let textView: UITextView = { + let textView = UITextView() + textView.font = .preferredFont(forTextStyle: .body) + textView.isScrollEnabled = false + textView.placeholder = L10n.Scene.Compose.ContentWarning.placeholder + textView.backgroundColor = .clear + return textView + }() + + override init(frame: CGRect) { + super.init(frame: frame) + _init() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + _init() + } + +} + +extension StatusContentWarningEditorView { + private func _init() { + let contentWarningStackView = UIStackView() + contentWarningStackView.axis = .horizontal + contentWarningStackView.translatesAutoresizingMaskIntoConstraints = false + addSubview(contentWarningStackView) + NSLayoutConstraint.activate([ + contentWarningStackView.topAnchor.constraint(equalTo: topAnchor), + contentWarningStackView.leadingAnchor.constraint(equalTo: leadingAnchor), + contentWarningStackView.trailingAnchor.constraint(equalTo: trailingAnchor), + contentWarningStackView.bottomAnchor.constraint(equalTo: bottomAnchor), + ]) + contentWarningStackView.addArrangedSubview(containerView) + + containerBackgroundView.translatesAutoresizingMaskIntoConstraints = false + containerView.addSubview(containerBackgroundView) + NSLayoutConstraint.activate([ + containerBackgroundView.topAnchor.constraint(equalTo: containerView.topAnchor), + containerBackgroundView.leadingAnchor.constraint(equalTo: containerView.leadingAnchor, constant: -1024), + containerBackgroundView.trailingAnchor.constraint(equalTo: containerView.trailingAnchor, constant: 1024), + containerBackgroundView.bottomAnchor.constraint(equalTo: containerView.bottomAnchor), + ]) + + iconImageView.translatesAutoresizingMaskIntoConstraints = false + containerView.addSubview(iconImageView) + NSLayoutConstraint.activate([ + iconImageView.centerYAnchor.constraint(equalTo: containerView.centerYAnchor), + iconImageView.leadingAnchor.constraint(equalTo: containerView.readableContentGuide.leadingAnchor), + iconImageView.widthAnchor.constraint(equalToConstant: StatusView.avatarImageSize.width).priority(.defaultHigh), // center alignment to avatar + ]) + iconImageView.setContentHuggingPriority(.defaultHigh, for: .horizontal) + + textView.translatesAutoresizingMaskIntoConstraints = false + containerView.addSubview(textView) + NSLayoutConstraint.activate([ + textView.topAnchor.constraint(equalTo: containerView.topAnchor, constant: 6), + textView.leadingAnchor.constraint(equalTo: iconImageView.trailingAnchor, constant: StatusView.avatarToLabelSpacing - 4), // align to name label. minus magic 4pt to remove addtion inset + textView.trailingAnchor.constraint(equalTo: containerView.readableContentGuide.trailingAnchor), + containerView.bottomAnchor.constraint(equalTo: textView.bottomAnchor, constant: 6), + ]) + } +} + +#if canImport(SwiftUI) && DEBUG +import SwiftUI + +struct StatusContentWarningEditorView_Previews: PreviewProvider { + + static var previews: some View { + UIViewPreview(width: 375) { + StatusContentWarningEditorView() + } + .previewLayout(.fixed(width: 375, height: 100)) + } + +} + +#endif + diff --git a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController+Provider.swift b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController+Provider.swift new file mode 100644 index 000000000..3bc3a36b5 --- /dev/null +++ b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController+Provider.swift @@ -0,0 +1,89 @@ +// +// 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 + } + +} + +extension HashtagTimelineViewController: UserProvider {} diff --git a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift new file mode 100644 index 000000000..638aa7665 --- /dev/null +++ b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift @@ -0,0 +1,341 @@ +// +// HashtagTimelineViewController.swift +// Mastodon +// +// Created by BradGao on 2021/3/30. +// + +import os.log +import UIKit +import AVKit +import Combine +import GameplayKit +import CoreData + +class HashtagTimelineViewController: UIViewController, NeedsDependency, MediaPreviewableViewController { + weak var context: AppContext! { willSet { precondition(!isViewLoaded) } } + weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } } + + var disposeBag = Set() + + var viewModel: HashtagTimelineViewModel! + + let mediaPreviewTransitionController = MediaPreviewTransitionController() + + let composeBarButtonItem: UIBarButtonItem = { + let barButtonItem = UIBarButtonItem() + barButtonItem.tintColor = Asset.Colors.Label.highlight.color + barButtonItem.image = UIImage(systemName: "square.and.pencil")?.withRenderingMode(.alwaysTemplate) + return barButtonItem + }() + + let tableView: UITableView = { + let tableView = ControlContainableTableView() + 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 + }() + + let refreshControl = UIRefreshControl() + + let titleView = DoubleTitleLabelNavigationBarTitleView() + + deinit { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s:", ((#file as NSString).lastPathComponent), #line, #function) + } +} + +extension HashtagTimelineViewController { + + override func viewDidLoad() { + super.viewDidLoad() + + title = "#\(viewModel.hashtag)" + titleView.update(title: viewModel.hashtag, subtitle: nil) + navigationItem.titleView = titleView + + view.backgroundColor = Asset.Colors.Background.secondarySystemBackground.color + + 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([ + 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 + ) + + // 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) + + viewModel.hashtagEntity + .receive(on: DispatchQueue.main) + .sink { [weak self] tag in + self?.updatePromptTitle() + } + .store(in: &disposeBag) + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + + aspectViewWillAppear(animated) + + viewModel.fetchTag() + guard viewModel.loadLatestStateMachine.currentState is HashtagTimelineViewModel.LoadLatestState.Initial else { return } + + refreshControl.beginRefreshing() + refreshControl.sendActions(for: .valueChanged) + } + + 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() + } + } + + private func updatePromptTitle() { + var subtitle: String? + defer { + titleView.update(title: "#" + viewModel.hashtag, subtitle: subtitle) + } + guard let histories = viewModel.hashtagEntity.value?.history else { + return + } + if histories.isEmpty { + // No tag history, remove the prompt title + return + } else { + let sortedHistory = histories.sorted { (h1, h2) -> Bool in + return h1.day > h2.day + } + let peopleTalkingNumber = sortedHistory + .prefix(2) + .compactMap({ Int($0.accounts) }) + .reduce(0, +) + subtitle = L10n.Scene.Hashtag.prompt("\(peopleTalkingNumber)") + } + } + +} + +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)) + 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 + } +} + +// 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 } +} + +// 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) + } + + 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) + } + +} + +// MARK: - StatusTableViewCellDelegate +extension HashtagTimelineViewController: StatusTableViewCellDelegate { + weak var playerViewControllerDelegate: AVPlayerViewControllerDelegate? { return self } + func parent() -> UIViewController { return self } +} diff --git a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+Diffable.swift b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+Diffable.swift new file mode 100644 index 000000000..ed7b3a844 --- /dev/null +++ b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+Diffable.swift @@ -0,0 +1,125 @@ +// +// HashtagTimelineViewModel+Diffable.swift +// Mastodon +// +// Created by BradGao on 2021/3/30. +// + +import os.log +import UIKit +import CoreData +import CoreDataStack + +extension HashtagTimelineViewModel { + func setupDiffableDataSource( + for tableView: UITableView, + dependency: NeedsDependency, + statusTableViewCellDelegate: StatusTableViewCellDelegate, + timelineMiddleLoaderTableViewCellDelegate: TimelineMiddleLoaderTableViewCellDelegate + ) { + let timestampUpdatePublisher = Timer.publish(every: 1.0, on: .main, in: .common) + .autoconnect() + .share() + .eraseToAnyPublisher() + + diffableDataSource = StatusSection.tableViewDiffableDataSource( + for: tableView, + dependency: dependency, + managedObjectContext: context.managedObjectContext, + timestampUpdatePublisher: timestampUpdatePublisher, + statusTableViewCellDelegate: statusTableViewCellDelegate, + timelineMiddleLoaderTableViewCellDelegate: timelineMiddleLoaderTableViewCellDelegate, + threadReplyLoaderTableViewCellDelegate: nil + ) + } +} + +// 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.apply(newSnapshot, animatingDifferences: false) { + tableView.scrollToRow(at: difference.targetIndexPath, at: .top, animated: false) + tableView.contentOffset.y = tableView.contentOffset.y - difference.offset + self.isFetchingLatestTimeline.value = false + } + } + } + + 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 + } +} diff --git a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+LoadLatestState.swift b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+LoadLatestState.swift new file mode 100644 index 000000000..b2d121d50 --- /dev/null +++ b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+LoadLatestState.swift @@ -0,0 +1,104 @@ +// +// 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 new file mode 100644 index 000000000..dcd3f81ac --- /dev/null +++ b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+LoadMiddleState.swift @@ -0,0 +1,131 @@ +// +// 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 + } + let statusIDs = (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 new file mode 100644 index 000000000..137373647 --- /dev/null +++ b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+LoadOldestState.swift @@ -0,0 +1,134 @@ +// +// HashtagTimelineViewModel+LoadOldestState.swift +// Mastodon +// +// Created by BradGao on 2021/3/31. +// + +import os.log +import Foundation +import GameplayKit +import CoreDataStack + +extension HashtagTimelineViewModel { + class LoadOldestState: 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?.loadOldestStateMachinePublisher.send(self) + } + } +} + +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? + + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + return stateClass == Fail.self || stateClass == Idle.self || stateClass == NoMore.self + } + + override func didEnter(from previousState: GKState?) { + super.didEnter(from: previousState) + guard let viewModel = viewModel, let stateMachine = stateMachine else { return } + guard let activeMastodonAuthenticationBox = viewModel.context.authenticationService.activeMastodonAuthenticationBox.value else { + assertionFailure() + stateMachine.enter(Fail.self) + return + } + + guard let last = viewModel.fetchedResultsController.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 + } + } 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) + } else { + stateMachine.enter(Idle.self) + } + var newStatusIDs = viewModel.fetchedResultsController.statusIDs.value + let fetchedStatusIDList = statuses.map { $0.id } + newStatusIDs.append(contentsOf: fetchedStatusIDList) + viewModel.fetchedResultsController.statusIDs.value = newStatusIDs + } + .store(in: &viewModel.disposeBag) + } + } + + class Fail: HashtagTimelineViewModel.LoadOldestState { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + return stateClass == Loading.self || stateClass == Idle.self + } + } + + class Idle: HashtagTimelineViewModel.LoadOldestState { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + return stateClass == Loading.self + } + } + + class NoMore: HashtagTimelineViewModel.LoadOldestState { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + // reset state if needs + return stateClass == Idle.self + } + + override func didEnter(from previousState: GKState?) { + guard let viewModel = viewModel else { return } + guard let diffableDataSource = viewModel.diffableDataSource else { + assertionFailure() + return + } + DispatchQueue.main.async { + var snapshot = diffableDataSource.snapshot() + snapshot.deleteItems([.bottomLoader]) + diffableDataSource.apply(snapshot) + } + } + } +} + diff --git a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel.swift b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel.swift new file mode 100644 index 000000000..54acbf4fe --- /dev/null +++ b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel.swift @@ -0,0 +1,105 @@ +// +// HashtagTimelineViewModel.swift +// Mastodon +// +// Created by BradGao on 2021/3/30. +// + +import os.log +import UIKit +import Combine +import CoreData +import CoreDataStack +import GameplayKit +import MastodonSDK + +final class HashtagTimelineViewModel: NSObject { + + let hashtag: String + + var disposeBag = Set() + + var needLoadMiddleIndex: Int? = nil + + // input + let context: AppContext + let fetchedResultsController: StatusFetchedResultsController + let isFetchingLatestTimeline = CurrentValueSubject(false) + let timelinePredicate = CurrentValueSubject(nil) + let hashtagEntity = CurrentValueSubject(nil) + + weak var contentOffsetAdjustableTimelineViewControllerDelegate: ContentOffsetAdjustableTimelineViewControllerDelegate? + weak var tableView: UITableView? + + // 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) + // bottom loader + private(set) lazy var loadoldestStateMachine: GKStateMachine = { + // exclude timeline middle fetcher state + let stateMachine = GKStateMachine(states: [ + LoadOldestState.Initial(viewModel: self), + LoadOldestState.Loading(viewModel: self), + LoadOldestState.Fail(viewModel: self), + LoadOldestState.Idle(viewModel: self), + LoadOldestState.NoMore(viewModel: self), + ]) + stateMachine.enter(LoadOldestState.Initial.self) + return stateMachine + }() + lazy var loadOldestStateMachinePublisher = CurrentValueSubject(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) + .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) + + } + + deinit { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s:", ((#file as NSString).lastPathComponent), #line, #function) + } + +} diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift index 69f0347e0..152bb62f0 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift @@ -7,6 +7,8 @@ import os.log import UIKit +import CoreData +import CoreDataStack #if DEBUG extension HomeTimelineViewController { @@ -17,10 +19,36 @@ extension HomeTimelineViewController { identifier: nil, options: .displayInline, children: [ + moveMenu, + dropMenu, + UIAction(title: "Show Welcome", image: UIImage(systemName: "figure.walk"), attributes: []) { [weak self] action in + guard let self = self else { return } + self.showWelcomeAction(action) + }, + UIAction(title: "Show Or Remove EmptyView", image: UIImage(systemName: "clear"), attributes: []) { [weak self] action in + guard let self = self else { return } + if self.emptyView.superview != nil { + self.emptyView.removeFromSuperview() + } else { + self.showEmptyView() + } + }, UIAction(title: "Show Public Timeline", image: UIImage(systemName: "list.dash"), attributes: []) { [weak self] action in guard let self = self else { return } self.showPublicTimelineAction(action) }, + UIAction(title: "Show Profile", image: UIImage(systemName: "person.crop.circle"), attributes: []) { [weak self] action in + guard let self = self else { return } + self.showProfileAction(action) + }, + UIAction(title: "Show Thread", image: UIImage(systemName: "bubble.left.and.bubble.right"), attributes: []) { [weak self] action in + guard let self = self else { return } + self.showThreadAction(action) + }, + UIAction(title: "Settings", image: UIImage(systemName: "gear"), attributes: []) { [weak self] action in + guard let self = self else { return } + self.showSettings(action) + }, UIAction(title: "Sign Out", image: UIImage(systemName: "escape"), attributes: .destructive) { [weak self] action in guard let self = self else { return } self.signOutAction(action) @@ -29,13 +57,292 @@ extension HomeTimelineViewController { ) return menu } + + var moveMenu: UIMenu { + return UIMenu( + title: "Move to…", + image: UIImage(systemName: "arrow.forward.circle"), + identifier: nil, + options: [], + children: [ + UIAction(title: "First Gap", image: nil, attributes: [], handler: { [weak self] action in + guard let self = self else { return } + self.moveToTopGapAction(action) + }), + UIAction(title: "First Replied Status", image: nil, attributes: [], handler: { [weak self] action in + guard let self = self else { return } + self.moveToFirstRepliedStatus(action) + }), + UIAction(title: "First Reblog Status", image: nil, attributes: [], handler: { [weak self] action in + guard let self = self else { return } + self.moveToFirstReblogStatus(action) + }), + UIAction(title: "First Poll Status", image: nil, attributes: [], handler: { [weak self] action in + guard let self = self else { return } + self.moveToFirstPollStatus(action) + }), + UIAction(title: "First Audio Status", image: nil, attributes: [], handler: { [weak self] action in + guard let self = self else { return } + self.moveToFirstAudioStatus(action) + }), + UIAction(title: "First Video Status", image: nil, attributes: [], handler: { [weak self] action in + guard let self = self else { return } + self.moveToFirstVideoStatus(action) + }), + UIAction(title: "First GIF status", image: nil, attributes: [], handler: { [weak self] action in + guard let self = self else { return } + self.moveToFirstGIFStatus(action) + }), + ] + ) + } + + var dropMenu: UIMenu { + return UIMenu( + title: "Drop…", + image: UIImage(systemName: "minus.circle"), + identifier: nil, + options: [], + children: [50, 100, 150, 200, 250, 300].map { count in + UIAction(title: "Drop Recent \(count) Statuses", image: nil, attributes: [], handler: { [weak self] action in + guard let self = self else { return } + self.dropRecentStatusAction(action, count: count) + }) + } + ) + } } extension HomeTimelineViewController { + @objc private func moveToTopGapAction(_ sender: UIAction) { + guard let diffableDataSource = viewModel.diffableDataSource else { return } + let snapshotTransitioning = diffableDataSource.snapshot() + let item = snapshotTransitioning.itemIdentifiers.first(where: { item in + switch item { + case .homeMiddleLoader: return true + default: return false + } + }) + if let targetItem = item, let index = snapshotTransitioning.indexOfItem(targetItem) { + tableView.scrollToRow(at: IndexPath(row: index, section: 0), at: .middle, animated: true) + } + } + + @objc private func moveToFirstReblogStatus(_ sender: UIAction) { + guard let diffableDataSource = viewModel.diffableDataSource else { return } + let snapshotTransitioning = diffableDataSource.snapshot() + let item = snapshotTransitioning.itemIdentifiers.first(where: { item in + switch item { + case .homeTimelineIndex(let objectID, _): + let homeTimelineIndex = viewModel.fetchedResultsController.managedObjectContext.object(with: objectID) as! HomeTimelineIndex + return homeTimelineIndex.status.reblog != nil + default: + return false + } + }) + if let targetItem = item, let index = snapshotTransitioning.indexOfItem(targetItem) { + tableView.scrollToRow(at: IndexPath(row: index, section: 0), at: .middle, animated: true) + tableView.blinkRow(at: IndexPath(row: index, section: 0)) + } else { + print("Not found reblog status") + } + } + + @objc private func moveToFirstPollStatus(_ sender: UIAction) { + guard let diffableDataSource = viewModel.diffableDataSource else { return } + let snapshotTransitioning = diffableDataSource.snapshot() + let item = snapshotTransitioning.itemIdentifiers.first(where: { item in + switch item { + case .homeTimelineIndex(let objectID, _): + let homeTimelineIndex = viewModel.fetchedResultsController.managedObjectContext.object(with: objectID) as! HomeTimelineIndex + let post = homeTimelineIndex.status.reblog ?? homeTimelineIndex.status + return post.poll != nil + default: + return false + } + }) + if let targetItem = item, let index = snapshotTransitioning.indexOfItem(targetItem) { + tableView.scrollToRow(at: IndexPath(row: index, section: 0), at: .middle, animated: true) + tableView.blinkRow(at: IndexPath(row: index, section: 0)) + } else { + print("Not found poll status") + } + } + + @objc private func moveToFirstRepliedStatus(_ sender: UIAction) { + guard let diffableDataSource = viewModel.diffableDataSource else { return } + let snapshotTransitioning = diffableDataSource.snapshot() + let item = snapshotTransitioning.itemIdentifiers.first(where: { item in + switch item { + case .homeTimelineIndex(let objectID, _): + let homeTimelineIndex = viewModel.fetchedResultsController.managedObjectContext.object(with: objectID) as! HomeTimelineIndex + guard homeTimelineIndex.status.inReplyToID != nil else { + return false + } + return true + default: + return false + } + }) + if let targetItem = item, let index = snapshotTransitioning.indexOfItem(targetItem) { + tableView.scrollToRow(at: IndexPath(row: index, section: 0), at: .middle, animated: true) + tableView.blinkRow(at: IndexPath(row: index, section: 0)) + } else { + print("Not found replied status") + } + } + + @objc private func moveToFirstAudioStatus(_ sender: UIAction) { + guard let diffableDataSource = viewModel.diffableDataSource else { return } + let snapshotTransitioning = diffableDataSource.snapshot() + let item = snapshotTransitioning.itemIdentifiers.first(where: { item in + switch item { + case .homeTimelineIndex(let objectID, _): + let homeTimelineIndex = viewModel.fetchedResultsController.managedObjectContext.object(with: objectID) as! HomeTimelineIndex + let status = homeTimelineIndex.status.reblog ?? homeTimelineIndex.status + return status.mediaAttachments?.contains(where: { $0.type == .audio }) ?? false + default: + return false + } + }) + if let targetItem = item, let index = snapshotTransitioning.indexOfItem(targetItem) { + tableView.scrollToRow(at: IndexPath(row: index, section: 0), at: .middle, animated: true) + tableView.blinkRow(at: IndexPath(row: index, section: 0)) + } else { + print("Not found audio status") + } + } + + @objc private func moveToFirstVideoStatus(_ sender: UIAction) { + guard let diffableDataSource = viewModel.diffableDataSource else { return } + let snapshotTransitioning = diffableDataSource.snapshot() + let item = snapshotTransitioning.itemIdentifiers.first(where: { item in + switch item { + case .homeTimelineIndex(let objectID, _): + let homeTimelineIndex = viewModel.fetchedResultsController.managedObjectContext.object(with: objectID) as! HomeTimelineIndex + let status = homeTimelineIndex.status.reblog ?? homeTimelineIndex.status + return status.mediaAttachments?.contains(where: { $0.type == .video }) ?? false + default: + return false + } + }) + if let targetItem = item, let index = snapshotTransitioning.indexOfItem(targetItem) { + tableView.scrollToRow(at: IndexPath(row: index, section: 0), at: .middle, animated: true) + tableView.blinkRow(at: IndexPath(row: index, section: 0)) + } else { + print("Not found video status") + } + } + + @objc private func moveToFirstGIFStatus(_ sender: UIAction) { + guard let diffableDataSource = viewModel.diffableDataSource else { return } + let snapshotTransitioning = diffableDataSource.snapshot() + let item = snapshotTransitioning.itemIdentifiers.first(where: { item in + switch item { + case .homeTimelineIndex(let objectID, _): + let homeTimelineIndex = viewModel.fetchedResultsController.managedObjectContext.object(with: objectID) as! HomeTimelineIndex + let status = homeTimelineIndex.status.reblog ?? homeTimelineIndex.status + return status.mediaAttachments?.contains(where: { $0.type == .gifv }) ?? false + default: + return false + } + }) + if let targetItem = item, let index = snapshotTransitioning.indexOfItem(targetItem) { + tableView.scrollToRow(at: IndexPath(row: index, section: 0), at: .middle, animated: true) + tableView.blinkRow(at: IndexPath(row: index, section: 0)) + } else { + print("Not found GIF status") + } + } + + @objc private func dropRecentStatusAction(_ sender: UIAction, count: Int) { + guard let diffableDataSource = viewModel.diffableDataSource else { return } + let snapshotTransitioning = diffableDataSource.snapshot() + + let droppingObjectIDs = snapshotTransitioning.itemIdentifiers.prefix(count).compactMap { item -> NSManagedObjectID? in + switch item { + case .homeTimelineIndex(let objectID, _): return objectID + default: return nil + } + } + var droppingStatusObjectIDs: [NSManagedObjectID] = [] + context.apiService.backgroundManagedObjectContext.performChanges { [weak self] in + guard let self = self else { return } + for objectID in droppingObjectIDs { + guard let homeTimelineIndex = try? self.context.apiService.backgroundManagedObjectContext.existingObject(with: objectID) as? HomeTimelineIndex else { continue } + droppingStatusObjectIDs.append(homeTimelineIndex.status.objectID) + self.context.apiService.backgroundManagedObjectContext.delete(homeTimelineIndex) + } + } + .sink { [weak self] result in + guard let self = self else { return } + switch result { + case .success: + self.context.apiService.backgroundManagedObjectContext.performChanges { [weak self] in + guard let self = self else { return } + for objectID in droppingStatusObjectIDs { + guard let post = try? self.context.apiService.backgroundManagedObjectContext.existingObject(with: objectID) as? Status else { continue } + self.context.apiService.backgroundManagedObjectContext.delete(post) + } + } + .sink { _ in + // do nothing + } + .store(in: &self.disposeBag) + case .failure(let error): + assertionFailure(error.localizedDescription) + } + } + .store(in: &disposeBag) + } + + @objc private func showWelcomeAction(_ sender: UIAction) { + coordinator.present(scene: .welcome, from: self, transition: .modal(animated: true, completion: nil)) + } + @objc private func showPublicTimelineAction(_ sender: UIAction) { coordinator.present(scene: .publicTimeline, from: self, transition: .show) } + @objc private func showProfileAction(_ sender: UIAction) { + let alertController = UIAlertController(title: "Enter User ID", message: nil, preferredStyle: .alert) + alertController.addTextField() + let showAction = UIAlertAction(title: "Show", style: .default) { [weak self, weak alertController] _ in + guard let self = self else { return } + guard let textField = alertController?.textFields?.first else { return } + let profileViewModel = RemoteProfileViewModel(context: self.context, userID: textField.text ?? "") + self.coordinator.present(scene: .profile(viewModel: profileViewModel), from: self, transition: .show) + } + alertController.addAction(showAction) + let cancelAction = UIAlertAction(title: "Cancel", style: .cancel, handler: nil) + alertController.addAction(cancelAction) + coordinator.present(scene: .alertController(alertController: alertController), from: self, transition: .alertController(animated: true, completion: nil)) + } + + @objc private func showThreadAction(_ sender: UIAction) { + let alertController = UIAlertController(title: "Enter Status ID", message: nil, preferredStyle: .alert) + alertController.addTextField() + let showAction = UIAlertAction(title: "Show", style: .default) { [weak self, weak alertController] _ in + guard let self = self else { return } + guard let textField = alertController?.textFields?.first else { return } + let threadViewModel = RemoteThreadViewModel(context: self.context, statusID: textField.text ?? "") + self.coordinator.present(scene: .thread(viewModel: threadViewModel), from: self, transition: .show) + } + alertController.addAction(showAction) + let cancelAction = UIAlertAction(title: "Cancel", style: .cancel, handler: nil) + alertController.addAction(cancelAction) + coordinator.present(scene: .alertController(alertController: alertController), from: self, transition: .alertController(animated: true, completion: nil)) + } + + @objc private func showSettings(_ sender: UIAction) { + guard let currentSetting = context.settingService.currentSetting.value else { return } + let settingsViewModel = SettingsViewModel(context: context, setting: currentSetting) + coordinator.present( + scene: .settings(viewModel: settingsViewModel), + from: self, + transition: .modal(animated: true, completion: nil) + ) + } } #endif diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+Provider.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+Provider.swift new file mode 100644 index 000000000..d735d5843 --- /dev/null +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+Provider.swift @@ -0,0 +1,89 @@ +// +// 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 + } + +} + +extension HomeTimelineViewController: UserProvider {} diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+StatusProvider.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+StatusProvider.swift deleted file mode 100644 index 697820072..000000000 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+StatusProvider.swift +++ /dev/null @@ -1,71 +0,0 @@ -// -// HomeTimelineViewController+StatusProvider.swift -// Mastodon -// -// Created by sxiaojian on 2021/2/5. -// - -import os.log -import UIKit -import Combine -import CoreDataStack - -// MARK: - StatusProvider -extension HomeTimelineViewController: StatusProvider { - - func toot() -> Future { - return Future { promise in promise(.success(nil)) } - } - - func toot(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 ?? self.tableView.indexPath(for: cell), - 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?.toot)) - } - default: - promise(.success(nil)) - } - } - } - - func toot(for cell: UICollectionViewCell) -> Future { - return Future { promise in promise(.success(nil)) } - } - - var tableViewDiffableDataSource: UITableViewDiffableDataSource? { - return viewModel.diffableDataSource - } - - func item(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 ?? self.tableView.indexPath(for: cell), - let item = diffableDataSource.itemIdentifier(for: indexPath) else { - promise(.success(nil)) - return - } - - promise(.success(item)) - } - } - -} diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift index d3906fd90..6db1c26f3 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift @@ -15,7 +15,7 @@ import GameplayKit import MastodonSDK import AlamofireImage -final class HomeTimelineViewController: UIViewController, NeedsDependency { +final class HomeTimelineViewController: UIViewController, NeedsDependency, MediaPreviewableViewController { weak var context: AppContext! { willSet { precondition(!isViewLoaded) } } weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } } @@ -23,6 +23,19 @@ final class HomeTimelineViewController: UIViewController, NeedsDependency { var disposeBag = Set() private(set) lazy var viewModel = HomeTimelineViewModel(context: context) + let mediaPreviewTransitionController = MediaPreviewTransitionController() + + lazy var emptyView: UIStackView = { + let emptyView = UIStackView() + emptyView.axis = .vertical + emptyView.distribution = .fill + emptyView.layoutMargins = UIEdgeInsets(top: 0, left: 20, bottom: 54, right: 20) + emptyView.isLayoutMarginsRelativeArrangement = true + return emptyView + }() + + let titleView = HomeTimelineNavigationBarTitleView() + let settingBarButtonItem: UIBarButtonItem = { let barButtonItem = UIBarButtonItem() barButtonItem.tintColor = Asset.Colors.Label.highlight.color @@ -45,10 +58,15 @@ final class HomeTimelineViewController: UIViewController, NeedsDependency { tableView.rowHeight = UITableView.automaticDimension tableView.separatorStyle = .none tableView.backgroundColor = .clear - return tableView }() + let publishProgressView: UIProgressView = { + let progressView = UIProgressView(progressViewStyle: .bar) + progressView.alpha = 0 + return progressView + }() + let refreshControl = UIRefreshControl() deinit { @@ -63,25 +81,26 @@ extension HomeTimelineViewController { super.viewDidLoad() title = L10n.Scene.HomeTimeline.title - view.backgroundColor = Asset.Colors.Background.systemGroupedBackground.color - navigationItem.titleView = { - let imageView = UIImageView(image: Asset.Asset.mastodonTextLogo.image.withRenderingMode(.alwaysTemplate)) - imageView.tintColor = Asset.Colors.Label.primary.color - return imageView - }() + view.backgroundColor = Asset.Colors.Background.secondarySystemBackground.color navigationItem.leftBarButtonItem = settingBarButtonItem + navigationItem.titleView = titleView + titleView.delegate = self + + viewModel.homeTimelineNavigationBarTitleViewModel.state + .receive(on: DispatchQueue.main) + .sink { [weak self] state in + guard let self = self else { return } + self.titleView.configure(state: state) + } + .store(in: &disposeBag) + + #if DEBUG // long press to trigger debug menu settingBarButtonItem.menu = debugMenu #else - // settingBarButtonItem.target = self - // settingBarButtonItem.action = #selector(HomeTimelineViewController.settingBarButtonItemPressed(_:)) - settingBarButtonItem.menu = UIMenu(title: "Settings", image: nil, identifier: nil, options: .displayInline, children: [ - UIAction(title: "Sign Out", image: UIImage(systemName: "escape"), attributes: .destructive) { [weak self] action in - guard let self = self else { return } - self.signOutAction(action) - } - ]) + settingBarButtonItem.target = self + settingBarButtonItem.action = #selector(HomeTimelineViewController.settingBarButtonItemPressed(_:)) #endif navigationItem.rightBarButtonItem = composeBarButtonItem @@ -99,14 +118,23 @@ extension HomeTimelineViewController { tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor), tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor), ]) + + publishProgressView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(publishProgressView) + NSLayoutConstraint.activate([ + publishProgressView.topAnchor.constraint(equalTo: view.layoutMarginsGuide.topAnchor), + publishProgressView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + publishProgressView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + ]) viewModel.tableView = tableView viewModel.contentOffsetAdjustableTimelineViewControllerDelegate = self tableView.delegate = self + tableView.prefetchDataSource = self viewModel.setupDiffableDataSource( for: tableView, dependency: self, - timelinePostTableViewCellDelegate: self, + statusTableViewCellDelegate: self, timelineMiddleLoaderTableViewCellDelegate: self ) @@ -119,12 +147,62 @@ extension HomeTimelineViewController { UIView.animate(withDuration: 0.5) { [weak self] in guard let self = self else { return } self.refreshControl.endRefreshing() + } completion: { _ in } + } + } + .store(in: &disposeBag) + + viewModel.homeTimelineNavigationBarTitleViewModel.publishingProgress + .receive(on: DispatchQueue.main) + .sink { [weak self] progress in + guard let self = self else { return } + guard progress > 0 else { + let dismissAnimator = UIViewPropertyAnimator(duration: 0.1, curve: .easeInOut) + dismissAnimator.addAnimations { + self.publishProgressView.alpha = 0 } + dismissAnimator.addCompletion { _ in + self.publishProgressView.setProgress(0, animated: false) + } + dismissAnimator.startAnimation() + return + } + if self.publishProgressView.alpha == 0 { + let progressAnimator = UIViewPropertyAnimator(duration: 0.1, curve: .easeOut) + progressAnimator.addAnimations { + self.publishProgressView.alpha = 1 + } + progressAnimator.startAnimation() + } + + self.publishProgressView.setProgress(progress, animated: true) + } + .store(in: &disposeBag) + + viewModel.timelineIsEmpty + .receive(on: DispatchQueue.main) + .sink { [weak self] isEmpty in + if isEmpty { + self?.showEmptyView() + } else { + self?.emptyView.removeFromSuperview() } } .store(in: &disposeBag) } - + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + + aspectViewWillAppear(animated) + + // needs trigger manually after onboarding dismiss + setNeedsStatusBarAppearanceUpdate() + + if (viewModel.fetchedResultsController.fetchedObjects ?? []).isEmpty { + viewModel.loadLatestStateMachine.enter(HomeTimelineViewModel.LoadLatestState.Loading.self) + } + } override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) @@ -142,6 +220,7 @@ extension HomeTimelineViewController { override func viewDidDisappear(_ animated: Bool) { super.viewDidDisappear(animated) + aspectViewDidDisappear(animated) } override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { @@ -158,15 +237,70 @@ extension HomeTimelineViewController { } extension HomeTimelineViewController { + func showEmptyView() { + if emptyView.superview != nil { + return + } + view.addSubview(emptyView) + emptyView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + emptyView.leadingAnchor.constraint(equalTo: view.readableContentGuide.leadingAnchor), + emptyView.trailingAnchor.constraint(equalTo: view.readableContentGuide.trailingAnchor), + emptyView.bottomAnchor.constraint(equalTo: view.readableContentGuide.bottomAnchor) + ]) + + if emptyView.arrangedSubviews.count > 0 { + return + } + let findPeopleButton: PrimaryActionButton = { + let button = PrimaryActionButton() + button.setTitle(L10n.Common.Controls.Actions.findPeople, for: .normal) + button.addTarget(self, action: #selector(HomeTimelineViewController.findPeopleButtonPressed(_:)), for: .touchUpInside) + return button + }() + NSLayoutConstraint.activate([ + findPeopleButton.heightAnchor.constraint(equalToConstant: 46) + ]) + + let manuallySearchButton: HighlightDimmableButton = { + let button = HighlightDimmableButton() + button.titleLabel?.font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 15, weight: .semibold)) + button.setTitle(L10n.Common.Controls.Actions.manuallySearch, for: .normal) + button.setTitleColor(Asset.Colors.brandBlue.color, for: .normal) + button.addTarget(self, action: #selector(HomeTimelineViewController.manuallySearchButtonPressed(_:)), for: .touchUpInside) + return button + }() + + emptyView.addArrangedSubview(findPeopleButton) + emptyView.setCustomSpacing(17, after: findPeopleButton) + emptyView.addArrangedSubview(manuallySearchButton) + + } +} + +extension HomeTimelineViewController { + + @objc private func findPeopleButtonPressed(_ sender: PrimaryActionButton) { + let viewModel = SuggestionAccountViewModel(context: context) + viewModel.delegate = self.viewModel + coordinator.present(scene: .suggestionAccount(viewModel: viewModel), from: self, transition: .modal(animated: true, completion: nil)) + } + + @objc private func manuallySearchButtonPressed(_ sender: UIButton) { + coordinator.switchToTabBar(tab: .search) + } @objc private func settingBarButtonItemPressed(_ sender: UIBarButtonItem) { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) - + guard let setting = context.settingService.currentSetting.value else { return } + let settingsViewModel = SettingsViewModel(context: context, setting: setting) + coordinator.present(scene: .settings(viewModel: settingsViewModel), from: self, transition: .modal(animated: true, completion: nil)) } @objc private func composeBarButtonItemPressed(_ sender: UIBarButtonItem) { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) - + let composeViewModel = ComposeViewModel(context: context, composeKind: .post) + coordinator.present(scene: .compose(viewModel: composeViewModel), from: self, transition: .modal(animated: true, completion: nil)) } @objc private func refreshControlValueChanged(_ sender: UIRefreshControl) { @@ -203,10 +337,19 @@ extension HomeTimelineViewController { } +// MARK: - StatusTableViewControllerAspect +extension HomeTimelineViewController: StatusTableViewControllerAspect { } + +extension HomeTimelineViewController: TableViewCellHeightCacheableContainer { + var cellFrameCache: NSCache { return viewModel.cellFrameCache } +} + // MARK: - UIScrollViewDelegate extension HomeTimelineViewController { func scrollViewDidScroll(_ scrollView: UIScrollView) { - handleScrollViewDidScroll(scrollView) + + aspectScrollViewDidScroll(scrollView) + viewModel.homeTimelineNavigationBarTitleViewModel.handleScrollViewDidScroll(scrollView) } } @@ -221,17 +364,44 @@ extension HomeTimelineViewController: LoadMoreConfigurableTableViewContainer { extension HomeTimelineViewController: 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 - } - // os_log("%{public}s[%{public}ld], %{public}s: cache cell frame %s", ((#file as NSString).lastPathComponent), #line, #function, frame.debugDescription) - - return ceil(frame.height) + 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) + } } // MARK: - ContentOffsetAdjustableTimelineViewControllerDelegate @@ -241,10 +411,9 @@ extension HomeTimelineViewController: ContentOffsetAdjustableTimelineViewControl } } - // MARK: - TimelineMiddleLoaderTableViewCellDelegate extension HomeTimelineViewController: TimelineMiddleLoaderTableViewCellDelegate { - func configure(cell: TimelineMiddleLoaderTableViewCell, upperTimelineTootID: String?, timelineIndexobjectID: NSManagedObjectID?) { + func configure(cell: TimelineMiddleLoaderTableViewCell, upperTimelineStatusID: String?, timelineIndexobjectID: NSManagedObjectID?) { guard let upperTimelineIndexObjectID = timelineIndexobjectID else { return } @@ -260,15 +429,13 @@ extension HomeTimelineViewController: TimelineMiddleLoaderTableViewCellDelegate // make success state same as loading due to snapshot updating delay let isLoading = state is HomeTimelineViewModel.LoadMiddleState.Loading || state is HomeTimelineViewModel.LoadMiddleState.Success - cell.loadMoreButton.isHidden = isLoading if isLoading { - cell.activityIndicatorView.startAnimating() + cell.startAnimating() } else { - cell.activityIndicatorView.stopAnimating() + cell.stopAnimating() } } else { - cell.loadMoreButton.isHidden = false - cell.activityIndicatorView.stopAnimating() + cell.stopAnimating() } } .store(in: &cell.disposeBag) @@ -332,5 +499,41 @@ extension HomeTimelineViewController: ScrollViewContainer { } +// MARK: - AVPlayerViewControllerDelegate +extension HomeTimelineViewController: AVPlayerViewControllerDelegate { + + func playerViewController(_ playerViewController: AVPlayerViewController, willBeginFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator) { + handlePlayerViewController(playerViewController, willBeginFullScreenPresentationWithAnimationCoordinator: coordinator) + } + + func playerViewController(_ playerViewController: AVPlayerViewController, willEndFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator) { + handlePlayerViewController(playerViewController, willEndFullScreenPresentationWithAnimationCoordinator: coordinator) + } + +} + // MARK: - StatusTableViewCellDelegate -extension HomeTimelineViewController: StatusTableViewCellDelegate { } +extension HomeTimelineViewController: StatusTableViewCellDelegate { + weak var playerViewControllerDelegate: AVPlayerViewControllerDelegate? { return self } + func parent() -> UIViewController { return self } +} + +// MARK: - HomeTimelineNavigationBarTitleViewDelegate +extension HomeTimelineViewController: HomeTimelineNavigationBarTitleViewDelegate { + func homeTimelineNavigationBarTitleView(_ titleView: HomeTimelineNavigationBarTitleView, buttonDidPressed sender: UIButton) { + switch titleView.state { + case .newPostButton: + guard let diffableDataSource = viewModel.diffableDataSource else { return } + let indexPath = IndexPath(row: 0, section: 0) + guard diffableDataSource.itemIdentifier(for: indexPath) != nil else { return } + tableView.scrollToRow(at: indexPath, at: .top, animated: true) + case .offlineButton: + // TODO: retry + break + case .publishedButton: + break + default: + break + } + } +} diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+Diffable.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+Diffable.swift index d5345de4f..6f5e66c0e 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+Diffable.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+Diffable.swift @@ -15,7 +15,7 @@ extension HomeTimelineViewModel { func setupDiffableDataSource( for tableView: UITableView, dependency: NeedsDependency, - timelinePostTableViewCellDelegate: StatusTableViewCellDelegate, + statusTableViewCellDelegate: StatusTableViewCellDelegate, timelineMiddleLoaderTableViewCellDelegate: TimelineMiddleLoaderTableViewCellDelegate ) { let timestampUpdatePublisher = Timer.publish(every: 1.0, on: .main, in: .common) @@ -28,9 +28,14 @@ extension HomeTimelineViewModel { dependency: dependency, managedObjectContext: fetchedResultsController.managedObjectContext, timestampUpdatePublisher: timestampUpdatePublisher, - timelinePostTableViewCellDelegate: timelinePostTableViewCellDelegate, - timelineMiddleLoaderTableViewCellDelegate: timelineMiddleLoaderTableViewCellDelegate + statusTableViewCellDelegate: statusTableViewCellDelegate, + timelineMiddleLoaderTableViewCellDelegate: timelineMiddleLoaderTableViewCellDelegate, + threadReplyLoaderTableViewCellDelegate: nil ) + +// var snapshot = NSDiffableDataSourceSnapshot() +// snapshot.appendSections([.main]) +// diffableDataSource?.apply(snapshot) } } @@ -73,7 +78,7 @@ extension HomeTimelineViewModel: NSFetchedResultsControllerDelegate { // that's will be the most fastest fetch because of upstream just update and no modify needs consider - var oldSnapshotAttributeDict: [NSManagedObjectID : Item.StatusTimelineAttribute] = [:] + var oldSnapshotAttributeDict: [NSManagedObjectID : Item.StatusAttribute] = [:] for item in oldSnapshot.itemIdentifiers { guard case let .homeTimelineIndex(objectID, attribute) = item else { continue } @@ -83,12 +88,8 @@ extension HomeTimelineViewModel: NSFetchedResultsControllerDelegate { var newTimelineItems: [Item] = [] for (i, timelineIndex) in timelineIndexes.enumerated() { - let toot = timelineIndex.toot.reblog ?? timelineIndex.toot - let isStatusTextSensitive: Bool = { - guard let spoilerText = toot.spoilerText, !spoilerText.isEmpty else { return false } - return true - }() - let attribute = oldSnapshotAttributeDict[timelineIndex.objectID] ?? Item.StatusTimelineAttribute(isStatusTextSensitive: isStatusTextSensitive, isStatusSensitive: toot.sensitive) + let attribute = oldSnapshotAttributeDict[timelineIndex.objectID] ?? Item.StatusAttribute() + attribute.isSeparatorLineHidden = false // append new item into snapshot newTimelineItems.append(.homeTimelineIndex(objectID: timelineIndex.objectID, attribute: attribute)) @@ -97,6 +98,7 @@ extension HomeTimelineViewModel: NSFetchedResultsControllerDelegate { switch (isLast, timelineIndex.hasMore) { case (false, true): newTimelineItems.append(.homeMiddleLoader(upperTimelineIndexAnchorObjectID: timelineIndex.objectID)) + attribute.isSeparatorLineHidden = true case (true, true): shouldAddBottomLoader = true default: diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+LoadLatestState.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+LoadLatestState.swift index c085471c6..425eb9aa0 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+LoadLatestState.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+LoadLatestState.swift @@ -55,7 +55,7 @@ extension HomeTimelineViewModel.LoadLatestState { managedObjectContext.perform { let start = CACurrentMediaTime() - let latestTootIDs: [Toot.ID] + let latestStatusIDs: [Status.ID] let request = HomeTimelineIndex.sortedFetchRequest request.returnsObjectsAsFaults = false request.predicate = predicate @@ -64,27 +64,29 @@ extension HomeTimelineViewModel.LoadLatestState { 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) - latestTootIDs = timelineIndexes - .prefix(APIService.onceRequestTootMaxCount) // avoid performance issue + latestStatusIDs = timelineIndexes + .prefix(APIService.onceRequestStatusMaxCount) // avoid performance issue .compactMap { timelineIndex in - timelineIndex.value(forKeyPath: #keyPath(HomeTimelineIndex.toot.id)) as? Toot.ID + timelineIndex.value(forKeyPath: #keyPath(HomeTimelineIndex.status.id)) as? Status.ID } } catch { stateMachine.enter(Fail.self) return } + let end = CACurrentMediaTime() - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: collect toots id cost: %.2fs", ((#file as NSString).lastPathComponent), #line, #function, end - start) + 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 toots failed. %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) + 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 @@ -93,14 +95,19 @@ extension HomeTimelineViewModel.LoadLatestState { stateMachine.enter(Idle.self) } receiveValue: { response in - // stop refresher if no new toots - let toots = response.value - let newToots = toots.filter { !latestTootIDs.contains($0.id) } - os_log("%{public}s[%{public}ld], %{public}s: load %{public}ld new toots", ((#file as NSString).lastPathComponent), #line, #function, newToots.count) - - if newToots.isEmpty { + // 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) } diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+LoadMiddleState.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+LoadMiddleState.swift index 5a212f357..b5b9e4ceb 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+LoadMiddleState.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+LoadMiddleState.swift @@ -58,29 +58,30 @@ extension HomeTimelineViewModel.LoadMiddleState { stateMachine.enter(Fail.self) return } - let tootIDs = (viewModel.fetchedResultsController.fetchedObjects ?? []).compactMap { timelineIndex in - timelineIndex.toot.id + let statusIDs = (viewModel.fetchedResultsController.fetchedObjects ?? []).compactMap { timelineIndex in + timelineIndex.status.id } // TODO: only set large count when using Wi-Fi - let maxID = timelineIndex.toot.id + 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 toots failed. %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) + 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 toots = response.value - let newToots = toots.filter { !tootIDs.contains($0.id) } - os_log("%{public}s[%{public}ld], %{public}s: load %{public}ld toots, %{public}%ld new toots", ((#file as NSString).lastPathComponent), #line, #function, toots.count, newToots.count) - if newToots.isEmpty { + 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) diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+LoadOldestState.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+LoadOldestState.swift index c6eb988b3..a74d03a52 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+LoadOldestState.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+LoadOldestState.swift @@ -53,22 +53,23 @@ extension HomeTimelineViewModel.LoadOldestState { } // TODO: only set large count when using Wi-Fi - let maxID = last.toot.id + 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 toots failed. %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) + os_log("%{public}s[%{public}ld], %{public}s: fetch statuses failed. %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) case .finished: // handle isFetchingLatestTimeline in fetch controller delegate break } } receiveValue: { response in - let toots = response.value - // enter no more state when no new toots - if toots.isEmpty || (toots.count == 1 && toots[0].id == maxID) { + let statuses = response.value + // enter no more state when no new statuses + if statuses.isEmpty || (statuses.count == 1 && statuses[0].id == maxID) { stateMachine.enter(NoMore.self) } else { stateMachine.enter(Idle.self) @@ -102,9 +103,11 @@ extension HomeTimelineViewModel.LoadOldestState { assertionFailure() return } - var snapshot = diffableDataSource.snapshot() - snapshot.deleteItems([.bottomLoader]) - diffableDataSource.apply(snapshot) + DispatchQueue.main.async { + var snapshot = diffableDataSource.snapshot() + snapshot.deleteItems([.bottomLoader]) + diffableDataSource.apply(snapshot) + } } } } diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel.swift index dd5ee97b1..717519464 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel.swift @@ -28,11 +28,14 @@ final class HomeTimelineViewModel: NSObject { let fetchedResultsController: NSFetchedResultsController let isFetchingLatestTimeline = CurrentValueSubject(false) let viewDidAppear = PassthroughSubject() + let homeTimelineNavigationBarTitleViewModel: HomeTimelineNavigationBarTitleViewModel weak var contentOffsetAdjustableTimelineViewControllerDelegate: ContentOffsetAdjustableTimelineViewControllerDelegate? weak var tableView: UITableView? weak var timelineMiddleLoaderTableViewCellDelegate: TimelineMiddleLoaderTableViewCellDelegate? + let timelineIsEmpty = CurrentValueSubject(false) + let homeTimelineNeedRefresh = PassthroughSubject() // output // top loader private(set) lazy var loadLatestStateMachine: GKStateMachine = { @@ -73,7 +76,7 @@ final class HomeTimelineViewModel: NSObject { let fetchRequest = HomeTimelineIndex.sortedFetchRequest fetchRequest.fetchBatchSize = 20 fetchRequest.returnsObjectsAsFaults = false - fetchRequest.relationshipKeyPathsForPrefetching = [#keyPath(HomeTimelineIndex.toot)] + fetchRequest.relationshipKeyPathsForPrefetching = [#keyPath(HomeTimelineIndex.status)] let controller = NSFetchedResultsController( fetchRequest: fetchRequest, managedObjectContext: context.managedObjectContext, @@ -83,6 +86,7 @@ final class HomeTimelineViewModel: NSObject { return controller }() + self.homeTimelineNavigationBarTitleViewModel = HomeTimelineNavigationBarTitleViewModel(context: context) super.init() fetchedResultsController.delegate = self @@ -110,16 +114,22 @@ final class HomeTimelineViewModel: NSObject { context.authenticationService.activeMastodonAuthentication .sink { [weak self] activeMastodonAuthentication in guard let self = self else { return } - guard let twitterAuthentication = activeMastodonAuthentication else { return } - let activeTwitterUserID = twitterAuthentication.userID + guard let mastodonAuthentication = activeMastodonAuthentication else { return } + let activeMastodonUserID = mastodonAuthentication.userID let predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [ - HomeTimelineIndex.predicate(userID: activeTwitterUserID), + HomeTimelineIndex.predicate(userID: activeMastodonUserID), HomeTimelineIndex.notDeleted() ]) self.timelinePredicate.value = predicate } .store(in: &disposeBag) + homeTimelineNeedRefresh + .sink { [weak self] _ in + self?.loadLatestStateMachine.enter(LoadLatestState.Loading.self) + } + .store(in: &disposeBag) + } deinit { @@ -127,3 +137,5 @@ final class HomeTimelineViewModel: NSObject { } } + +extension HomeTimelineViewModel: SuggestionAccountViewModelDelegate { } diff --git a/Mastodon/Scene/HomeTimeline/View/HomeTimelineNavigationBarTitleView.swift b/Mastodon/Scene/HomeTimeline/View/HomeTimelineNavigationBarTitleView.swift new file mode 100644 index 000000000..91020f12a --- /dev/null +++ b/Mastodon/Scene/HomeTimeline/View/HomeTimelineNavigationBarTitleView.swift @@ -0,0 +1,210 @@ +// +// HomeTimelineNavigationBarTitleView.swift +// Mastodon +// +// Created by sxiaojian on 2021/3/15. +// + +import os.log +import UIKit + +protocol HomeTimelineNavigationBarTitleViewDelegate: AnyObject { + func homeTimelineNavigationBarTitleView(_ titleView: HomeTimelineNavigationBarTitleView, buttonDidPressed sender: UIButton) +} + +final class HomeTimelineNavigationBarTitleView: UIView { + + let containerView = UIStackView() + + let imageView = UIImageView() + let button = RoundedEdgesButton() + let label = UILabel() + + // input + private var blockingState: HomeTimelineNavigationBarTitleViewModel.State? + weak var delegate: HomeTimelineNavigationBarTitleViewDelegate? + + // output + private(set) var state: HomeTimelineNavigationBarTitleViewModel.State = .logoImage + + override init(frame: CGRect) { + super.init(frame: frame) + _init() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + _init() + } + +} + +extension HomeTimelineNavigationBarTitleView { + private func _init() { + containerView.translatesAutoresizingMaskIntoConstraints = false + addSubview(containerView) + NSLayoutConstraint.activate([ + containerView.topAnchor.constraint(equalTo: topAnchor), + containerView.leadingAnchor.constraint(equalTo: leadingAnchor), + containerView.trailingAnchor.constraint(equalTo: trailingAnchor), + containerView.bottomAnchor.constraint(equalTo: bottomAnchor), + ]) + + containerView.addArrangedSubview(imageView) + button.translatesAutoresizingMaskIntoConstraints = false + containerView.addArrangedSubview(button) + NSLayoutConstraint.activate([ + button.heightAnchor.constraint(equalToConstant: 24).priority(.defaultHigh) + ]) + containerView.addArrangedSubview(label) + + configure(state: .logoImage) + button.addTarget(self, action: #selector(HomeTimelineNavigationBarTitleView.buttonDidPressed(_:)), for: .touchUpInside) + } +} + +extension HomeTimelineNavigationBarTitleView { + @objc private func buttonDidPressed(_ sender: UIButton) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + delegate?.homeTimelineNavigationBarTitleView(self, buttonDidPressed: sender) + } +} + +extension HomeTimelineNavigationBarTitleView { + + func resetContainer() { + imageView.isHidden = true + button.isHidden = true + label.isHidden = true + } + + func configure(state: HomeTimelineNavigationBarTitleViewModel.State) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: configure title view: %s", ((#file as NSString).lastPathComponent), #line, #function, state.rawValue) + self.state = state + + // check state block or not + guard blockingState == nil else { + return + } + + resetContainer() + + switch state { + case .logoImage: + imageView.tintColor = Asset.Colors.Label.primary.color + imageView.image = Asset.Asset.mastodonTextLogo.image.withRenderingMode(.alwaysTemplate) + imageView.contentMode = .center + imageView.isHidden = false + case .newPostButton: + configureButton( + title: L10n.Scene.HomeTimeline.NavigationBarState.newPosts, + textColor: .white, + backgroundColor: Asset.Colors.Button.normal.color + ) + button.isHidden = false + case .offlineButton: + configureButton( + title: L10n.Scene.HomeTimeline.NavigationBarState.offline, + textColor: .white, + backgroundColor: Asset.Colors.Background.danger.color + ) + button.isHidden = false + case .publishingPostLabel: + label.font = .systemFont(ofSize: 17, weight: .semibold) + label.textColor = Asset.Colors.Label.primary.color + label.text = L10n.Scene.HomeTimeline.NavigationBarState.publishing + label.textAlignment = .center + label.isHidden = false + case .publishedButton: + blockingState = state + configureButton( + title: L10n.Scene.HomeTimeline.NavigationBarState.published, + textColor: .white, + backgroundColor: Asset.Colors.successGreen.color + ) + button.isHidden = false + + let presentDuration: TimeInterval = 0.33 + let scaleAnimator = UIViewPropertyAnimator(duration: presentDuration, timingParameters: UISpringTimingParameters()) + button.transform = CGAffineTransform(scaleX: 0.5, y: 0.5) + scaleAnimator.addAnimations { + self.button.transform = .identity + } + let alphaAnimator = UIViewPropertyAnimator(duration: presentDuration, curve: .easeInOut) + button.alpha = 0.3 + alphaAnimator.addAnimations { + self.button.alpha = 1 + } + scaleAnimator.startAnimation() + alphaAnimator.startAnimation() + + let dismissDuration: TimeInterval = 3 + let dissolveAnimator = UIViewPropertyAnimator(duration: dismissDuration, curve: .easeInOut) + dissolveAnimator.addAnimations({ + self.button.alpha = 0 + }, delayFactor: 0.9) // at 2.7s + dissolveAnimator.addCompletion { _ in + self.blockingState = nil + self.configure(state: self.state) + self.button.alpha = 1 + } + dissolveAnimator.startAnimation() + } + } + + private func configureButton(title: String, textColor: UIColor, backgroundColor: UIColor) { + button.setBackgroundImage(.placeholder(color: backgroundColor), for: .normal) + button.setBackgroundImage(.placeholder(color: backgroundColor.withAlphaComponent(0.5)), for: .highlighted) + button.setTitleColor(textColor, for: .normal) + button.setTitleColor(textColor.withAlphaComponent(0.5), for: .highlighted) + button.setTitle(title, for: .normal) + button.contentEdgeInsets = UIEdgeInsets(top: 1, left: 16, bottom: 1, right: 16) + button.titleLabel?.font = .systemFont(ofSize: 15, weight: .bold) + } + +} + +#if canImport(SwiftUI) && DEBUG +import SwiftUI + +struct HomeTimelineNavigationBarTitleView_Previews: PreviewProvider { + + static var previews: some View { + Group { + UIViewPreview(width: 375) { + let titleView = HomeTimelineNavigationBarTitleView() + titleView.configure(state: .logoImage) + return titleView + } + .previewLayout(.fixed(width: 375, height: 44)) + UIViewPreview(width: 150) { + let titleView = HomeTimelineNavigationBarTitleView() + titleView.configure(state: .newPostButton) + return titleView + } + .previewLayout(.fixed(width: 150, height: 24)) + UIViewPreview(width: 120) { + let titleView = HomeTimelineNavigationBarTitleView() + titleView.configure(state: .offlineButton) + return titleView + } + .previewLayout(.fixed(width: 120, height: 24)) + UIViewPreview(width: 375) { + let titleView = HomeTimelineNavigationBarTitleView() + titleView.configure(state: .publishingPostLabel) + return titleView + } + .previewLayout(.fixed(width: 375, height: 44)) + UIViewPreview(width: 120) { + let titleView = HomeTimelineNavigationBarTitleView() + titleView.configure(state: .publishedButton) + return titleView + } + .previewLayout(.fixed(width: 120, height: 24)) + } + } + +} + +#endif + diff --git a/Mastodon/Scene/HomeTimeline/View/HomeTimelineNavigationBarTitleViewModel.swift b/Mastodon/Scene/HomeTimeline/View/HomeTimelineNavigationBarTitleViewModel.swift new file mode 100644 index 000000000..e1fc3174e --- /dev/null +++ b/Mastodon/Scene/HomeTimeline/View/HomeTimelineNavigationBarTitleViewModel.swift @@ -0,0 +1,174 @@ +// +// HomeTimelineNavigationBarTitleViewModel.swift +// Mastodon +// +// Created by sxiaojian on 2021/3/15. +// + +import Combine +import Foundation +import UIKit + +final class HomeTimelineNavigationBarTitleViewModel { + + static let offlineCounterLimit = 3 + + var disposeBag = Set() + private(set) var publishingProgressSubscription: AnyCancellable? + + // input + let context: AppContext + var networkErrorCount = CurrentValueSubject(0) + var networkErrorPublisher = PassthroughSubject() + + // output + let state = CurrentValueSubject(.logoImage) + let hasNewPosts = CurrentValueSubject(false) + let isOffline = CurrentValueSubject(false) + let isPublishingPost = CurrentValueSubject(false) + let isPublished = CurrentValueSubject(false) + let publishingProgress = PassthroughSubject() + + init(context: AppContext) { + self.context = context + + networkErrorPublisher + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + guard let self = self else { return } + self.networkErrorCount.value += self.networkErrorCount.value + 1 + } + .store(in: &disposeBag) + + networkErrorCount + .receive(on: DispatchQueue.main) + .map { count in + return count >= HomeTimelineNavigationBarTitleViewModel.offlineCounterLimit + } + .assign(to: \.value, on: isOffline) + .store(in: &disposeBag) + + context.statusPublishService.latestPublishingComposeViewModel + .receive(on: DispatchQueue.main) + .sink { [weak self] composeViewModel in + guard let self = self else { return } + guard let composeViewModel = composeViewModel, + let state = composeViewModel.publishStateMachine.currentState else { + self.isPublishingPost.value = false + self.isPublished.value = false + return + } + + self.isPublishingPost.value = state is ComposeViewModel.PublishState.Publishing || state is ComposeViewModel.PublishState.Fail + self.isPublished.value = state is ComposeViewModel.PublishState.Finish + } + .store(in: &disposeBag) + + Publishers.CombineLatest4( + hasNewPosts.eraseToAnyPublisher(), + isOffline.eraseToAnyPublisher(), + isPublishingPost.eraseToAnyPublisher(), + isPublished.eraseToAnyPublisher() + ) + .map { hasNewPosts, isOffline, isPublishingPost, isPublished -> State in + guard !isPublished else { return .publishedButton } + guard !isPublishingPost else { return .publishingPostLabel } + guard !isOffline else { return .offlineButton } + guard !hasNewPosts else { return .newPostButton } + return .logoImage + } + .receive(on: DispatchQueue.main) + .assign(to: \.value, on: state) + .store(in: &disposeBag) + + state + .removeDuplicates() + .receive(on: DispatchQueue.main) + .sink { [weak self] state in + guard let self = self else { return } + switch state { + case .publishingPostLabel: + self.setupPublishingProgress() + default: + self.suspendPublishingProgress() + } + } + .store(in: &disposeBag) + } +} + +extension HomeTimelineNavigationBarTitleViewModel { + // state order by priority from low to high + enum State: String { + case logoImage + case newPostButton + case offlineButton + case publishingPostLabel + case publishedButton + } +} + +// MARK: - New post state +extension HomeTimelineNavigationBarTitleViewModel { + + func newPostsIncoming() { + hasNewPosts.value = true + } + + private func resetNewPostState() { + hasNewPosts.value = false + } + +} + +// MARK: - Offline state +extension HomeTimelineNavigationBarTitleViewModel { + + func resetOfflineCounterListener() { + networkErrorCount.value = 0 + } + + func receiveLoadingStateCompletion(_ completion: Subscribers.Completion) { + switch completion { + case .failure: + networkErrorPublisher.send() + case .finished: + resetOfflineCounterListener() + } + } + + func handleScrollViewDidScroll(_ scrollView: UIScrollView) { + guard hasNewPosts.value else { return } + + let contentOffsetY = scrollView.contentOffset.y + let isScrollToTop = contentOffsetY < -scrollView.contentInset.top + guard isScrollToTop else { return } + resetNewPostState() + } + +} + +// MARK: Publish post state +extension HomeTimelineNavigationBarTitleViewModel { + + func setupPublishingProgress() { + let progressUpdatePublisher = Timer.publish(every: 0.016, on: .main, in: .common) // ~ 60FPS + .autoconnect() + .share() + .eraseToAnyPublisher() + + publishingProgressSubscription = progressUpdatePublisher + .map { _ in Float(0) } + .scan(0.0) { progress, _ -> Float in + return 0.95 * progress + 0.05 // progress + 0.05 * (1.0 - progress). ~ 1 sec to 0.95 (under 60FPS) + } + .subscribe(publishingProgress) + } + + func suspendPublishingProgress() { + publishingProgressSubscription = nil + publishingProgress.send(0) + } + +} + diff --git a/Mastodon/Scene/MainTab/MainTabBarController.swift b/Mastodon/Scene/MainTab/MainTabBarController.swift index a556854e5..8b1701578 100644 --- a/Mastodon/Scene/MainTab/MainTabBarController.swift +++ b/Mastodon/Scene/MainTab/MainTabBarController.swift @@ -25,10 +25,10 @@ class MainTabBarController: UITabBarController { var title: String { switch self { - case .home: return "Home" - case .search: return "Search" - case .notification: return "Notification" - case .me: return "Me" + case .home: return L10n.Common.Controls.Tabs.home + case .search: return L10n.Common.Controls.Tabs.search + case .notification: return L10n.Common.Controls.Tabs.notification + case .me: return L10n.Common.Controls.Tabs.profile } } @@ -63,10 +63,11 @@ class MainTabBarController: UITabBarController { let _viewController = ProfileViewController() _viewController.context = context _viewController.coordinator = coordinator + _viewController.viewModel = MeProfileViewModel(context: context) viewController = _viewController } viewController.title = self.title - return UINavigationController(rootViewController: viewController) + return AdaptiveStatusBarStyleNavigationController(rootViewController: viewController) } } @@ -84,6 +85,10 @@ class MainTabBarController: UITabBarController { extension MainTabBarController { + open override var childForStatusBarStyle: UIViewController? { + return selectedViewController + } + override func viewDidLoad() { super.viewDidLoad() @@ -94,15 +99,16 @@ extension MainTabBarController { let viewController = tab.viewController(context: context, coordinator: coordinator) viewController.tabBarItem.title = "" // set text to empty string for image only style (SDK failed to layout when set to nil) viewController.tabBarItem.image = tab.image + viewController.tabBarItem.accessibilityLabel = tab.title return viewController } setViewControllers(viewControllers, animated: false) selectedIndex = 0 // TODO: custom accent color - let tabBarAppearance = UITabBarAppearance() - tabBarAppearance.configureWithDefaultBackground() - tabBar.standardAppearance = tabBarAppearance +// let tabBarAppearance = UITabBarAppearance() +// tabBarAppearance.configureWithDefaultBackground() +// tabBar.standardAppearance = tabBarAppearance context.apiService.error .receive(on: DispatchQueue.main) @@ -123,10 +129,63 @@ extension MainTabBarController { } } .store(in: &disposeBag) + + // handle post failure + context.statusPublishService + .latestPublishingComposeViewModel + .receive(on: DispatchQueue.main) + .sink { [weak self] composeViewModel in + guard let self = self else { return } + guard let composeViewModel = composeViewModel else { return } + guard let currentState = composeViewModel.publishStateMachine.currentState else { return } + guard currentState is ComposeViewModel.PublishState.Fail else { return } - #if DEBUG - // selectedIndex = 1 - #endif + let alertController = UIAlertController(title: L10n.Common.Alerts.PublishPostFailure.title, message: L10n.Common.Alerts.PublishPostFailure.message, preferredStyle: .alert) + let discardAction = UIAlertAction(title: L10n.Common.Controls.Actions.discard, style: .destructive) { [weak self, weak composeViewModel] _ in + guard let self = self else { return } + guard let composeViewModel = composeViewModel else { return } + self.context.statusPublishService.remove(composeViewModel: composeViewModel) + } + alertController.addAction(discardAction) + let retryAction = UIAlertAction(title: L10n.Common.Controls.Actions.tryAgain, style: .default) { [weak composeViewModel] _ in + guard let composeViewModel = composeViewModel else { return } + composeViewModel.publishStateMachine.enter(ComposeViewModel.PublishState.Publishing.self) + } + alertController.addAction(retryAction) + self.present(alertController, animated: true, completion: nil) + } + .store(in: &disposeBag) + + // handle push notification. toggle entry when finish fetch latest notification + context.notificationService.hasUnreadPushNotification + .receive(on: DispatchQueue.main) + .sink { [weak self] hasUnreadPushNotification in + guard let self = self else { return } + guard let notificationViewController = self.notificationViewController else { return } + + let image = hasUnreadPushNotification ? UIImage(systemName: "bell.badge.fill")! : UIImage(systemName: "bell.fill")! + notificationViewController.tabBarItem.image = image + notificationViewController.navigationController?.tabBarItem.image = image + } + .store(in: &disposeBag) + + context.notificationService.requestRevealNotificationPublisher + .receive(on: DispatchQueue.main) + .sink { [weak self] notificationID in + guard let self = self else { return } + self.coordinator.switchToTabBar(tab: .notification) + let threadViewModel = RemoteThreadViewModel(context: self.context, notificationID: notificationID) + self.coordinator.present(scene: .thread(viewModel: threadViewModel), from: nil, transition: .show) + } + .store(in: &disposeBag) } } + +extension MainTabBarController { + + var notificationViewController: NotificationViewController? { + return viewController(of: NotificationViewController.self) + } + +} diff --git a/Mastodon/Scene/MediaPreview/MediaPreviewViewController.swift b/Mastodon/Scene/MediaPreview/MediaPreviewViewController.swift new file mode 100644 index 000000000..eee56e4d0 --- /dev/null +++ b/Mastodon/Scene/MediaPreview/MediaPreviewViewController.swift @@ -0,0 +1,242 @@ +// +// MediaPreviewViewController.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-28. +// + +import os.log +import UIKit +import Combine +import Pageboy + +final class MediaPreviewViewController: UIViewController, NeedsDependency { + + static let closeButtonSize = CGSize(width: 30, height: 30) + + weak var context: AppContext! { willSet { precondition(!isViewLoaded) } } + weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } } + + var disposeBag = Set() + var viewModel: MediaPreviewViewModel! + + let visualEffectView = UIVisualEffectView(effect: UIBlurEffect(style: .systemMaterial)) + let pagingViewConttroller = MediaPreviewPagingViewController() + + let closeButtonBackground: UIVisualEffectView = { + let backgroundView = UIVisualEffectView(effect: UIBlurEffect(style: .systemUltraThinMaterial)) + backgroundView.alpha = 0.9 + backgroundView.layer.masksToBounds = true + backgroundView.layer.cornerRadius = MediaPreviewViewController.closeButtonSize.width * 0.5 + return backgroundView + }() + + let closeButtonBackgroundVisualEffectView = UIVisualEffectView(effect: UIVibrancyEffect(blurEffect: UIBlurEffect(style: .systemUltraThinMaterial))) + + let closeButton: UIButton = { + let button = HighlightDimmableButton() + button.expandEdgeInsets = UIEdgeInsets(top: -10, left: -10, bottom: -10, right: -10) + button.imageView?.tintColor = .label + button.setImage(UIImage(systemName: "xmark", withConfiguration: UIImage.SymbolConfiguration(pointSize: 16, weight: .bold))!, for: .normal) + return button + }() + + deinit { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + } + +} + +extension MediaPreviewViewController { + + override func viewDidLoad() { + super.viewDidLoad() + + overrideUserInterfaceStyle = .dark + + visualEffectView.frame = view.bounds + view.addSubview(visualEffectView) + + pagingViewConttroller.view.translatesAutoresizingMaskIntoConstraints = false + addChild(pagingViewConttroller) + visualEffectView.contentView.addSubview(pagingViewConttroller.view) + NSLayoutConstraint.activate([ + visualEffectView.topAnchor.constraint(equalTo: pagingViewConttroller.view.topAnchor), + visualEffectView.bottomAnchor.constraint(equalTo: pagingViewConttroller.view.bottomAnchor), + visualEffectView.leadingAnchor.constraint(equalTo: pagingViewConttroller.view.leadingAnchor), + visualEffectView.trailingAnchor.constraint(equalTo: pagingViewConttroller.view.trailingAnchor), + ]) + pagingViewConttroller.didMove(toParent: self) + + closeButtonBackground.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(closeButtonBackground) + NSLayoutConstraint.activate([ + closeButtonBackground.topAnchor.constraint(equalTo: view.layoutMarginsGuide.topAnchor, constant: 12), + closeButtonBackground.leadingAnchor.constraint(equalTo: view.layoutMarginsGuide.leadingAnchor) + ]) + closeButtonBackgroundVisualEffectView.autoresizingMask = [.flexibleWidth, .flexibleHeight] + closeButtonBackground.contentView.addSubview(closeButtonBackgroundVisualEffectView) + + closeButton.translatesAutoresizingMaskIntoConstraints = false + closeButtonBackgroundVisualEffectView.contentView.addSubview(closeButton) + NSLayoutConstraint.activate([ + closeButton.topAnchor.constraint(equalTo: closeButtonBackgroundVisualEffectView.topAnchor), + closeButton.leadingAnchor.constraint(equalTo: closeButtonBackgroundVisualEffectView.leadingAnchor), + closeButtonBackgroundVisualEffectView.trailingAnchor.constraint(equalTo: closeButton.trailingAnchor), + closeButtonBackgroundVisualEffectView.bottomAnchor.constraint(equalTo: closeButton.bottomAnchor), + closeButton.heightAnchor.constraint(equalToConstant: MediaPreviewViewController.closeButtonSize.height).priority(.defaultHigh), + closeButton.widthAnchor.constraint(equalToConstant: MediaPreviewViewController.closeButtonSize.width).priority(.defaultHigh), + ]) + + viewModel.mediaPreviewImageViewControllerDelegate = self + + pagingViewConttroller.interPageSpacing = 10 + pagingViewConttroller.delegate = self + pagingViewConttroller.dataSource = viewModel + + closeButton.addTarget(self, action: #selector(MediaPreviewViewController.closeButtonPressed(_:)), for: .touchUpInside) + + // bind view model + 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): + UIView.animate(withDuration: 0.3) { + mosaicImageViewContainer.setImageViews(alpha: 1) + mosaicImageViewContainer.setImageView(alpha: 0, index: index) + } + case .profileAvatar, .profileBanner: + break + } + } + .store(in: &disposeBag) + } + +} + +extension MediaPreviewViewController { + + @objc private func closeButtonPressed(_ sender: UIButton) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + dismiss(animated: true, completion: nil) + } + +} + +// MARK: - MediaPreviewingViewController +extension MediaPreviewViewController: MediaPreviewingViewController { + + func isInteractiveDismissable() -> Bool { + if let mediaPreviewImageViewController = pagingViewConttroller.currentViewController as? MediaPreviewImageViewController { + let previewImageView = mediaPreviewImageViewController.previewImageView + // TODO: allow zooming pan dismiss + guard previewImageView.zoomScale == previewImageView.minimumZoomScale else { + return false + } + + let safeAreaInsets = previewImageView.safeAreaInsets + let statusBarFrameHeight = view.window?.windowScene?.statusBarManager?.statusBarFrame.height ?? 0 + let dismissable = previewImageView.contentOffset.y <= -(safeAreaInsets.top - statusBarFrameHeight) + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: dismissable %s", ((#file as NSString).lastPathComponent), #line, #function, dismissable ? "true" : "false") + return dismissable + } + + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: dismissable false", ((#file as NSString).lastPathComponent), #line, #function) + return false + } + +} + +// MARK: - PageboyViewControllerDelegate +extension MediaPreviewViewController: PageboyViewControllerDelegate { + func pageboyViewController( + _ pageboyViewController: PageboyViewController, + willScrollToPageAt index: PageboyViewController.PageIndex, + direction: PageboyViewController.NavigationDirection, + animated: Bool + ) { + // do nothing + } + + func pageboyViewController( + _ pageboyViewController: PageboyViewController, + didScrollTo position: CGPoint, + direction: PageboyViewController.NavigationDirection, + animated: Bool + ) { + // do nothing + } + + func pageboyViewController( + _ pageboyViewController: PageboyViewController, + didScrollToPageAt index: PageboyViewController.PageIndex, + direction: PageboyViewController.NavigationDirection, + animated: Bool + ) { + // update page control + // pageControl.currentPage = index + viewModel.currentPage.value = index + } + + func pageboyViewController( + _ pageboyViewController: PageboyViewController, + didReloadWith currentViewController: UIViewController, + currentPageIndex: PageboyViewController.PageIndex + ) { + // do nothing + } + +} + + +// MARK: - MediaPreviewImageViewControllerDelegate +extension MediaPreviewViewController: MediaPreviewImageViewControllerDelegate { + + func mediaPreviewImageViewController(_ viewController: MediaPreviewImageViewController, tapGestureRecognizerDidTrigger tapGestureRecognizer: UITapGestureRecognizer) { + // do nothing + } + + func mediaPreviewImageViewController(_ viewController: MediaPreviewImageViewController, longPressGestureRecognizerDidTrigger longPressGestureRecognizer: UILongPressGestureRecognizer) { + // do nothing + } + + func mediaPreviewImageViewController(_ viewController: MediaPreviewImageViewController, contextMenuActionPerform action: MediaPreviewImageViewController.ContextMenuAction) { + switch action { + case .savePhoto: + switch viewController.viewModel.item { + case .status(let meta): + context.photoLibraryService.saveImage(url: meta.url) + .sink { [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: &context.disposeBag) + case .local(let meta): + context.photoLibraryService.save(image: meta.image, withNotificationFeedback: true) + } + case .share: + let applicationActivities: [UIActivity] = [ + SafariActivity(sceneCoordinator: self.coordinator) + ] + let activityViewController = UIActivityViewController( + activityItems: viewController.viewModel.item.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 new file mode 100644 index 000000000..cd019fc9b --- /dev/null +++ b/Mastodon/Scene/MediaPreview/MediaPreviewViewModel.swift @@ -0,0 +1,148 @@ +// +// MediaPreviewViewModel.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-28. +// + +import UIKit +import Combine +import CoreData +import CoreDataStack +import Pageboy + +final class MediaPreviewViewModel: NSObject { + + // input + let context: AppContext + let initialItem: PreviewItem + weak var mediaPreviewImageViewControllerDelegate: MediaPreviewImageViewControllerDelegate? + let currentPage: CurrentValueSubject + + // output + let pushTransitionItem: MediaPreviewTransitionItem + let viewControllers: [UIViewController] + + init(context: AppContext, meta: StatusImagePreviewMeta, pushTransitionItem: MediaPreviewTransitionItem) { + self.context = context + self.initialItem = .status(meta) + 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 + } + } + } + self.viewControllers = viewControllers + self.currentPage = CurrentValueSubject(meta.initialIndex) + self.pushTransitionItem = pushTransitionItem + super.init() + } + + init(context: AppContext, meta: ProfileBannerImagePreviewMeta, pushTransitionItem: MediaPreviewTransitionItem) { + self.context = context + self.initialItem = .profileBanner(meta) + var viewControllers: [UIViewController] = [] + let managedObjectContext = self.context.managedObjectContext + managedObjectContext.performAndWait { + let account = managedObjectContext.object(with: meta.accountObjectID) as! MastodonUser + let avatarURL = account.headerImageURLWithFallback(domain: account.domain) + let meta = MediaPreviewImageViewModel.RemoteImagePreviewMeta(url: avatarURL, thumbnail: meta.preloadThumbnailImage, altText: nil) + let mediaPreviewImageModel = MediaPreviewImageViewModel(meta: meta) + let mediaPreviewImageViewController = MediaPreviewImageViewController() + mediaPreviewImageViewController.viewModel = mediaPreviewImageModel + viewControllers.append(mediaPreviewImageViewController) + } + self.viewControllers = viewControllers + self.currentPage = CurrentValueSubject(0) + self.pushTransitionItem = pushTransitionItem + super.init() + } + + init(context: AppContext, meta: ProfileAvatarImagePreviewMeta, pushTransitionItem: MediaPreviewTransitionItem) { + self.context = context + self.initialItem = .profileAvatar(meta) + var viewControllers: [UIViewController] = [] + let managedObjectContext = self.context.managedObjectContext + managedObjectContext.performAndWait { + let account = managedObjectContext.object(with: meta.accountObjectID) as! MastodonUser + let avatarURL = account.avatarImageURLWithFallback(domain: account.domain) + let meta = MediaPreviewImageViewModel.RemoteImagePreviewMeta(url: avatarURL, thumbnail: meta.preloadThumbnailImage, altText: nil) + let mediaPreviewImageModel = MediaPreviewImageViewModel(meta: meta) + let mediaPreviewImageViewController = MediaPreviewImageViewController() + mediaPreviewImageViewController.viewModel = mediaPreviewImageModel + viewControllers.append(mediaPreviewImageViewController) + } + self.viewControllers = viewControllers + self.currentPage = CurrentValueSubject(0) + self.pushTransitionItem = pushTransitionItem + super.init() + } + +} + +extension MediaPreviewViewModel { + + enum PreviewItem { + case status(StatusImagePreviewMeta) + case profileAvatar(ProfileAvatarImagePreviewMeta) + case profileBanner(ProfileBannerImagePreviewMeta) + case local(LocalImagePreviewMeta) + } + + struct StatusImagePreviewMeta { + let statusObjectID: NSManagedObjectID + let initialIndex: Int + let preloadThumbnailImages: [UIImage?] + } + + struct ProfileAvatarImagePreviewMeta { + let accountObjectID: NSManagedObjectID + let preloadThumbnailImage: UIImage? + } + + struct ProfileBannerImagePreviewMeta { + let accountObjectID: NSManagedObjectID + let preloadThumbnailImage: UIImage? + } + + struct LocalImagePreviewMeta { + let image: UIImage + } + +} + +// MARK: - PageboyViewControllerDataSource +extension MediaPreviewViewModel: PageboyViewControllerDataSource { + + func numberOfViewControllers(in pageboyViewController: PageboyViewController) -> Int { + return viewControllers.count + } + + func viewController(for pageboyViewController: PageboyViewController, at index: PageboyViewController.PageIndex) -> UIViewController? { + let viewController = viewControllers[index] + if let mediaPreviewImageViewController = viewController as? MediaPreviewImageViewController { + mediaPreviewImageViewController.delegate = mediaPreviewImageViewControllerDelegate + } + return viewController + } + + func defaultPage(for pageboyViewController: PageboyViewController) -> PageboyViewController.Page? { + guard case let .status(meta) = initialItem else { return nil } + return .at(index: meta.initialIndex) + } + +} diff --git a/Mastodon/Scene/MediaPreview/Paging/Image/MediaPreviewImageView.swift b/Mastodon/Scene/MediaPreview/Paging/Image/MediaPreviewImageView.swift new file mode 100644 index 000000000..aa11e494d --- /dev/null +++ b/Mastodon/Scene/MediaPreview/Paging/Image/MediaPreviewImageView.swift @@ -0,0 +1,220 @@ +// +// MediaPreviewImageView.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-28. +// + +import os.log +import func AVFoundation.AVMakeRect +import UIKit + +final class MediaPreviewImageView: UIScrollView { + + let imageView: UIImageView = { + let imageView = UIImageView() + imageView.contentMode = .scaleAspectFit + imageView.clipsToBounds = true + imageView.isUserInteractionEnabled = true + // accessibility + imageView.accessibilityIgnoresInvertColors = true + imageView.isAccessibilityElement = true + return imageView + }() + + let doubleTapGestureRecognizer: UITapGestureRecognizer = { + let tapGestureRecognizer = UITapGestureRecognizer() + tapGestureRecognizer.numberOfTapsRequired = 2 + return tapGestureRecognizer + }() + + private var containerFrame: CGRect? + + override init(frame: CGRect) { + super.init(frame: frame) + _init() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + _init() + } + +} + +extension MediaPreviewImageView { + + private func _init() { + isUserInteractionEnabled = true + showsVerticalScrollIndicator = false + showsHorizontalScrollIndicator = false + + bouncesZoom = true + minimumZoomScale = 1.0 + maximumZoomScale = 4.0 + + addSubview(imageView) + + doubleTapGestureRecognizer.addTarget(self, action: #selector(MediaPreviewImageView.doubleTapGestureRecognizerHandler(_:))) + imageView.addGestureRecognizer(doubleTapGestureRecognizer) + + delegate = self + } + + override func layoutSubviews() { + super.layoutSubviews() + + guard let image = imageView.image else { return } + setup(image: image, container: self) + } + +} + +extension MediaPreviewImageView { + + @objc private func doubleTapGestureRecognizerHandler(_ sender: UITapGestureRecognizer) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + + let middleZoomScale = 0.5 * maximumZoomScale + if zoomScale >= middleZoomScale { + setZoomScale(minimumZoomScale, animated: true) + } else { + let center = sender.location(in: imageView) + let zoomRect: CGRect = { + let width = bounds.width / middleZoomScale + let height = bounds.height / middleZoomScale + return CGRect( + x: center.x - 0.5 * width, + y: center.y - 0.5 * height, + width: width, + height: height + ) + }() + zoom(to: zoomRect, animated: true) + } + } + +} + +extension MediaPreviewImageView { + + func setup(image: UIImage, container: UIView, forceUpdate: Bool = false) { + guard image.size.width > 0, image.size.height > 0 else { return } + guard container.bounds.width > 0, container.bounds.height > 0 else { return } + + // do not setup when frame not change except force update + if containerFrame == container.frame && !forceUpdate { + return + } + containerFrame = container.frame + + // reset to normal + zoomScale = minimumZoomScale + + let imageViewSize = AVMakeRect(aspectRatio: image.size, insideRect: container.bounds).size + let imageContentInset: UIEdgeInsets = { + if imageViewSize.width == container.bounds.width { + return UIEdgeInsets(top: 0.5 * (container.bounds.height - imageViewSize.height), left: 0, bottom: 0, right: 0) + } else { + return UIEdgeInsets(top: 0, left: 0.5 * (container.bounds.width - imageViewSize.width), bottom: 0, right: 0) + } + }() + imageView.frame = CGRect(origin: .zero, size: imageViewSize) + imageView.image = image + contentSize = imageViewSize + contentInset = imageContentInset + + centerScrollViewContents() + contentOffset = CGPoint(x: -contentInset.left, y: -contentInset.top) + + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: setup image for container %s", ((#file as NSString).lastPathComponent), #line, #function, container.frame.debugDescription) + } + +} + +// MARK: - UIScrollViewDelegate +extension MediaPreviewImageView: UIScrollViewDelegate { + + func scrollViewShouldScrollToTop(_ scrollView: UIScrollView) -> Bool { + return false + } + + func scrollViewDidZoom(_ scrollView: UIScrollView) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + centerScrollViewContents() + + // set bounce when zoom in + alwaysBounceVertical = zoomScale > minimumZoomScale + } + + func viewForZooming(in scrollView: UIScrollView) -> UIView? { + return imageView + } + +} + +// Ref: https://stackoverflow.com/questions/14069571/keep-zoomable-image-in-center-of-uiscrollview +extension MediaPreviewImageView { + + private var scrollViewVisibleSize: CGSize { + let contentInset = self.contentInset + let scrollViewSize = bounds.standardized.size + let width = scrollViewSize.width - contentInset.left - contentInset.right + let height = scrollViewSize.height - contentInset.top - contentInset.bottom + return CGSize(width: width, height: height) + } + + private var scrollViewCenter: CGPoint { + let scrollViewSize = self.scrollViewVisibleSize + return CGPoint(x: scrollViewSize.width / 2.0, + y: scrollViewSize.height / 2.0) + } + + private func centerScrollViewContents() { + guard let image = imageView.image else { return } + + let imageViewSize = imageView.frame.size + let imageSize = image.size + + var realImageSize: CGSize + if imageSize.width / imageSize.height > imageViewSize.width / imageViewSize.height { + realImageSize = CGSize(width: imageViewSize.width, + height: imageViewSize.width / imageSize.width * imageSize.height) + } else { + realImageSize = CGSize(width: imageViewSize.height / imageSize.height * imageSize.width, + height: imageViewSize.height) + } + + var frame = CGRect.zero + frame.size = realImageSize + imageView.frame = frame + + let screenSize = self.frame.size + let offsetX = screenSize.width > realImageSize.width ? (screenSize.width - realImageSize.width) / 2 : 0 + let offsetY = screenSize.height > realImageSize.height ? (screenSize.height - realImageSize.height) / 2 : 0 + contentInset = UIEdgeInsets(top: offsetY, left: offsetX, bottom: offsetY, right: offsetX) + + // The scroll view has zoomed, so you need to re-center the contents + let scrollViewSize = scrollViewVisibleSize + + // First assume that image center coincides with the contents box center. + // This is correct when the image is bigger than scrollView due to zoom + var imageCenter = CGPoint(x: contentSize.width / 2.0, + y: contentSize.height / 2.0) + + let center = scrollViewCenter + + //if image is smaller than the scrollView visible size - fix the image center accordingly + if contentSize.width < scrollViewSize.width { + imageCenter.x = center.x + } + + if contentSize.height < scrollViewSize.height { + imageCenter.y = center.y + } + + imageView.center = imageCenter + } + +} + diff --git a/Mastodon/Scene/MediaPreview/Paging/Image/MediaPreviewImageViewController.swift b/Mastodon/Scene/MediaPreview/Paging/Image/MediaPreviewImageViewController.swift new file mode 100644 index 000000000..e1f2736ff --- /dev/null +++ b/Mastodon/Scene/MediaPreview/Paging/Image/MediaPreviewImageViewController.swift @@ -0,0 +1,147 @@ +// +// MediaPreviewImageViewController.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-28. +// + +import os.log +import UIKit +import Combine + +protocol MediaPreviewImageViewControllerDelegate: AnyObject { + func mediaPreviewImageViewController(_ viewController: MediaPreviewImageViewController, tapGestureRecognizerDidTrigger tapGestureRecognizer: UITapGestureRecognizer) + func mediaPreviewImageViewController(_ viewController: MediaPreviewImageViewController, longPressGestureRecognizerDidTrigger longPressGestureRecognizer: UILongPressGestureRecognizer) + func mediaPreviewImageViewController(_ viewController: MediaPreviewImageViewController, contextMenuActionPerform action: MediaPreviewImageViewController.ContextMenuAction) +} + +final class MediaPreviewImageViewController: UIViewController { + + var disposeBag = Set() + var observations = Set() + + var viewModel: MediaPreviewImageViewModel! + weak var delegate: MediaPreviewImageViewControllerDelegate? + + // let progressBarView = ProgressBarView() + let previewImageView = MediaPreviewImageView() + + let tapGestureRecognizer = UITapGestureRecognizer.singleTapGestureRecognizer + let longPressGestureRecognizer = UILongPressGestureRecognizer() + + deinit { + os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + previewImageView.imageView.af.cancelImageRequest() + } +} + +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([ + previewImageView.frameLayoutGuide.topAnchor.constraint(equalTo: view.topAnchor), + previewImageView.frameLayoutGuide.leadingAnchor.constraint(equalTo: view.leadingAnchor), + previewImageView.frameLayoutGuide.trailingAnchor.constraint(equalTo: view.trailingAnchor), + previewImageView.frameLayoutGuide.bottomAnchor.constraint(equalTo: view.bottomAnchor), + ]) + + tapGestureRecognizer.addTarget(self, action: #selector(MediaPreviewImageViewController.tapGestureRecognizerHandler(_:))) + longPressGestureRecognizer.addTarget(self, action: #selector(MediaPreviewImageViewController.longPressGestureRecognizerHandler(_:))) + tapGestureRecognizer.require(toFail: previewImageView.doubleTapGestureRecognizer) + tapGestureRecognizer.require(toFail: longPressGestureRecognizer) + previewImageView.addGestureRecognizer(tapGestureRecognizer) + previewImageView.addGestureRecognizer(longPressGestureRecognizer) + + let previewImageViewContextMenuInteraction = UIContextMenuInteraction(delegate: self) + previewImageView.addInteraction(previewImageViewContextMenuInteraction) + + viewModel.image + .receive(on: RunLoop.main) // use RunLoop prevent set image during zooming (TODO: handle transitioning state) + .sink { [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) + self.previewImageView.imageView.accessibilityLabel = self.viewModel.altText + } + .store(in: &disposeBag) + } + +} + +extension MediaPreviewImageViewController { + + @objc private func tapGestureRecognizerHandler(_ sender: UITapGestureRecognizer) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + delegate?.mediaPreviewImageViewController(self, tapGestureRecognizerDidTrigger: sender) + } + + @objc private func longPressGestureRecognizerHandler(_ sender: UILongPressGestureRecognizer) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + delegate?.mediaPreviewImageViewController(self, longPressGestureRecognizerDidTrigger: sender) + } + +} + +// MARK: - UIContextMenuInteractionDelegate +extension MediaPreviewImageViewController: UIContextMenuInteractionDelegate { + func contextMenuInteraction(_ interaction: UIContextMenuInteraction, configurationForMenuAtLocation location: CGPoint) -> UIContextMenuConfiguration? { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + + let previewProvider: UIContextMenuContentPreviewProvider = { () -> UIViewController? in + return nil + } + + let saveAction = 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.delegate?.mediaPreviewImageViewController(self, contextMenuActionPerform: .savePhoto) + } + + 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.delegate?.mediaPreviewImageViewController(self, contextMenuActionPerform: .share) + } + + let actionProvider: UIContextMenuActionProvider = { elements -> UIMenu? in + return UIMenu(title: "", image: nil, identifier: nil, options: [], children: [ + saveAction, + shareAction + ]) + } + + return UIContextMenuConfiguration(identifier: nil, previewProvider: previewProvider, actionProvider: actionProvider) + } + + func contextMenuInteraction(_ interaction: UIContextMenuInteraction, previewForHighlightingMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? { + // set preview view + return UITargetedPreview(view: previewImageView.imageView) + } + +} + +extension MediaPreviewImageViewController { + enum ContextMenuAction { + case savePhoto + case share + } +} diff --git a/Mastodon/Scene/MediaPreview/Paging/Image/MediaPreviewImageViewModel.swift b/Mastodon/Scene/MediaPreview/Paging/Image/MediaPreviewImageViewModel.swift new file mode 100644 index 000000000..c9afac8c7 --- /dev/null +++ b/Mastodon/Scene/MediaPreview/Paging/Image/MediaPreviewImageViewModel.swift @@ -0,0 +1,77 @@ +// +// MediaPreviewImageViewModel.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-28. +// + +import os.log +import UIKit +import Combine +import AlamofireImage + +class MediaPreviewImageViewModel { + + // input + let item: ImagePreviewItem + + // output + let image: CurrentValueSubject + let altText: String? + + init(meta: RemoteImagePreviewMeta) { + self.item = .status(meta) + self.image = CurrentValueSubject(meta.thumbnail) + self.altText = meta.altText + + let url = meta.url + ImageDownloader.default.download(URLRequest(url: url), completion: { [weak self] response in + guard let self = self else { return } + switch response.result { + 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) + case .success(let image): + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: download image %s success", ((#file as NSString).lastPathComponent), #line, #function, url.debugDescription) + self.image.value = image + } + }) + } + + init(meta: LocalImagePreviewMeta) { + self.item = .local(meta) + self.image = CurrentValueSubject(meta.image) + self.altText = nil + } + +} + +extension MediaPreviewImageViewModel { + enum ImagePreviewItem { + case status(RemoteImagePreviewMeta) + case local(LocalImagePreviewMeta) + + var activityItems: [Any] { + var items: [Any] = [] + + switch self { + case .status(let meta): + items.append(meta.url) + case .local(let meta): + items.append(meta.image) + } + + return items + } + } + + struct RemoteImagePreviewMeta { + let url: URL + let thumbnail: UIImage? + let altText: String? + } + + struct LocalImagePreviewMeta { + let image: UIImage + } + +} diff --git a/Mastodon/Scene/MediaPreview/Paging/MediaPreviewPagingViewController.swift b/Mastodon/Scene/MediaPreview/Paging/MediaPreviewPagingViewController.swift new file mode 100644 index 000000000..b3a3eb41f --- /dev/null +++ b/Mastodon/Scene/MediaPreview/Paging/MediaPreviewPagingViewController.swift @@ -0,0 +1,11 @@ +// +// MediaPreviewPagingViewController.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-28. +// + +import UIKit +import Pageboy + +final class MediaPreviewPagingViewController: PageboyViewController { } diff --git a/Mastodon/Scene/Notification/NotificationViewController.swift b/Mastodon/Scene/Notification/NotificationViewController.swift index f8b3ba815..aea9d3318 100644 --- a/Mastodon/Scene/Notification/NotificationViewController.swift +++ b/Mastodon/Scene/Notification/NotificationViewController.swift @@ -2,23 +2,257 @@ // NotificationViewController.swift // Mastodon // -// Created by MainasuK Cirno on 2021-2-23. +// Created by sxiaojian on 2021/4/12. // +import Combine +import CoreData +import CoreDataStack +import GameplayKit +import MastodonSDK +import OSLog import UIKit final class NotificationViewController: UIViewController, NeedsDependency { - weak var context: AppContext! { willSet { precondition(!isViewLoaded) } } weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } } - + + var disposeBag = Set() + private(set) lazy var viewModel = NotificationViewModel(context: context) + + 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.separatorInset = UIEdgeInsets(top: 0, left: 0, bottom: 0, right: 0) + tableView.register(NotificationTableViewCell.self, forCellReuseIdentifier: String(describing: NotificationTableViewCell.self)) + tableView.register(NotificationStatusTableViewCell.self, forCellReuseIdentifier: String(describing: NotificationStatusTableViewCell.self)) + tableView.register(TimelineBottomLoaderTableViewCell.self, forCellReuseIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self)) + tableView.rowHeight = UITableView.automaticDimension + tableView.estimatedRowHeight = UITableView.automaticDimension + tableView.separatorStyle = .none + tableView.tableFooterView = UIView() + tableView.backgroundColor = .clear + return tableView + }() + + let refreshControl = UIRefreshControl() } extension NotificationViewController { - override func viewDidLoad() { super.viewDidLoad() + view.backgroundColor = Asset.Colors.Background.secondarySystemBackground.color + navigationItem.titleView = segmentControl + 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, delegate: self, dependency: 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) + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + + tableView.deselectRow(with: transitionCoordinator, animated: animated) + + // fetch latest if has unread push notification + if context.notificationService.hasUnreadPushNotification.value { + viewModel.loadLatestStateMachine.enter(NotificationViewModel.LoadLatestState.Loading.self) + } + + // needs trigger manually after onboarding dismiss + 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) + } + } + } + + 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() + } + } +} + +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) + guard let domain = viewModel.activeMastodonAuthenticationBox.value?.domain, let userID = viewModel.activeMastodonAuthenticationBox.value?.userID else { + return + } + if sender.selectedSegmentIndex == NotificationViewModel.NotificationSegment.EveryThing.rawValue { + viewModel.notificationPredicate.value = MastodonNotification.predicate(domain: domain, userID: userID) + } else { + viewModel.notificationPredicate.value = MastodonNotification.predicate(domain: domain, userID: userID, typeRaw: Mastodon.Entity.Notification.NotificationType.mention.rawValue) + } + 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 + } + } +} + +extension NotificationViewController { + 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 } + let key = item.hashValue + let frame = cell.frame + viewModel.cellFrameCache.setObject(NSValue(cgRect: frame), forKey: NSNumber(value: key)) + } + + 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 } + guard let frame = viewModel.cellFrameCache.object(forKey: NSNumber(value: item.hashValue))?.cgRectValue else { + if case .bottomLoader = item { + return TimelineLoaderTableViewCell.cellHeight + } else { + return UITableView.automaticDimension + } + } + + return ceil(frame.height) + } +} +// MARK: - UITableViewDelegate + +extension NotificationViewController: UITableViewDelegate { + + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + guard let diffableDataSource = viewModel.diffableDataSource else { return } + guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return } + 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 + } + } + + func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) { + guard let diffableDataSource = viewModel.diffableDataSource else { return } + guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return } + switch item { + case .bottomLoader: + if !tableView.isDragging, !tableView.isDecelerating { + viewModel.loadoldestStateMachine.enter(NotificationViewModel.LoadOldestState.Loading.self) + } + default: + break + } + } +} + +// MARK: - ContentOffsetAdjustableTimelineViewControllerDelegate + +extension NotificationViewController: ContentOffsetAdjustableTimelineViewControllerDelegate { + func navigationBar() -> UINavigationBar? { + navigationController?.navigationBar + } +} + +// MARK: - NotificationTableViewCellDelegate +extension NotificationViewController: NotificationTableViewCellDelegate { + func notificationTableViewCell(_ cell: NotificationTableViewCell, notification: MastodonNotification, acceptButtonDidPressed button: UIButton) { + viewModel.acceptFollowRequest(notification: notification) } + func notificationTableViewCell(_ cell: NotificationTableViewCell, notification: MastodonNotification, rejectButtonDidPressed button: UIButton) { + viewModel.rejectFollowRequest(notification: notification) + } + + func userAvatarDidPressed(notification: MastodonNotification) { + let viewModel = ProfileViewModel(context: context, optionalMastodonUser: 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(dependency: self, cell: cell) + } + + func notificationStatusTableViewCell(_ cell: NotificationStatusTableViewCell, statusView: StatusView, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView) { + StatusProviderFacade.responseToStatusContentWarningRevealAction(dependency: self, cell: cell) + } + + func notificationStatusTableViewCell(_ cell: NotificationStatusTableViewCell, statusView: StatusView, playerContainerView: PlayerContainerView, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView) { + StatusProviderFacade.responseToStatusContentWarningRevealAction(dependency: self, cell: cell) + } +} + +// MARK: - UIScrollViewDelegate + +extension NotificationViewController { + func scrollViewDidScroll(_ scrollView: UIScrollView) { + handleScrollViewDidScroll(scrollView) + } +} + +extension NotificationViewController: LoadMoreConfigurableTableViewContainer { + typealias BottomLoaderTableViewCell = TimelineBottomLoaderTableViewCell + typealias LoadingState = NotificationViewModel.LoadOldestState.Loading + var loadMoreConfigurableTableView: UITableView { tableView } + var loadMoreConfigurableStateMachine: GKStateMachine { viewModel.loadoldestStateMachine } } diff --git a/Mastodon/Scene/Notification/NotificationViewModel+LoadLatestState.swift b/Mastodon/Scene/Notification/NotificationViewModel+LoadLatestState.swift new file mode 100644 index 000000000..04d33202f --- /dev/null +++ b/Mastodon/Scene/Notification/NotificationViewModel+LoadLatestState.swift @@ -0,0 +1,97 @@ +// +// 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: + // toggle unread state + viewModel.context.notificationService.hasUnreadPushNotification.value = false + // 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 new file mode 100644 index 000000000..8075ce375 --- /dev/null +++ b/Mastodon/Scene/Notification/NotificationViewModel+LoadOldestState.swift @@ -0,0 +1,145 @@ +// +// 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: [.followRequest], + 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+diffable.swift b/Mastodon/Scene/Notification/NotificationViewModel+diffable.swift new file mode 100644 index 000000000..cd28c5f5a --- /dev/null +++ b/Mastodon/Scene/Notification/NotificationViewModel+diffable.swift @@ -0,0 +1,146 @@ +// +// NotificationViewModel+diffable.swift +// Mastodon +// +// Created by sxiaojian on 2021/4/13. +// + +import CoreData +import CoreDataStack +import os.log +import UIKit + +extension NotificationViewModel { + func setupDiffableDataSource( + for tableView: UITableView, + delegate: NotificationTableViewCellDelegate, + dependency: NeedsDependency + ) { + let timestampUpdatePublisher = Timer.publish(every: 30.0, on: .main, in: .common) + .autoconnect() + .share() + .eraseToAnyPublisher() + + diffableDataSource = NotificationSection.tableViewDiffableDataSource( + for: tableView, + timestampUpdatePublisher: timestampUpdatePublisher, + managedObjectContext: context.managedObjectContext, + delegate: delegate, + dependency: dependency + ) + } +} + +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 = fetchedResultsController.fetchRequest.predicate + 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 items: [NotificationItem] = notifications.map { notification in + let attribute: Item.StatusAttribute = oldSnapshotAttributeDict[notification.objectID] ?? Item.StatusAttribute() + +// let attribute: Item.StatusAttribute = { +// if let attribute = oldSnapshotAttributeDict[notification.objectID] { +// return attribute +// } else if let status = notification.status { +// let attribute = Item.StatusAttribute() +// let isSensitive = status.sensitive || !(status.spoilerText ?? "").isEmpty +// attribute.isRevealing.value = !isSensitive +// return attribute +// } else { +// return Item.StatusAttribute() +// } +// }() + return NotificationItem.notification(objectID: notification.objectID, attribute: attribute) + } + newSnapshot.appendItems(items, toSection: .main) + if !notifications.isEmpty, self.noMoreNotification.value == false { + newSnapshot.appendItems([.bottomLoader], toSection: .main) + } + guard let difference = self.calculateReloadSnapshotDifference(navigationBar: navigationBar, tableView: tableView, oldSnapshot: oldSnapshot, newSnapshot: newSnapshot) else { + diffableDataSource.apply(newSnapshot, animatingDifferences: false) + self.isFetchingLatestNotification.value = false + tableView.reloadData() + return + } + + diffableDataSource.apply(newSnapshot, animatingDifferences: false) { + tableView.scrollToRow(at: difference.targetIndexPath, at: .top, animated: false) + tableView.contentOffset.y = tableView.contentOffset.y - difference.offset + self.isFetchingLatestNotification.value = false + } + } + } + } + + 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 } + + if oldSnapshot.itemIdentifiers.elementsEqual(newSnapshot.itemIdentifiers) { + 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/Notification/NotificationViewModel.swift b/Mastodon/Scene/Notification/NotificationViewModel.swift new file mode 100644 index 000000000..f535c5598 --- /dev/null +++ b/Mastodon/Scene/Notification/NotificationViewModel.swift @@ -0,0 +1,163 @@ +// +// NotificationViewModel.swift +// Mastodon +// +// Created by sxiaojian on 2021/4/12. +// + +import Combine +import CoreData +import CoreDataStack +import Foundation +import GameplayKit +import MastodonSDK +import UIKit +import OSLog + +final class NotificationViewModel: NSObject { + 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 activeMastodonAuthenticationBox: CurrentValueSubject + let fetchedResultsController: NSFetchedResultsController! + let notificationPredicate = CurrentValueSubject(nil) + let cellFrameCache = NSCache() + + 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 + }() + + 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.relationshipKeyPathsForPrefetching = [#keyPath(MastodonNotification.status), #keyPath(MastodonNotification.account)] + let controller = NSFetchedResultsController( + fetchRequest: fetchRequest, + managedObjectContext: context.managedObjectContext, + sectionNameKeyPath: nil, + cacheName: nil + ) + + return controller + }() + + super.init() + fetchedResultsController.delegate = self + context.authenticationService.activeMastodonAuthenticationBox + .sink(receiveValue: { [weak self] box in + guard let self = self else { return } + self.activeMastodonAuthenticationBox.value = box + if let domain = box?.domain, let userID = box?.userID { + self.notificationPredicate.value = MastodonNotification.predicate(domain: domain, userID: userID) + } + }) + .store(in: &disposeBag) + + notificationPredicate + .compactMap { $0 } + .sink { [weak self] predicate in + guard let self = self else { return } + self.fetchedResultsController.fetchRequest.predicate = predicate + do { + self.diffableDataSource?.defaultRowAnimation = .fade + try self.fetchedResultsController.performFetch() + DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in + guard let self = self else { return } + self.diffableDataSource?.defaultRowAnimation = .automatic + } + } catch { + assertionFailure(error.localizedDescription) + } + } + .store(in: &disposeBag) + + viewDidLoad + .sink { [weak self] in + + guard let domain = self?.activeMastodonAuthenticationBox.value?.domain, let userID = self?.activeMastodonAuthenticationBox.value?.userID else { return } + self?.notificationPredicate.value = MastodonNotification.predicate(domain: domain, userID: userID) + } + .store(in: &disposeBag) + } + + 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 NotificationViewModel { + enum NotificationSegment: Int { + case EveryThing + case Mentions + } +} diff --git a/Mastodon/Scene/Notification/TableViewCell/NotificationStatusTableViewCell.swift b/Mastodon/Scene/Notification/TableViewCell/NotificationStatusTableViewCell.swift new file mode 100644 index 000000000..a950ede46 --- /dev/null +++ b/Mastodon/Scene/Notification/TableViewCell/NotificationStatusTableViewCell.swift @@ -0,0 +1,316 @@ +// +// NotificationStatusTableViewCell.swift +// Mastodon +// +// Created by sxiaojian on 2021/4/14. +// + +import Combine +import Foundation +import UIKit +import ActiveLabel + +final class NotificationStatusTableViewCell: UITableViewCell, StatusCell { + static let actionImageBorderWidth: CGFloat = 2 + static let statusPadding = UIEdgeInsets(top: 50, left: 73, bottom: 24, right: 24) + var disposeBag = Set() + var pollCountdownSubscription: AnyCancellable? + var delegate: NotificationTableViewCellDelegate? + + let avatatImageView: UIImageView = { + let imageView = UIImageView() + imageView.layer.cornerRadius = 4 + imageView.layer.cornerCurve = .continuous + imageView.clipsToBounds = true + return imageView + }() + + let actionImageView: UIImageView = { + let imageView = UIImageView() + imageView.tintColor = Asset.Colors.Background.systemBackground.color + return imageView + }() + + let actionImageBackground: UIView = { + let view = UIView() + view.layer.cornerRadius = (24 + NotificationStatusTableViewCell.actionImageBorderWidth) / 2 + view.layer.cornerCurve = .continuous + view.clipsToBounds = true + view.layer.borderWidth = NotificationStatusTableViewCell.actionImageBorderWidth + view.layer.borderColor = Asset.Colors.Background.systemBackground.color.cgColor + view.tintColor = Asset.Colors.Background.systemBackground.color + return view + }() + + let avatarContainer: UIView = { + let view = UIView() + return view + }() + + let actionLabel: 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.lineBreakMode = .byTruncatingTail + return label + }() + + let nameLabel: UILabel = { + let label = UILabel() + label.textColor = Asset.Colors.brandBlue.color + label.font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 15, weight: .semibold), maximumPointSize: 20) + label.lineBreakMode = .byTruncatingTail + return label + }() + + let statusBorder: UIView = { + let view = UIView() + view.backgroundColor = .clear + view.layer.cornerRadius = 6 + view.layer.borderWidth = 2 + view.layer.cornerCurve = .continuous + view.layer.borderColor = Asset.Colors.Border.notification.color.cgColor + view.clipsToBounds = true + return view + }() + + let statusView = StatusView() + + let separatorLine = UIView.separatorLine + + var separatorLineToEdgeLeadingLayoutConstraint: NSLayoutConstraint! + var separatorLineToEdgeTrailingLayoutConstraint: NSLayoutConstraint! + + var separatorLineToMarginLeadingLayoutConstraint: NSLayoutConstraint! + var separatorLineToMarginTrailingLayoutConstraint: NSLayoutConstraint! + + override func prepareForReuse() { + super.prepareForReuse() + avatatImageView.af.cancelImageRequest() + 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() + } + + override func layoutSubviews() { + super.layoutSubviews() + + // precondition: app is active + guard UIApplication.shared.applicationState == .active else { return } + DispatchQueue.main.async { + self.statusView.drawContentWarningImageView() + } + } +} + +extension NotificationStatusTableViewCell { + func configure() { + backgroundColor = Asset.Colors.Background.systemBackground.color + + let containerStackView = UIStackView() + containerStackView.axis = .horizontal + containerStackView.alignment = .top + containerStackView.spacing = 4 + containerStackView.layoutMargins = UIEdgeInsets(top: 14, 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), + contentView.readableContentGuide.trailingAnchor.constraint(equalTo: containerStackView.trailingAnchor), + contentView.bottomAnchor.constraint(equalTo: containerStackView.bottomAnchor), + ]) + + containerStackView.addArrangedSubview(avatarContainer) + avatarContainer.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + avatarContainer.heightAnchor.constraint(equalToConstant: 47).priority(.required - 1), + avatarContainer.widthAnchor.constraint(equalToConstant: 47).priority(.required - 1) + ]) + + avatarContainer.addSubview(avatatImageView) + avatatImageView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + avatatImageView.heightAnchor.constraint(equalToConstant: 35).priority(.required - 1), + avatatImageView.widthAnchor.constraint(equalToConstant: 35).priority(.required - 1), + avatatImageView.topAnchor.constraint(equalTo: avatarContainer.topAnchor), + avatatImageView.leadingAnchor.constraint(equalTo: avatarContainer.leadingAnchor) + ]) + + avatarContainer.addSubview(actionImageBackground) + actionImageBackground.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + actionImageBackground.heightAnchor.constraint(equalToConstant: 24 + NotificationTableViewCell.actionImageBorderWidth).priority(.required - 1), + actionImageBackground.widthAnchor.constraint(equalToConstant: 24 + NotificationTableViewCell.actionImageBorderWidth).priority(.required - 1), + actionImageBackground.bottomAnchor.constraint(equalTo: avatarContainer.bottomAnchor), + actionImageBackground.trailingAnchor.constraint(equalTo: avatarContainer.trailingAnchor) + ]) + + avatarContainer.addSubview(actionImageView) + actionImageView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + actionImageView.centerXAnchor.constraint(equalTo: actionImageBackground.centerXAnchor), + actionImageView.centerYAnchor.constraint(equalTo: actionImageBackground.centerYAnchor) + ]) + + let actionStackView = UIStackView() + actionStackView.axis = .horizontal + actionStackView.distribution = .fill + actionStackView.spacing = 4 + actionStackView.translatesAutoresizingMaskIntoConstraints = false + + nameLabel.translatesAutoresizingMaskIntoConstraints = false + actionStackView.addArrangedSubview(nameLabel) + actionLabel.translatesAutoresizingMaskIntoConstraints = false + actionStackView.addArrangedSubview(actionLabel) + nameLabel.setContentCompressionResistancePriority(.required - 1, for: .vertical) + nameLabel.setContentHuggingPriority(.defaultHigh, for: .horizontal) + actionLabel.setContentHuggingPriority(.defaultLow, for: .horizontal) + let statusStackView = UIStackView() + statusStackView.axis = .vertical + + statusStackView.distribution = .fill + statusStackView.spacing = 4 + statusStackView.translatesAutoresizingMaskIntoConstraints = false + statusView.translatesAutoresizingMaskIntoConstraints = false + statusStackView.addArrangedSubview(actionStackView) + + statusBorder.translatesAutoresizingMaskIntoConstraints = false + statusView.translatesAutoresizingMaskIntoConstraints = false + statusBorder.addSubview(statusView) + NSLayoutConstraint.activate([ + statusView.topAnchor.constraint(equalTo: statusBorder.topAnchor, constant: 12), + statusView.leadingAnchor.constraint(equalTo: statusBorder.leadingAnchor, constant: 12), + statusBorder.bottomAnchor.constraint(equalTo: statusView.bottomAnchor, constant: 12), + statusBorder.trailingAnchor.constraint(equalTo: statusView.trailingAnchor, constant: 12), + ]) + + statusView.delegate = self + + statusStackView.addArrangedSubview(statusBorder) + + containerStackView.addArrangedSubview(statusStackView) + + 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() + + // remove item don't display + statusView.actionToolbarContainer.removeFromStackView() + // it affect stackView's height,need remove + statusView.avatarView.removeFromStackView() + statusView.usernameLabel.removeFromStackView() + } + + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + + resetSeparatorLineLayout() + statusBorder.layer.borderColor = Asset.Colors.Border.notification.color.cgColor + actionImageBackground.layer.borderColor = Asset.Colors.Background.systemBackground.color.cgColor + } + + override func setHighlighted(_ highlighted: Bool, animated: Bool) { + super.setHighlighted(highlighted, animated: animated) + + resetContentOverlayBlurImageBackgroundColor(selected: highlighted) + } + + override func setSelected(_ selected: Bool, animated: Bool) { + super.setSelected(selected, animated: animated) + + resetContentOverlayBlurImageBackgroundColor(selected: selected) + } + + private func resetContentOverlayBlurImageBackgroundColor(selected: Bool) { + let imageViewBackgroundColor: UIColor? = selected ? selectedBackgroundView?.backgroundColor : backgroundColor + statusView.contentWarningOverlayView.blurContentImageView.backgroundColor = imageViewBackgroundColor + } +} + +// MARK: - StatusViewDelegate +extension NotificationStatusTableViewCell: StatusViewDelegate { + func statusView(_ statusView: StatusView, headerInfoLabelDidPressed label: UILabel) { + // do nothing + } + + func statusView(_ statusView: StatusView, avatarButtonDidPressed button: UIButton) { + // 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, activeLabel: ActiveLabel, didSelectActiveEntity entity: ActiveEntity) { + // do nothing + } + +} + +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, + ]) + } + } + } + +} diff --git a/Mastodon/Scene/Notification/TableViewCell/NotificationTableViewCell.swift b/Mastodon/Scene/Notification/TableViewCell/NotificationTableViewCell.swift new file mode 100644 index 000000000..067283935 --- /dev/null +++ b/Mastodon/Scene/Notification/TableViewCell/NotificationTableViewCell.swift @@ -0,0 +1,251 @@ +// +// NotificationTableViewCell.swift +// Mastodon +// +// Created by sxiaojian on 2021/4/13. +// + +import Combine +import CoreDataStack +import Foundation +import UIKit + +protocol NotificationTableViewCellDelegate: AnyObject { + var context: AppContext! { get } + + func parent() -> UIViewController + + func userAvatarDidPressed(notification: MastodonNotification) + + 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 notificationTableViewCell(_ cell: NotificationTableViewCell, notification: MastodonNotification, acceptButtonDidPressed button: UIButton) + + func notificationTableViewCell(_ cell: NotificationTableViewCell, notification: MastodonNotification, rejectButtonDidPressed button: UIButton) + +} + +final class NotificationTableViewCell: UITableViewCell { + static let actionImageBorderWidth: CGFloat = 2 + + var disposeBag = Set() + + var delegate: NotificationTableViewCellDelegate? + + let avatatImageView: UIImageView = { + let imageView = UIImageView() + imageView.layer.cornerRadius = 4 + imageView.layer.cornerCurve = .continuous + imageView.clipsToBounds = true + return imageView + }() + + let actionImageView: UIImageView = { + let imageView = UIImageView() + imageView.tintColor = Asset.Colors.Background.systemBackground.color + return imageView + }() + + let actionImageBackground: UIView = { + let view = UIView() + view.layer.cornerRadius = (24 + NotificationTableViewCell.actionImageBorderWidth) / 2 + view.layer.cornerCurve = .continuous + view.clipsToBounds = true + view.layer.borderWidth = NotificationTableViewCell.actionImageBorderWidth + view.layer.borderColor = Asset.Colors.Background.systemBackground.color.cgColor + view.tintColor = Asset.Colors.Background.systemBackground.color + return view + }() + + let avatarContainer: UIView = { + let view = UIView() + return view + }() + + let actionLabel: 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.lineBreakMode = .byTruncatingTail + return label + }() + + let nameLabel: UILabel = { + let label = UILabel() + label.textColor = Asset.Colors.brandBlue.color + label.font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 15, weight: .semibold), maximumPointSize: 20) + label.lineBreakMode = .byTruncatingTail + return label + }() + + 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 buttonStackView = UIStackView() + + let separatorLine = UIView.separatorLine + + var separatorLineToEdgeLeadingLayoutConstraint: NSLayoutConstraint! + var separatorLineToEdgeTrailingLayoutConstraint: NSLayoutConstraint! + + var separatorLineToMarginLeadingLayoutConstraint: NSLayoutConstraint! + var separatorLineToMarginTrailingLayoutConstraint: NSLayoutConstraint! + + override func prepareForReuse() { + super.prepareForReuse() + avatatImageView.af.cancelImageRequest() + 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 NotificationTableViewCell { + func configure() { + + let containerStackView = UIStackView() + containerStackView.axis = .vertical + containerStackView.alignment = .fill + containerStackView.layoutMargins = UIEdgeInsets(top: 14, 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), + contentView.readableContentGuide.trailingAnchor.constraint(equalTo: containerStackView.trailingAnchor), + contentView.bottomAnchor.constraint(equalTo: containerStackView.bottomAnchor), + ]) + + let horizontalStackView = UIStackView() + horizontalStackView.translatesAutoresizingMaskIntoConstraints = false + horizontalStackView.axis = .horizontal + horizontalStackView.spacing = 6 + + horizontalStackView.addArrangedSubview(avatarContainer) + avatarContainer.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + avatarContainer.heightAnchor.constraint(equalToConstant: 47).priority(.required - 1), + avatarContainer.widthAnchor.constraint(equalToConstant: 47).priority(.required - 1) + ]) + + avatarContainer.addSubview(avatatImageView) + avatatImageView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + avatatImageView.heightAnchor.constraint(equalToConstant: 35).priority(.required - 1), + avatatImageView.widthAnchor.constraint(equalToConstant: 35).priority(.required - 1), + avatatImageView.topAnchor.constraint(equalTo: avatarContainer.topAnchor), + avatatImageView.leadingAnchor.constraint(equalTo: avatarContainer.leadingAnchor) + ]) + + avatarContainer.addSubview(actionImageBackground) + actionImageBackground.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + actionImageBackground.heightAnchor.constraint(equalToConstant: 24 + NotificationTableViewCell.actionImageBorderWidth).priority(.required - 1), + actionImageBackground.widthAnchor.constraint(equalToConstant: 24 + NotificationTableViewCell.actionImageBorderWidth).priority(.required - 1), + actionImageBackground.bottomAnchor.constraint(equalTo: avatarContainer.bottomAnchor), + actionImageBackground.trailingAnchor.constraint(equalTo: avatarContainer.trailingAnchor) + ]) + + avatarContainer.addSubview(actionImageView) + actionImageView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + actionImageView.centerXAnchor.constraint(equalTo: actionImageBackground.centerXAnchor), + actionImageView.centerYAnchor.constraint(equalTo: actionImageBackground.centerYAnchor) + ]) + + nameLabel.translatesAutoresizingMaskIntoConstraints = false + horizontalStackView.addArrangedSubview(nameLabel) + actionLabel.translatesAutoresizingMaskIntoConstraints = false + horizontalStackView.addArrangedSubview(actionLabel) + nameLabel.setContentCompressionResistancePriority(.required - 1, for: .horizontal) + nameLabel.setContentHuggingPriority(.required - 1, for: .horizontal) + actionLabel.setContentHuggingPriority(.defaultLow, for: .horizontal) + + containerStackView.addArrangedSubview(horizontalStackView) + + buttonStackView.translatesAutoresizingMaskIntoConstraints = false + buttonStackView.axis = .horizontal + buttonStackView.distribution = .fillEqually + acceptButton.translatesAutoresizingMaskIntoConstraints = false + rejectButton.translatesAutoresizingMaskIntoConstraints = false + buttonStackView.addArrangedSubview(acceptButton) + buttonStackView.addArrangedSubview(rejectButton) + containerStackView.addArrangedSubview(buttonStackView) + + 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() + } + + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + + actionImageBackground.layer.borderColor = Asset.Colors.Background.systemBackground.color.cgColor + resetSeparatorLineLayout() + } +} + +extension NotificationTableViewCell { + + 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, + ]) + } + } + } + +} diff --git a/Mastodon/Scene/Onboarding/ConfirmEmail/MastodonConfirmEmailViewController.swift b/Mastodon/Scene/Onboarding/ConfirmEmail/MastodonConfirmEmailViewController.swift index 2d69f0dd3..54994cb1f 100644 --- a/Mastodon/Scene/Onboarding/ConfirmEmail/MastodonConfirmEmailViewController.swift +++ b/Mastodon/Scene/Onboarding/ConfirmEmail/MastodonConfirmEmailViewController.swift @@ -36,11 +36,18 @@ final class MastodonConfirmEmailViewController: UIViewController, NeedsDependenc label.numberOfLines = 0 return label }() + + let emailImageView: UIImageView = { + let imageView = UIImageView() + imageView.image = Asset.Asset.email.image + imageView.contentMode = .scaleAspectFit + return imageView + }() let openEmailButton: UIButton = { let button = UIButton(type: .system) button.titleLabel?.font = .preferredFont(forTextStyle: .headline) - button.setBackgroundImage(UIImage.placeholder(color: Asset.Colors.lightBrandBlue.color), for: .normal) + button.setBackgroundImage(UIImage.placeholder(color: Asset.Colors.brandBlue.color), for: .normal) button.setTitleColor(.white, for: .normal) button.setTitle(L10n.Scene.ConfirmEmail.Button.openEmailApp, for: .normal) button.layer.masksToBounds = true @@ -53,7 +60,7 @@ final class MastodonConfirmEmailViewController: UIViewController, NeedsDependenc let dontReceiveButton: UIButton = { let button = UIButton(type: .system) button.titleLabel?.font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: UIFont.boldSystemFont(ofSize: 15)) - button.setTitleColor(Asset.Colors.lightBrandBlue.color, for: .normal) + button.setTitleColor(Asset.Colors.brandBlue.color, for: .normal) button.setTitle(L10n.Scene.ConfirmEmail.Button.dontReceiveEmail, for: .normal) button.addTarget(self, action: #selector(dontReceiveButtonPressed(_:)), for: UIControl.Event.touchUpInside) return button @@ -70,11 +77,6 @@ extension MastodonConfirmEmailViewController { override func viewDidLoad() { setupOnboardingAppearance() - - // resizedView - let resizedView = UIView() - resizedView.translatesAutoresizingMaskIntoConstraints = false - resizedView.setContentHuggingPriority(.defaultLow, for: .vertical) // stackView let stackView = UIStackView() @@ -85,7 +87,9 @@ extension MastodonConfirmEmailViewController { stackView.isLayoutMarginsRelativeArrangement = true stackView.addArrangedSubview(self.largeTitleLabel) stackView.addArrangedSubview(self.subtitleLabel) - stackView.addArrangedSubview(resizedView) + stackView.addArrangedSubview(self.emailImageView) + emailImageView.setContentHuggingPriority(.defaultLow, for: .vertical) + emailImageView.setContentCompressionResistancePriority(.defaultLow, for: .vertical) stackView.addArrangedSubview(self.openEmailButton) stackView.addArrangedSubview(self.dontReceiveButton) @@ -111,7 +115,24 @@ extension MastodonConfirmEmailViewController { case .failure(let error): 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: - break + // 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) + } + } receiveValue: { _ in + // do nothing + } + .store(in: &self.context.disposeBag) // execute in the background } } 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) @@ -179,3 +200,27 @@ extension MastodonConfirmEmailViewController { // MARK: - OnboardingViewControllerAppearance extension MastodonConfirmEmailViewController: OnboardingViewControllerAppearance { } + +#if canImport(SwiftUI) && DEBUG +import SwiftUI + +struct MastodonConfirmEmailViewController_Previews: PreviewProvider { + + static var controls: some View { + UIViewControllerPreview { + let viewController = MastodonConfirmEmailViewController() + return viewController + } + .previewLayout(.fixed(width: 375, height: 800)) + } + + static var previews: some View { + Group { + controls.colorScheme(.light) + controls.colorScheme(.dark) + } + } + +} + +#endif diff --git a/Mastodon/Scene/Onboarding/ConfirmEmail/MastodonConfirmEmailViewModel.swift b/Mastodon/Scene/Onboarding/ConfirmEmail/MastodonConfirmEmailViewModel.swift index aff254741..9fbd24eda 100644 --- a/Mastodon/Scene/Onboarding/ConfirmEmail/MastodonConfirmEmailViewModel.swift +++ b/Mastodon/Scene/Onboarding/ConfirmEmail/MastodonConfirmEmailViewModel.swift @@ -12,20 +12,29 @@ import MastodonSDK final class MastodonConfirmEmailViewModel { var disposeBag = Set() + // input let context: AppContext var email: String let authenticateInfo: AuthenticationViewModel.AuthenticateInfo let userToken: Mastodon.Entity.Token + let updateCredentialQuery: Mastodon.API.Account.UpdateCredentialQuery let timestampUpdatePublisher = Timer.publish(every: 4.0, on: .main, in: .common) .autoconnect() .share() .eraseToAnyPublisher() - init(context: AppContext, email: String, authenticateInfo: AuthenticationViewModel.AuthenticateInfo, userToken: Mastodon.Entity.Token) { + init( + context: AppContext, + email: String, + authenticateInfo: AuthenticationViewModel.AuthenticateInfo, + userToken: Mastodon.Entity.Token, + updateCredentialQuery: Mastodon.API.Account.UpdateCredentialQuery + ) { self.context = context self.email = email self.authenticateInfo = authenticateInfo self.userToken = userToken + self.updateCredentialQuery = updateCredentialQuery } } diff --git a/Mastodon/Scene/Onboarding/PickServer/CollectionViewCell/PickServerCategoryCollectionViewCell.swift b/Mastodon/Scene/Onboarding/PickServer/CollectionViewCell/PickServerCategoryCollectionViewCell.swift index 1f02baad6..9793d40fb 100644 --- a/Mastodon/Scene/Onboarding/PickServer/CollectionViewCell/PickServerCategoryCollectionViewCell.swift +++ b/Mastodon/Scene/Onboarding/PickServer/CollectionViewCell/PickServerCategoryCollectionViewCell.swift @@ -9,11 +9,7 @@ import UIKit class PickServerCategoryCollectionViewCell: UICollectionViewCell { - var category: MastodonPickServerViewModel.Category? { - didSet { - categoryView.category = category - } - } + var observations = Set() var categoryView: PickServerCategoryView = { let view = PickServerCategoryView() @@ -21,10 +17,9 @@ class PickServerCategoryCollectionViewCell: UICollectionViewCell { return view }() - override var isSelected: Bool { - didSet { - categoryView.selected = isSelected - } + override func prepareForReuse() { + super.prepareForReuse() + observations.removeAll() } override init(frame: CGRect) { diff --git a/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerAppearance.swift b/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerAppearance.swift new file mode 100644 index 000000000..c5bc56c0c --- /dev/null +++ b/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerAppearance.swift @@ -0,0 +1,12 @@ +// +// MastodonPickServerAppearance.swift +// Mastodon +// +// Created by Cirno MainasuK on 2021/3/6. +// + +import UIKit + +enum MastodonPickServerAppearance { + static let tableViewCornerRadius: CGFloat = 10 +} diff --git a/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewController.swift b/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewController.swift index 9e10cd329..71e74d56b 100644 --- a/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewController.swift +++ b/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewController.swift @@ -5,37 +5,33 @@ // Created by BradGao on 2021/2/20. // +import os.log import UIKit import Combine -import OSLog -import MastodonSDK final class MastodonPickServerViewController: UIViewController, NeedsDependency { private var disposeBag = Set() + private var tableViewObservation: NSKeyValueObservation? weak var context: AppContext! { willSet { precondition(!isViewLoaded) } } weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } } var viewModel: MastodonPickServerViewModel! - private var isAuthenticating = CurrentValueSubject(false) - private var expandServerDomainSet = Set() - enum Section: CaseIterable { - case title - case categories - case search - case serverList - } - + private let emptyStateView = PickServerEmptyStateView() + let tableViewTopPaddingView = UIView() // fix empty state view background display when tableView bounce scrolling + var tableViewTopPaddingViewHeightLayoutConstraint: NSLayoutConstraint! + let tableView: UITableView = { let tableView = ControlContainableTableView() tableView.register(PickServerTitleCell.self, forCellReuseIdentifier: String(describing: PickServerTitleCell.self)) tableView.register(PickServerCategoriesCell.self, forCellReuseIdentifier: String(describing: PickServerCategoriesCell.self)) tableView.register(PickServerSearchCell.self, forCellReuseIdentifier: String(describing: PickServerSearchCell.self)) tableView.register(PickServerCell.self, forCellReuseIdentifier: String(describing: PickServerCell.self)) + tableView.register(PickServerLoaderTableViewCell.self, forCellReuseIdentifier: String(describing: PickServerLoaderTableViewCell.self)) tableView.rowHeight = UITableView.automaticDimension tableView.separatorStyle = .none tableView.backgroundColor = .clear @@ -53,6 +49,7 @@ final class MastodonPickServerViewController: UIViewController, NeedsDependency }() deinit { + tableViewObservation = nil os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) } @@ -60,15 +57,23 @@ final class MastodonPickServerViewController: UIViewController, NeedsDependency extension MastodonPickServerViewController { - override var preferredStatusBarStyle: UIStatusBarStyle { - return .darkContent - } override func viewDidLoad() { super.viewDidLoad() setupOnboardingAppearance() defer { setupNavigationBarBackgroundView() } + + #if DEBUG + navigationItem.rightBarButtonItem = UIBarButtonItem(image: UIImage(systemName: "ellipsis.circle"), style: .plain, target: nil, action: nil) + let children: [UIMenuElement] = [ + UIAction(title: "Dismiss", image: nil, identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off, handler: { [weak self] _ in + guard let self = self else { return } + self.dismiss(animated: true, completion: nil) + }) + ] + navigationItem.rightBarButtonItem?.menu = UIMenu(title: "Debug Tool", image: nil, identifier: nil, options: [], children: children) + #endif view.addSubview(nextStepButton) NSLayoutConstraint.activate([ @@ -77,15 +82,48 @@ extension MastodonPickServerViewController { nextStepButton.heightAnchor.constraint(equalToConstant: MastodonPickServerViewController.actionButtonHeight).priority(.defaultHigh), view.layoutMarginsGuide.bottomAnchor.constraint(equalTo: nextStepButton.bottomAnchor, constant: WelcomeViewController.viewBottomPaddingHeight), ]) + + // fix AutoLayout warning when observe before view appear + viewModel.viewWillAppear + .receive(on: DispatchQueue.main) + .sink { [weak self] in + guard let self = self else { return } + self.tableViewObservation = self.tableView.observe(\.contentSize, options: [.initial, .new]) { [weak self] tableView, _ in + guard let self = self else { return } + self.updateEmptyStateViewLayout() + } + } + .store(in: &disposeBag) + + tableViewTopPaddingView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(tableViewTopPaddingView) + tableViewTopPaddingViewHeightLayoutConstraint = tableViewTopPaddingView.heightAnchor.constraint(equalToConstant: 0.0).priority(.defaultHigh) + NSLayoutConstraint.activate([ + tableViewTopPaddingView.topAnchor.constraint(equalTo: view.layoutMarginsGuide.topAnchor), + tableViewTopPaddingView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + tableViewTopPaddingView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + tableViewTopPaddingViewHeightLayoutConstraint, + ]) + tableViewTopPaddingView.backgroundColor = Asset.Colors.Background.systemGroupedBackground.color view.addSubview(tableView) NSLayoutConstraint.activate([ tableView.topAnchor.constraint(equalTo: view.topAnchor), tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor), tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor), - nextStepButton.topAnchor.constraint(equalTo: tableView.bottomAnchor, constant: 7) + nextStepButton.topAnchor.constraint(equalTo: tableView.bottomAnchor, constant: 7), ]) + emptyStateView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(emptyStateView) + NSLayoutConstraint.activate([ + emptyStateView.topAnchor.constraint(equalTo: view.topAnchor), + emptyStateView.leadingAnchor.constraint(equalTo: tableView.readableContentGuide.leadingAnchor), + emptyStateView.trailingAnchor.constraint(equalTo: tableView.readableContentGuide.trailingAnchor), + nextStepButton.topAnchor.constraint(equalTo: emptyStateView.bottomAnchor, constant: 21), + ]) + view.sendSubviewToBack(emptyStateView) + switch viewModel.mode { case .signIn: nextStepButton.setTitle(L10n.Common.Controls.Actions.signIn, for: .normal) @@ -95,29 +133,17 @@ extension MastodonPickServerViewController { nextStepButton.addTarget(self, action: #selector(nextStepButtonDidClicked(_:)), for: .touchUpInside) tableView.delegate = self - tableView.dataSource = self - - viewModel - .searchedServers - .receive(on: DispatchQueue.main) - .sink { _ in - - } receiveValue: { [weak self] servers in - self?.tableView.reloadSections(IndexSet(integer: 3), with: .automatic) - if let selectedServer = self?.viewModel.selectedServer.value, servers.contains(selectedServer) { - // Previously selected server is still in the list, do nothing - } else { - // Previously selected server is not in the updated list, reset the selectedServer's value - self?.viewModel.selectedServer.send(nil) - } - } - .store(in: &disposeBag) + viewModel.setupDiffableDataSource( + for: tableView, + dependency: self, + pickServerCategoriesCellDelegate: self, + pickServerSearchCellDelegate: self, + pickServerCellDelegate: self + ) viewModel .selectedServer - .map { - $0 != nil - } + .map { $0 != nil } .assign(to: \.isEnabled, on: nextStepButton) .store(in: &disposeBag) @@ -156,7 +182,7 @@ extension MastodonPickServerViewController { } .store(in: &disposeBag) - isAuthenticating + viewModel.isAuthenticating .receive(on: DispatchQueue.main) .sink { [weak self] isAuthenticating in guard let self = self else { return } @@ -164,9 +190,42 @@ extension MastodonPickServerViewController { } .store(in: &disposeBag) - viewModel.fetchAllServers() + viewModel.emptyStateViewState + .receive(on: DispatchQueue.main) + .sink { [weak self] state in + guard let self = self else { return } + switch state { + case .none: + self.emptyStateView.isHidden = true + case .loading: + self.emptyStateView.isHidden = false + self.emptyStateView.networkIndicatorImageView.isHidden = true + self.emptyStateView.activityIndicatorView.startAnimating() + self.emptyStateView.infoLabel.isHidden = false + self.emptyStateView.infoLabel.text = L10n.Scene.ServerPicker.EmptyState.findingServers + self.emptyStateView.infoLabel.textAlignment = self.traitCollection.layoutDirection == .rightToLeft ? .right : .left + case .badNetwork: + self.emptyStateView.isHidden = false + self.emptyStateView.networkIndicatorImageView.isHidden = false + self.emptyStateView.activityIndicatorView.stopAnimating() + self.emptyStateView.infoLabel.isHidden = false + self.emptyStateView.infoLabel.text = L10n.Scene.ServerPicker.EmptyState.badNetwork + self.emptyStateView.infoLabel.textAlignment = .center + } + } + .store(in: &disposeBag) } + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + viewModel.viewWillAppear.send() + } + + +} + +extension MastodonPickServerViewController { + @objc private func nextStepButtonDidClicked(_ sender: UIButton) { switch viewModel.mode { @@ -179,7 +238,7 @@ extension MastodonPickServerViewController { private func doSignIn() { guard let server = viewModel.selectedServer.value else { return } - isAuthenticating.send(true) + viewModel.isAuthenticating.send(true) context.apiService.createApplication(domain: server.domain) .tryMap { response -> MastodonPickServerViewModel.AuthenticateInfo in let application = response.value @@ -191,7 +250,7 @@ extension MastodonPickServerViewController { .receive(on: DispatchQueue.main) .sink { [weak self] completion in guard let self = self else { return } - self.isAuthenticating.send(false) + self.viewModel.isAuthenticating.send(false) switch completion { case .failure(let error): @@ -219,7 +278,7 @@ extension MastodonPickServerViewController { private func doSignUp() { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) guard let server = viewModel.selectedServer.value else { return } - isAuthenticating.send(true) + viewModel.isAuthenticating.send(true) context.apiService.instance(domain: server.domain) .compactMap { [weak self] response -> AnyPublisher? in @@ -255,7 +314,7 @@ extension MastodonPickServerViewController { .receive(on: DispatchQueue.main) .sink { [weak self] completion in guard let self = self else { return } - self.isAuthenticating.send(false) + self.viewModel.isAuthenticating.send(false) switch completion { case .failure(let error): @@ -265,154 +324,161 @@ extension MastodonPickServerViewController { } } receiveValue: { [weak self] response in guard let self = self else { return } - let mastodonRegisterViewModel = MastodonRegisterViewModel( - domain: server.domain, - authenticateInfo: response.authenticateInfo, - instance: response.instance.value, - applicationToken: response.applicationToken.value - ) - self.coordinator.present(scene: .mastodonRegister(viewModel: mastodonRegisterViewModel), from: nil, transition: .show) + if let rules = response.instance.value.rules, !rules.isEmpty { + // show server rules before register + let mastodonServerRulesViewModel = MastodonServerRulesViewModel( + domain: server.domain, + authenticateInfo: response.authenticateInfo, + rules: rules, + instance: response.instance.value, + applicationToken: response.applicationToken.value + ) + self.coordinator.present(scene: .mastodonServerRules(viewModel: mastodonServerRulesViewModel), from: self, transition: .show) + } else { + let mastodonRegisterViewModel = MastodonRegisterViewModel( + domain: server.domain, + context: self.context, + authenticateInfo: response.authenticateInfo, + instance: response.instance.value, + applicationToken: response.applicationToken.value + ) + self.coordinator.present(scene: .mastodonRegister(viewModel: mastodonRegisterViewModel), from: nil, transition: .show) + } } .store(in: &disposeBag) } } +// MARK: - UITableViewDelegate extension MastodonPickServerViewController: UITableViewDelegate { + + func scrollViewDidScroll(_ scrollView: UIScrollView) { + guard scrollView === tableView else { return } + let offsetY = scrollView.contentOffset.y + scrollView.safeAreaInsets.top + if offsetY < 0 { + tableViewTopPaddingViewHeightLayoutConstraint.constant = abs(offsetY) + } else { + tableViewTopPaddingViewHeightLayoutConstraint.constant = 0 + } + } + + func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? { + let headerView = UIView() + headerView.backgroundColor = Asset.Colors.Background.systemGroupedBackground.color + return headerView + } + func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat { - let category = Section.allCases[section] - switch category { - case .title: + guard let diffableDataSource = viewModel.diffableDataSource else { return 0 } + let sections = diffableDataSource.snapshot().sectionIdentifiers + let section = sections[section] + switch section { + case .header: return 20 - case .categories: + case .category: // Since category view has a blur shadow effect, its height need to be large than the actual height, // Thus we reduce the section header's height by 10, and make the category cell height 60+20(10 inset for top and bottom) return 10 case .search: // Same reason as above return 10 - case .serverList: - // Header with 1 height as the separator - return 1 + case .servers: + return 0 } } func tableView(_ tableView: UITableView, willSelectRowAt indexPath: IndexPath) -> IndexPath? { + guard let diffableDataSource = viewModel.diffableDataSource else { return nil } + guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return nil } + guard case .server = item else { return nil } + if tableView.indexPathForSelectedRow == indexPath { tableView.deselectRow(at: indexPath, animated: false) viewModel.selectedServer.send(nil) return nil } + return indexPath } func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + guard let diffableDataSource = viewModel.diffableDataSource else { return } + guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return } + guard case let .server(server, _) = item else { return } tableView.selectRow(at: indexPath, animated: false, scrollPosition: .none) - viewModel.selectedServer.send(viewModel.searchedServers.value[indexPath.row]) + viewModel.selectedServer.send(server) } - + func tableView(_ tableView: UITableView, didDeselectRowAt indexPath: IndexPath) { tableView.deselectRow(at: indexPath, animated: false) viewModel.selectedServer.send(nil) } -} - -extension MastodonPickServerViewController: UITableViewDataSource { - func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? { - return UIView() - } - func numberOfSections(in tableView: UITableView) -> Int { - return Self.Section.allCases.count - } - - func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - let section = Self.Section.allCases[section] - switch section { - case .title, - .categories, - .search: - return 1 - case .serverList: - return viewModel.searchedServers.value.count - } - } - - func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + 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 } - let section = Self.Section.allCases[indexPath.section] - switch section { - case .title: - let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: PickServerTitleCell.self), for: indexPath) as! PickServerTitleCell - return cell - case .categories: - let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: PickServerCategoriesCell.self), for: indexPath) as! PickServerCategoriesCell - cell.dataSource = self - cell.delegate = self - return cell + switch item { + case .categoryPicker: + guard let cell = cell as? PickServerCategoriesCell else { return } + guard let diffableDataSource = cell.diffableDataSource else { return } + let snapshot = diffableDataSource.snapshot() + + let item = viewModel.selectCategoryItem.value + guard let section = snapshot.indexOfSection(.main), + let row = snapshot.indexOfItem(item) else { return } + cell.collectionView.selectItem(at: IndexPath(item: row, section: section), animated: false, scrollPosition: .centeredHorizontally) case .search: - let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: PickServerSearchCell.self), for: indexPath) as! PickServerSearchCell - cell.delegate = self - return cell - case .serverList: - let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: PickServerCell.self), for: indexPath) as! PickServerCell - let server = viewModel.searchedServers.value[indexPath.row] - cell.server = server - if expandServerDomainSet.contains(server.domain) { - cell.mode = .expand - } else { - cell.mode = .collapse - } - if server == viewModel.selectedServer.value { - tableView.selectRow(at: indexPath, animated: false, scrollPosition: .none) - } else { - tableView.deselectRow(at: indexPath, animated: false) - } - - cell.delegate = self - return cell + guard let cell = cell as? PickServerSearchCell else { return } + cell.searchTextField.text = viewModel.searchText.value + default: + break } } + +} + +extension MastodonPickServerViewController { + private func updateEmptyStateViewLayout() { + guard let diffableDataSource = self.viewModel.diffableDataSource else { return } + guard let indexPath = diffableDataSource.indexPath(for: .search) else { return } + let rectInTableView = tableView.rectForRow(at: indexPath) + + emptyStateView.topPaddingViewTopLayoutConstraint.constant = rectInTableView.maxY + } } +// MARK: - PickServerCategoriesCellDelegate +extension MastodonPickServerViewController: PickServerCategoriesCellDelegate { + func pickServerCategoriesCell(_ cell: PickServerCategoriesCell, collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { + guard let diffableDataSource = cell.diffableDataSource else { return } + let item = diffableDataSource.itemIdentifier(for: indexPath) + viewModel.selectCategoryItem.value = item ?? .all + } +} + +// MARK: - PickServerSearchCellDelegate +extension MastodonPickServerViewController: PickServerSearchCellDelegate { + func pickServerSearchCell(_ cell: PickServerSearchCell, searchTextDidChange searchText: String?) { + viewModel.searchText.send(searchText ?? "") + } +} + +// MARK: - PickServerCellDelegate extension MastodonPickServerViewController: PickServerCellDelegate { - func pickServerCell(modeChange server: Mastodon.Entity.Server, newMode: PickServerCell.Mode, updates: (() -> Void)) { - if newMode == .collapse { - expandServerDomainSet.remove(server.domain) - } else { - expandServerDomainSet.insert(server.domain) - } + func pickServerCell(_ cell: PickServerCell, expandButtonPressed button: UIButton) { + guard let diffableDataSource = viewModel.diffableDataSource else { return } + guard let indexPath = tableView.indexPath(for: cell) else { return } + guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return } + guard case let .server(_, attribute) = item else { return } + attribute.isExpand.toggle() tableView.beginUpdates() - updates() + cell.updateExpandMode(mode: attribute.isExpand ? .expand : .collapse) tableView.endUpdates() - if newMode == .expand, let modeChangeIndex = self.viewModel.searchedServers.value.firstIndex(where: { $0 == server }), self.tableView.indexPathsForVisibleRows?.last?.row == modeChangeIndex { - self.tableView.scrollToRow(at: IndexPath(row: modeChangeIndex, section: 3), at: .bottom, animated: true) - } - } -} - -extension MastodonPickServerViewController: PickServerSearchCellDelegate { - func pickServerSearchCell(didChange searchText: String?) { - viewModel.searchText.send(searchText) - } -} - -extension MastodonPickServerViewController: PickServerCategoriesDataSource, PickServerCategoriesDelegate { - func numberOfCategories() -> Int { - return viewModel.categories.count - } - - func category(at index: Int) -> MastodonPickServerViewModel.Category { - return viewModel.categories[index] - } - - func selectedIndex() -> Int { - return viewModel.selectCategoryIndex.value - } - - func pickServerCategoriesCell(didSelect index: Int) { - return viewModel.selectCategoryIndex.send(index) + // expand attribute change do not needs apply snapshot to diffable data source + // but should I block the viewModel data binding during tableView.beginUpdates/endUpdates? } } diff --git a/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewModel+Diffable.swift b/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewModel+Diffable.swift new file mode 100644 index 000000000..9da0399e1 --- /dev/null +++ b/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewModel+Diffable.swift @@ -0,0 +1,39 @@ +// +// MastodonPickServerViewController+Diffable.swift +// Mastodon +// +// Created by Cirno MainasuK on 2021/3/5. +// + +import UIKit + +extension MastodonPickServerViewModel { + + func setupDiffableDataSource( + for tableView: UITableView, + dependency: NeedsDependency, + pickServerCategoriesCellDelegate: PickServerCategoriesCellDelegate, + pickServerSearchCellDelegate: PickServerSearchCellDelegate, + pickServerCellDelegate: PickServerCellDelegate + ) { + diffableDataSource = PickServerSection.tableViewDiffableDataSource( + for: tableView, + dependency: dependency, + pickServerCategoriesCellDelegate: pickServerCategoriesCellDelegate, + pickServerSearchCellDelegate: pickServerSearchCellDelegate, + pickServerCellDelegate: pickServerCellDelegate + ) + + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([.header, .category, .search, .servers]) + snapshot.appendItems([.header], toSection: .header) + snapshot.appendItems([.categoryPicker(items: categoryPickerItems)], toSection: .category) + snapshot.appendItems([.search], toSection: .search) + diffableDataSource?.apply(snapshot, animatingDifferences: false, completion: nil) + + loadIndexedServerStateMachine.enter(LoadIndexedServerState.Loading.self) + } + +} + + diff --git a/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewModel+LoadIndexedServerState.swift b/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewModel+LoadIndexedServerState.swift new file mode 100644 index 000000000..69f6a82fb --- /dev/null +++ b/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewModel+LoadIndexedServerState.swift @@ -0,0 +1,92 @@ +// +// MastodonPickServerViewModel+LoadIndexedServerState.swift +// Mastodon +// +// Created by Cirno MainasuK on 2021/3/5. +// + +import os.log +import Foundation +import GameplayKit +import MastodonSDK + +extension MastodonPickServerViewModel { + class LoadIndexedServerState: GKState { + weak var viewModel: MastodonPickServerViewModel? + + init(viewModel: MastodonPickServerViewModel) { + 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 MastodonPickServerViewModel.LoadIndexedServerState { + + class Initial: MastodonPickServerViewModel.LoadIndexedServerState { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + return stateClass == Loading.self + } + } + + class Loading: MastodonPickServerViewModel.LoadIndexedServerState { + 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 = self.viewModel, let stateMachine = self.stateMachine else { return } + viewModel.isLoadingIndexedServers.value = true + viewModel.context.apiService.servers(language: nil, category: nil) + .sink { completion in + switch completion { + case .failure: + // TODO: handle error + stateMachine.enter(Fail.self) + case .finished: + break + } + } receiveValue: { [weak self] response in + guard let _ = self else { return } + stateMachine.enter(Idle.self) + viewModel.indexedServers.value = response.value + } + .store(in: &viewModel.disposeBag) + } + } + + class Fail: MastodonPickServerViewModel.LoadIndexedServerState { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + return stateClass == Loading.self + } + + override func didEnter(from previousState: GKState?) { + super.didEnter(from: previousState) + + guard let stateMachine = self.stateMachine else { return } + DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in + guard let _ = self else { return } + stateMachine.enter(Loading.self) + } + } + } + + class Idle: MastodonPickServerViewModel.LoadIndexedServerState { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + return false + } + + override func didEnter(from previousState: GKState?) { + super.didEnter(from: previousState) + + guard let viewModel = self.viewModel else { return } + viewModel.isLoadingIndexedServers.value = false + } + } + +} diff --git a/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewModel.swift b/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewModel.swift index 3a701f09f..0edc0a350 100644 --- a/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewModel.swift +++ b/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewModel.swift @@ -5,82 +5,65 @@ // Created by BradGao on 2021/2/23. // +import os.log import UIKit -import OSLog import Combine +import GameplayKit import MastodonSDK import CoreDataStack class MastodonPickServerViewModel: NSObject { + enum PickServerMode { case signUp case signIn } - enum Category { - // `all` means search for all categories - case all - // `some` means search for specific category - case some(Mastodon.Entity.Category) - - var title: String { - switch self { - case .all: - return L10n.Scene.ServerPicker.Button.Category.all - case .some(let masCategory): - // TODO: Use emoji as placeholders - switch masCategory.category { - case .academia: - return "📚" - case .activism: - return "✊" - case .food: - return "ðŸ•" - case .furry: - return "ðŸ¦" - case .games: - return "🕹" - case .general: - return "GE" - case .journalism: - return "📰" - case .lgbt: - return "ðŸ³ï¸â€ðŸŒˆ" - case .regional: - return "ðŸ“" - case .art: - return "🎨" - case .music: - return "🎼" - case .tech: - return "📱" - case ._other: - return "â“" - } - } - } + enum EmptyStateViewState { + case none + case loading + case badNetwork } + var disposeBag = Set() + + // input let mode: PickServerMode let context: AppContext + var categoryPickerItems: [CategoryPickerItem] = { + var items: [CategoryPickerItem] = [] + items.append(.all) + items.append(contentsOf: APIService.stubCategories().map { CategoryPickerItem.category(category: $0) }) + return items + }() + let selectCategoryItem = CurrentValueSubject(.all) + let searchText = CurrentValueSubject("") + let indexedServers = CurrentValueSubject<[Mastodon.Entity.Server], Never>([]) + let unindexedServers = CurrentValueSubject<[Mastodon.Entity.Server]?, Never>([]) // set nil when loading + let viewWillAppear = PassthroughSubject() - var categories = [Category]() - let selectCategoryIndex = CurrentValueSubject(0) - - let searchText = CurrentValueSubject(nil) - - let allServers = CurrentValueSubject<[Mastodon.Entity.Server], Never>([]) - let searchedServers = CurrentValueSubject<[Mastodon.Entity.Server], Error>([]) - + // output + var diffableDataSource: UITableViewDiffableDataSource? + private(set) lazy var loadIndexedServerStateMachine: GKStateMachine = { + // exclude timeline middle fetcher state + let stateMachine = GKStateMachine(states: [ + LoadIndexedServerState.Initial(viewModel: self), + LoadIndexedServerState.Loading(viewModel: self), + LoadIndexedServerState.Fail(viewModel: self), + LoadIndexedServerState.Idle(viewModel: self), + ]) + stateMachine.enter(LoadIndexedServerState.Initial.self) + return stateMachine + }() + let filteredIndexedServers = CurrentValueSubject<[Mastodon.Entity.Server], Never>([]) + let servers = CurrentValueSubject<[Mastodon.Entity.Server], Error>([]) let selectedServer = CurrentValueSubject(nil) let error = PassthroughSubject() let authenticated = PassthroughSubject<(domain: String, account: Mastodon.Entity.Account), Never>() - - private var disposeBag = Set() - - weak var tableView: UITableView? - -// private var expandServerDomainSet = Set() + let isAuthenticating = CurrentValueSubject(false) + + let isLoadingIndexedServers = CurrentValueSubject(false) + let emptyStateViewState = CurrentValueSubject(.none) var mastodonPinBasedAuthenticationViewController: UIViewController? @@ -92,85 +75,165 @@ class MastodonPickServerViewModel: NSObject { configure() } - private func configure() { - let masCategories = context.apiService.stubCategories() - categories.append(.all) - categories.append(contentsOf: masCategories.map { Category.some($0) }) - - Publishers.CombineLatest3( - selectCategoryIndex, - searchText.debounce(for: .milliseconds(300), scheduler: DispatchQueue.main).removeDuplicates(), - allServers - ) - .flatMap { [weak self] (selectCategoryIndex, searchText, allServers) -> AnyPublisher, Never> in - guard let self = self else { return Just(Result.success([])).eraseToAnyPublisher() } - - // 1. Search from the servers recorded in joinmastodon.org - let searchedServersFromAPI = self.searchServersFromAPI(category: self.categories[selectCategoryIndex], searchText: searchText, allServers: allServers) - if !searchedServersFromAPI.isEmpty { - // If found servers, just return - return Just(Result.success(searchedServersFromAPI)).eraseToAnyPublisher() - } - // 2. No server found in the recorded list, check if searchText is a valid mastodon server domain - if let toSearchText = searchText, !toSearchText.isEmpty, let _ = URL(string: "https://\(toSearchText)") { - return self.context.apiService.instance(domain: toSearchText) - .map { return Result.success([Mastodon.Entity.Server(instance: $0.value)]) } - .catch({ error -> Just> in - return Just(Result.failure(error)) - }) - .eraseToAnyPublisher() - } - return Just(Result.success(searchedServersFromAPI)).eraseToAnyPublisher() - } - .sink { _ in - - } receiveValue: { [weak self] result in - switch result { - case .success(let servers): - self?.searchedServers.send(servers) - case .failure(let error): - // TODO: What should be presented when user inputs invalid search text? - self?.searchedServers.send([]) - } - - } - .store(in: &disposeBag) - - + deinit { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) } - func fetchAllServers() { - context.apiService.servers(language: nil, category: nil) - .sink { completion in - // TODO: Add a reload button when fails to fetch servers initially - } receiveValue: { [weak self] result in - self?.allServers.send(result.value) +} + +extension MastodonPickServerViewModel { + + private func configure() { + Publishers.CombineLatest( + filteredIndexedServers, + unindexedServers + ) + .receive(on: DispatchQueue.main) + .sink(receiveValue: { [weak self] indexedServers, unindexedServers in + guard let self = self else { return } + guard let diffableDataSource = self.diffableDataSource else { return } + + let oldSnapshot = diffableDataSource.snapshot() + var oldSnapshotServerItemAttributeDict: [String : PickServerItem.ServerItemAttribute] = [:] + for item in oldSnapshot.itemIdentifiers { + guard case let .server(server, attribute) = item else { continue } + oldSnapshotServerItemAttributeDict[server.domain] = attribute } + + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([.header, .category, .search, .servers]) + snapshot.appendItems([.header], toSection: .header) + snapshot.appendItems([.categoryPicker(items: self.categoryPickerItems)], toSection: .category) + snapshot.appendItems([.search], toSection: .search) + // TODO: handle filter + var serverItems: [PickServerItem] = [] + for server in indexedServers { + let attribute = oldSnapshotServerItemAttributeDict[server.domain] ?? PickServerItem.ServerItemAttribute(isLast: false, isExpand: false) + attribute.isLast = false + let item = PickServerItem.server(server: server, attribute: attribute) + guard !serverItems.contains(item) else { continue } + serverItems.append(item) + } + + if let unindexedServers = unindexedServers { + if !unindexedServers.isEmpty { + for server in unindexedServers { + let attribute = oldSnapshotServerItemAttributeDict[server.domain] ?? PickServerItem.ServerItemAttribute(isLast: false, isExpand: false) + attribute.isLast = false + let item = PickServerItem.server(server: server, attribute: attribute) + guard !serverItems.contains(item) else { continue } + serverItems.append(item) + } + } else { + if indexedServers.isEmpty && !self.isLoadingIndexedServers.value { + serverItems.append(.loader(attribute: PickServerItem.LoaderItemAttribute(isLast: false, isEmptyResult: true))) + } + } + } else { + serverItems.append(.loader(attribute: PickServerItem.LoaderItemAttribute(isLast: false, isEmptyResult: false))) + } + + if case let .server(_, attribute) = serverItems.last { + attribute.isLast = true + } + if case let .loader(attribute) = serverItems.last { + attribute.isLast = true + } + snapshot.appendItems(serverItems, toSection: .servers) + + diffableDataSource.defaultRowAnimation = .fade + diffableDataSource.apply(snapshot, animatingDifferences: true, completion: nil) + }) + .store(in: &disposeBag) + + isLoadingIndexedServers + .map { isLoadingIndexedServers -> EmptyStateViewState in + if isLoadingIndexedServers { + return .loading + } else { + return .none + } + } + .assign(to: \.value, on: emptyStateViewState) .store(in: &disposeBag) + Publishers.CombineLatest3( + indexedServers.eraseToAnyPublisher(), + selectCategoryItem.eraseToAnyPublisher(), + searchText.debounce(for: .milliseconds(300), scheduler: DispatchQueue.main).removeDuplicates() + ) + .map { indexedServers, selectCategoryItem, searchText -> [Mastodon.Entity.Server] in + // Filter the indexed servers from joinmastodon.org + switch selectCategoryItem { + case .all: + return MastodonPickServerViewModel.filterServers(servers: indexedServers, category: nil, searchText: searchText) + case .category(let category): + return MastodonPickServerViewModel.filterServers(servers: indexedServers, category: category.category.rawValue, searchText: searchText) + } + } + .assign(to: \.value, on: filteredIndexedServers) + .store(in: &disposeBag) + + searchText + .debounce(for: .milliseconds(300), scheduler: DispatchQueue.main) + .removeDuplicates() + .compactMap { [weak self] searchText -> AnyPublisher, Error>, Never>? in + // Check if searchText is a valid mastodon server domain + guard let self = self else { return nil } + guard let domain = AuthenticationViewModel.parseDomain(from: searchText) else { + return Just(Result.failure(APIService.APIError.implicit(.badRequest))).eraseToAnyPublisher() + } + self.unindexedServers.value = nil + return self.context.apiService.instance(domain: domain) + .map { response -> Result, Error>in + let newResponse = response.map { [Mastodon.Entity.Server(instance: $0)] } + return Result.success(newResponse) + } + .catch { error in + return Just(Result.failure(error)) + } + .eraseToAnyPublisher() + } + .switchToLatest() + .sink(receiveValue: { [weak self] result in + guard let self = self else { return } + switch result { + case .success(let response): + self.unindexedServers.send(response.value) + case .failure(let error): + if let error = error as? APIService.APIError, + case let .implicit(reason) = error, + case .badRequest = reason { + self.unindexedServers.send([]) + } else { + self.unindexedServers.send(nil) + } + } + }) + .store(in: &disposeBag) } - - private func searchServersFromAPI(category: Category, searchText: String?, allServers: [Mastodon.Entity.Server]) -> [Mastodon.Entity.Server] { - return allServers + +} + +extension MastodonPickServerViewModel { + private static func filterServers(servers: [Mastodon.Entity.Server], category: String?, searchText: String) -> [Mastodon.Entity.Server] { + return servers // 1. Filter the category .filter { - switch category { - case .all: - return true - case .some(let masCategory): - return $0.category.caseInsensitiveCompare(masCategory.category.rawValue) == .orderedSame - } + guard let category = category else { return true } + return $0.category.caseInsensitiveCompare(category) == .orderedSame } // 2. Filter the searchText .filter { - if let searchText = searchText, !searchText.isEmpty { - return $0.domain.lowercased().contains(searchText.lowercased()) - } else { + let searchText = searchText.trimmingCharacters(in: .whitespacesAndNewlines) + guard !searchText.isEmpty else { return true } + return $0.domain.lowercased().contains(searchText.lowercased()) } } } + // MARK: - SignIn methods & structs extension MastodonPickServerViewModel { diff --git a/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerCategoriesCell.swift b/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerCategoriesCell.swift index 8f66e9847..373a90ddf 100644 --- a/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerCategoriesCell.swift +++ b/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerCategoriesCell.swift @@ -5,24 +5,20 @@ // Created by BradGao on 2021/2/23. // +import os.log import UIKit import MastodonSDK -protocol PickServerCategoriesDataSource: class { - func numberOfCategories() -> Int - func category(at index: Int) -> MastodonPickServerViewModel.Category - func selectedIndex() -> Int -} - -protocol PickServerCategoriesDelegate: class { - func pickServerCategoriesCell(didSelect index: Int) +protocol PickServerCategoriesCellDelegate: AnyObject { + func pickServerCategoriesCell(_ cell: PickServerCategoriesCell, collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) } final class PickServerCategoriesCell: UITableViewCell { - weak var dataSource: PickServerCategoriesDataSource! - weak var delegate: PickServerCategoriesDelegate! + weak var delegate: PickServerCategoriesCellDelegate? + var diffableDataSource: UICollectionViewDiffableDataSource? + let metricView = UIView() let collectionView: UICollectionView = { @@ -38,6 +34,12 @@ final class PickServerCategoriesCell: UITableViewCell { return view }() + override func prepareForReuse() { + super.prepareForReuse() + + delegate = nil + } + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { super.init(style: style, reuseIdentifier: reuseIdentifier) _init() @@ -52,8 +54,8 @@ final class PickServerCategoriesCell: UITableViewCell { extension PickServerCategoriesCell { private func _init() { - self.selectionStyle = .none - backgroundColor = .clear + selectionStyle = .none + backgroundColor = Asset.Colors.Background.systemGroupedBackground.color metricView.translatesAutoresizingMaskIntoConstraints = false contentView.addSubview(metricView) @@ -75,7 +77,6 @@ extension PickServerCategoriesCell { ]) collectionView.delegate = self - collectionView.dataSource = self } override func layoutSubviews() { @@ -86,45 +87,40 @@ extension PickServerCategoriesCell { } +// MARK: - UICollectionViewDelegateFlowLayout extension PickServerCategoriesCell: UICollectionViewDelegateFlowLayout { - func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { - collectionView.selectItem(at: indexPath, animated: true, scrollPosition: .centeredHorizontally) - delegate.pickServerCategoriesCell(didSelect: indexPath.row) - } + 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: UICollectionViewDataSource { - func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { - return dataSource.numberOfCategories() - } - - func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { - let category = dataSource.category(at: indexPath.row) - let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: PickServerCategoryCollectionViewCell.self), for: indexPath) as! PickServerCategoryCollectionViewCell - cell.category = category - - // Select the default category by default - if indexPath.row == dataSource.selectedIndex() { - // Use `[]` as the scrollPosition to avoid contentOffset change - collectionView.selectItem(at: indexPath, animated: false, scrollPosition: []) - cell.isSelected = true - } - return cell - } - } +extension PickServerCategoriesCell { + + override func accessibilityElementCount() -> Int { + guard let diffableDataSource = diffableDataSource else { return 0 } + return diffableDataSource.snapshot().itemIdentifiers.count + } + + override func accessibilityElement(at index: Int) -> Any? { + guard let item = collectionView.cellForItem(at: IndexPath(item: index, section: 0)) else { return nil } + return item + } + +} diff --git a/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerCell.swift b/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerCell.swift index 711822186..8eb0cb771 100644 --- a/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerCell.swift +++ b/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerCell.swift @@ -5,60 +5,64 @@ // Created by BradGao on 2021/2/24. // +import os.log import UIKit +import Combine import MastodonSDK -import Kingfisher +import AlamofireImage +import Kanna -protocol PickServerCellDelegate: class { - func pickServerCell(modeChange server: Mastodon.Entity.Server, newMode: PickServerCell.Mode, updates: (() -> Void)) +protocol PickServerCellDelegate: AnyObject { + func pickServerCell(_ cell: PickServerCell, expandButtonPressed button: UIButton) } class PickServerCell: UITableViewCell { weak var delegate: PickServerCellDelegate? - enum Mode { - case collapse - case expand - } + var disposeBag = Set() - private var containerView: UIView = { + let expandMode = CurrentValueSubject(.collapse) + + let containerView: UIView = { let view = UIView() view.layoutMargins = UIEdgeInsets(top: 16, left: 16, bottom: 10, right: 16) - view.backgroundColor = Asset.Colors.lightWhite.color + view.backgroundColor = Asset.Colors.Background.secondaryGroupedSystemBackground.color view.translatesAutoresizingMaskIntoConstraints = false return view }() - private var domainLabel: UILabel = { + let domainLabel: UILabel = { let label = UILabel() - label.font = .preferredFont(forTextStyle: .headline) - label.textColor = Asset.Colors.lightDarkGray.color + label.font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 17, weight: .semibold), maximumPointSize: 22) + label.textColor = Asset.Colors.Label.primary.color label.adjustsFontForContentSizeCategory = true label.translatesAutoresizingMaskIntoConstraints = false return label }() - private var checkbox: UIImageView = { + let checkbox: UIImageView = { let imageView = UIImageView() imageView.preferredSymbolConfiguration = UIImage.SymbolConfiguration(textStyle: .body) - imageView.tintColor = Asset.Colors.lightSecondaryText.color + imageView.tintColor = Asset.Colors.Label.secondary.color imageView.contentMode = .scaleAspectFill imageView.translatesAutoresizingMaskIntoConstraints = false return imageView }() - private var descriptionLabel: UILabel = { + let descriptionLabel: UILabel = { let label = UILabel() - label.font = .preferredFont(forTextStyle: .subheadline) + label.font = UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 15, weight: .regular)) label.numberOfLines = 0 - label.textColor = Asset.Colors.lightDarkGray.color + label.textColor = Asset.Colors.Label.primary.color label.adjustsFontForContentSizeCategory = true label.translatesAutoresizingMaskIntoConstraints = false return label }() - private var thumbImageView: UIImageView = { + let thumbnailActivityIdicator = UIActivityIndicatorView(style: .medium) + + let thumbnailImageView: UIImageView = { let imageView = UIImageView() imageView.clipsToBounds = true imageView.contentMode = .scaleAspectFill @@ -66,7 +70,7 @@ class PickServerCell: UITableViewCell { return imageView }() - private var infoStackView: UIStackView = { + let infoStackView: UIStackView = { let stackView = UIStackView() stackView.axis = .horizontal stackView.alignment = .fill @@ -75,64 +79,67 @@ class PickServerCell: UITableViewCell { return stackView }() - private var expandBox: UIView = { + let expandBox: UIView = { let view = UIView() view.backgroundColor = .clear view.translatesAutoresizingMaskIntoConstraints = false return view }() - private var expandButton: UIButton = { + let expandButton: UIButton = { let button = UIButton(type: .custom) - button.setTitle(L10n.Scene.ServerPicker.Button.seemore, for: .normal) - button.setTitle(L10n.Scene.ServerPicker.Button.seeless, for: .selected) - button.setTitleColor(Asset.Colors.lightBrandBlue.color, for: .normal) - button.titleLabel?.font = .preferredFont(forTextStyle: .footnote) + button.setImage(UIImage(systemName: "chevron.down", withConfiguration: UIImage.SymbolConfiguration(pointSize: 13)), for: .normal) + button.setTitle(L10n.Scene.ServerPicker.Button.seeMore, for: .normal) + button.setTitleColor(Asset.Colors.brandBlue.color, for: .normal) + button.titleLabel?.font = .systemFont(ofSize: 13, weight: .regular) button.translatesAutoresizingMaskIntoConstraints = false + button.imageView?.transform = CGAffineTransform(scaleX: -1, y: 1) + button.titleLabel?.transform = CGAffineTransform(scaleX: -1, y: 1) + button.transform = CGAffineTransform(scaleX: -1, y: 1) return button }() - private var seperator: UIView = { + let seperator: UIView = { let view = UIView() - view.backgroundColor = Asset.Colors.lightBackground.color + view.backgroundColor = Asset.Colors.Background.systemGroupedBackground.color view.translatesAutoresizingMaskIntoConstraints = false return view }() - private var langValueLabel: UILabel = { + let langValueLabel: UILabel = { let label = UILabel() - label.textColor = Asset.Colors.lightDarkGray.color - label.font = UIFontMetrics(forTextStyle: .title2).scaledFont(for: UIFont.systemFont(ofSize: 22, weight: .semibold)) + label.textColor = Asset.Colors.Label.primary.color + label.font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 22, weight: .semibold), maximumPointSize: 27) label.textAlignment = .center label.adjustsFontForContentSizeCategory = true label.translatesAutoresizingMaskIntoConstraints = false return label }() - private var usersValueLabel: UILabel = { + let usersValueLabel: UILabel = { let label = UILabel() - label.textColor = Asset.Colors.lightDarkGray.color - label.font = UIFontMetrics(forTextStyle: .title2).scaledFont(for: UIFont.systemFont(ofSize: 22, weight: .semibold)) + label.textColor = Asset.Colors.Label.primary.color + label.font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 22, weight: .semibold), maximumPointSize: 27) label.textAlignment = .center label.adjustsFontForContentSizeCategory = true label.translatesAutoresizingMaskIntoConstraints = false return label }() - private var categoryValueLabel: UILabel = { + let categoryValueLabel: UILabel = { let label = UILabel() - label.textColor = Asset.Colors.lightDarkGray.color - label.font = UIFontMetrics(forTextStyle: .title2).scaledFont(for: UIFont.systemFont(ofSize: 22, weight: .semibold)) + label.textColor = Asset.Colors.Label.primary.color + label.font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 22, weight: .semibold), maximumPointSize: 27) label.textAlignment = .center label.adjustsFontForContentSizeCategory = true label.translatesAutoresizingMaskIntoConstraints = false return label }() - private var langTitleLabel: UILabel = { + let langTitleLabel: UILabel = { let label = UILabel() - label.textColor = Asset.Colors.lightDarkGray.color - label.font = .preferredFont(forTextStyle: .caption2) + label.textColor = Asset.Colors.Label.primary.color + label.font = UIFontMetrics(forTextStyle: .subheadline).scaledFont(for: .systemFont(ofSize: 11, weight: .regular), maximumPointSize: 16) label.text = L10n.Scene.ServerPicker.Label.language label.textAlignment = .center label.adjustsFontForContentSizeCategory = true @@ -140,10 +147,10 @@ class PickServerCell: UITableViewCell { return label }() - private var usersTitleLabel: UILabel = { + let usersTitleLabel: UILabel = { let label = UILabel() - label.textColor = Asset.Colors.lightDarkGray.color - label.font = .preferredFont(forTextStyle: .caption2) + label.textColor = Asset.Colors.Label.primary.color + label.font = UIFontMetrics(forTextStyle: .subheadline).scaledFont(for: .systemFont(ofSize: 11, weight: .regular), maximumPointSize: 16) label.text = L10n.Scene.ServerPicker.Label.users label.textAlignment = .center label.adjustsFontForContentSizeCategory = true @@ -151,10 +158,10 @@ class PickServerCell: UITableViewCell { return label }() - private var categoryTitleLabel: UILabel = { + let categoryTitleLabel: UILabel = { let label = UILabel() - label.textColor = Asset.Colors.lightDarkGray.color - label.font = .preferredFont(forTextStyle: .caption2) + label.textColor = Asset.Colors.Label.primary.color + label.font = UIFontMetrics(forTextStyle: .subheadline).scaledFont(for: .systemFont(ofSize: 11, weight: .regular), maximumPointSize: 16) label.text = L10n.Scene.ServerPicker.Label.category label.textAlignment = .center label.adjustsFontForContentSizeCategory = true @@ -165,16 +172,13 @@ class PickServerCell: UITableViewCell { private var collapseConstraints: [NSLayoutConstraint] = [] private var expandConstraints: [NSLayoutConstraint] = [] - var mode: PickServerCell.Mode = .collapse { - didSet { - updateMode() - } - } - - var server: Mastodon.Entity.Server? { - didSet { - updateServerInfo() - } + override func prepareForReuse() { + super.prepareForReuse() + + thumbnailImageView.isHidden = false + thumbnailImageView.af.cancelImageRequest() + thumbnailActivityIdicator.stopAnimating() + disposeBag.removeAll() } override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { @@ -186,6 +190,7 @@ class PickServerCell: UITableViewCell { super.init(coder: coder) _init() } + } // MARK: - Methods to configure appearance @@ -204,7 +209,7 @@ extension PickServerCell { // Always add the expandbox which contains elements only visible in expand mode containerView.addSubview(expandBox) - expandBox.addSubview(thumbImageView) + expandBox.addSubview(thumbnailImageView) expandBox.addSubview(infoStackView) expandBox.isHidden = true @@ -215,7 +220,7 @@ extension PickServerCell { infoStackView.addArrangedSubview(verticalInfoStackViewUsers) infoStackView.addArrangedSubview(verticalInfoStackViewCategory) - let expandButtonTopConstraintInCollapse = expandButton.topAnchor.constraint(equalTo: descriptionLabel.lastBaselineAnchor, constant: 12).priority(.required) + let expandButtonTopConstraintInCollapse = expandButton.topAnchor.constraint(equalTo: descriptionLabel.lastBaselineAnchor, constant: 12).priority(.required - 1) collapseConstraints.append(expandButtonTopConstraintInCollapse) let expandButtonTopConstraintInExpand = expandButton.topAnchor.constraint(equalTo: expandBox.bottomAnchor, constant: 8).priority(.defaultHigh) @@ -231,7 +236,7 @@ extension PickServerCell { // Set bottom separator seperator.leadingAnchor.constraint(equalTo: containerView.leadingAnchor), containerView.trailingAnchor.constraint(equalTo: seperator.trailingAnchor), - containerView.bottomAnchor.constraint(equalTo: seperator.bottomAnchor), + containerView.topAnchor.constraint(equalTo: seperator.topAnchor), seperator.heightAnchor.constraint(equalToConstant: 1).priority(.defaultHigh), domainLabel.topAnchor.constraint(equalTo: containerView.layoutMarginsGuide.topAnchor), @@ -253,20 +258,29 @@ extension PickServerCell { expandBox.topAnchor.constraint(equalTo: descriptionLabel.bottomAnchor, constant: 8), expandBox.bottomAnchor.constraint(equalTo: infoStackView.bottomAnchor).priority(.defaultHigh), - thumbImageView.topAnchor.constraint(equalTo: expandBox.topAnchor), - thumbImageView.leadingAnchor.constraint(equalTo: expandBox.leadingAnchor), - expandBox.trailingAnchor.constraint(equalTo: thumbImageView.trailingAnchor), - thumbImageView.heightAnchor.constraint(equalTo: thumbImageView.widthAnchor, multiplier: 151.0 / 303.0).priority(.defaultHigh), + thumbnailImageView.topAnchor.constraint(equalTo: expandBox.topAnchor), + thumbnailImageView.leadingAnchor.constraint(equalTo: expandBox.leadingAnchor), + expandBox.trailingAnchor.constraint(equalTo: thumbnailImageView.trailingAnchor), + thumbnailImageView.heightAnchor.constraint(equalTo: thumbnailImageView.widthAnchor, multiplier: 151.0 / 303.0).priority(.defaultHigh), infoStackView.leadingAnchor.constraint(equalTo: expandBox.leadingAnchor), expandBox.trailingAnchor.constraint(equalTo: infoStackView.trailingAnchor), - infoStackView.topAnchor.constraint(equalTo: thumbImageView.bottomAnchor, constant: 16), + infoStackView.topAnchor.constraint(equalTo: thumbnailImageView.bottomAnchor, constant: 16), expandButton.leadingAnchor.constraint(equalTo: containerView.layoutMarginsGuide.leadingAnchor), containerView.layoutMarginsGuide.trailingAnchor.constraint(equalTo: expandButton.trailingAnchor), containerView.layoutMarginsGuide.bottomAnchor.constraint(equalTo: expandButton.bottomAnchor), ]) + thumbnailActivityIdicator.translatesAutoresizingMaskIntoConstraints = false + thumbnailImageView.addSubview(thumbnailActivityIdicator) + NSLayoutConstraint.activate([ + thumbnailActivityIdicator.centerXAnchor.constraint(equalTo: thumbnailImageView.centerXAnchor), + thumbnailActivityIdicator.centerYAnchor.constraint(equalTo: thumbnailImageView.centerYAnchor), + ]) + thumbnailActivityIdicator.hidesWhenStopped = true + thumbnailActivityIdicator.stopAnimating() + NSLayoutConstraint.activate(collapseConstraints) domainLabel.setContentHuggingPriority(.required - 1, for: .vertical) @@ -274,7 +288,7 @@ extension PickServerCell { descriptionLabel.setContentHuggingPriority(.required - 2, for: .vertical) descriptionLabel.setContentCompressionResistancePriority(.required - 2, for: .vertical) - expandButton.addTarget(self, action: #selector(expandButtonDidClicked(_:)), for: .touchUpInside) + expandButton.addTarget(self, action: #selector(expandButtonDidPressed(_:)), for: .touchUpInside) } private func makeVerticalInfoStackView(arrangedView: UIView...) -> UIStackView { @@ -287,22 +301,7 @@ extension PickServerCell { arrangedView.forEach { stackView.addArrangedSubview($0) } return stackView } - - private func updateMode() { - switch mode { - case .collapse: - expandBox.isHidden = true - expandButton.isSelected = false - NSLayoutConstraint.deactivate(expandConstraints) - NSLayoutConstraint.activate(collapseConstraints) - case .expand: - expandBox.isHidden = false - expandButton.isSelected = true - NSLayoutConstraint.activate(expandConstraints) - NSLayoutConstraint.deactivate(collapseConstraints) - } - } - + override func setSelected(_ selected: Bool, animated: Bool) { super.setSelected(selected, animated: animated) if selected { @@ -313,39 +312,38 @@ extension PickServerCell { } @objc - private func expandButtonDidClicked(_ sender: UIButton) { - let newMode: Mode = mode == .collapse ? .expand : .collapse - delegate?.pickServerCell(modeChange: server!, newMode: newMode, updates: { [weak self] in - self?.mode = newMode - }) + private func expandButtonDidPressed(_ sender: UIButton) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + delegate?.pickServerCell(self, expandButtonPressed: sender) } } -// MARK: - Methods to update data extension PickServerCell { - private func updateServerInfo() { - guard let serverInfo = server else { return } - domainLabel.text = serverInfo.domain - descriptionLabel.text = serverInfo.description - let processor = RoundCornerImageProcessor(cornerRadius: 3) - thumbImageView.kf.indicatorType = .activity - thumbImageView.kf.setImage(with: URL(string: serverInfo.proxiedThumbnail ?? "")!, placeholder: UIImage.placeholder(color: Asset.Colors.lightBackground.color), options: [ - .processor(processor), - .scaleFactor(UIScreen.main.scale), - .transition(.fade(1)) - ]) - langValueLabel.text = serverInfo.language.uppercased() - usersValueLabel.text = parseUsersCount(serverInfo.totalUsers) - categoryValueLabel.text = serverInfo.category.uppercased() + + enum ExpandMode { + case collapse + case expand } - private func parseUsersCount(_ usersCount: Int) -> String { - switch usersCount { - case 0..<1000: - return "\(usersCount)" - default: - let usersCountInThousand = Float(usersCount) / 1000.0 - return String(format: "%.1fK", usersCountInThousand) + func updateExpandMode(mode: ExpandMode) { + switch mode { + case .collapse: + expandButton.setImage(UIImage(systemName: "chevron.down", withConfiguration: UIImage.SymbolConfiguration(pointSize: 13)), for: .normal) + expandButton.setTitle(L10n.Scene.ServerPicker.Button.seeMore, for: .normal) + expandBox.isHidden = true + expandButton.isSelected = false + NSLayoutConstraint.deactivate(expandConstraints) + NSLayoutConstraint.activate(collapseConstraints) + case .expand: + expandButton.setImage(UIImage(systemName: "chevron.up", withConfiguration: UIImage.SymbolConfiguration(pointSize: 13)), for: .normal) + expandButton.setTitle(L10n.Scene.ServerPicker.Button.seeLess, for: .normal) + expandBox.isHidden = false + expandButton.isSelected = true + NSLayoutConstraint.activate(expandConstraints) + NSLayoutConstraint.deactivate(collapseConstraints) } + + expandMode.value = mode } + } diff --git a/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerLoaderTableViewCell.swift b/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerLoaderTableViewCell.swift new file mode 100644 index 000000000..37135fa9b --- /dev/null +++ b/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerLoaderTableViewCell.swift @@ -0,0 +1,86 @@ +// +// PickServerLoaderTableViewCell.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-5-13. +// + +import UIKit +import Combine + +final class PickServerLoaderTableViewCell: TimelineLoaderTableViewCell { + + let containerView: UIView = { + let view = UIView() + view.layoutMargins = UIEdgeInsets(top: 16, left: 16, bottom: 10, right: 16) + view.backgroundColor = Asset.Colors.Background.secondaryGroupedSystemBackground.color + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + + let seperator: UIView = { + let view = UIView() + view.backgroundColor = Asset.Colors.Background.systemGroupedBackground.color + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + + let emptyStatusLabel: UILabel = { + let label = UILabel() + label.text = L10n.Scene.ServerPicker.EmptyState.noResults + label.textColor = Asset.Colors.Label.secondary.color + label.textAlignment = .center + label.font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 14, weight: .semibold), maximumPointSize: 19) + return label + }() + + override func _init() { + super._init() + + contentView.addSubview(containerView) + contentView.addSubview(seperator) + + NSLayoutConstraint.activate([ + // Set background view + containerView.topAnchor.constraint(equalTo: contentView.topAnchor), + containerView.leadingAnchor.constraint(equalTo: contentView.readableContentGuide.leadingAnchor), + contentView.readableContentGuide.trailingAnchor.constraint(equalTo: containerView.trailingAnchor), + contentView.bottomAnchor.constraint(equalTo: containerView.bottomAnchor, constant: 1), + + // Set bottom separator + seperator.leadingAnchor.constraint(equalTo: containerView.leadingAnchor), + containerView.trailingAnchor.constraint(equalTo: seperator.trailingAnchor), + containerView.topAnchor.constraint(equalTo: seperator.topAnchor), + seperator.heightAnchor.constraint(equalToConstant: 1).priority(.defaultHigh), + ]) + + emptyStatusLabel.translatesAutoresizingMaskIntoConstraints = false + containerView.addSubview(emptyStatusLabel) + NSLayoutConstraint.activate([ + emptyStatusLabel.leadingAnchor.constraint(equalTo: containerView.readableContentGuide.leadingAnchor), + containerView.readableContentGuide.trailingAnchor.constraint(equalTo: emptyStatusLabel.trailingAnchor), + emptyStatusLabel.centerYAnchor.constraint(equalTo: containerView.centerYAnchor), + ]) + emptyStatusLabel.isHidden = true + + contentView.bringSubviewToFront(stackView) + activityIndicatorView.isHidden = false + startAnimating() + } +} + +#if canImport(SwiftUI) && DEBUG +import SwiftUI + +struct PickServerLoaderTableViewCell_Previews: PreviewProvider { + + static var previews: some View { + UIViewPreview(width: 375) { + PickServerLoaderTableViewCell() + } + .previewLayout(.fixed(width: 375, height: 100)) + } + +} + +#endif diff --git a/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerSearchCell.swift b/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerSearchCell.swift index 6df8affa2..dc048f67a 100644 --- a/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerSearchCell.swift +++ b/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerSearchCell.swift @@ -7,8 +7,8 @@ import UIKit -protocol PickServerSearchCellDelegate: class { - func pickServerSearchCell(didChange searchText: String?) +protocol PickServerSearchCellDelegate: AnyObject { + func pickServerSearchCell(_ cell: PickServerSearchCell, searchTextDidChange searchText: String?) } class PickServerSearchCell: UITableViewCell { @@ -17,20 +17,20 @@ class PickServerSearchCell: UITableViewCell { private var bgView: UIView = { let view = UIView() - view.backgroundColor = Asset.Colors.lightWhite.color + view.backgroundColor = Asset.Colors.Background.secondaryGroupedSystemBackground.color view.translatesAutoresizingMaskIntoConstraints = false view.layer.maskedCorners = [ .layerMinXMinYCorner, .layerMaxXMinYCorner ] view.layer.cornerCurve = .continuous - view.layer.cornerRadius = 10 + view.layer.cornerRadius = MastodonPickServerAppearance.tableViewCornerRadius return view }() private var textFieldBgView: UIView = { let view = UIView() - view.backgroundColor = Asset.Colors.lightBackground.color.withAlphaComponent(0.6) + view.backgroundColor = Asset.Colors.TextField.background.color view.translatesAutoresizingMaskIntoConstraints = false view.layer.masksToBounds = true view.layer.cornerRadius = 6 @@ -38,23 +38,29 @@ class PickServerSearchCell: UITableViewCell { return view }() - private var searchTextField: UITextField = { + let searchTextField: UITextField = { let textField = UITextField() textField.translatesAutoresizingMaskIntoConstraints = false textField.font = .preferredFont(forTextStyle: .headline) - textField.tintColor = Asset.Colors.lightDarkGray.color - textField.textColor = Asset.Colors.lightDarkGray.color + 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.preferredFont(forTextStyle: .headline), - .foregroundColor: Asset.Colors.lightSecondaryText.color.withAlphaComponent(0.6)]) + .foregroundColor: Asset.Colors.Label.secondary.color.withAlphaComponent(0.6)]) textField.clearButtonMode = .whileEditing textField.autocapitalizationType = .none textField.autocorrectionType = .no return textField }() + override func prepareForReuse() { + super.prepareForReuse() + + delegate = nil + } + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { super.init(style: style, reuseIdentifier: reuseIdentifier) _init() @@ -68,8 +74,8 @@ class PickServerSearchCell: UITableViewCell { extension PickServerSearchCell { private func _init() { - self.selectionStyle = .none - backgroundColor = .clear + selectionStyle = .none + backgroundColor = Asset.Colors.Background.systemGroupedBackground.color searchTextField.addTarget(self, action: #selector(textFieldDidChange(_:)), for: .editingChanged) @@ -97,7 +103,7 @@ extension PickServerSearchCell { } extension PickServerSearchCell { - @objc func textFieldDidChange(_ textField: UITextField) { - delegate?.pickServerSearchCell(didChange: textField.text) + @objc private func textFieldDidChange(_ textField: UITextField) { + delegate?.pickServerSearchCell(self, searchTextDidChange: textField.text) } } diff --git a/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerTitleCell.swift b/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerTitleCell.swift index 82d155535..30d24ddc0 100644 --- a/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerTitleCell.swift +++ b/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerTitleCell.swift @@ -34,8 +34,8 @@ final class PickServerTitleCell: UITableViewCell { extension PickServerTitleCell { private func _init() { - self.selectionStyle = .none - backgroundColor = .clear + selectionStyle = .none + backgroundColor = Asset.Colors.Background.systemGroupedBackground.color contentView.addSubview(titleLabel) NSLayoutConstraint.activate([ diff --git a/Mastodon/Scene/Onboarding/PickServer/View/PickServerCategoryView.swift b/Mastodon/Scene/Onboarding/PickServer/View/PickServerCategoryView.swift index 30fcbc1f9..fd1a3ea6d 100644 --- a/Mastodon/Scene/Onboarding/PickServer/View/PickServerCategoryView.swift +++ b/Mastodon/Scene/Onboarding/PickServer/View/PickServerCategoryView.swift @@ -9,16 +9,6 @@ import UIKit import MastodonSDK class PickServerCategoryView: UIView { - var category: MastodonPickServerViewModel.Category? { - didSet { - updateCategory() - } - } - var selected: Bool = false { - didSet { - updateSelectStatus() - } - } var bgShadowView: UIView = { let view = UIView() @@ -53,47 +43,34 @@ class PickServerCategoryView: UIView { } extension PickServerCategoryView { + private func configure() { addSubview(bgView) addSubview(titleLabel) - - bgView.backgroundColor = Asset.Colors.lightWhite.color - + + bgView.backgroundColor = Asset.Colors.Background.secondaryGroupedSystemBackground.color + NSLayoutConstraint.activate([ bgView.leadingAnchor.constraint(equalTo: self.leadingAnchor), bgView.trailingAnchor.constraint(equalTo: self.trailingAnchor), bgView.topAnchor.constraint(equalTo: self.topAnchor), bgView.bottomAnchor.constraint(equalTo: self.bottomAnchor), - + titleLabel.centerXAnchor.constraint(equalTo: self.centerXAnchor), titleLabel.centerYAnchor.constraint(equalTo: self.centerYAnchor), ]) } - private func updateCategory() { - guard let category = category else { return } - titleLabel.text = category.title - switch category { - case .all: - titleLabel.font = UIFont.systemFont(ofSize: 17) - case .some: - titleLabel.font = UIFont.systemFont(ofSize: 28) - } - } - - private func updateSelectStatus() { - if selected { - bgView.backgroundColor = Asset.Colors.lightBrandBlue.color - bgView.applyShadow(color: Asset.Colors.lightBrandBlue.color, alpha: 1, x: 0, y: 0, blur: 4.0) - if case .all = category { - titleLabel.textColor = Asset.Colors.lightWhite.color - } - } else { - bgView.backgroundColor = Asset.Colors.lightWhite.color - bgView.applyShadow(color: Asset.Colors.lightBrandBlue.color, alpha: 0, x: 0, y: 0, blur: 0.0) - if case .all = category { - titleLabel.textColor = Asset.Colors.lightBrandBlue.color - } +} + +#if DEBUG && canImport(SwiftUI) +import SwiftUI + +struct PickServerCategoryView_Previews: PreviewProvider { + static var previews: some View { + UIViewPreview { + PickServerCategoryView() } } } +#endif diff --git a/Mastodon/Scene/Onboarding/PickServer/View/PickServerEmptyStateView.swift b/Mastodon/Scene/Onboarding/PickServer/View/PickServerEmptyStateView.swift new file mode 100644 index 000000000..af744fa92 --- /dev/null +++ b/Mastodon/Scene/Onboarding/PickServer/View/PickServerEmptyStateView.swift @@ -0,0 +1,141 @@ +// +// PickServerEmptyStateView.swift +// Mastodon +// +// Created by Cirno MainasuK on 2021/3/6. +// + +import UIKit + +final class PickServerEmptyStateView: UIView { + + var topPaddingViewTopLayoutConstraint: NSLayoutConstraint! + + let networkIndicatorImageView: UIImageView = { + let imageView = UIImageView() + let configuration = UIImage.SymbolConfiguration(pointSize: 64, weight: .regular) + imageView.image = UIImage(systemName: "wifi.exclamationmark", withConfiguration: configuration) + imageView.tintColor = Asset.Colors.Label.secondary.color + return imageView + }() + let activityIndicatorView = UIActivityIndicatorView(style: .medium) + let infoLabel: UILabel = { + let label = UILabel() + label.font = .systemFont(ofSize: 17) + label.textAlignment = .center + label.textColor = Asset.Colors.Label.secondary.color + label.text = "info" + label.numberOfLines = 0 + return label + }() + + override init(frame: CGRect) { + super.init(frame: frame) + _init() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + _init() + } + +} + +extension PickServerEmptyStateView { + + private func _init() { + backgroundColor = Asset.Colors.Background.secondaryGroupedSystemBackground.color + layer.maskedCorners = [ + .layerMinXMaxYCorner, + .layerMaxXMaxYCorner + ] + layer.cornerCurve = .continuous + layer.cornerRadius = MastodonPickServerAppearance.tableViewCornerRadius + + let topPaddingView = UIView() + topPaddingView.translatesAutoresizingMaskIntoConstraints = false + addSubview(topPaddingView) + topPaddingViewTopLayoutConstraint = topPaddingView.topAnchor.constraint(equalTo: topAnchor, constant: 0) + NSLayoutConstraint.activate([ + topPaddingViewTopLayoutConstraint, + topPaddingView.leadingAnchor.constraint(equalTo: leadingAnchor), + topPaddingView.trailingAnchor.constraint(equalTo: trailingAnchor), + ]) + + let containerStackView = UIStackView() + containerStackView.axis = .vertical + containerStackView.alignment = .center + containerStackView.distribution = .fill + containerStackView.spacing = 16 + containerStackView.translatesAutoresizingMaskIntoConstraints = false + addSubview(containerStackView) + NSLayoutConstraint.activate([ + containerStackView.topAnchor.constraint(equalTo: topPaddingView.bottomAnchor), + containerStackView.leadingAnchor.constraint(equalTo: leadingAnchor), + containerStackView.trailingAnchor.constraint(equalTo: trailingAnchor), + ]) + containerStackView.addArrangedSubview(networkIndicatorImageView) + + let infoContainerView = UIView() + activityIndicatorView.translatesAutoresizingMaskIntoConstraints = false + infoContainerView.addSubview(activityIndicatorView) + NSLayoutConstraint.activate([ + activityIndicatorView.leadingAnchor.constraint(equalTo: infoContainerView.leadingAnchor), + activityIndicatorView.centerYAnchor.constraint(equalTo: infoContainerView.centerYAnchor), + activityIndicatorView.bottomAnchor.constraint(equalTo: infoContainerView.bottomAnchor), + ]) + infoLabel.translatesAutoresizingMaskIntoConstraints = false + infoContainerView.addSubview(infoLabel) + NSLayoutConstraint.activate([ + infoLabel.leadingAnchor.constraint(equalTo: activityIndicatorView.trailingAnchor, constant: 4), + infoLabel.centerYAnchor.constraint(equalTo: infoContainerView.centerYAnchor), + infoLabel.trailingAnchor.constraint(equalTo: infoContainerView.trailingAnchor), + ]) + containerStackView.addArrangedSubview(infoContainerView) + + let bottomPaddingView = UIView() + bottomPaddingView.translatesAutoresizingMaskIntoConstraints = false + addSubview(bottomPaddingView) + NSLayoutConstraint.activate([ + bottomPaddingView.topAnchor.constraint(equalTo: containerStackView.bottomAnchor), + bottomPaddingView.leadingAnchor.constraint(equalTo: leadingAnchor), + bottomPaddingView.trailingAnchor.constraint(equalTo: trailingAnchor), + bottomPaddingView.bottomAnchor.constraint(equalTo: bottomAnchor), + ]) + + NSLayoutConstraint.activate([ + bottomPaddingView.heightAnchor.constraint(equalTo: topPaddingView.heightAnchor, multiplier: 2.0), + ]) + + activityIndicatorView.hidesWhenStopped = true + activityIndicatorView.startAnimating() + } + +} + +#if DEBUG && canImport(SwiftUI) +import SwiftUI + +struct PickServerEmptyStateView_Previews: PreviewProvider { + static var previews: some View { + Group { + UIViewPreview(width: 375) { + let emptyStateView = PickServerEmptyStateView() + emptyStateView.infoLabel.text = L10n.Scene.ServerPicker.EmptyState.badNetwork + emptyStateView.infoLabel.textAlignment = .center + emptyStateView.activityIndicatorView.stopAnimating() + return emptyStateView + } + .previewLayout(.fixed(width: 375, height: 400)) + UIViewPreview(width: 375) { + let emptyStateView = PickServerEmptyStateView() + emptyStateView.infoLabel.text = L10n.Scene.ServerPicker.EmptyState.findingServers + emptyStateView.infoLabel.textAlignment = UIApplication.shared.userInterfaceLayoutDirection == .rightToLeft ? .right : .left + emptyStateView.activityIndicatorView.startAnimating() + return emptyStateView + } + .previewLayout(.fixed(width: 375, height: 400)) + } + } +} +#endif diff --git a/Mastodon/Scene/Onboarding/PinBasedAuthentication/MastodonPinBasedAuthenticationViewController.swift b/Mastodon/Scene/Onboarding/PinBasedAuthentication/MastodonPinBasedAuthenticationViewController.swift index fa57ddfd4..d566da4c3 100644 --- a/Mastodon/Scene/Onboarding/PinBasedAuthentication/MastodonPinBasedAuthenticationViewController.swift +++ b/Mastodon/Scene/Onboarding/PinBasedAuthentication/MastodonPinBasedAuthenticationViewController.swift @@ -39,8 +39,6 @@ final class MastodonPinBasedAuthenticationViewController: UIViewController, Need } - - extension MastodonPinBasedAuthenticationViewController { override func viewDidLoad() { diff --git a/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController+Avatar.swift b/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController+Avatar.swift new file mode 100644 index 000000000..b1fa1b432 --- /dev/null +++ b/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController+Avatar.swift @@ -0,0 +1,131 @@ +// +// MastodonRegisterViewController+Avatar.swift +// Mastodon +// +// Created by sxiaojian on 2021/3/2. +// + +import CropViewController +import Foundation +import OSLog +import PhotosUI +import UIKit + +extension MastodonRegisterViewController { + func createMediaContextMenu() -> UIMenu { + var children: [UIMenuElement] = [] + let photoLibraryAction = UIAction(title: L10n.Scene.Compose.MediaSelection.photoLibrary, image: UIImage(systemName: "rectangle.on.rectangle"), identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off) { [weak self] _ in + guard let self = self else { return } + self.present(self.imagePicker, animated: true, completion: nil) + } + children.append(photoLibraryAction) + if UIImagePickerController.isSourceTypeAvailable(.camera) { + let cameraAction = UIAction(title: L10n.Scene.Compose.MediaSelection.camera, image: UIImage(systemName: "camera"), identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off, handler: { [weak self] _ in + guard let self = self else { return } + self.present(self.imagePickerController, animated: true, completion: nil) + }) + children.append(cameraAction) + } + let browseAction = UIAction(title: L10n.Scene.Compose.MediaSelection.browse, image: UIImage(systemName: "ellipsis"), identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off) { [weak self] _ in + guard let self = self else { return } + self.present(self.documentPickerController, animated: true, completion: nil) + } + children.append(browseAction) + if self.viewModel.avatarImage.value != nil { + let deleteAction = UIAction(title: L10n.Scene.Register.Input.Avatar.delete, image: UIImage(systemName: "delete.left"), identifier: nil, discoverabilityTitle: nil, attributes: [.destructive], state: .off) { [weak self] _ in + guard let self = self else { return } + self.viewModel.avatarImage.value = nil + } + children.append(deleteAction) + } + + return UIMenu(title: "", image: nil, identifier: nil, options: .displayInline, children: children) + } + + private func cropImage(image: UIImage, pickerViewController: UIViewController) { + DispatchQueue.main.async { + let cropController = CropViewController(croppingStyle: .default, image: image) + cropController.delegate = self + cropController.setAspectRatioPreset(.presetSquare, animated: true) + cropController.aspectRatioPickerButtonHidden = true + cropController.aspectRatioLockEnabled = true + pickerViewController.dismiss(animated: true, completion: { + self.present(cropController, animated: true, completion: nil) + }) + } + } +} + +// MARK: - PHPickerViewControllerDelegate + +extension MastodonRegisterViewController: PHPickerViewControllerDelegate { + func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) { + guard let itemProvider = results.first?.itemProvider, itemProvider.canLoadObject(ofClass: UIImage.self) else { + picker.dismiss(animated: true, completion: {}) + return + } + itemProvider.loadObject(ofClass: UIImage.self) { [weak self] image, error in + guard let self = self else { return } + guard let image = image as? UIImage else { + DispatchQueue.main.async { + guard let error = error else { return } + let alertController = UIAlertController(for: error, title: "", preferredStyle: .alert) + let okAction = UIAlertAction(title: L10n.Common.Controls.Actions.ok, style: .default, handler: nil) + alertController.addAction(okAction) + self.coordinator.present( + scene: .alertController(alertController: alertController), + from: nil, + transition: .alertController(animated: true, completion: nil) + ) + } + return + } + self.cropImage(image: image, pickerViewController: picker) + } + } +} + +// MARK: - UIImagePickerControllerDelegate + +extension MastodonRegisterViewController: UIImagePickerControllerDelegate & UINavigationControllerDelegate { + func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) { + picker.dismiss(animated: true, completion: nil) + + guard let image = info[.originalImage] as? UIImage else { return } + + cropImage(image: image, pickerViewController: picker) + } + + func imagePickerControllerDidCancel(_ picker: UIImagePickerController) { + os_log("%{public}s[%{public}ld], %{public}s", (#file as NSString).lastPathComponent, #line, #function) + picker.dismiss(animated: true, completion: nil) + } +} + +// MARK: - UIDocumentPickerDelegate + +extension MastodonRegisterViewController: UIDocumentPickerDelegate { + func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) { + guard let url = urls.first else { return } + + do { + guard url.startAccessingSecurityScopedResource() else { return } + defer { url.stopAccessingSecurityScopedResource() } + let imageData = try Data(contentsOf: url) + guard let image = UIImage(data: imageData) else { return } + cropImage(image: image, pickerViewController: controller) + } catch { + os_log("%{public}s[%{public}ld], %{public}s: %s", (#file as NSString).lastPathComponent, #line, #function, error.localizedDescription) + } + } +} + +// MARK: - CropViewControllerDelegate + +extension MastodonRegisterViewController: CropViewControllerDelegate { + public func cropViewController(_ cropViewController: CropViewController, didCropToImage image: UIImage, withRect cropRect: CGRect, angle: Int) { + self.viewModel.avatarImage.value = image + cropViewController.dismiss(animated: true, completion: nil) + } +} + diff --git a/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController.swift b/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController.swift index ff979c3dd..b6214d4e7 100644 --- a/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController.swift +++ b/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController.swift @@ -5,13 +5,20 @@ // Created by MainasuK Cirno on 2021-2-5. // +import AlamofireImage import Combine import MastodonSDK import os.log +import PhotosUI import UIKit -import UITextField_Shake final class MastodonRegisterViewController: UIViewController, NeedsDependency, OnboardingViewControllerAppearance { + + static let avatarImageMaxSizeInPixel = CGSize(width: 400, height: 400) + + static let textFieldLabelFont = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 17, weight: .semibold), maximumPointSize: 22) + static let errorPromptLabelFont = UIFontMetrics(forTextStyle: .caption1).scaledFont(for: .systemFont(ofSize: 13, weight: .semibold), maximumPointSize: 18) + var disposeBag = Set() weak var context: AppContext! { willSet { precondition(!isViewLoaded) } } @@ -19,6 +26,29 @@ final class MastodonRegisterViewController: UIViewController, NeedsDependency, O var viewModel: MastodonRegisterViewModel! + // picker + private(set) lazy var imagePicker: PHPickerViewController = { + var configuration = PHPickerConfiguration() + configuration.filter = .images + configuration.selectionLimit = 1 + + let imagePicker = PHPickerViewController(configuration: configuration) + imagePicker.delegate = self + return imagePicker + }() + private(set) lazy var imagePickerController: UIImagePickerController = { + let imagePickerController = UIImagePickerController() + imagePickerController.sourceType = .camera + imagePickerController.delegate = self + return imagePickerController + }() + + private(set) lazy var documentPickerController: UIDocumentPickerViewController = { + let documentPickerController = UIDocumentPickerViewController(forOpeningContentTypes: [.image]) + documentPickerController.delegate = self + return documentPickerController + }() + let tapGestureRecognizer = UITapGestureRecognizer.singleTapGestureRecognizer let scrollView: UIScrollView = { @@ -26,7 +56,7 @@ final class MastodonRegisterViewController: UIViewController, NeedsDependency, O scrollview.showsVerticalScrollIndicator = false scrollview.keyboardDismissMode = .interactive scrollview.alwaysBounceVertical = true - scrollview.clipsToBounds = false // make content could display over bleeding + scrollview.clipsToBounds = false // make content could display over bleeding scrollview.translatesAutoresizingMaskIntoConstraints = false return scrollview }() @@ -34,55 +64,47 @@ final class MastodonRegisterViewController: UIViewController, NeedsDependency, O let largeTitleLabel: UILabel = { let label = UILabel() label.font = UIFontMetrics(forTextStyle: .largeTitle).scaledFont(for: UIFont.boldSystemFont(ofSize: 34)) - label.textColor = .black + label.textColor = Asset.Colors.Label.primary.color label.text = L10n.Scene.Register.title + label.numberOfLines = 0 return label }() - let photoView: UIView = { + let avatarView: UIView = { let view = UIView() view.backgroundColor = .clear return view }() - let photoButton: UIButton = { - let button = UIButton(type: .custom) + let avatarButton: UIButton = { + let button = HighlightDimmableButton() let boldFont = UIFont.systemFont(ofSize: 42) let configuration = UIImage.SymbolConfiguration(font: boldFont) let image = UIImage(systemName: "person.fill.viewfinder", withConfiguration: configuration) button.setImage(image?.withRenderingMode(UIImage.RenderingMode.alwaysTemplate), for: UIControl.State.normal) - button.imageView?.tintColor = Asset.Colors.Icon.photo.color - button.backgroundColor = .white - button.layer.cornerRadius = 45 + button.imageView?.tintColor = Asset.Colors.Label.secondary.color + button.backgroundColor = Asset.Colors.Background.secondaryGroupedSystemBackground.color + button.layer.cornerRadius = 10 button.clipsToBounds = true + return button }() - let plusIconBackground: UIImageView = { + let plusIconImageView: UIImageView = { let icon = UIImageView() - let boldFont = UIFont.systemFont(ofSize: 24) - let configuration = UIImage.SymbolConfiguration(font: boldFont) - let image = UIImage(systemName: "plus.circle", withConfiguration: configuration) - icon.image = image - icon.tintColor = .white - return icon - }() - - let plusIcon: UIImageView = { - let icon = UIImageView() - let boldFont = UIFont.systemFont(ofSize: 24) - let configuration = UIImage.SymbolConfiguration(font: boldFont) - let image = UIImage(systemName: "plus.circle.fill", withConfiguration: configuration) + + let image = Asset.Circles.plusCircleFill.image.withRenderingMode(.alwaysTemplate) icon.image = image icon.tintColor = Asset.Colors.Icon.plus.color + icon.backgroundColor = Asset.Colors.Background.systemGroupedBackground.color return icon }() let domainLabel: UILabel = { let label = UILabel() - label.font = .preferredFont(forTextStyle: .headline) - label.textColor = .black + label.font = MastodonRegisterViewController.textFieldLabelFont + label.textColor = Asset.Colors.Label.primary.color return label }() @@ -91,11 +113,11 @@ final class MastodonRegisterViewController: UIViewController, NeedsDependency, O textField.autocapitalizationType = .none textField.autocorrectionType = .no - textField.backgroundColor = .white + textField.backgroundColor = Asset.Colors.Background.secondaryGroupedSystemBackground.color textField.textColor = Asset.Colors.Label.primary.color textField.attributedPlaceholder = NSAttributedString(string: L10n.Scene.Register.Input.Username.placeholder, attributes: [NSAttributedString.Key.foregroundColor: Asset.Colors.Label.secondary.color, - NSAttributedString.Key.font: UIFont.preferredFont(forTextStyle: .headline)]) + NSAttributedString.Key.font: MastodonRegisterViewController.textFieldLabelFont]) textField.borderStyle = UITextField.BorderStyle.roundedRect let paddingView = UIView(frame: CGRect(x: 0, y: 0, width: 5, height: textField.frame.height)) textField.leftView = paddingView @@ -103,8 +125,10 @@ final class MastodonRegisterViewController: UIViewController, NeedsDependency, O return textField }() - let usernameIsTakenLabel: UILabel = { + let usernameErrorPromptLabel: UILabel = { let label = UILabel() + let color = Asset.Colors.danger.color + let font = MastodonRegisterViewController.errorPromptLabelFont return label }() @@ -112,11 +136,11 @@ final class MastodonRegisterViewController: UIViewController, NeedsDependency, O let textField = UITextField() textField.autocapitalizationType = .none textField.autocorrectionType = .no - textField.backgroundColor = .white + textField.backgroundColor = Asset.Colors.Background.secondaryGroupedSystemBackground.color textField.textColor = Asset.Colors.Label.primary.color textField.attributedPlaceholder = NSAttributedString(string: L10n.Scene.Register.Input.DisplayName.placeholder, attributes: [NSAttributedString.Key.foregroundColor: Asset.Colors.Label.secondary.color, - NSAttributedString.Key.font: UIFont.preferredFont(forTextStyle: .headline)]) + NSAttributedString.Key.font: MastodonRegisterViewController.textFieldLabelFont]) textField.borderStyle = UITextField.BorderStyle.roundedRect let paddingView = UIView(frame: CGRect(x: 0, y: 0, width: 5, height: textField.frame.height)) textField.leftView = paddingView @@ -129,11 +153,36 @@ final class MastodonRegisterViewController: UIViewController, NeedsDependency, O textField.autocapitalizationType = .none textField.autocorrectionType = .no textField.keyboardType = .emailAddress - textField.backgroundColor = .white + textField.backgroundColor = Asset.Colors.Background.secondaryGroupedSystemBackground.color textField.textColor = Asset.Colors.Label.primary.color textField.attributedPlaceholder = NSAttributedString(string: L10n.Scene.Register.Input.Email.placeholder, attributes: [NSAttributedString.Key.foregroundColor: Asset.Colors.Label.secondary.color, - NSAttributedString.Key.font: UIFont.preferredFont(forTextStyle: .headline)]) + NSAttributedString.Key.font: MastodonRegisterViewController.textFieldLabelFont]) + textField.borderStyle = UITextField.BorderStyle.roundedRect + let paddingView = UIView(frame: CGRect(x: 0, y: 0, width: 5, height: textField.frame.height)) + textField.leftView = paddingView + textField.leftViewMode = .always + return textField + }() + + let emailErrorPromptLabel: UILabel = { + let label = UILabel() + let color = Asset.Colors.danger.color + let font = MastodonRegisterViewController.errorPromptLabelFont + return label + }() + + let passwordTextField: UITextField = { + let textField = UITextField() + textField.autocapitalizationType = .none + textField.autocorrectionType = .no + textField.keyboardType = .asciiCapable + textField.isSecureTextEntry = true + textField.backgroundColor = Asset.Colors.Background.secondaryGroupedSystemBackground.color + textField.textColor = Asset.Colors.Label.primary.color + textField.attributedPlaceholder = NSAttributedString(string: L10n.Scene.Register.Input.Password.placeholder, + attributes: [NSAttributedString.Key.foregroundColor: Asset.Colors.Label.secondary.color, + NSAttributedString.Key.font: MastodonRegisterViewController.textFieldLabelFont]) textField.borderStyle = UITextField.BorderStyle.roundedRect let paddingView = UIView(frame: CGRect(x: 0, y: 0, width: 5, height: textField.frame.height)) textField.leftView = paddingView @@ -147,17 +196,23 @@ final class MastodonRegisterViewController: UIViewController, NeedsDependency, O return label }() - let passwordTextField: UITextField = { + let passwordErrorPromptLabel: UILabel = { + let label = UILabel() + let color = Asset.Colors.danger.color + let font = MastodonRegisterViewController.errorPromptLabelFont + return label + }() + + + lazy var reasonTextField: UITextField = { let textField = UITextField() textField.autocapitalizationType = .none textField.autocorrectionType = .no - textField.keyboardType = .asciiCapable - textField.isSecureTextEntry = true - textField.backgroundColor = .white + textField.backgroundColor = Asset.Colors.Background.secondaryGroupedSystemBackground.color textField.textColor = Asset.Colors.Label.primary.color - textField.attributedPlaceholder = NSAttributedString(string: L10n.Scene.Register.Input.Password.placeholder, + textField.attributedPlaceholder = NSAttributedString(string: L10n.Scene.Register.Input.Invite.registrationUserInviteRequest, attributes: [NSAttributedString.Key.foregroundColor: Asset.Colors.Label.secondary.color, - NSAttributedString.Key.font: UIFont.preferredFont(forTextStyle: .headline)]) + NSAttributedString.Key.font: MastodonRegisterViewController.textFieldLabelFont]) textField.borderStyle = UITextField.BorderStyle.roundedRect let paddingView = UIView(frame: CGRect(x: 0, y: 0, width: 5, height: textField.frame.height)) textField.leftView = paddingView @@ -165,20 +220,11 @@ final class MastodonRegisterViewController: UIViewController, NeedsDependency, O return textField }() - lazy var inviteTextField: UITextField = { - let textField = UITextField() - textField.autocapitalizationType = .none - textField.autocorrectionType = .no - textField.backgroundColor = .white - textField.textColor = Asset.Colors.Label.primary.color - textField.attributedPlaceholder = NSAttributedString(string: L10n.Scene.Register.Input.Invite.registrationUserInviteRequest, - attributes: [NSAttributedString.Key.foregroundColor: Asset.Colors.Label.secondary.color, - NSAttributedString.Key.font: UIFont.preferredFont(forTextStyle: .headline)]) - textField.borderStyle = UITextField.BorderStyle.roundedRect - let paddingView = UIView(frame: CGRect(x: 0, y: 0, width: 5, height: textField.frame.height)) - textField.leftView = paddingView - textField.leftViewMode = .always - return textField + let reasonErrorPromptLabel: UILabel = { + let label = UILabel() + let color = Asset.Colors.danger.color + let font = MastodonRegisterViewController.errorPromptLabelFont + return label }() let buttonContainer = UIView() @@ -190,9 +236,8 @@ final class MastodonRegisterViewController: UIViewController, NeedsDependency, O }() deinit { - os_log(.info, log: .debug, "%{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) } - } extension MastodonRegisterViewController { @@ -203,9 +248,12 @@ extension MastodonRegisterViewController { setupOnboardingAppearance() defer { setupNavigationBarBackgroundView() } + avatarButton.menu = createMediaContextMenu() + avatarButton.showsMenuAsPrimaryAction = true + domainLabel.text = "@" + viewModel.domain + " " domainLabel.sizeToFit() - passwordCheckLabel.attributedText = viewModel.attributeStringForPassword() + passwordCheckLabel.attributedText = MastodonRegisterViewModel.attributeStringForPassword(validateState: .empty) usernameTextField.rightView = domainLabel usernameTextField.rightViewMode = .always usernameTextField.delegate = self @@ -225,15 +273,40 @@ extension MastodonRegisterViewController { stackView.layoutMargins = UIEdgeInsets(top: 20, left: 0, bottom: 26, right: 0) stackView.isLayoutMarginsRelativeArrangement = true stackView.addArrangedSubview(largeTitleLabel) - stackView.addArrangedSubview(photoView) + stackView.addArrangedSubview(avatarView) stackView.addArrangedSubview(usernameTextField) stackView.addArrangedSubview(displayNameTextField) stackView.addArrangedSubview(emailTextField) stackView.addArrangedSubview(passwordTextField) stackView.addArrangedSubview(passwordCheckLabel) - if self.viewModel.approvalRequired { - stackView.addArrangedSubview(inviteTextField) + if viewModel.approvalRequired { + stackView.addArrangedSubview(reasonTextField) } + + usernameErrorPromptLabel.translatesAutoresizingMaskIntoConstraints = false + stackView.addSubview(usernameErrorPromptLabel) + NSLayoutConstraint.activate([ + usernameErrorPromptLabel.topAnchor.constraint(equalTo: usernameTextField.bottomAnchor, constant: 6), + usernameErrorPromptLabel.leadingAnchor.constraint(equalTo: usernameTextField.leadingAnchor), + usernameErrorPromptLabel.trailingAnchor.constraint(equalTo: usernameTextField.trailingAnchor), + ]) + + emailErrorPromptLabel.translatesAutoresizingMaskIntoConstraints = false + stackView.addSubview(emailErrorPromptLabel) + NSLayoutConstraint.activate([ + emailErrorPromptLabel.topAnchor.constraint(equalTo: emailTextField.bottomAnchor, constant: 6), + emailErrorPromptLabel.leadingAnchor.constraint(equalTo: emailTextField.leadingAnchor), + emailErrorPromptLabel.trailingAnchor.constraint(equalTo: emailTextField.trailingAnchor), + ]) + + passwordErrorPromptLabel.translatesAutoresizingMaskIntoConstraints = false + stackView.addSubview(passwordErrorPromptLabel) + NSLayoutConstraint.activate([ + passwordErrorPromptLabel.topAnchor.constraint(equalTo: passwordCheckLabel.bottomAnchor, constant: 2), + passwordErrorPromptLabel.leadingAnchor.constraint(equalTo: passwordTextField.leadingAnchor), + passwordErrorPromptLabel.trailingAnchor.constraint(equalTo: passwordTextField.trailingAnchor), + ]) + // scrollView view.addSubview(scrollView) NSLayoutConstraint.activate([ @@ -256,29 +329,24 @@ extension MastodonRegisterViewController { ]) // photoview - photoView.translatesAutoresizingMaskIntoConstraints = false - photoView.addSubview(photoButton) + avatarView.translatesAutoresizingMaskIntoConstraints = false + avatarView.addSubview(avatarButton) NSLayoutConstraint.activate([ - photoView.heightAnchor.constraint(equalToConstant: 90).priority(.defaultHigh), + avatarView.heightAnchor.constraint(equalToConstant: 90).priority(.defaultHigh), ]) - photoButton.translatesAutoresizingMaskIntoConstraints = false + avatarButton.translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ - photoButton.heightAnchor.constraint(equalToConstant: 90).priority(.defaultHigh), - photoButton.widthAnchor.constraint(equalToConstant: 90).priority(.defaultHigh), - photoButton.centerXAnchor.constraint(equalTo: photoView.centerXAnchor), - photoButton.centerYAnchor.constraint(equalTo: photoView.centerYAnchor), + avatarButton.heightAnchor.constraint(equalToConstant: 92).priority(.defaultHigh), + avatarButton.widthAnchor.constraint(equalToConstant: 92).priority(.defaultHigh), + avatarButton.centerXAnchor.constraint(equalTo: avatarView.centerXAnchor), + avatarButton.centerYAnchor.constraint(equalTo: avatarView.centerYAnchor), ]) - plusIconBackground.translatesAutoresizingMaskIntoConstraints = false - photoView.addSubview(plusIconBackground) + + plusIconImageView.translatesAutoresizingMaskIntoConstraints = false + avatarView.addSubview(plusIconImageView) NSLayoutConstraint.activate([ - plusIconBackground.trailingAnchor.constraint(equalTo: photoButton.trailingAnchor), - plusIconBackground.bottomAnchor.constraint(equalTo: photoButton.bottomAnchor), - ]) - plusIcon.translatesAutoresizingMaskIntoConstraints = false - photoView.addSubview(plusIcon) - NSLayoutConstraint.activate([ - plusIcon.trailingAnchor.constraint(equalTo: photoButton.trailingAnchor), - plusIcon.bottomAnchor.constraint(equalTo: photoButton.bottomAnchor), + plusIconImageView.centerXAnchor.constraint(equalTo: avatarButton.trailingAnchor), + plusIconImageView.centerYAnchor.constraint(equalTo: avatarButton.bottomAnchor), ]) // textfield @@ -292,6 +360,14 @@ extension MastodonRegisterViewController { // password stackView.setCustomSpacing(6, after: passwordTextField) stackView.setCustomSpacing(32, after: passwordCheckLabel) + + //return + if viewModel.approvalRequired { + passwordTextField.returnKeyType = .continue + } else { + passwordTextField.returnKeyType = .done + } + reasonTextField.returnKeyType = .done // button stackView.addArrangedSubview(buttonContainer) @@ -307,7 +383,7 @@ extension MastodonRegisterViewController { Publishers.CombineLatest( KeyboardResponderService.shared.state.eraseToAnyPublisher(), - KeyboardResponderService.shared.willEndFrame.eraseToAnyPublisher() + KeyboardResponderService.shared.endFrame.eraseToAnyPublisher() ) .sink(receiveValue: { [weak self] state, endFrame in guard let self = self else { return } @@ -339,6 +415,15 @@ extension MastodonRegisterViewController { } }) .store(in: &disposeBag) + + avatarButton.publisher(for: \.isHighlighted, options: .new) + .receive(on: DispatchQueue.main) + .sink { [weak self] isHighlighted in + guard let self = self else { return } + let alpha: CGFloat = isHighlighted ? 0.6 : 1 + self.plusIconImageView.alpha = alpha + } + .store(in: &disposeBag) viewModel.isRegistering .receive(on: DispatchQueue.main) @@ -355,6 +440,13 @@ extension MastodonRegisterViewController { self.setTextFieldValidAppearance(self.usernameTextField, validateState: validateState) } .store(in: &disposeBag) + viewModel.usernameErrorPrompt + .receive(on: DispatchQueue.main) + .sink { [weak self] prompt in + guard let self = self else { return } + self.usernameErrorPromptLabel.attributedText = prompt + } + .store(in: &disposeBag) viewModel.displayNameValidateState .receive(on: DispatchQueue.main) .sink { [weak self] validateState in @@ -369,29 +461,49 @@ extension MastodonRegisterViewController { self.setTextFieldValidAppearance(self.emailTextField, validateState: validateState) } .store(in: &disposeBag) + viewModel.emailErrorPrompt + .receive(on: DispatchQueue.main) + .sink { [weak self] prompt in + guard let self = self else { return } + self.emailErrorPromptLabel.attributedText = prompt + } + .store(in: &disposeBag) viewModel.passwordValidateState .receive(on: DispatchQueue.main) .sink { [weak self] validateState in guard let self = self else { return } self.setTextFieldValidAppearance(self.passwordTextField, validateState: validateState) - self.passwordCheckLabel.attributedText = self.viewModel.attributeStringForPassword(eightCharacters: validateState == .valid) - + self.passwordCheckLabel.attributedText = MastodonRegisterViewModel.attributeStringForPassword(validateState: validateState) + } + .store(in: &disposeBag) + viewModel.passwordErrorPrompt + .receive(on: DispatchQueue.main) + .sink { [weak self] prompt in + guard let self = self else { return } + self.passwordErrorPromptLabel.attributedText = prompt + } + .store(in: &disposeBag) + viewModel.reasonErrorPrompt + .receive(on: DispatchQueue.main) + .sink { [weak self] prompt in + guard let self = self else { return } + self.reasonErrorPromptLabel.attributedText = prompt } .store(in: &disposeBag) viewModel.isAllValid - .receive(on: DispatchQueue.main) - .sink { [weak self] isAllValid in - guard let self = self else { return } - self.signUpButton.isEnabled = isAllValid - } - .store(in: &disposeBag) + .receive(on: DispatchQueue.main) + .sink { [weak self] isAllValid in + guard let self = self else { return } + self.signUpButton.isEnabled = isAllValid + } + .store(in: &disposeBag) viewModel.error - .compactMap { $0 } .receive(on: DispatchQueue.main) .sink { [weak self] error in guard let self = self else { return } + guard let error = error as? Mastodon.API.Error else { return } let alertController = UIAlertController(for: error, title: "Sign Up Failure", preferredStyle: .alert) let okAction = UIAlertAction(title: L10n.Common.Controls.Actions.ok, style: .default, handler: nil) alertController.addAction(okAction) @@ -403,6 +515,21 @@ extension MastodonRegisterViewController { } .store(in: &disposeBag) + viewModel.avatarImage + .receive(on: DispatchQueue.main) + .sink{ [weak self] image in + guard let self = self else { return } + self.avatarButton.menu = self.createMediaContextMenu() + if let avatar = image { + self.avatarButton.setImage(avatar, for: .normal) + } else { + let boldFont = UIFont.systemFont(ofSize: 42) + let configuration = UIImage.SymbolConfiguration(font: boldFont) + let image = UIImage(systemName: "person.fill.viewfinder", withConfiguration: configuration) + self.avatarButton.setImage(image?.withRenderingMode(UIImage.RenderingMode.alwaysTemplate), for: UIControl.State.normal) + } + } + .store(in: &disposeBag) NotificationCenter.default .publisher(for: UITextField.textDidChangeNotification, object: usernameTextField) .receive(on: DispatchQueue.main) @@ -439,27 +566,32 @@ extension MastodonRegisterViewController { } .store(in: &disposeBag) - if self.viewModel.approvalRequired { - - inviteTextField.delegate = self + if viewModel.approvalRequired { + reasonTextField.delegate = self NSLayoutConstraint.activate([ - inviteTextField.heightAnchor.constraint(equalToConstant: 50).priority(.defaultHigh) + reasonTextField.heightAnchor.constraint(equalToConstant: 50).priority(.defaultHigh), + ]) + reasonErrorPromptLabel.translatesAutoresizingMaskIntoConstraints = false + stackView.addSubview(reasonErrorPromptLabel) + NSLayoutConstraint.activate([ + reasonErrorPromptLabel.topAnchor.constraint(equalTo: reasonTextField.bottomAnchor, constant: 6), + reasonErrorPromptLabel.leadingAnchor.constraint(equalTo: reasonTextField.leadingAnchor), + reasonErrorPromptLabel.trailingAnchor.constraint(equalTo: reasonTextField.trailingAnchor), ]) - viewModel.inviteValidateState + viewModel.reasonValidateState .receive(on: DispatchQueue.main) .sink { [weak self] validateState in guard let self = self else { return } - self.setTextFieldValidAppearance(self.inviteTextField, validateState: validateState) - + self.setTextFieldValidAppearance(self.reasonTextField, validateState: validateState) } .store(in: &disposeBag) NotificationCenter.default - .publisher(for: UITextField.textDidChangeNotification, object: inviteTextField) + .publisher(for: UITextField.textDidChangeNotification, object: reasonTextField) .receive(on: DispatchQueue.main) .sink { [weak self] _ in guard let self = self else { return } - self.viewModel.reason.value = self.inviteTextField.text?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + self.viewModel.reason.value = self.reasonTextField.text?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" } .store(in: &disposeBag) } @@ -467,10 +599,15 @@ extension MastodonRegisterViewController { signUpButton.addTarget(self, action: #selector(MastodonRegisterViewController.signUpButtonPressed(_:)), for: .touchUpInside) } + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + plusIconImageView.layer.cornerRadius = plusIconImageView.frame.width / 2 + plusIconImageView.layer.masksToBounds = true + } + } extension MastodonRegisterViewController: UITextFieldDelegate { - func textFieldDidBeginEditing(_ textField: UITextField) { let text = textField.text?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" @@ -483,13 +620,35 @@ extension MastodonRegisterViewController: UITextFieldDelegate { viewModel.email.value = text case passwordTextField: viewModel.password.value = text - case inviteTextField: + case reasonTextField: viewModel.reason.value = text default: break } } + func textFieldShouldReturn(_ textField: UITextField) -> Bool { + switch textField { + case usernameTextField: + displayNameTextField.becomeFirstResponder() + case displayNameTextField: + emailTextField.becomeFirstResponder() + case emailTextField: + passwordTextField.becomeFirstResponder() + case passwordTextField: + if viewModel.approvalRequired { + reasonTextField.becomeFirstResponder() + } else { + passwordTextField.resignFirstResponder() + } + case reasonTextField: + reasonTextField.resignFirstResponder() + default: + break + } + return true + } + func showShadowWithColor(color: UIColor, textField: UITextField) { // To apply Shadow textField.layer.shadowOpacity = 1 @@ -512,7 +671,6 @@ extension MastodonRegisterViewController: UITextFieldDelegate { } extension MastodonRegisterViewController { - @objc private func tapGestureRecognizerHandler(_ sender: UITapGestureRecognizer) { view.endEditing(true) } @@ -532,49 +690,46 @@ extension MastodonRegisterViewController { username: username, email: email, password: password, - agreement: true, // TODO: - locale: "en" // TODO: + agreement: true, // user confirmed in the server rules scene + locale: Locale.current.languageCode ?? "en" ) - - if let rules = viewModel.instance.rules, !rules.isEmpty { - // show server rules before register - let mastodonServerRulesViewModel = MastodonServerRulesViewModel( - context: context, - domain: viewModel.domain, - authenticateInfo: viewModel.authenticateInfo, - rules: rules, - registerQuery: query, - applicationAuthorization: viewModel.applicationAuthorization - ) - - viewModel.isRegistering.value = false - view.endEditing(true) - coordinator.present(scene: .mastodonServerRules(viewModel: mastodonServerRulesViewModel), from: self, transition: .show) - return - } else { - // register without show server rules - context.apiService.accountRegister( - domain: viewModel.domain, - query: query, - authorization: viewModel.applicationAuthorization - ) - .receive(on: DispatchQueue.main) - .sink { [weak self] completion in - guard let self = self else { return } - self.viewModel.isRegistering.value = false - switch completion { - case .failure(let error): - self.viewModel.error.send(error) - case .finished: - break - } - } receiveValue: { [weak self] response in - guard let self = self else { return } - let userToken = response.value - let viewModel = MastodonConfirmEmailViewModel(context: self.context, email: email, authenticateInfo: self.viewModel.authenticateInfo, userToken: userToken) - self.coordinator.present(scene: .mastodonConfirmEmail(viewModel: viewModel), from: self, transition: .show) + + // register without show server rules + context.apiService.accountRegister( + domain: viewModel.domain, + query: query, + authorization: viewModel.applicationAuthorization + ) + .receive(on: DispatchQueue.main) + .sink { [weak self] completion in + guard let self = self else { return } + self.viewModel.isRegistering.value = false + switch completion { + case .failure(let error): + self.viewModel.error.send(error) + case .finished: + break } - .store(in: &disposeBag) + } receiveValue: { [weak self] response in + guard let self = self else { return } + let userToken = response.value + let updateCredentialQuery: Mastodon.API.Account.UpdateCredentialQuery = { + let displayName: String? = self.viewModel.displayName.value.isEmpty ? nil : self.viewModel.displayName.value + let avatar: Mastodon.Query.MediaAttachment? = { + guard let avatarImage = self.viewModel.avatarImage.value else { return nil } + guard avatarImage.size.width <= MastodonRegisterViewController.avatarImageMaxSizeInPixel.width else { + return .png(avatarImage.af.imageScaled(to: MastodonRegisterViewController.avatarImageMaxSizeInPixel).pngData()) + } + return .png(avatarImage.pngData()) + }() + return Mastodon.API.Account.UpdateCredentialQuery( + displayName: displayName, + avatar: avatar + ) + }() + let viewModel = MastodonConfirmEmailViewModel(context: self.context, email: email, authenticateInfo: self.viewModel.authenticateInfo, userToken: userToken, updateCredentialQuery: updateCredentialQuery) + self.coordinator.present(scene: .mastodonConfirmEmail(viewModel: viewModel), from: self, transition: .show) } + .store(in: &disposeBag) } } diff --git a/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewModel.swift b/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewModel.swift index 5a9098347..85b934a25 100644 --- a/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewModel.swift +++ b/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewModel.swift @@ -18,12 +18,19 @@ final class MastodonRegisterViewModel { let authenticateInfo: AuthenticationViewModel.AuthenticateInfo let instance: Mastodon.Entity.Instance let applicationToken: Mastodon.Entity.Token + let context: AppContext let username = CurrentValueSubject("") let displayName = CurrentValueSubject("") let email = CurrentValueSubject("") let password = CurrentValueSubject("") let reason = CurrentValueSubject("") + let avatarImage = CurrentValueSubject(nil) + + let usernameErrorPrompt = CurrentValueSubject(nil) + let emailErrorPrompt = CurrentValueSubject(nil) + let passwordErrorPrompt = CurrentValueSubject(nil) + let reasonErrorPrompt = CurrentValueSubject(nil) // output let approvalRequired: Bool @@ -32,19 +39,21 @@ final class MastodonRegisterViewModel { let displayNameValidateState = CurrentValueSubject(.empty) let emailValidateState = CurrentValueSubject(.empty) let passwordValidateState = CurrentValueSubject(.empty) - let inviteValidateState = CurrentValueSubject(.empty) - + let reasonValidateState = CurrentValueSubject(.empty) + let isRegistering = CurrentValueSubject(false) let isAllValid = CurrentValueSubject(false) let error = CurrentValueSubject(nil) init( domain: String, + context: AppContext, authenticateInfo: AuthenticationViewModel.AuthenticateInfo, instance: Mastodon.Entity.Instance, applicationToken: Mastodon.Entity.Token ) { self.domain = domain + self.context = context self.authenticateInfo = authenticateInfo self.instance = instance self.applicationToken = applicationToken @@ -72,6 +81,45 @@ final class MastodonRegisterViewModel { } .assign(to: \.value, on: usernameValidateState) .store(in: &disposeBag) + + username + .filter { !$0.isEmpty } + .debounce(for: .milliseconds(300), scheduler: DispatchQueue.main) + .removeDuplicates() + .compactMap { [weak self] text -> AnyPublisher, Error>, Never>? in + guard let self = self else { return nil } + let query = Mastodon.API.Account.AccountLookupQuery(acct: text) + return context.apiService.accountLookup(domain: domain, query: query, authorization: self.applicationAuthorization) + .map { + response -> Result, Error> in + Result.success(response) + } + .catch { error in + Just(Result.failure(error)) + } + .eraseToAnyPublisher() + } + .switchToLatest() + .sink { [weak self] result in + guard let self = self else { return } + switch result { + case .success: + let text = L10n.Scene.Register.Error.Reason.taken(L10n.Scene.Register.Error.Item.username) + self.usernameErrorPrompt.value = MastodonRegisterViewModel.errorPromptAttributedString(for: text) + case .failure: + break + } + } + .store(in: &disposeBag) + + usernameValidateState + .sink { [weak self] validateState in + if validateState == .valid { + self?.usernameErrorPrompt.value = nil + } + } + .store(in: &disposeBag) + displayName .map { displayname in guard !displayname.isEmpty else { return .empty } @@ -99,25 +147,44 @@ final class MastodonRegisterViewModel { guard !invite.isEmpty else { return .empty } return .valid } - .assign(to: \.value, on: inviteValidateState) + .assign(to: \.value, on: reasonValidateState) .store(in: &disposeBag) } + + error + .sink { [weak self] error in + guard let self = self else { return } + let error = error as? Mastodon.API.Error + let mastodonError = error?.mastodonError + if case let .generic(genericMastodonError) = mastodonError, + let details = genericMastodonError.details + { + self.usernameErrorPrompt.value = details.usernameErrorDescriptions.first.flatMap { MastodonRegisterViewModel.errorPromptAttributedString(for: $0) } + self.emailErrorPrompt.value = details.emailErrorDescriptions.first.flatMap { MastodonRegisterViewModel.errorPromptAttributedString(for: $0) } + self.passwordErrorPrompt.value = details.passwordErrorDescriptions.first.flatMap { MastodonRegisterViewModel.errorPromptAttributedString(for: $0) } + self.reasonErrorPrompt.value = details.reasonErrorDescriptions.first.flatMap { MastodonRegisterViewModel.errorPromptAttributedString(for: $0) } + } else { + self.usernameErrorPrompt.value = nil + self.emailErrorPrompt.value = nil + self.passwordErrorPrompt.value = nil + self.reasonErrorPrompt.value = nil + } + } + .store(in: &disposeBag) + let publisherOne = Publishers.CombineLatest4( usernameValidateState.eraseToAnyPublisher(), displayNameValidateState.eraseToAnyPublisher(), emailValidateState.eraseToAnyPublisher(), passwordValidateState.eraseToAnyPublisher() - ).map { - $0.0 == .valid && $0.1 == .valid && $0.2 == .valid && $0.3 == .valid - } + ) + .map { $0.0 == .valid && $0.1 == .valid && $0.2 == .valid && $0.3 == .valid } Publishers.CombineLatest( publisherOne, - approvalRequired ? inviteValidateState.map {$0 == .valid}.eraseToAnyPublisher() : Just(true).eraseToAnyPublisher() + approvalRequired ? reasonValidateState.map { $0 == .valid }.eraseToAnyPublisher() : Just(true).eraseToAnyPublisher() ) - .map { - return $0 && $1 - } + .map { $0 && $1 } .assign(to: \.value, on: isAllValid) .store(in: &disposeBag) } @@ -139,40 +206,46 @@ extension MastodonRegisterViewModel { return emailPred.evaluate(with: email) } - func attributeStringForUsername() -> NSAttributedString { - let resultAttributeString = NSMutableAttributedString() - let redImage = NSTextAttachment() - let font = UIFont.preferredFont(forTextStyle: .caption1) + static func checkmarkImage(font: UIFont = .preferredFont(forTextStyle: .caption1)) -> UIImage { let configuration = UIImage.SymbolConfiguration(font: font) - redImage.image = UIImage(systemName: "xmark.octagon.fill", withConfiguration: configuration)?.withTintColor(Asset.Colors.lightDangerRed.color) - let imageAttribute = NSAttributedString(attachment: redImage) - let stringAttribute = NSAttributedString(string: "This username is taken.", attributes: [NSAttributedString.Key.font: font, NSAttributedString.Key.foregroundColor: Asset.Colors.lightDangerRed.color]) - resultAttributeString.append(imageAttribute) - resultAttributeString.append(stringAttribute) - return resultAttributeString + return UIImage(systemName: "checkmark.circle.fill", withConfiguration: configuration)! + } + + static func xmarkImage(font: UIFont = .preferredFont(forTextStyle: .caption1)) -> UIImage { + let configuration = UIImage.SymbolConfiguration(font: font) + return UIImage(systemName: "xmark.octagon.fill", withConfiguration: configuration)! } - func attributeStringForPassword(eightCharacters: Bool = false) -> NSAttributedString { - let font = UIFont.preferredFont(forTextStyle: .caption1) - let color = UIColor.black - let falseColor = UIColor.clear + static func attributedStringImage(with image: UIImage, tintColor: UIColor) -> NSAttributedString { + let attachment = NSTextAttachment() + attachment.image = image.withTintColor(tintColor) + return NSAttributedString(attachment: attachment) + } + + static func attributeStringForPassword(validateState: ValidateState) -> NSAttributedString { + let font = UIFontMetrics(forTextStyle: .caption1).scaledFont(for: .systemFont(ofSize: 13, weight: .regular), maximumPointSize: 18) let attributeString = NSMutableAttributedString() - - let start = NSAttributedString(string: "Your password needs at least:\n", attributes: [NSAttributedString.Key.font: font, NSAttributedString.Key.foregroundColor: color]) - attributeString.append(start) - - attributeString.append(checkmarkImage(color: eightCharacters ? color : falseColor)) - let eightCharactersDescription = NSAttributedString(string: " Eight characters\n", attributes: [NSAttributedString.Key.font: font, NSAttributedString.Key.foregroundColor: color]) + + let image = MastodonRegisterViewModel.checkmarkImage(font: font) + attributeString.append(attributedStringImage(with: image, tintColor: validateState == .valid ? Asset.Colors.Label.primary.color : .clear)) + attributeString.append(NSAttributedString(string: " ")) + let eightCharactersDescription = NSAttributedString(string: L10n.Scene.Register.Input.Password.hint, attributes: [NSAttributedString.Key.font: font, NSAttributedString.Key.foregroundColor: Asset.Colors.Label.primary.color]) attributeString.append(eightCharactersDescription) return attributeString } + + static func errorPromptAttributedString(for prompt: String) -> NSAttributedString { + let font = UIFontMetrics(forTextStyle: .caption1).scaledFont(for: .systemFont(ofSize: 13, weight: .regular), maximumPointSize: 18) + let attributeString = NSMutableAttributedString() - func checkmarkImage(color: UIColor) -> NSAttributedString { - let checkmarkImage = NSTextAttachment() - let font = UIFont.preferredFont(forTextStyle: .caption1) - let configuration = UIImage.SymbolConfiguration(font: font) - checkmarkImage.image = UIImage(systemName: "checkmark.circle.fill", withConfiguration: configuration)?.withTintColor(color) - return NSAttributedString(attachment: checkmarkImage) + let image = MastodonRegisterViewModel.xmarkImage(font: font) + attributeString.append(attributedStringImage(with: image, tintColor: Asset.Colors.danger.color)) + attributeString.append(NSAttributedString(string: " ")) + + let promptAttributedString = NSAttributedString(string: prompt, attributes: [NSAttributedString.Key.font: font, NSAttributedString.Key.foregroundColor: Asset.Colors.danger.color]) + attributeString.append(promptAttributedString) + + return attributeString } } diff --git a/Mastodon/Scene/Onboarding/ResendEmail/MastodonResendEmailViewController.swift b/Mastodon/Scene/Onboarding/ResendEmail/MastodonResendEmailViewController.swift index 25b9ca402..1d3a29cb5 100644 --- a/Mastodon/Scene/Onboarding/ResendEmail/MastodonResendEmailViewController.swift +++ b/Mastodon/Scene/Onboarding/ResendEmail/MastodonResendEmailViewController.swift @@ -40,9 +40,10 @@ final class MastodonResendEmailViewController: UIViewController, NeedsDependency } extension MastodonResendEmailViewController { + override func viewDidLoad() { super.viewDidLoad() - + setupOnboardingAppearance() navigationItem.leftBarButtonItem = UIBarButtonItem(barButtonSystemItem: .cancel, target: self, action: #selector(MastodonResendEmailViewController.cancelBarButtonItemPressed(_:))) webView.translatesAutoresizingMaskIntoConstraints = false @@ -59,6 +60,7 @@ extension MastodonResendEmailViewController { webView.load(request) os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: resendEmail via: %s", (#file as NSString).lastPathComponent, #line, #function, viewModel.resendEmailURL.debugDescription) } + } extension MastodonResendEmailViewController { @@ -66,3 +68,6 @@ extension MastodonResendEmailViewController { dismiss(animated: true, completion: nil) } } + +// MARK: - OnboardingViewControllerAppearance +extension MastodonResendEmailViewController: OnboardingViewControllerAppearance { } diff --git a/Mastodon/Scene/Onboarding/ServerRules/MastodonServerRulesViewController.swift b/Mastodon/Scene/Onboarding/ServerRules/MastodonServerRulesViewController.swift index cc7992e21..d865c96ec 100644 --- a/Mastodon/Scene/Onboarding/ServerRules/MastodonServerRulesViewController.swift +++ b/Mastodon/Scene/Onboarding/ServerRules/MastodonServerRulesViewController.swift @@ -8,6 +8,8 @@ import os.log import UIKit import Combine +import MastodonSDK +import SafariServices final class MastodonServerRulesViewController: UIViewController, NeedsDependency { @@ -23,6 +25,7 @@ final class MastodonServerRulesViewController: UIViewController, NeedsDependency label.font = UIFontMetrics(forTextStyle: .largeTitle).scaledFont(for: .systemFont(ofSize: 34, weight: .bold)) label.textColor = .label label.text = L10n.Scene.ServerRules.title + label.numberOfLines = 0 return label }() @@ -38,25 +41,27 @@ final class MastodonServerRulesViewController: UIViewController, NeedsDependency let rulesLabel: UILabel = { let label = UILabel() label.font = .preferredFont(forTextStyle: .body) - label.textColor = .black + label.textColor = Asset.Colors.Label.primary.color label.text = "Rules" label.numberOfLines = 0 return label }() - let bottonContainerView: UIView = { + let bottomContainerView: UIView = { let view = UIView() - view.backgroundColor = Asset.Colors.Background.onboardingBackground.color + view.backgroundColor = Asset.Colors.Background.systemGroupedBackground.color return view }() - private(set) lazy var bottomPromptLabel: UILabel = { - let label = UILabel() - label.font = .preferredFont(forTextStyle: .body) - label.textColor = .label - label.text = L10n.Scene.ServerRules.prompt(viewModel.domain) - label.numberOfLines = 0 - return label + private(set) lazy var bottomPromptTextView: UITextView = { + let textView = UITextView() + textView.font = UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 17, weight: .regular), maximumPointSize: 22) + textView.textColor = .label + textView.isSelectable = true + textView.isEditable = false + textView.isScrollEnabled = false + textView.backgroundColor = Asset.Colors.Background.systemGroupedBackground.color + return textView }() let confirmButton: PrimaryActionButton = { @@ -86,36 +91,38 @@ extension MastodonServerRulesViewController { super.viewDidLoad() setupOnboardingAppearance() + configTextView() + defer { setupNavigationBarBackgroundView() } - bottonContainerView.translatesAutoresizingMaskIntoConstraints = false - view.addSubview(bottonContainerView) + bottomContainerView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(bottomContainerView) NSLayoutConstraint.activate([ - view.bottomAnchor.constraint(equalTo: bottonContainerView.bottomAnchor), - bottonContainerView.leadingAnchor.constraint(equalTo: view.leadingAnchor), - bottonContainerView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + view.bottomAnchor.constraint(equalTo: bottomContainerView.bottomAnchor), + bottomContainerView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + bottomContainerView.trailingAnchor.constraint(equalTo: view.trailingAnchor), ]) - bottonContainerView.preservesSuperviewLayoutMargins = true + bottomContainerView.preservesSuperviewLayoutMargins = true defer { - view.bringSubviewToFront(bottonContainerView) + view.bringSubviewToFront(bottomContainerView) } confirmButton.translatesAutoresizingMaskIntoConstraints = false - bottonContainerView.addSubview(confirmButton) + bottomContainerView.addSubview(confirmButton) NSLayoutConstraint.activate([ - bottonContainerView.layoutMarginsGuide.bottomAnchor.constraint(equalTo: confirmButton.bottomAnchor, constant: MastodonServerRulesViewController.viewBottomPaddingHeight), - confirmButton.leadingAnchor.constraint(equalTo: bottonContainerView.readableContentGuide.leadingAnchor, constant: MastodonServerRulesViewController.actionButtonMargin), - bottonContainerView.readableContentGuide.trailingAnchor.constraint(equalTo: confirmButton.trailingAnchor, constant: MastodonServerRulesViewController.actionButtonMargin), + bottomContainerView.layoutMarginsGuide.bottomAnchor.constraint(equalTo: confirmButton.bottomAnchor, constant: MastodonServerRulesViewController.viewBottomPaddingHeight), + confirmButton.leadingAnchor.constraint(equalTo: bottomContainerView.readableContentGuide.leadingAnchor, constant: MastodonServerRulesViewController.actionButtonMargin), + bottomContainerView.readableContentGuide.trailingAnchor.constraint(equalTo: confirmButton.trailingAnchor, constant: MastodonServerRulesViewController.actionButtonMargin), confirmButton.heightAnchor.constraint(equalToConstant: MastodonServerRulesViewController.actionButtonHeight).priority(.defaultHigh), ]) - bottomPromptLabel.translatesAutoresizingMaskIntoConstraints = false - bottonContainerView.addSubview(bottomPromptLabel) + bottomPromptTextView.translatesAutoresizingMaskIntoConstraints = false + bottomContainerView.addSubview(bottomPromptTextView) NSLayoutConstraint.activate([ - bottomPromptLabel.topAnchor.constraint(equalTo: bottonContainerView.topAnchor, constant: 20), - bottomPromptLabel.leadingAnchor.constraint(equalTo: bottonContainerView.readableContentGuide.leadingAnchor), - bottomPromptLabel.trailingAnchor.constraint(equalTo: bottonContainerView.readableContentGuide.trailingAnchor), - confirmButton.topAnchor.constraint(equalTo: bottomPromptLabel.bottomAnchor, constant: 20), + bottomPromptTextView.frameLayoutGuide.topAnchor.constraint(equalTo: bottomContainerView.topAnchor, constant: 20), + bottomPromptTextView.frameLayoutGuide.leadingAnchor.constraint(equalTo: bottomContainerView.readableContentGuide.leadingAnchor), + bottomPromptTextView.frameLayoutGuide.trailingAnchor.constraint(equalTo: bottomContainerView.readableContentGuide.trailingAnchor), + confirmButton.topAnchor.constraint(equalTo: bottomPromptTextView.frameLayoutGuide.bottomAnchor, constant: 20), ]) scrollView.translatesAutoresizingMaskIntoConstraints = false @@ -148,30 +155,6 @@ extension MastodonServerRulesViewController { rulesLabel.attributedText = viewModel.rulesAttributedString confirmButton.addTarget(self, action: #selector(MastodonServerRulesViewController.confirmButtonPressed(_:)), for: .touchUpInside) - - viewModel.isRegistering - .receive(on: DispatchQueue.main) - .sink { [weak self] isRegistering in - guard let self = self else { return } - isRegistering ? self.confirmButton.showLoading() : self.confirmButton.stopLoading() - } - .store(in: &disposeBag) - - viewModel.error - .compactMap { $0 } - .receive(on: DispatchQueue.main) - .sink { [weak self] error in - guard let self = self else { return } - let alertController = UIAlertController(for: error, title: "Sign Up Failure", preferredStyle: .alert) - let okAction = UIAlertAction(title: L10n.Common.Controls.Actions.ok, style: .default, handler: nil) - alertController.addAction(okAction) - self.coordinator.present( - scene: .alertController(alertController: alertController), - from: nil, - transition: .alertController(animated: true, completion: nil) - ) - } - .store(in: &disposeBag) } override func viewDidLayoutSubviews() { @@ -189,39 +172,41 @@ extension MastodonServerRulesViewController { extension MastodonServerRulesViewController { func updateScrollViewContentInset() { view.layoutIfNeeded() - scrollView.contentInset.bottom = bottonContainerView.frame.height - scrollView.verticalScrollIndicatorInsets.bottom = bottonContainerView.frame.height + scrollView.contentInset.bottom = bottomContainerView.frame.height + scrollView.verticalScrollIndicatorInsets.bottom = bottomContainerView.frame.height + } + + func configTextView() { + let linkColor = Asset.Colors.Button.normal.color + + let str = NSString(string: L10n.Scene.ServerRules.prompt(viewModel.domain)) + let termsOfServiceRange = str.range(of: L10n.Scene.ServerRules.termsOfService) + let privacyRange = str.range(of: L10n.Scene.ServerRules.privacyPolicy) + let attributeString = NSMutableAttributedString(string: L10n.Scene.ServerRules.prompt(viewModel.domain), attributes: [NSAttributedString.Key.font: UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 17, weight: .regular), maximumPointSize: 22), NSAttributedString.Key.foregroundColor: UIColor.label]) + attributeString.addAttribute(.link, value: Mastodon.API.serverRulesURL(domain: viewModel.domain), range: termsOfServiceRange) + attributeString.addAttribute(.link, value: Mastodon.API.privacyURL(domain: viewModel.domain), range: privacyRange) + let linkAttributes = [NSAttributedString.Key.foregroundColor:linkColor] + bottomPromptTextView.attributedText = attributeString + bottomPromptTextView.linkTextAttributes = linkAttributes + bottomPromptTextView.delegate = self + } + +} + +extension MastodonServerRulesViewController: UITextViewDelegate { + func textView(_ textView: UITextView, shouldInteractWith URL: URL, in characterRange: NSRange, interaction: UITextItemInteraction) -> Bool { + let safariVC = SFSafariViewController(url: URL) + self.present(safariVC, animated: true, completion: nil) + return false } } extension MastodonServerRulesViewController { @objc private func confirmButtonPressed(_ sender: UIButton) { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) - - let email = viewModel.registerQuery.email - - context.apiService.accountRegister( - domain: viewModel.domain, - query: viewModel.registerQuery, - authorization: viewModel.applicationAuthorization - ) - .receive(on: DispatchQueue.main) - .sink { [weak self] completion in - guard let self = self else { return } - self.viewModel.isRegistering.value = false - switch completion { - case .failure(let error): - self.viewModel.error.send(error) - case .finished: - break - } - } receiveValue: { [weak self] response in - guard let self = self else { return } - let userToken = response.value - let viewModel = MastodonConfirmEmailViewModel(context: self.context, email: email, authenticateInfo: self.viewModel.authenticateInfo, userToken: userToken) - self.coordinator.present(scene: .mastodonConfirmEmail(viewModel: viewModel), from: self, transition: .show) - } - .store(in: &disposeBag) + + let viewModel = MastodonRegisterViewModel(domain: self.viewModel.domain, context: self.context, authenticateInfo: self.viewModel.authenticateInfo, instance: self.viewModel.instance, applicationToken: self.viewModel.applicationToken) + self.coordinator.present(scene: .mastodonRegister(viewModel: viewModel), from: self, transition: .show) } } diff --git a/Mastodon/Scene/Onboarding/ServerRules/MastodonServerRulesViewModel.swift b/Mastodon/Scene/Onboarding/ServerRules/MastodonServerRulesViewModel.swift index 9569ffe81..b1d000db5 100644 --- a/Mastodon/Scene/Onboarding/ServerRules/MastodonServerRulesViewModel.swift +++ b/Mastodon/Scene/Onboarding/ServerRules/MastodonServerRulesViewModel.swift @@ -10,45 +10,41 @@ import Combine import MastodonSDK final class MastodonServerRulesViewModel { - // input - let context: AppContext + let domain: String let authenticateInfo: AuthenticationViewModel.AuthenticateInfo let rules: [Mastodon.Entity.Instance.Rule] - let registerQuery: Mastodon.API.Account.RegisterQuery - let applicationAuthorization: Mastodon.API.OAuth.Authorization - - // output - let isRegistering = CurrentValueSubject(false) - let error = CurrentValueSubject(nil) + let instance: Mastodon.Entity.Instance + let applicationToken: Mastodon.Entity.Token init( - context: AppContext, domain: String, authenticateInfo: AuthenticationViewModel.AuthenticateInfo, rules: [Mastodon.Entity.Instance.Rule], - registerQuery: Mastodon.API.Account.RegisterQuery, - applicationAuthorization: Mastodon.API.OAuth.Authorization + instance: Mastodon.Entity.Instance, + applicationToken: Mastodon.Entity.Token ) { - self.context = context self.domain = domain self.authenticateInfo = authenticateInfo self.rules = rules - self.registerQuery = registerQuery - self.applicationAuthorization = applicationAuthorization + self.instance = instance + self.applicationToken = applicationToken } var rulesAttributedString: NSAttributedString { let attributedString = NSMutableAttributedString(string: "\n") + let configuration = UIImage.SymbolConfiguration(font: .preferredFont(forTextStyle: .title3)) for (i, rule) in rules.enumerated() { - let index = String(i + 1) - let indexString = NSAttributedString(string: index + ". ", attributes: [ - NSAttributedString.Key.foregroundColor: UIColor.secondaryLabel - ]) - let ruleString = NSAttributedString(string: rule.text + "\n\n") - attributedString.append(indexString) + let imageName = String(i + 1) + ".circle.fill" + let image = UIImage(systemName: imageName, withConfiguration: configuration)! + let attachment = NSTextAttachment() + attachment.image = image.withTintColor(Asset.Colors.Label.primary.color) + let imageAttribute = NSAttributedString(attachment: attachment) + + let ruleString = NSAttributedString(string: " " + rule.text + "\n\n") + attributedString.append(imageAttribute) attributedString.append(ruleString) } return attributedString diff --git a/Mastodon/Scene/Onboarding/Share/AuthenticationViewModel.swift b/Mastodon/Scene/Onboarding/Share/AuthenticationViewModel.swift index cb197dc0a..0bd1bf09b 100644 --- a/Mastodon/Scene/Onboarding/Share/AuthenticationViewModel.swift +++ b/Mastodon/Scene/Onboarding/Share/AuthenticationViewModel.swift @@ -42,21 +42,7 @@ final class AuthenticationViewModel { input .map { input in - let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - guard !trimmed.isEmpty else { return nil } - - let urlString = trimmed.hasPrefix("https://") ? trimmed : "https://" + trimmed - guard let url = URL(string: urlString), - let host = url.host else { - return nil - } - let components = host.components(separatedBy: ".") - guard !components.contains(where: { $0.isEmpty }) else { return nil } - guard components.count >= 2 else { return nil } - - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: iput host: %s", ((#file as NSString).lastPathComponent), #line, #function, host) - - return host + AuthenticationViewModel.parseDomain(from: input) } .assign(to: \.value, on: domain) .store(in: &disposeBag) @@ -77,6 +63,26 @@ final class AuthenticationViewModel { } +extension AuthenticationViewModel { + static func parseDomain(from input: String) -> String? { + let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + guard !trimmed.isEmpty else { return nil } + + let urlString = trimmed.hasPrefix("https://") ? trimmed : "https://" + trimmed + guard let url = URL(string: urlString), + let host = url.host else { + return nil + } + let components = host.components(separatedBy: ".") + guard !components.contains(where: { $0.isEmpty }) else { return nil } + guard components.count >= 2 else { return nil } + + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: input host: %s", ((#file as NSString).lastPathComponent), #line, #function, host) + + return host + } +} + extension AuthenticationViewModel { enum AuthenticationError: Error, LocalizedError { case badCredentials diff --git a/Mastodon/Scene/Onboarding/Share/OnboardingViewControllerAppearance.swift b/Mastodon/Scene/Onboarding/Share/OnboardingViewControllerAppearance.swift index c4b26321a..0784b51ea 100644 --- a/Mastodon/Scene/Onboarding/Share/OnboardingViewControllerAppearance.swift +++ b/Mastodon/Scene/Onboarding/Share/OnboardingViewControllerAppearance.swift @@ -20,8 +20,7 @@ extension OnboardingViewControllerAppearance { static var viewBottomPaddingHeight: CGFloat { return 11 } func setupOnboardingAppearance() { - overrideUserInterfaceStyle = .light - view.backgroundColor = Asset.Colors.Background.onboardingBackground.color + view.backgroundColor = Asset.Colors.Background.systemGroupedBackground.color setupNavigationBarAppearance() @@ -43,7 +42,7 @@ extension OnboardingViewControllerAppearance { func setupNavigationBarBackgroundView() { let navigationBarBackgroundView: UIView = { let view = UIView() - view.backgroundColor = Asset.Colors.Background.onboardingBackground.color + view.backgroundColor = Asset.Colors.Background.systemGroupedBackground.color return view }() diff --git a/Mastodon/Scene/Onboarding/Welcome/View/WelcomeIllustrationView.swift b/Mastodon/Scene/Onboarding/Welcome/View/WelcomeIllustrationView.swift new file mode 100644 index 000000000..f5d8c41c8 --- /dev/null +++ b/Mastodon/Scene/Onboarding/Welcome/View/WelcomeIllustrationView.swift @@ -0,0 +1,156 @@ +// +// WelcomeIllustrationView.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-1. +// + +import UIKit + +final class WelcomeIllustrationView: UIView { + + static let artworkImageSize = CGSize(width: 375, height: 1500) + + let cloudBaseImageView = UIImageView() + let rightHillImageView = UIImageView() + let leftHillImageView = UIImageView() + let centerHillImageView = UIImageView() + + private let cloudBaseImage = Asset.Scene.Welcome.Illustration.cloudBase.image + private let elephantThreeOnGrassWithTreeTwoImage = Asset.Scene.Welcome.Illustration.elephantThreeOnGrassWithTreeTwo.image + private let elephantThreeOnGrassWithTreeThreeImage = Asset.Scene.Welcome.Illustration.elephantThreeOnGrassWithTreeThree.image + private let elephantThreeOnGrassImage = Asset.Scene.Welcome.Illustration.elephantThreeOnGrass.image + + // layout outside + let elephantOnAirplaneWithContrailImageView: UIImageView = { + let imageView = UIImageView(image: Asset.Scene.Welcome.Illustration.elephantOnAirplaneWithContrail.image) + imageView.contentMode = .scaleAspectFill + return imageView + }() + + override init(frame: CGRect) { + super.init(frame: frame) + _init() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + _init() + } + +} + +extension WelcomeIllustrationView { + + private func _init() { + backgroundColor = Asset.Scene.Welcome.Illustration.backgroundCyan.color + + let topPaddingView = UIView() + + topPaddingView.translatesAutoresizingMaskIntoConstraints = false + addSubview(topPaddingView) + NSLayoutConstraint.activate([ + topPaddingView.topAnchor.constraint(equalTo: topAnchor), + topPaddingView.leadingAnchor.constraint(equalTo: leadingAnchor), + topPaddingView.trailingAnchor.constraint(equalTo: trailingAnchor), + ]) + + cloudBaseImageView.translatesAutoresizingMaskIntoConstraints = false + addSubview(cloudBaseImageView) + NSLayoutConstraint.activate([ + cloudBaseImageView.topAnchor.constraint(equalTo: topPaddingView.bottomAnchor), + cloudBaseImageView.leadingAnchor.constraint(equalTo: leadingAnchor), + cloudBaseImageView.trailingAnchor.constraint(equalTo: trailingAnchor), + cloudBaseImageView.bottomAnchor.constraint(equalTo: bottomAnchor), + cloudBaseImageView.widthAnchor.constraint(equalTo: cloudBaseImageView.heightAnchor, multiplier: WelcomeIllustrationView.artworkImageSize.width / WelcomeIllustrationView.artworkImageSize.height), + ]) + + [ + rightHillImageView, + leftHillImageView, + centerHillImageView, + ].forEach { imageView in + imageView.translatesAutoresizingMaskIntoConstraints = false + addSubview(imageView) + NSLayoutConstraint.activate([ + imageView.topAnchor.constraint(equalTo: cloudBaseImageView.topAnchor), + imageView.leadingAnchor.constraint(equalTo: cloudBaseImageView.leadingAnchor), + imageView.trailingAnchor.constraint(equalTo: cloudBaseImageView.trailingAnchor), + imageView.bottomAnchor.constraint(equalTo: cloudBaseImageView.bottomAnchor), + ]) + } + } + + override func layoutSubviews() { + super.layoutSubviews() + updateImage() + } + + private func updateImage() { + let size = WelcomeIllustrationView.artworkImageSize + let width = size.width + let height = size.height + + cloudBaseImageView.image = UIGraphicsImageRenderer(size: size).image { context in + // clear background + UIColor.clear.setFill() + context.fill(CGRect(origin: .zero, size: size)) + + // draw cloud + cloudBaseImage.draw(at: CGPoint(x: 0, y: height - cloudBaseImage.size.height)) + } + + rightHillImageView.image = UIGraphicsImageRenderer(size: size).image { context in + // clear background + UIColor.clear.setFill() + context.fill(CGRect(origin: .zero, size: size)) + + // draw elephantThreeOnGrassWithTreeTwoImage + // elephantThreeOnGrassWithTreeTwo.bottomY - 25 align to elephantThreeOnGrassImage.centerY + elephantThreeOnGrassWithTreeTwoImage.draw(at: CGPoint(x: width - elephantThreeOnGrassWithTreeTwoImage.size.width, y: height - 0.5 * elephantThreeOnGrassImage.size.height - elephantThreeOnGrassWithTreeTwoImage.size.height + 25)) + } + + leftHillImageView.image = UIGraphicsImageRenderer(size: size).image { context in + // clear background + UIColor.clear.setFill() + context.fill(CGRect(origin: .zero, size: size)) + + // draw elephantThreeOnGrassWithTreeThree + // elephantThreeOnGrassWithTreeThree.bottomY + 30 align to elephantThreeOnGrassImage.centerY + elephantThreeOnGrassWithTreeThreeImage.draw(at: CGPoint(x: 0, y: height - 0.5 * elephantThreeOnGrassImage.size.height - elephantThreeOnGrassWithTreeThreeImage.size.height - 30)) + } + + centerHillImageView.image = UIGraphicsImageRenderer(size: size).image { context in + // clear background + UIColor.clear.setFill() + context.fill(CGRect(origin: .zero, size: size)) + + // draw elephantThreeOnGrass + elephantThreeOnGrassImage.draw(at: CGPoint(x: 0, y: height - elephantThreeOnGrassImage.size.height)) + } + } + +} + +#if canImport(SwiftUI) && DEBUG +import SwiftUI + +struct WelcomeIllustrationView_Previews: PreviewProvider { + + static var previews: some View { + Group { + UIViewPreview(width: 375) { + WelcomeIllustrationView() + } + .previewLayout(.fixed(width: 375, height: 1500)) + UIViewPreview(width: 1125) { + WelcomeIllustrationView() + } + .previewLayout(.fixed(width: 1125, height: 5000)) + } + } + +} + +#endif + diff --git a/Mastodon/Scene/Onboarding/Welcome/WelcomeViewController.swift b/Mastodon/Scene/Onboarding/Welcome/WelcomeViewController.swift index e832e5a43..3654c9f08 100644 --- a/Mastodon/Scene/Onboarding/Welcome/WelcomeViewController.swift +++ b/Mastodon/Scene/Onboarding/Welcome/WelcomeViewController.swift @@ -13,14 +13,17 @@ final class WelcomeViewController: UIViewController, NeedsDependency { weak var context: AppContext! { willSet { precondition(!isViewLoaded) } } weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } } + let welcomeIllustrationView = WelcomeIllustrationView() + var welcomeIllustrationViewBottomAnchorLayoutConstraint: NSLayoutConstraint? + private(set) lazy var logoImageView: UIImageView = { - let image = view.traitCollection.userInterfaceIdiom == .phone ? Asset.Welcome.mastodonLogo.image : Asset.Welcome.mastodonLogoLarge.image + let image = view.traitCollection.userInterfaceIdiom == .phone ? Asset.Scene.Welcome.mastodonLogo.image : Asset.Scene.Welcome.mastodonLogoBlackLarge.image let imageView = UIImageView(image: image) imageView.translatesAutoresizingMaskIntoConstraints = false return imageView }() - let sloganLabel: UILabel = { + private(set) lazy var sloganLabel: UILabel = { let label = UILabel() label.font = UIFontMetrics(forTextStyle: .largeTitle).scaledFont(for: .systemFont(ofSize: 34, weight: .bold)) label.textColor = Asset.Colors.Label.primary.color @@ -31,19 +34,24 @@ final class WelcomeViewController: UIViewController, NeedsDependency { return label }() - let signUpButton: PrimaryActionButton = { + private(set) lazy var signUpButton: PrimaryActionButton = { let button = PrimaryActionButton() button.setTitle(L10n.Common.Controls.Actions.signUp, for: .normal) + let backgroundImageColor: UIColor = traitCollection.userInterfaceIdiom == .phone ? .white : Asset.Colors.Button.normal.color + button.setBackgroundImage(.placeholder(color: backgroundImageColor), for: .normal) + button.setBackgroundImage(.placeholder(color: backgroundImageColor.withAlphaComponent(0.9)), for: .highlighted) + let titleColor: UIColor = traitCollection.userInterfaceIdiom == .phone ? Asset.Colors.Button.normal.color : UIColor.white + button.setTitleColor(titleColor, for: .normal) button.translatesAutoresizingMaskIntoConstraints = false return button }() - let signInButton: UIButton = { + private(set) lazy var signInButton: UIButton = { let button = UIButton(type: .system) button.titleLabel?.font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 15, weight: .semibold)) button.setTitle(L10n.Common.Controls.Actions.signIn, for: .normal) - button.setTitleColor(Asset.Colors.lightBrandBlue.color, for: .normal) - button.setInsets(forContentPadding: UIEdgeInsets(top: 12, left: 0, bottom: 12, right: 0), imageTitlePadding: 0) + let titleColor: UIColor = traitCollection.userInterfaceIdiom == .phone ? UIColor.white.withAlphaComponent(0.8) : Asset.Colors.Button.normal.color + button.setTitleColor(titleColor, for: .normal) button.translatesAutoresizingMaskIntoConstraints = false return button }() @@ -60,22 +68,8 @@ extension WelcomeViewController { super.viewDidLoad() setupOnboardingAppearance() - - view.addSubview(logoImageView) - NSLayoutConstraint.activate([ - logoImageView.topAnchor.constraint(equalTo: view.layoutMarginsGuide.topAnchor), - logoImageView.leadingAnchor.constraint(equalTo: view.readableContentGuide.leadingAnchor, constant: 35), - view.readableContentGuide.trailingAnchor.constraint(equalTo: logoImageView.trailingAnchor, constant: 35), - logoImageView.heightAnchor.constraint(equalTo: logoImageView.widthAnchor, multiplier: 65.4/265.1), - ]) - - 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), - ]) - + setupIllustrationLayout() + view.addSubview(signInButton) view.addSubview(signUpButton) NSLayoutConstraint.activate([ @@ -83,19 +77,126 @@ extension WelcomeViewController { view.readableContentGuide.trailingAnchor.constraint(equalTo: signInButton.trailingAnchor, constant: WelcomeViewController.actionButtonMargin), view.layoutMarginsGuide.bottomAnchor.constraint(equalTo: signInButton.bottomAnchor, constant: WelcomeViewController.viewBottomPaddingHeight), signInButton.heightAnchor.constraint(equalToConstant: WelcomeViewController.actionButtonHeight).priority(.defaultHigh), - + signInButton.topAnchor.constraint(equalTo: signUpButton.bottomAnchor, constant: 9), signUpButton.leadingAnchor.constraint(equalTo: view.readableContentGuide.leadingAnchor, constant: WelcomeViewController.actionButtonMargin), view.readableContentGuide.trailingAnchor.constraint(equalTo: signUpButton.trailingAnchor, constant: WelcomeViewController.actionButtonMargin), signUpButton.heightAnchor.constraint(equalToConstant: WelcomeViewController.actionButtonHeight).priority(.defaultHigh), ]) - + signUpButton.addTarget(self, action: #selector(signUpButtonDidClicked(_:)), for: .touchUpInside) signInButton.addTarget(self, action: #selector(signInButtonDidClicked(_:)), for: .touchUpInside) } - override var preferredStatusBarStyle: UIStatusBarStyle { return .darkContent } + override func viewSafeAreaInsetsDidChange() { + super.viewSafeAreaInsetsDidChange() + + // shift illustration down for non-notch phone + var overlap: CGFloat = 5 + if view.safeAreaInsets.bottom == 0 { + overlap += 56 + } + welcomeIllustrationViewBottomAnchorLayoutConstraint?.constant = overlap + } + +} + +extension WelcomeViewController { + private func setupIllustrationLayout() { + // set logo + if logoImageView.superview == nil { + view.addSubview(logoImageView) + NSLayoutConstraint.activate([ + logoImageView.topAnchor.constraint(equalTo: view.layoutMarginsGuide.topAnchor), + logoImageView.leadingAnchor.constraint(equalTo: view.readableContentGuide.leadingAnchor, constant: 35), + view.readableContentGuide.trailingAnchor.constraint(equalTo: logoImageView.trailingAnchor, constant: 35), + logoImageView.heightAnchor.constraint(equalTo: logoImageView.widthAnchor, multiplier: 65.4/265.1), + ]) + logoImageView.setContentHuggingPriority(.defaultHigh, for: .vertical) + } + + // set illustration for phone + if traitCollection.userInterfaceIdiom == .phone { + guard welcomeIllustrationView.superview == nil else { + return + } + + welcomeIllustrationView.translatesAutoresizingMaskIntoConstraints = false + welcomeIllustrationViewBottomAnchorLayoutConstraint = welcomeIllustrationView.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: 5) + + view.addSubview(welcomeIllustrationView) + NSLayoutConstraint.activate([ + view.leftAnchor.constraint(equalTo: welcomeIllustrationView.leftAnchor, constant: 15), + welcomeIllustrationView.rightAnchor.constraint(equalTo: view.rightAnchor, constant: 15), + welcomeIllustrationViewBottomAnchorLayoutConstraint! + ]) + + welcomeIllustrationView.cloudBaseImageView.addMotionEffect( + UIInterpolatingMotionEffect.motionEffect(minX: -5, maxX: 5, minY: -5, maxY: 5) + ) + welcomeIllustrationView.rightHillImageView.addMotionEffect( + UIInterpolatingMotionEffect.motionEffect(minX: -15, maxX: 25, minY: -10, maxY: 10) + ) + welcomeIllustrationView.leftHillImageView.addMotionEffect( + UIInterpolatingMotionEffect.motionEffect(minX: -25, maxX: 15, minY: -15, maxY: 15) + ) + welcomeIllustrationView.centerHillImageView.addMotionEffect( + UIInterpolatingMotionEffect.motionEffect(minX: -14, maxX: 14, minY: -5, maxY: 25) + ) + + let topPaddingView = UIView() + topPaddingView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(topPaddingView) + NSLayoutConstraint.activate([ + topPaddingView.topAnchor.constraint(equalTo: logoImageView.bottomAnchor), + topPaddingView.leadingAnchor.constraint(equalTo: logoImageView.leadingAnchor), + topPaddingView.trailingAnchor.constraint(equalTo: logoImageView.trailingAnchor), + ]) + welcomeIllustrationView.elephantOnAirplaneWithContrailImageView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(welcomeIllustrationView.elephantOnAirplaneWithContrailImageView) + NSLayoutConstraint.activate([ + view.leftAnchor.constraint(equalTo: welcomeIllustrationView.elephantOnAirplaneWithContrailImageView.leftAnchor, constant: 12), // add 12pt bleeding + welcomeIllustrationView.elephantOnAirplaneWithContrailImageView.topAnchor.constraint(equalTo: topPaddingView.bottomAnchor), + // make a little bit large + welcomeIllustrationView.elephantOnAirplaneWithContrailImageView.widthAnchor.constraint(equalTo: view.widthAnchor, multiplier: 0.84), + welcomeIllustrationView.elephantOnAirplaneWithContrailImageView.heightAnchor.constraint(equalTo: welcomeIllustrationView.elephantOnAirplaneWithContrailImageView.widthAnchor, multiplier: 105.0/318.0), + ]) + let bottomPaddingView = UIView() + bottomPaddingView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(bottomPaddingView) + NSLayoutConstraint.activate([ + bottomPaddingView.topAnchor.constraint(equalTo: welcomeIllustrationView.elephantOnAirplaneWithContrailImageView.bottomAnchor), + bottomPaddingView.leadingAnchor.constraint(equalTo: logoImageView.leadingAnchor), + bottomPaddingView.trailingAnchor.constraint(equalTo: logoImageView.trailingAnchor), + bottomPaddingView.bottomAnchor.constraint(equalTo: view.centerYAnchor), + bottomPaddingView.heightAnchor.constraint(equalTo: topPaddingView.heightAnchor, multiplier: 4), + ]) + + welcomeIllustrationView.elephantOnAirplaneWithContrailImageView.addMotionEffect( + UIInterpolatingMotionEffect.motionEffect(minX: -20, maxX: 12, minY: -20, maxY: 12) // maxX should not larger then the bleeding (12pt) + ) + + view.bringSubviewToFront(logoImageView) + view.bringSubviewToFront(sloganLabel) + } + + // set slogan for non-phone + if traitCollection.userInterfaceIdiom != .phone { + guard sloganLabel.superview == nil else { + return + } + view.addSubview(sloganLabel) + NSLayoutConstraint.activate([ + sloganLabel.leadingAnchor.constraint(equalTo: view.readableContentGuide.leadingAnchor, constant: 16), + view.readableContentGuide.trailingAnchor.constraint(equalTo: sloganLabel.trailingAnchor, constant: 16), + sloganLabel.topAnchor.constraint(equalTo: logoImageView.bottomAnchor, constant: 168), + ]) + } + + view.bringSubviewToFront(sloganLabel) + view.bringSubviewToFront(logoImageView) + } } extension WelcomeViewController { @@ -116,6 +217,7 @@ extension WelcomeViewController: OnboardingViewControllerAppearance { } // MARK: - UIAdaptivePresentationControllerDelegate extension WelcomeViewController: UIAdaptivePresentationControllerDelegate { func adaptivePresentationStyle(for controller: UIPresentationController, traitCollection: UITraitCollection) -> UIModalPresentationStyle { + // make underneath view controller alive to fix layout issue due to view life cycle return .fullScreen } } diff --git a/Mastodon/Scene/Profile/CachedProfileViewModel.swift b/Mastodon/Scene/Profile/CachedProfileViewModel.swift new file mode 100644 index 000000000..083724be1 --- /dev/null +++ b/Mastodon/Scene/Profile/CachedProfileViewModel.swift @@ -0,0 +1,17 @@ +// +// CachedProfileViewModel.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-31. +// + +import Foundation +import CoreDataStack + +final class CachedProfileViewModel: ProfileViewModel { + + init(context: AppContext, mastodonUser: MastodonUser) { + super.init(context: context, optionalMastodonUser: mastodonUser) + } + +} diff --git a/Mastodon/Scene/Profile/Favorite/FavoriteViewController+Provider.swift b/Mastodon/Scene/Profile/Favorite/FavoriteViewController+Provider.swift new file mode 100644 index 000000000..88f368c15 --- /dev/null +++ b/Mastodon/Scene/Profile/Favorite/FavoriteViewController+Provider.swift @@ -0,0 +1,89 @@ +// +// 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 + } + +} + +extension FavoriteViewController: UserProvider {} diff --git a/Mastodon/Scene/Profile/Favorite/FavoriteViewController.swift b/Mastodon/Scene/Profile/Favorite/FavoriteViewController.swift new file mode 100644 index 000000000..01d76f4b8 --- /dev/null +++ b/Mastodon/Scene/Profile/Favorite/FavoriteViewController.swift @@ -0,0 +1,175 @@ +// +// FavoriteViewController.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-6. +// + +// Note: Prefer use US favorite then EN favourite in coding +// to following the text checker auto-correct behavior + +import os.log +import UIKit +import AVKit +import Combine +import GameplayKit + +final class FavoriteViewController: UIViewController, NeedsDependency, MediaPreviewableViewController { + + weak var context: AppContext! { willSet { precondition(!isViewLoaded) } } + weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } } + + var disposeBag = Set() + var viewModel: FavoriteViewModel! + + let mediaPreviewTransitionController = MediaPreviewTransitionController() + + let titleView = DoubleTitleLabelNavigationBarTitleView() + + lazy var tableView: UITableView = { + let tableView = UITableView() + tableView.register(StatusTableViewCell.self, forCellReuseIdentifier: String(describing: StatusTableViewCell.self)) + tableView.register(TimelineBottomLoaderTableViewCell.self, forCellReuseIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self)) + tableView.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 FavoriteViewController { + + override func viewDidLoad() { + super.viewDidLoad() + + view.backgroundColor = Asset.Colors.Background.secondarySystemBackground.color + navigationItem.titleView = titleView + titleView.update(title: L10n.Scene.Favorite.title, subtitle: nil) + + 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 + tableView.prefetchDataSource = self + viewModel.setupDiffableDataSource( + for: tableView, + dependency: self, + statusTableViewCellDelegate: self + ) + + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + + aspectViewWillAppear(animated) + } + + override func viewDidDisappear(_ animated: Bool) { + super.viewDidDisappear(animated) + + aspectViewDidDisappear(animated) + } + +} + +// MARK: - StatusTableViewControllerAspect +extension FavoriteViewController: StatusTableViewControllerAspect { } + +// MARK: - TableViewCellHeightCacheableContainer +extension FavoriteViewController: TableViewCellHeightCacheableContainer { + var cellFrameCache: NSCache { + return viewModel.cellFrameCache + } +} + +// MARK: - UIScrollViewDelegate +extension FavoriteViewController { + func scrollViewDidScroll(_ scrollView: UIScrollView) { + aspectScrollViewDidScroll(scrollView) + } +} + +// MARK: - UITableViewDelegate +extension FavoriteViewController: UITableViewDelegate { + + func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat { + aspectTableView(tableView, estimatedHeightForRowAt: indexPath) + } + + func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) { + aspectTableView(tableView, willDisplay: cell, forRowAt: indexPath) + } + + func tableView(_ tableView: UITableView, didEndDisplaying cell: UITableViewCell, forRowAt indexPath: IndexPath) { + aspectTableView(tableView, didEndDisplaying: cell, forRowAt: indexPath) + } + + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + aspectTableView(tableView, didSelectRowAt: indexPath) + } + + func tableView(_ tableView: UITableView, contextMenuConfigurationForRowAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? { + return aspectTableView(tableView, contextMenuConfigurationForRowAt: indexPath, point: point) + } + + func tableView(_ tableView: UITableView, previewForHighlightingContextMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? { + return aspectTableView(tableView, previewForHighlightingContextMenuWithConfiguration: configuration) + } + + func tableView(_ tableView: UITableView, previewForDismissingContextMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? { + return aspectTableView(tableView, previewForDismissingContextMenuWithConfiguration: configuration) + } + + func tableView(_ tableView: UITableView, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) { + aspectTableView(tableView, willPerformPreviewActionForMenuWith: configuration, animator: animator) + } + +} + +// MARK: - UITableViewDataSourcePrefetching +extension FavoriteViewController: UITableViewDataSourcePrefetching { + func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) { + aspectTableView(tableView, prefetchRowsAt: indexPaths) + } +} + +// MARK: - AVPlayerViewControllerDelegate +extension FavoriteViewController: AVPlayerViewControllerDelegate { + + func playerViewController(_ playerViewController: AVPlayerViewController, willBeginFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator) { + aspectPlayerViewController(playerViewController, willBeginFullScreenPresentationWithAnimationCoordinator: coordinator) + } + + func playerViewController(_ playerViewController: AVPlayerViewController, willEndFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator) { + aspectPlayerViewController(playerViewController, willEndFullScreenPresentationWithAnimationCoordinator: coordinator) + } + +} + +// MARK: - TimelinePostTableViewCellDelegate +extension FavoriteViewController: StatusTableViewCellDelegate { + weak var playerViewControllerDelegate: AVPlayerViewControllerDelegate? { return self } + func parent() -> UIViewController { return self } +} + +// MARK: - LoadMoreConfigurableTableViewContainer +extension FavoriteViewController: LoadMoreConfigurableTableViewContainer { + typealias BottomLoaderTableViewCell = TimelineBottomLoaderTableViewCell + typealias LoadingState = FavoriteViewModel.State.Loading + + var loadMoreConfigurableTableView: UITableView { return tableView } + var loadMoreConfigurableStateMachine: GKStateMachine { return viewModel.stateMachine } +} + diff --git a/Mastodon/Scene/Profile/Favorite/FavoriteViewModel+Diffable.swift b/Mastodon/Scene/Profile/Favorite/FavoriteViewModel+Diffable.swift new file mode 100644 index 000000000..85928e852 --- /dev/null +++ b/Mastodon/Scene/Profile/Favorite/FavoriteViewModel+Diffable.swift @@ -0,0 +1,40 @@ +// +// FavoriteViewModel+Diffable.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-7. +// + +import UIKit + +extension FavoriteViewModel { + + func setupDiffableDataSource( + for tableView: UITableView, + dependency: NeedsDependency, + statusTableViewCellDelegate: StatusTableViewCellDelegate + ) { + let timestampUpdatePublisher = Timer.publish(every: 1.0, on: .main, in: .common) + .autoconnect() + .share() + .eraseToAnyPublisher() + + diffableDataSource = StatusSection.tableViewDiffableDataSource( + for: tableView, + dependency: dependency, + managedObjectContext: statusFetchedResultsController.fetchedResultsController.managedObjectContext, + timestampUpdatePublisher: timestampUpdatePublisher, + statusTableViewCellDelegate: statusTableViewCellDelegate, + timelineMiddleLoaderTableViewCellDelegate: nil, + threadReplyLoaderTableViewCellDelegate: nil + ) + + // set empty section to make update animation top-to-bottom style + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([.main]) + diffableDataSource?.apply(snapshot) + + stateMachine.enter(State.Reloading.self) + } + +} diff --git a/Mastodon/Scene/Profile/Favorite/FavoriteViewModel+State.swift b/Mastodon/Scene/Profile/Favorite/FavoriteViewModel+State.swift new file mode 100644 index 000000000..c4420e88b --- /dev/null +++ b/Mastodon/Scene/Profile/Favorite/FavoriteViewModel+State.swift @@ -0,0 +1,177 @@ +// +// FavoriteViewModel+State.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-7. +// + +import os.log +import Foundation +import GameplayKit +import MastodonSDK + +extension FavoriteViewModel { + class State: GKState { + weak var viewModel: FavoriteViewModel? + + init(viewModel: FavoriteViewModel) { + 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 FavoriteViewModel.State { + class Initial: FavoriteViewModel.State { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + guard let viewModel = viewModel else { return false } + switch stateClass { + case is Reloading.Type: + return viewModel.activeMastodonAuthenticationBox.value != nil + default: + return false + } + } + } + + class Reloading: FavoriteViewModel.State { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + switch stateClass { + case is Loading.Type: + return true + default: + return false + } + } + + override func didEnter(from previousState: GKState?) { + super.didEnter(from: previousState) + guard let viewModel = viewModel, let stateMachine = stateMachine else { return } + + // reset + viewModel.statusFetchedResultsController.statusIDs.value = [] + + stateMachine.enter(Loading.self) + } + } + + class Fail: FavoriteViewModel.State { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + switch stateClass { + case is Loading.Type: + return true + default: + return false + } + } + + override func didEnter(from previousState: GKState?) { + super.didEnter(from: previousState) + guard let _ = viewModel, let stateMachine = stateMachine else { return } + + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: retry loading 3s later…", ((#file as NSString).lastPathComponent), #line, #function) + DispatchQueue.main.asyncAfter(deadline: .now() + 3) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: retry loading", ((#file as NSString).lastPathComponent), #line, #function) + stateMachine.enter(Loading.self) + } + } + } + + class Idle: FavoriteViewModel.State { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + switch stateClass { + case is Reloading.Type, is Loading.Type: + return true + default: + return false + } + } + } + + class Loading: FavoriteViewModel.State { + + var maxID: String? + + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + switch stateClass { + case is Fail.Type: + return true + case is Idle.Type: + return true + case is NoMore.Type: + return true + default: + return false + } + } + + override func didEnter(from previousState: GKState?) { + super.didEnter(from: previousState) + guard let viewModel = viewModel, let stateMachine = stateMachine else { return } + + guard let activeMastodonAuthenticationBox = 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) + + 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 + }() + + if hasNewStatusesAppend && hasNextPage { + stateMachine.enter(Idle.self) + } else { + stateMachine.enter(NoMore.self) + } + viewModel.statusFetchedResultsController.statusIDs.value = statusIDs + } + .store(in: &viewModel.disposeBag) + } + } + + class NoMore: FavoriteViewModel.State { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + switch stateClass { + case is Reloading.Type: + return true + default: + return false + } + } + } +} diff --git a/Mastodon/Scene/Profile/Favorite/FavoriteViewModel.swift b/Mastodon/Scene/Profile/Favorite/FavoriteViewModel.swift new file mode 100644 index 000000000..589ffe190 --- /dev/null +++ b/Mastodon/Scene/Profile/Favorite/FavoriteViewModel.swift @@ -0,0 +1,101 @@ +// +// FavoriteViewModel.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-6. +// + +import UIKit +import Combine +import CoreData +import CoreDataStack +import GameplayKit + +final class FavoriteViewModel { + + var disposeBag = Set() + + // input + let context: AppContext + let activeMastodonAuthenticationBox: CurrentValueSubject + let statusFetchedResultsController: StatusFetchedResultsController + let cellFrameCache = NSCache() + + // output + var diffableDataSource: UITableViewDiffableDataSource? + private(set) lazy var stateMachine: GKStateMachine = { + let stateMachine = GKStateMachine(states: [ + State.Initial(viewModel: self), + State.Reloading(viewModel: self), + State.Fail(viewModel: self), + State.Idle(viewModel: self), + State.Loading(viewModel: self), + State.NoMore(viewModel: self), + ]) + stateMachine.enter(State.Initial.self) + return stateMachine + }() + + + init(context: AppContext) { + self.context = context + self.activeMastodonAuthenticationBox = CurrentValueSubject(context.authenticationService.activeMastodonAuthenticationBox.value) + self.statusFetchedResultsController = StatusFetchedResultsController( + managedObjectContext: context.managedObjectContext, + domain: nil, + additionalTweetPredicate: Status.notDeleted() + ) + + context.authenticationService.activeMastodonAuthenticationBox + .assign(to: \.value, on: activeMastodonAuthenticationBox) + .store(in: &disposeBag) + + activeMastodonAuthenticationBox + .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/Header/ProfileHeaderViewController.swift b/Mastodon/Scene/Profile/Header/ProfileHeaderViewController.swift new file mode 100644 index 000000000..217ea6584 --- /dev/null +++ b/Mastodon/Scene/Profile/Header/ProfileHeaderViewController.swift @@ -0,0 +1,437 @@ +// +// ProfileHeaderViewController.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-29. +// + +import os.log +import UIKit +import Combine +import PhotosUI +import AlamofireImage +import CropViewController +import TwitterTextEditor + +protocol ProfileHeaderViewControllerDelegate: AnyObject { + func profileHeaderViewController(_ viewController: ProfileHeaderViewController, viewLayoutDidUpdate view: UIView) + func profileHeaderViewController(_ viewController: ProfileHeaderViewController, pageSegmentedControlValueChanged segmentedControl: UISegmentedControl, selectedSegmentIndex index: Int) +} + +final class ProfileHeaderViewController: UIViewController { + + static let segmentedControlHeight: CGFloat = 32 + static let segmentedControlMarginHeight: CGFloat = 20 + static let headerMinHeight: CGFloat = segmentedControlHeight + 2 * segmentedControlMarginHeight + + var disposeBag = Set() + weak var delegate: ProfileHeaderViewControllerDelegate? + + var viewModel: ProfileHeaderViewModel! + + let titleView: DoubleTitleLabelNavigationBarTitleView = { + let titleView = DoubleTitleLabelNavigationBarTitleView() + titleView.titleLabel.textColor = .white + titleView.titleLabel.alpha = 0 + titleView.subtitleLabel.textColor = .white + titleView.subtitleLabel.alpha = 0 + titleView.layer.masksToBounds = true + return titleView + }() + + let profileHeaderView = ProfileHeaderView() + let pageSegmentedControl: UISegmentedControl = { + let segmenetedControl = UISegmentedControl(items: ["A", "B"]) + segmenetedControl.selectedSegmentIndex = 0 + return segmenetedControl + }() + + private var isBannerPinned = false + private var bottomShadowAlpha: CGFloat = 0.0 + + // private var isAdjustBannerImageViewForSafeAreaInset = false + private var containerSafeAreaInset: UIEdgeInsets = .zero + + private(set) lazy var imagePicker: PHPickerViewController = { + var configuration = PHPickerConfiguration() + configuration.filter = .images + configuration.selectionLimit = 1 + + let imagePicker = PHPickerViewController(configuration: configuration) + imagePicker.delegate = self + return imagePicker + }() + private(set) lazy var imagePickerController: UIImagePickerController = { + let imagePickerController = UIImagePickerController() + imagePickerController.sourceType = .camera + imagePickerController.delegate = self + return imagePickerController + }() + + private(set) lazy var documentPickerController: UIDocumentPickerViewController = { + let documentPickerController = UIDocumentPickerViewController(forOpeningContentTypes: [.image]) + documentPickerController.delegate = self + return documentPickerController + }() + + deinit { + os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + } + +} + +extension ProfileHeaderViewController { + + override func viewDidLoad() { + super.viewDidLoad() + + view.backgroundColor = Asset.Colors.Background.systemGroupedBackground.color + + profileHeaderView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(profileHeaderView) + NSLayoutConstraint.activate([ + profileHeaderView.topAnchor.constraint(equalTo: view.topAnchor), + profileHeaderView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + profileHeaderView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + ]) + profileHeaderView.preservesSuperviewLayoutMargins = true + + pageSegmentedControl.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(pageSegmentedControl) + NSLayoutConstraint.activate([ + pageSegmentedControl.topAnchor.constraint(equalTo: profileHeaderView.bottomAnchor, constant: ProfileHeaderViewController.segmentedControlMarginHeight), + pageSegmentedControl.leadingAnchor.constraint(equalTo: view.readableContentGuide.leadingAnchor), + 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() + ) + .receive(on: DispatchQueue.main) + .sink { [weak self] viewDidAppear, isTitleViewContentOffsetDidSetted in + guard let self = self else { return } + self.titleView.titleLabel.alpha = viewDidAppear && isTitleViewContentOffsetDidSetted ? 1 : 0 + self.titleView.subtitleLabel.alpha = viewDidAppear && isTitleViewContentOffsetDidSetted ? 1 : 0 + } + .store(in: &disposeBag) + + viewModel.needsSetupBottomShadow + .receive(on: DispatchQueue.main) + .sink { [weak self] needsSetupBottomShadow in + guard let self = self else { return } + self.setupBottomShadow() + } + .store(in: &disposeBag) + + Publishers.CombineLatest4( + 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 + 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 + ) + ) + } + .store(in: &disposeBag) + Publishers.CombineLatest3( + viewModel.isEditing.eraseToAnyPublisher(), + viewModel.displayProfileInfo.name.removeDuplicates().eraseToAnyPublisher(), + viewModel.editProfileInfo.name.removeDuplicates().eraseToAnyPublisher() + ) + .receive(on: DispatchQueue.main) + .sink { [weak self] isEditing, name, editingName in + guard let self = self else { return } + self.profileHeaderView.nameTextField.text = isEditing ? editingName : name + } + .store(in: &disposeBag) + + Publishers.CombineLatest3( + viewModel.isEditing.eraseToAnyPublisher(), + viewModel.displayProfileInfo.note.removeDuplicates().eraseToAnyPublisher(), + viewModel.editProfileInfo.note.removeDuplicates().eraseToAnyPublisher() + ) + .receive(on: DispatchQueue.main) + .sink { [weak self] isEditing, note, editingNote in + guard let self = self else { return } + self.profileHeaderView.bioActiveLabel.configure(note: note ?? "", emojiDict: [:]) // FIXME: custom emoji + self.profileHeaderView.bioTextEditorView.text = editingNote ?? "" + } + .store(in: &disposeBag) + + profileHeaderView.bioTextEditorView.changeObserver = self + NotificationCenter.default.publisher(for: UITextField.textDidChangeNotification, object: profileHeaderView.nameTextField) + .receive(on: DispatchQueue.main) + .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 + } + .store(in: &disposeBag) + + profileHeaderView.editAvatarButton.menu = createAvatarContextMenu() + profileHeaderView.editAvatarButton.showsMenuAsPrimaryAction = true + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + + viewModel.viewDidAppear.value = true + + // Deprecated: + // not needs this tweak due to force layout update in the parent + // if !isAdjustBannerImageViewForSafeAreaInset { + // isAdjustBannerImageViewForSafeAreaInset = true + // profileHeaderView.bannerImageView.frame.origin.y = -containerSafeAreaInset.top + // profileHeaderView.bannerImageView.frame.size.height += containerSafeAreaInset.top + // } + } + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + + delegate?.profileHeaderViewController(self, viewLayoutDidUpdate: view) + setupBottomShadow() + } + +} + +extension ProfileHeaderViewController { + private func createAvatarContextMenu() -> UIMenu { + var children: [UIMenuElement] = [] + let photoLibraryAction = UIAction(title: L10n.Scene.Compose.MediaSelection.photoLibrary, image: UIImage(systemName: "rectangle.on.rectangle"), identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off) { [weak self] _ in + guard let self = self else { return } + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: mediaSelectionType: .photoLibaray", ((#file as NSString).lastPathComponent), #line, #function) + self.present(self.imagePicker, animated: true, completion: nil) + } + children.append(photoLibraryAction) + if UIImagePickerController.isSourceTypeAvailable(.camera) { + let cameraAction = UIAction(title: L10n.Scene.Compose.MediaSelection.camera, image: UIImage(systemName: "camera"), identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off, handler: { [weak self] _ in + guard let self = self else { return } + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: mediaSelectionType: .camera", ((#file as NSString).lastPathComponent), #line, #function) + self.present(self.imagePickerController, animated: true, completion: nil) + }) + children.append(cameraAction) + } + let browseAction = UIAction(title: L10n.Scene.Compose.MediaSelection.browse, image: UIImage(systemName: "ellipsis"), identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off) { [weak self] _ in + guard let self = self else { return } + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: mediaSelectionType: .browse", ((#file as NSString).lastPathComponent), #line, #function) + self.present(self.documentPickerController, animated: true, completion: nil) + } + children.append(browseAction) + + return UIMenu(title: "", image: nil, identifier: nil, options: .displayInline, children: children) + } + + private func cropImage(image: UIImage, pickerViewController: UIViewController) { + DispatchQueue.main.async { + let cropController = CropViewController(croppingStyle: .default, image: image) + cropController.delegate = self + cropController.setAspectRatioPreset(.presetSquare, animated: true) + cropController.aspectRatioPickerButtonHidden = true + cropController.aspectRatioLockEnabled = true + pickerViewController.dismiss(animated: true, completion: { + self.present(cropController, animated: true, completion: nil) + }) + } + } +} + +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) + } + +} + +extension ProfileHeaderViewController { + + func updateHeaderContainerSafeAreaInset(_ inset: UIEdgeInsets) { + containerSafeAreaInset = inset + } + + func setupBottomShadow() { + guard viewModel.needsSetupBottomShadow.value else { + view.layer.shadowColor = nil + view.layer.shadowRadius = 0 + return + } + view.layer.setupShadow(color: UIColor.black.withAlphaComponent(0.12), alpha: Float(bottomShadowAlpha), x: 0, y: 2, blur: 2, spread: 0, roundedRect: view.bounds, byRoundingCorners: .allCorners, cornerRadii: .zero) + } + + private func updateHeaderBottomShadow(progress: CGFloat) { + let alpha = min(max(0, 10 * progress - 9), 1) + if bottomShadowAlpha != alpha { + bottomShadowAlpha = alpha + view.setNeedsLayout() + } + } + + func updateHeaderScrollProgress(_ progress: CGFloat) { + // os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: progress: %.2f", ((#file as NSString).lastPathComponent), #line, #function, progress) + updateHeaderBottomShadow(progress: progress) + + let bannerImageView = profileHeaderView.bannerImageView + guard bannerImageView.bounds != .zero else { + // wait layout finish + return + } + + let bannerContainerInWindow = profileHeaderView.convert(profileHeaderView.bannerContainerView.frame, to: nil) + let bannerContainerBottomOffset = bannerContainerInWindow.origin.y + bannerContainerInWindow.height + + // scroll from bottom to top: 1 -> 2 -> 3 + if bannerContainerInWindow.origin.y > containerSafeAreaInset.top { + // 1 + // banner top pin to window top and expand + bannerImageView.frame.origin.y = -bannerContainerInWindow.origin.y + bannerImageView.frame.size.height = bannerContainerInWindow.origin.y + bannerContainerInWindow.size.height + } else if bannerContainerBottomOffset < containerSafeAreaInset.top { + // 3 + // banner bottom pin to navigation bar bottom and + // the `progress` growth to 1 then segemented control pin to top + bannerImageView.frame.origin.y = -containerSafeAreaInset.top + let bannerImageHeight = bannerContainerInWindow.size.height + containerSafeAreaInset.top + (containerSafeAreaInset.top - bannerContainerBottomOffset) + bannerImageView.frame.size.height = bannerImageHeight + } else { + // 2 + // banner move with scrolling from bottom to top until the + // banner bottom higher than navigation bar bottom + bannerImageView.frame.origin.y = -containerSafeAreaInset.top + bannerImageView.frame.size.height = bannerContainerInWindow.size.height + containerSafeAreaInset.top + } + + // set title view offset + let nameTextFieldInWindow = profileHeaderView.nameTextField.superview!.convert(profileHeaderView.nameTextField.frame, to: nil) + let nameTextFieldTopToNavigationBarBottomOffset = containerSafeAreaInset.top - nameTextFieldInWindow.origin.y + let titleViewContentOffset: CGFloat = titleView.frame.height - nameTextFieldTopToNavigationBarBottomOffset + let transformY = max(0, titleViewContentOffset) + titleView.containerView.transform = CGAffineTransform(translationX: 0, y: transformY) + viewModel.isTitleViewDisplaying.value = transformY < titleView.containerView.frame.height + + if viewModel.viewDidAppear.value { + viewModel.isTitleViewContentOffsetSet.value = true + } + + // set avatar + if progress > 0 { + setProfileBannerFade(alpha: 0) + } else if progress > -0.3 { + // y = -(10/3)x + let alpha = -10.0 / 3.0 * progress + setProfileBannerFade(alpha: alpha) + } else { + setProfileBannerFade(alpha: 1) + } + } + + private func setProfileBannerFade(alpha: CGFloat) { + profileHeaderView.avatarImageViewBackgroundView.alpha = alpha + profileHeaderView.avatarImageView.alpha = alpha + profileHeaderView.editAvatarBackgroundView.alpha = alpha + profileHeaderView.nameTextFieldBackgroundView.alpha = alpha + profileHeaderView.nameTextField.alpha = alpha + profileHeaderView.usernameLabel.alpha = alpha + } + +} + +// MARK: - TextEditorViewChangeObserver +extension ProfileHeaderViewController: TextEditorViewChangeObserver { + func textEditorView(_ textEditorView: TextEditorView, didChangeWithChangeResult changeResult: TextEditorViewChangeResult) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: text: %s", ((#file as NSString).lastPathComponent), #line, #function, textEditorView.text) + guard changeResult.isTextChanged else { return } + assert(textEditorView === profileHeaderView.bioTextEditorView) + viewModel.editProfileInfo.note.value = textEditorView.text + } +} + +// MARK: - PHPickerViewControllerDelegate +extension ProfileHeaderViewController: PHPickerViewControllerDelegate { + func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) { + picker.dismiss(animated: true, completion: nil) + guard let result = results.first else { return } + PHPickerResultLoader.loadImageData(from: result) + .sink { [weak self] completion in + guard let _ = self else { return } + switch completion { + case .failure: + // TODO: handle error + break + case .finished: + break + } + } receiveValue: { [weak self] imageData in + guard let self = self else { return } + guard let imageData = imageData else { return } + guard let image = UIImage(data: imageData) else { return } + self.cropImage(image: image, pickerViewController: picker) + } + .store(in: &disposeBag) + } +} + +// MARK: - UIImagePickerControllerDelegate +extension ProfileHeaderViewController: UIImagePickerControllerDelegate & UINavigationControllerDelegate { + + func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey : Any]) { + picker.dismiss(animated: true, completion: nil) + + guard let image = info[.originalImage] as? UIImage else { return } + cropImage(image: image, pickerViewController: picker) + } + + func imagePickerControllerDidCancel(_ picker: UIImagePickerController) { + os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + picker.dismiss(animated: true, completion: nil) + } +} + +// MARK: - UIDocumentPickerDelegate +extension ProfileHeaderViewController: UIDocumentPickerDelegate { + func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) { + guard let url = urls.first else { return } + + do { + guard url.startAccessingSecurityScopedResource() else { return } + defer { url.stopAccessingSecurityScopedResource() } + let imageData = try Data(contentsOf: url) + guard let image = UIImage(data: imageData) else { return } + cropImage(image: image, pickerViewController: controller) + } catch { + os_log("%{public}s[%{public}ld], %{public}s: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) + } + } +} + +// MARK: - CropViewControllerDelegate +extension ProfileHeaderViewController: CropViewControllerDelegate { + public func cropViewController(_ cropViewController: CropViewController, didCropToImage image: UIImage, withRect cropRect: CGRect, angle: Int) { + viewModel.editProfileInfo.avatarImageResource.value = .image(image) + cropViewController.dismiss(animated: true, completion: nil) + } +} + diff --git a/Mastodon/Scene/Profile/Header/ProfileHeaderViewModel.swift b/Mastodon/Scene/Profile/Header/ProfileHeaderViewModel.swift new file mode 100644 index 000000000..6e4fe2def --- /dev/null +++ b/Mastodon/Scene/Profile/Header/ProfileHeaderViewModel.swift @@ -0,0 +1,116 @@ +// +// ProfileHeaderViewModel.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-9. +// + +import UIKit +import Combine +import Kanna +import MastodonSDK + +final class ProfileHeaderViewModel { + + var disposeBag = Set() + + // input + let context: AppContext + let isEditing = CurrentValueSubject(false) + let viewDidAppear = CurrentValueSubject(false) + let needsSetupBottomShadow = CurrentValueSubject(true) + let isTitleViewContentOffsetSet = CurrentValueSubject(false) + + // output + let displayProfileInfo = ProfileInfo() + let editProfileInfo = ProfileInfo() + let isTitleViewDisplaying = CurrentValueSubject(false) + + init(context: AppContext) { + self.context = context + + isEditing + .removeDuplicates() // only triiger when value toggle + .receive(on: DispatchQueue.main) + .sink { [weak self] isEditing in + guard let self = self 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) + } + .store(in: &disposeBag) + } + +} + +extension ProfileHeaderViewModel { + struct ProfileInfo { + let name = CurrentValueSubject(nil) + let avatarImageResource = CurrentValueSubject(nil) + let note = CurrentValueSubject(nil) + + enum ImageResource { + case url(URL?) + case image(UIImage?) + } + } +} + +extension ProfileHeaderViewModel { + + static func normalize(note: String?) -> String? { + guard let note = note?.trimmingCharacters(in: .whitespacesAndNewlines),!note.isEmpty else { + return nil + } + + let html = try? HTML(html: note, encoding: .utf8) + return html?.text + } + + // check if profile chagned 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 } + + 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 <= MastodonRegisterViewController.avatarImageMaxSizeInPixel.width else { + return image.af.imageScaled(to: MastodonRegisterViewController.avatarImageMaxSizeInPixel) + } + return image + }() + + 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: nil // TODO: + ) + return context.apiService.accountUpdateCredentials( + domain: domain, + query: query, + authorization: authorization + ) + } + +} diff --git a/Mastodon/Scene/Profile/Header/View/ProfileFieldView.swift b/Mastodon/Scene/Profile/Header/View/ProfileFieldView.swift new file mode 100644 index 000000000..320a495eb --- /dev/null +++ b/Mastodon/Scene/Profile/Header/View/ProfileFieldView.swift @@ -0,0 +1,100 @@ +// +// ProfileFieldView.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-30. +// + +import UIKit +import ActiveLabel + +final class ProfileFieldView: UIView { + + let titleLabel: UILabel = { + let label = UILabel() + label.font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 17, weight: .semibold)) + label.textColor = Asset.Colors.Label.primary.color + label.text = "Title" + return label + }() + + let valueActiveLabel: ActiveLabel = { + let label = ActiveLabel(style: .profileField) + label.configure(content: "value", emojiDict: [:]) + return label + }() + + let topSeparatorLine = UIView.separatorLine + let bottomSeparatorLine = UIView.separatorLine + + override init(frame: CGRect) { + super.init(frame: frame) + _init() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + _init() + } + +} + +extension ProfileFieldView { + private func _init() { + titleLabel.translatesAutoresizingMaskIntoConstraints = false + addSubview(titleLabel) + NSLayoutConstraint.activate([ + titleLabel.topAnchor.constraint(equalTo: topAnchor), + titleLabel.leadingAnchor.constraint(equalTo: readableContentGuide.leadingAnchor), + titleLabel.bottomAnchor.constraint(equalTo: bottomAnchor), + titleLabel.heightAnchor.constraint(equalToConstant: 44).priority(.defaultHigh), + ]) + titleLabel.setContentHuggingPriority(.defaultLow, for: .horizontal) + + valueActiveLabel.translatesAutoresizingMaskIntoConstraints = false + addSubview(valueActiveLabel) + NSLayoutConstraint.activate([ + valueActiveLabel.centerYAnchor.constraint(equalTo: centerYAnchor), + valueActiveLabel.leadingAnchor.constraint(equalTo: titleLabel.trailingAnchor), + valueActiveLabel.trailingAnchor.constraint(equalTo: readableContentGuide.trailingAnchor), + ]) + valueActiveLabel.setContentHuggingPriority(.defaultHigh, for: .horizontal) + + topSeparatorLine.translatesAutoresizingMaskIntoConstraints = false + addSubview(topSeparatorLine) + NSLayoutConstraint.activate([ + topSeparatorLine.topAnchor.constraint(equalTo: topAnchor), + topSeparatorLine.leadingAnchor.constraint(equalTo: leadingAnchor), + topSeparatorLine.trailingAnchor.constraint(equalTo: trailingAnchor), + topSeparatorLine.heightAnchor.constraint(equalToConstant: UIView.separatorLineHeight(of: self)).priority(.defaultHigh), + ]) + + bottomSeparatorLine.translatesAutoresizingMaskIntoConstraints = false + addSubview(bottomSeparatorLine) + NSLayoutConstraint.activate([ + bottomSeparatorLine.bottomAnchor.constraint(equalTo: bottomAnchor), + bottomSeparatorLine.leadingAnchor.constraint(equalTo: readableContentGuide.leadingAnchor), + bottomSeparatorLine.trailingAnchor.constraint(equalTo: trailingAnchor), + bottomSeparatorLine.heightAnchor.constraint(equalToConstant: UIView.separatorLineHeight(of: self)).priority(.defaultHigh), + ]) + } +} + +#if canImport(SwiftUI) && DEBUG +import SwiftUI + +struct ProfileFieldView_Previews: PreviewProvider { + + static var previews: some View { + UIViewPreview(width: 375) { + let filedView = ProfileFieldView() + filedView.valueActiveLabel.configure(field: "https://mastodon.online") + 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 new file mode 100644 index 000000000..e37427e32 --- /dev/null +++ b/Mastodon/Scene/Profile/Header/View/ProfileHeaderView.swift @@ -0,0 +1,474 @@ +// +// ProfileBannerView.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-29. +// + +import os.log +import UIKit +import ActiveLabel +import TwitterTextEditor + +protocol ProfileHeaderViewDelegate: AnyObject { + func profileHeaderView(_ profileHeaderView: ProfileHeaderView, avatarImageViewDidPressed imageView: UIImageView) + func profileHeaderView(_ profileHeaderView: ProfileHeaderView, bannerImageViewDidPressed imageView: UIImageView) + func profileHeaderView(_ profileHeaderView: ProfileHeaderView, relationshipButtonDidPressed button: ProfileRelationshipActionButton) + func profileHeaderView(_ profileHeaderView: ProfileHeaderView, activeLabel: ActiveLabel, entityDidPressed entity: ActiveEntity) + + func profileHeaderView(_ profileHeaderView: ProfileHeaderView, profileStatusDashboardView: ProfileStatusDashboardView, postDashboardMeterViewDidPressed dashboardMeterView: ProfileStatusDashboardMeterView) + func profileHeaderView(_ profileHeaderView: ProfileHeaderView, profileStatusDashboardView: ProfileStatusDashboardView, followingDashboardMeterViewDidPressed dwingDashboardMeterView: ProfileStatusDashboardMeterView) + func profileHeaderView(_ profileHeaderView: ProfileHeaderView, profileStatusDashboardView: ProfileStatusDashboardView, followersDashboardMeterViewDidPressed dwersDashboardMeterView: ProfileStatusDashboardMeterView) +} + +final class ProfileHeaderView: UIView { + + static let avatarImageViewSize = CGSize(width: 56, height: 56) + static let avatarImageViewCornerRadius: CGFloat = 6 + static let avatarImageViewBorderColor = UIColor.white + static let avatarImageViewBorderWidth: CGFloat = 2 + static let friendshipActionButtonSize = CGSize(width: 108, height: 34) + static let bannerImageViewPlaceholderColor = UIColor.systemGray + + static let bannerImageViewOverlayViewBackgroundNormalColor = UIColor.black.withAlphaComponent(0.5) + static let bannerImageViewOverlayViewBackgroundEditingColor = UIColor.black.withAlphaComponent(0.8) + + weak var delegate: ProfileHeaderViewDelegate? + + var state: State? + + let bannerContainerView = UIView() + let bannerImageView: UIImageView = { + let imageView = UIImageView() + imageView.contentMode = .scaleAspectFill + imageView.image = .placeholder(color: ProfileHeaderView.bannerImageViewPlaceholderColor) + imageView.backgroundColor = ProfileHeaderView.bannerImageViewPlaceholderColor + imageView.layer.masksToBounds = true + imageView.isUserInteractionEnabled = true + // accessibility + imageView.accessibilityIgnoresInvertColors = true + return imageView + }() + let bannerImageViewOverlayView: UIView = { + let overlayView = UIView() + overlayView.backgroundColor = ProfileHeaderView.bannerImageViewOverlayViewBackgroundNormalColor + return overlayView + }() + + let avatarImageViewBackgroundView: UIView = { + let view = UIView() + view.layer.masksToBounds = true + view.layer.cornerRadius = ProfileHeaderView.avatarImageViewCornerRadius + view.layer.cornerCurve = .continuous + view.layer.borderColor = ProfileHeaderView.avatarImageViewBorderColor.cgColor + view.layer.borderWidth = ProfileHeaderView.avatarImageViewBorderWidth + return view + }() + + let avatarImageView: UIImageView = { + let imageView = UIImageView() + let placeholderImage = UIImage + .placeholder(size: ProfileHeaderView.avatarImageViewSize, color: Asset.Colors.Background.systemGroupedBackground.color) + .af.imageRounded(withCornerRadius: ProfileHeaderView.avatarImageViewCornerRadius, divideRadiusByImageScale: false) + imageView.image = placeholderImage + return imageView + }() + + let editAvatarBackgroundView: UIView = { + let view = UIView() + view.backgroundColor = UIColor.black.withAlphaComponent(0.6) + view.layer.masksToBounds = true + view.layer.cornerCurve = .continuous + view.layer.cornerRadius = ProfileHeaderView.avatarImageViewCornerRadius + return view + }() + + let editAvatarButton: HighlightDimmableButton = { + let button = HighlightDimmableButton() + button.setImage(UIImage(systemName: "photo", withConfiguration: UIImage.SymbolConfiguration(pointSize: 28)), for: .normal) + button.tintColor = .white + return button + }() + + let nameTextFieldBackgroundView: UIView = { + let view = UIView() + view.layer.masksToBounds = true + view.layer.cornerCurve = .continuous + view.layer.cornerRadius = 10 + return view + }() + + let nameTextField: UITextField = { + let textField = UITextField() + textField.font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 20, weight: .semibold), maximumPointSize: 28) + textField.textColor = .white + 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.adjustsFontSizeToFitWidth = true + label.minimumScaleFactor = 0.5 + label.textColor = Asset.Scene.Profile.Banner.usernameGray.color + label.text = "@alice" + label.applyShadow(color: UIColor.black.withAlphaComponent(0.2), alpha: 0.5, x: 0, y: 2, blur: 2, spread: 0) + return label + }() + + let statusDashboardView = ProfileStatusDashboardView() + let relationshipActionButton: ProfileRelationshipActionButton = { + let button = ProfileRelationshipActionButton() + button.titleLabel?.font = .systemFont(ofSize: 17, weight: .semibold) + return button + }() + + let bioContainerView = UIView() + let bioContainerStackView = UIStackView() + let fieldContainerStackView = UIStackView() + + let bioActiveLabelContainer: UIView = { + // use to set margin for active label + // the display/edit mode bio transition animation should without flicker with that + let view = UIView() + // note: comment out to see how it works + view.layoutMargins = UIEdgeInsets(top: 8, left: 5, bottom: 8, right: 5) // magic from TextEditorView + return view + }() + let bioActiveLabel = ActiveLabel(style: .default) + let bioTextEditorView: TextEditorView = { + let textEditorView = TextEditorView() + textEditorView.scrollView.isScrollEnabled = false + textEditorView.isScrollEnabled = false + textEditorView.font = .preferredFont(forTextStyle: .body) + textEditorView.backgroundColor = Asset.Scene.Profile.Banner.bioEditBackgroundGray.color + textEditorView.layer.masksToBounds = true + textEditorView.layer.cornerCurve = .continuous + textEditorView.layer.cornerRadius = 10 + return textEditorView + }() + + override init(frame: CGRect) { + super.init(frame: frame) + _init() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + _init() + } + +} + +extension ProfileHeaderView { + private func _init() { + backgroundColor = Asset.Colors.Background.systemGroupedBackground.color + + // banner + bannerContainerView.translatesAutoresizingMaskIntoConstraints = false + bannerContainerView.preservesSuperviewLayoutMargins = true + addSubview(bannerContainerView) + NSLayoutConstraint.activate([ + bannerContainerView.topAnchor.constraint(equalTo: topAnchor), + bannerContainerView.leadingAnchor.constraint(equalTo: leadingAnchor), + trailingAnchor.constraint(equalTo: bannerContainerView.trailingAnchor), + readableContentGuide.widthAnchor.constraint(equalTo: bannerContainerView.heightAnchor, multiplier: 3), // set height to 1/3 of readable frame width + ]) + + bannerImageView.autoresizingMask = [.flexibleWidth, .flexibleHeight] + bannerImageView.frame = bannerContainerView.bounds + bannerContainerView.addSubview(bannerImageView) + + bannerImageViewOverlayView.translatesAutoresizingMaskIntoConstraints = false + bannerImageView.addSubview(bannerImageViewOverlayView) + NSLayoutConstraint.activate([ + bannerImageViewOverlayView.topAnchor.constraint(equalTo: bannerImageView.topAnchor), + bannerImageViewOverlayView.leadingAnchor.constraint(equalTo: bannerImageView.leadingAnchor), + bannerImageViewOverlayView.trailingAnchor.constraint(equalTo: bannerImageView.trailingAnchor), + bannerImageViewOverlayView.bottomAnchor.constraint(equalTo: bannerImageView.bottomAnchor), + ]) + + // avatar + avatarImageView.translatesAutoresizingMaskIntoConstraints = false + bannerContainerView.addSubview(avatarImageView) + NSLayoutConstraint.activate([ + avatarImageView.leadingAnchor.constraint(equalTo: bannerContainerView.readableContentGuide.leadingAnchor), + bannerContainerView.bottomAnchor.constraint(equalTo: avatarImageView.bottomAnchor, constant: 20), + avatarImageView.widthAnchor.constraint(equalToConstant: ProfileHeaderView.avatarImageViewSize.width).priority(.required - 1), + avatarImageView.heightAnchor.constraint(equalToConstant: ProfileHeaderView.avatarImageViewSize.height).priority(.required - 1), + ]) + + avatarImageViewBackgroundView.translatesAutoresizingMaskIntoConstraints = false + bannerContainerView.insertSubview(avatarImageViewBackgroundView, belowSubview: avatarImageView) + 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), + ]) + + editAvatarBackgroundView.translatesAutoresizingMaskIntoConstraints = false + avatarImageView.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), + ]) + + editAvatarButton.translatesAutoresizingMaskIntoConstraints = false + editAvatarBackgroundView.addSubview(editAvatarButton) + NSLayoutConstraint.activate([ + editAvatarButton.topAnchor.constraint(equalTo: editAvatarBackgroundView.topAnchor), + editAvatarButton.leadingAnchor.constraint(equalTo: editAvatarBackgroundView.leadingAnchor), + editAvatarButton.trailingAnchor.constraint(equalTo: editAvatarBackgroundView.trailingAnchor), + editAvatarButton.bottomAnchor.constraint(equalTo: editAvatarBackgroundView.bottomAnchor), + ]) + editAvatarBackgroundView.isUserInteractionEnabled = true + avatarImageView.isUserInteractionEnabled = true + + // name container: [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), + ]) + + let displayNameStackView = UIStackView() + displayNameStackView.axis = .horizontal + nameTextField.translatesAutoresizingMaskIntoConstraints = false + displayNameStackView.addArrangedSubview(nameTextField) + NSLayoutConstraint.activate([ + nameTextField.widthAnchor.constraint(greaterThanOrEqualToConstant: 44).priority(.defaultHigh), + ]) + nameTextField.setContentHuggingPriority(.defaultHigh, for: .horizontal) + nameTextFieldBackgroundView.translatesAutoresizingMaskIntoConstraints = false + displayNameStackView.addSubview(nameTextFieldBackgroundView) + NSLayoutConstraint.activate([ + nameTextField.topAnchor.constraint(equalTo: nameTextFieldBackgroundView.topAnchor, constant: 5), + nameTextField.leadingAnchor.constraint(equalTo: nameTextFieldBackgroundView.leadingAnchor, constant: 5), + nameTextFieldBackgroundView.bottomAnchor.constraint(equalTo: nameTextField.bottomAnchor, constant: 5), + nameTextFieldBackgroundView.trailingAnchor.constraint(equalTo: nameTextField.trailingAnchor, constant: 5), + ]) + displayNameStackView.bringSubviewToFront(nameTextField) + displayNameStackView.addArrangedSubview(UIView()) + + 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), + ]) + + relationshipActionButton.translatesAutoresizingMaskIntoConstraints = false + dashboardContainerView.addSubview(relationshipActionButton) + NSLayoutConstraint.activate([ + relationshipActionButton.topAnchor.constraint(equalTo: dashboardContainerView.topAnchor), + relationshipActionButton.leadingAnchor.constraint(greaterThanOrEqualTo: statusDashboardView.trailingAnchor, constant: 8), + relationshipActionButton.trailingAnchor.constraint(equalTo: dashboardContainerView.readableContentGuide.trailingAnchor), + relationshipActionButton.widthAnchor.constraint(equalToConstant: ProfileHeaderView.friendshipActionButtonSize.width).priority(.defaultHigh), + relationshipActionButton.heightAnchor.constraint(equalToConstant: ProfileHeaderView.friendshipActionButtonSize.height).priority(.defaultHigh), + ]) + + bioContainerView.preservesSuperviewLayoutMargins = true + metaContainerStackView.addArrangedSubview(bioContainerView) + + bioContainerStackView.translatesAutoresizingMaskIntoConstraints = false + bioContainerView.addSubview(bioContainerStackView) + NSLayoutConstraint.activate([ + bioContainerStackView.topAnchor.constraint(equalTo: bioContainerView.topAnchor), + bioContainerStackView.leadingAnchor.constraint(equalTo: bioContainerView.readableContentGuide.leadingAnchor), + bioContainerStackView.trailingAnchor.constraint(equalTo: bioContainerView.readableContentGuide.trailingAnchor), + bioContainerStackView.bottomAnchor.constraint(equalTo: bioContainerView.bottomAnchor), + ]) + + bioActiveLabel.translatesAutoresizingMaskIntoConstraints = false + bioActiveLabelContainer.addSubview(bioActiveLabel) + NSLayoutConstraint.activate([ + bioActiveLabel.topAnchor.constraint(equalTo: bioActiveLabelContainer.layoutMarginsGuide.topAnchor), + bioActiveLabel.leadingAnchor.constraint(equalTo: bioActiveLabelContainer.layoutMarginsGuide.leadingAnchor), + bioActiveLabel.trailingAnchor.constraint(equalTo: bioActiveLabelContainer.layoutMarginsGuide.trailingAnchor), + bioActiveLabel.bottomAnchor.constraint(equalTo: bioActiveLabelContainer.layoutMarginsGuide.bottomAnchor), + ]) + + bioContainerStackView.axis = .vertical + bioContainerStackView.addArrangedSubview(bioActiveLabelContainer) + bioContainerStackView.addArrangedSubview(bioTextEditorView) + + fieldContainerStackView.preservesSuperviewLayoutMargins = true + metaContainerStackView.addSubview(fieldContainerStackView) + + bringSubviewToFront(bannerContainerView) + bringSubviewToFront(nameContainerStackView) + + bioActiveLabel.delegate = 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(_:))) + + relationshipActionButton.addTarget(self, action: #selector(ProfileHeaderView.relationshipActionButtonDidPressed(_:)), for: .touchUpInside) + + configure(state: .normal) + } + +} + +extension ProfileHeaderView { + enum State { + case normal + case editing + } + + func configure(state: State) { + guard self.state != state else { return } // avoid redundant animation + self.state = state + + let animator = UIViewPropertyAnimator(duration: 0.33, curve: .easeInOut) + + switch state { + case .normal: + nameTextField.isEnabled = false + bioActiveLabelContainer.isHidden = false + bioTextEditorView.isHidden = true + + animator.addAnimations { + self.bannerImageViewOverlayView.backgroundColor = ProfileHeaderView.bannerImageViewOverlayViewBackgroundNormalColor + self.nameTextFieldBackgroundView.backgroundColor = .clear + self.editAvatarBackgroundView.alpha = 0 + } + animator.addCompletion { _ in + self.editAvatarBackgroundView.isHidden = true + } + case .editing: + nameTextField.isEnabled = true + bioActiveLabelContainer.isHidden = true + bioTextEditorView.isHidden = false + + editAvatarBackgroundView.isHidden = false + editAvatarBackgroundView.alpha = 0 + bioTextEditorView.backgroundColor = .clear + animator.addAnimations { + self.bannerImageViewOverlayView.backgroundColor = ProfileHeaderView.bannerImageViewOverlayViewBackgroundEditingColor + self.nameTextFieldBackgroundView.backgroundColor = Asset.Scene.Profile.Banner.nameEditBackgroundGray.color + self.editAvatarBackgroundView.alpha = 1 + self.bioTextEditorView.backgroundColor = Asset.Scene.Profile.Banner.bioEditBackgroundGray.color + } + } + + animator.startAnimation() + } +} + +extension ProfileHeaderView { + @objc private func relationshipActionButtonDidPressed(_ sender: UIButton) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + assert(sender === relationshipActionButton) + delegate?.profileHeaderView(self, relationshipButtonDidPressed: relationshipActionButton) + } + + @objc private func avatarImageViewDidPressed(_ sender: UITapGestureRecognizer) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + delegate?.profileHeaderView(self, avatarImageViewDidPressed: avatarImageView) + } + + @objc private func bannerImageViewDidPressed(_ sender: UITapGestureRecognizer) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + delegate?.profileHeaderView(self, bannerImageViewDidPressed: bannerImageView) + } +} + +// MARK: - ActiveLabelDelegate +extension ProfileHeaderView: ActiveLabelDelegate { + func activeLabel(_ activeLabel: ActiveLabel, didSelectActiveEntity entity: ActiveEntity) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: select entity: %s", ((#file as NSString).lastPathComponent), #line, #function, entity.primaryText) + delegate?.profileHeaderView(self, activeLabel: activeLabel, entityDidPressed: entity) + } +} + +// MARK: - ProfileStatusDashboardViewDelegate +extension ProfileHeaderView: ProfileStatusDashboardViewDelegate { + + func profileStatusDashboardView(_ dashboardView: ProfileStatusDashboardView, postDashboardMeterViewDidPressed dashboardMeterView: ProfileStatusDashboardMeterView) { + delegate?.profileHeaderView(self, profileStatusDashboardView: dashboardView, postDashboardMeterViewDidPressed: dashboardMeterView) + } + + func profileStatusDashboardView(_ dashboardView: ProfileStatusDashboardView, followingDashboardMeterViewDidPressed dashboardMeterView: ProfileStatusDashboardMeterView) { + delegate?.profileHeaderView(self, profileStatusDashboardView: dashboardView, followingDashboardMeterViewDidPressed: dashboardMeterView) + } + + func profileStatusDashboardView(_ dashboardView: ProfileStatusDashboardView, followersDashboardMeterViewDidPressed dashboardMeterView: ProfileStatusDashboardMeterView) { + delegate?.profileHeaderView(self, profileStatusDashboardView: dashboardView, followersDashboardMeterViewDidPressed: dashboardMeterView) + } + +} + +// MARK: - AvatarConfigurableView +extension ProfileHeaderView: AvatarConfigurableView { + static var configurableAvatarImageSize: CGSize { avatarImageViewSize } + static var configurableAvatarImageCornerRadius: CGFloat { avatarImageViewCornerRadius } + var configurableAvatarImageView: UIImageView? { return avatarImageView } + var configurableAvatarButton: UIButton? { return nil } +} + + +#if DEBUG +import SwiftUI + +struct ProfileHeaderView_Previews: PreviewProvider { + static var previews: some View { + Group { + UIViewPreview(width: 375) { + let banner = ProfileHeaderView() + banner.bannerImageView.image = UIImage(named: "lucas-ludwig") + return banner + } + .previewLayout(.fixed(width: 375, height: 800)) + UIViewPreview(width: 375) { + let banner = ProfileHeaderView() + //banner.bannerImageView.image = UIImage(named: "peter-luo") + return banner + } + .preferredColorScheme(.dark) + .previewLayout(.fixed(width: 375, height: 800)) + } + } +} +#endif diff --git a/Mastodon/Scene/Profile/Header/View/ProfileRelationshipActionButton.swift b/Mastodon/Scene/Profile/Header/View/ProfileRelationshipActionButton.swift new file mode 100644 index 000000000..948d22b0f --- /dev/null +++ b/Mastodon/Scene/Profile/Header/View/ProfileRelationshipActionButton.swift @@ -0,0 +1,67 @@ +// +// ProfileRelationshipActionButton.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-30. +// + +import UIKit + +final class ProfileRelationshipActionButton: RoundedEdgesButton { + + let activityIndicatorView: UIActivityIndicatorView = { + let activityIndicatorView = UIActivityIndicatorView(style: .medium) + activityIndicatorView.color = .white + return activityIndicatorView + }() + + override init(frame: CGRect) { + super.init(frame: frame) + _init() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + _init() + } + +} + +extension ProfileRelationshipActionButton { + private func _init() { + titleLabel?.font = .systemFont(ofSize: 17, weight: .semibold) + + activityIndicatorView.translatesAutoresizingMaskIntoConstraints = false + addSubview(activityIndicatorView) + NSLayoutConstraint.activate([ + activityIndicatorView.centerXAnchor.constraint(equalTo: centerXAnchor), + activityIndicatorView.centerYAnchor.constraint(equalTo: centerYAnchor), + ]) + + activityIndicatorView.hidesWhenStopped = true + activityIndicatorView.stopAnimating() + } +} + +extension ProfileRelationshipActionButton { + func configure(actionOptionSet: ProfileViewModel.RelationshipActionOptionSet) { + setTitle(actionOptionSet.title, for: .normal) + setTitleColor(.white, for: .normal) + setTitleColor(UIColor.white.withAlphaComponent(0.5), for: .highlighted) + setBackgroundImage(.placeholder(color: actionOptionSet.backgroundColor), for: .normal) + setBackgroundImage(.placeholder(color: actionOptionSet.backgroundColor.withAlphaComponent(0.5)), for: .highlighted) + setBackgroundImage(.placeholder(color: actionOptionSet.backgroundColor.withAlphaComponent(0.5)), for: .disabled) + + activityIndicatorView.stopAnimating() + + if let option = actionOptionSet.highPriorityAction(except: .editOptions), option == .blocked || option == .suspended { + isEnabled = false + } else if actionOptionSet.contains(.updating) { + isEnabled = false + activityIndicatorView.startAnimating() + } else { + isEnabled = true + } + } +} + diff --git a/Mastodon/Scene/Profile/Header/View/ProfileStatusDashboardMeterView.swift b/Mastodon/Scene/Profile/Header/View/ProfileStatusDashboardMeterView.swift new file mode 100644 index 000000000..4355fdc3e --- /dev/null +++ b/Mastodon/Scene/Profile/Header/View/ProfileStatusDashboardMeterView.swift @@ -0,0 +1,79 @@ +// +// ProfileStatusDashboardMeterView.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-30. +// + +import UIKit + +final class ProfileStatusDashboardMeterView: UIView { + + let numberLabel: UILabel = { + let label = UILabel() + label.font = { + let font = UIFont.systemFont(ofSize: 20, weight: .semibold) + return font.fontDescriptor.withDesign(.rounded).flatMap { + UIFont(descriptor: $0, size: 20) + } ?? font + }() + label.textColor = Asset.Colors.Label.primary.color + label.text = "999" + label.textAlignment = .center + return label + }() + + let textLabel: UILabel = { + let label = UILabel() + label.font = .systemFont(ofSize: 13, weight: .regular) + label.textColor = Asset.Colors.Label.primary.color + label.text = L10n.Scene.Profile.Dashboard.posts + label.textAlignment = .center + return label + }() + + override init(frame: CGRect) { + super.init(frame: frame) + _init() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + _init() + } + +} + +extension ProfileStatusDashboardMeterView { + private func _init() { + numberLabel.translatesAutoresizingMaskIntoConstraints = false + addSubview(numberLabel) + NSLayoutConstraint.activate([ + numberLabel.topAnchor.constraint(equalTo: topAnchor), + numberLabel.leadingAnchor.constraint(equalTo: leadingAnchor), + trailingAnchor.constraint(equalTo: numberLabel.trailingAnchor), + ]) + + textLabel.translatesAutoresizingMaskIntoConstraints = false + addSubview(textLabel) + NSLayoutConstraint.activate([ + textLabel.topAnchor.constraint(equalTo: numberLabel.bottomAnchor), + textLabel.leadingAnchor.constraint(equalTo: leadingAnchor), + trailingAnchor.constraint(equalTo: textLabel.trailingAnchor), + bottomAnchor.constraint(equalTo: textLabel.bottomAnchor), + ]) + } +} + +#if DEBUG +import SwiftUI + +struct ProfileStatusDashboardMeterView_Previews: PreviewProvider { + static var previews: some View { + UIViewPreview(width: 54) { + ProfileStatusDashboardMeterView() + } + .previewLayout(.fixed(width: 54, height: 41)) + } +} +#endif diff --git a/Mastodon/Scene/Profile/Header/View/ProfileStatusDashboardView.swift b/Mastodon/Scene/Profile/Header/View/ProfileStatusDashboardView.swift new file mode 100644 index 000000000..38c093d1b --- /dev/null +++ b/Mastodon/Scene/Profile/Header/View/ProfileStatusDashboardView.swift @@ -0,0 +1,103 @@ +// +// ProfileStatusDashboardView.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-30. +// + +import os.log +import UIKit + +protocol ProfileStatusDashboardViewDelegate: AnyObject { + func profileStatusDashboardView(_ dashboardView: ProfileStatusDashboardView, postDashboardMeterViewDidPressed dashboardMeterView: ProfileStatusDashboardMeterView) + func profileStatusDashboardView(_ dashboardView: ProfileStatusDashboardView, followingDashboardMeterViewDidPressed dashboardMeterView: ProfileStatusDashboardMeterView) + func profileStatusDashboardView(_ dashboardView: ProfileStatusDashboardView, followersDashboardMeterViewDidPressed dashboardMeterView: ProfileStatusDashboardMeterView) +} + +final class ProfileStatusDashboardView: UIView { + + let postDashboardMeterView = ProfileStatusDashboardMeterView() + let followingDashboardMeterView = ProfileStatusDashboardMeterView() + let followersDashboardMeterView = ProfileStatusDashboardMeterView() + + weak var delegate: ProfileStatusDashboardViewDelegate? + + override init(frame: CGRect) { + super.init(frame: frame) + _init() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + _init() + } + +} + +extension ProfileStatusDashboardView { + private func _init() { + let containerStackView = UIStackView() + 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.heightAnchor.constraint(equalToConstant: 44).priority(.defaultHigh), + ]) + + let spacing: CGFloat = 16 + containerStackView.spacing = spacing + containerStackView.axis = .horizontal + containerStackView.distribution = .fillEqually + containerStackView.alignment = .top + containerStackView.addArrangedSubview(postDashboardMeterView) + containerStackView.setCustomSpacing(spacing - 2, after: postDashboardMeterView) + containerStackView.addArrangedSubview(followingDashboardMeterView) + containerStackView.setCustomSpacing(spacing + 2, after: followingDashboardMeterView) + containerStackView.addArrangedSubview(followersDashboardMeterView) + + postDashboardMeterView.textLabel.text = L10n.Scene.Profile.Dashboard.posts + followingDashboardMeterView.textLabel.text = L10n.Scene.Profile.Dashboard.following + followersDashboardMeterView.textLabel.text = L10n.Scene.Profile.Dashboard.followers + + [postDashboardMeterView, followingDashboardMeterView, followersDashboardMeterView].forEach { meterView in + let tapGestureRecognizer = UITapGestureRecognizer.singleTapGestureRecognizer + tapGestureRecognizer.addTarget(self, action: #selector(ProfileStatusDashboardView.tapGestureRecognizerHandler(_:))) + meterView.addGestureRecognizer(tapGestureRecognizer) + } + + } +} + +extension ProfileStatusDashboardView { + @objc private func tapGestureRecognizerHandler(_ sender: UITapGestureRecognizer) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + guard let sourceView = sender.view as? ProfileStatusDashboardMeterView else { + assertionFailure() + return + } + if sourceView === postDashboardMeterView { + delegate?.profileStatusDashboardView(self, postDashboardMeterViewDidPressed: sourceView) + } else if sourceView === followingDashboardMeterView { + delegate?.profileStatusDashboardView(self, followingDashboardMeterViewDidPressed: sourceView) + } else if sourceView === followersDashboardMeterView { + delegate?.profileStatusDashboardView(self, followersDashboardMeterViewDidPressed: sourceView) + } + } +} + + +#if DEBUG +import SwiftUI + +struct ProfileBannerStatusView_Previews: PreviewProvider { + static var previews: some View { + UIViewPreview(width: 375) { + ProfileStatusDashboardView() + } + .previewLayout(.fixed(width: 375, height: 100)) + } +} +#endif diff --git a/Mastodon/Scene/Profile/MeProfileViewModel.swift b/Mastodon/Scene/Profile/MeProfileViewModel.swift new file mode 100644 index 000000000..d1c0cb49d --- /dev/null +++ b/Mastodon/Scene/Profile/MeProfileViewModel.swift @@ -0,0 +1,33 @@ +// +// MeProfileViewModel.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-30. +// + +import os.log +import UIKit +import Combine +import CoreData +import CoreDataStack +import MastodonSDK + +final class MeProfileViewModel: ProfileViewModel { + + init(context: AppContext) { + super.init( + context: context, + optionalMastodonUser: context.authenticationService.activeMastodonAuthentication.value?.user + ) + + self.currentMastodonUser + .sink { [weak self] currentMastodonUser in + os_log("%{public}s[%{public}ld], %{public}s: current active mastodon user: %s", ((#file as NSString).lastPathComponent), #line, #function, currentMastodonUser?.username ?? "") + + guard let self = self else { return } + self.mastodonUser.value = currentMastodonUser + } + .store(in: &disposeBag) + } + +} diff --git a/Mastodon/Scene/Profile/ProfileViewController+UserProvider.swift b/Mastodon/Scene/Profile/ProfileViewController+UserProvider.swift new file mode 100644 index 000000000..6bfa132b8 --- /dev/null +++ b/Mastodon/Scene/Profile/ProfileViewController+UserProvider.swift @@ -0,0 +1,27 @@ +// +// 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 b3c46a42d..c60c20400 100644 --- a/Mastodon/Scene/Profile/ProfileViewController.swift +++ b/Mastodon/Scene/Profile/ProfileViewController.swift @@ -5,20 +5,867 @@ // Created by MainasuK Cirno on 2021-2-23. // +import os.log import UIKit +import Combine +import ActiveLabel -final class ProfileViewController: UIViewController, NeedsDependency { +final class ProfileViewController: UIViewController, NeedsDependency, MediaPreviewableViewController { weak var context: AppContext! { willSet { precondition(!isViewLoaded) } } weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } } + var disposeBag = Set() + var viewModel: ProfileViewModel! + + let mediaPreviewTransitionController = MediaPreviewTransitionController() + + private(set) lazy var cancelEditingBarButtonItem: UIBarButtonItem = { + let barButtonItem = UIBarButtonItem(title: L10n.Common.Controls.Actions.cancel, style: .plain, target: self, action: #selector(ProfileViewController.cancelEditingBarButtonItemPressed(_:))) + barButtonItem.tintColor = .white + return barButtonItem + }() + + private(set) lazy var settingBarButtonItem: UIBarButtonItem = { + let barButtonItem = UIBarButtonItem(image: UIImage(systemName: "gear"), style: .plain, target: self, action: #selector(ProfileViewController.settingBarButtonItemPressed(_:))) + barButtonItem.tintColor = .white + return barButtonItem + }() + + private(set) lazy var shareBarButtonItem: UIBarButtonItem = { + let barButtonItem = UIBarButtonItem(image: UIImage(systemName: "square.and.arrow.up"), style: .plain, target: self, action: #selector(ProfileViewController.shareBarButtonItemPressed(_:))) + barButtonItem.tintColor = .white + return barButtonItem + }() + + private(set) lazy var favoriteBarButtonItem: UIBarButtonItem = { + let barButtonItem = UIBarButtonItem(image: UIImage(systemName: "star"), style: .plain, target: self, action: #selector(ProfileViewController.favoriteBarButtonItemPressed(_:))) + barButtonItem.tintColor = .white + return barButtonItem + }() + + private(set) lazy var replyBarButtonItem: UIBarButtonItem = { + let barButtonItem = UIBarButtonItem(image: UIImage(systemName: "arrowshape.turn.up.left"), style: .plain, target: self, action: #selector(ProfileViewController.replyBarButtonItemPressed(_:))) + barButtonItem.tintColor = .white + return barButtonItem + }() + + let moreMenuBarButtonItem: UIBarButtonItem = { + let barButtonItem = UIBarButtonItem(image: UIImage(systemName: "ellipsis.circle"), style: .plain, target: nil, action: nil) + barButtonItem.tintColor = .white + return barButtonItem + }() + + let refreshControl: UIRefreshControl = { + let refreshControl = UIRefreshControl() + refreshControl.tintColor = .label + return refreshControl + }() + + let containerScrollView: UIScrollView = { + let scrollView = UIScrollView() + scrollView.scrollsToTop = false + scrollView.showsVerticalScrollIndicator = false + scrollView.preservesSuperviewLayoutMargins = true + scrollView.delaysContentTouches = false + return scrollView + }() + + let overlayScrollView: UIScrollView = { + let scrollView = UIScrollView() + scrollView.showsVerticalScrollIndicator = false + scrollView.backgroundColor = .clear + scrollView.delaysContentTouches = false + return scrollView + }() + + private(set) lazy var profileSegmentedViewController = ProfileSegmentedViewController() + private(set) lazy var profileHeaderViewController: ProfileHeaderViewController = { + let viewController = ProfileHeaderViewController() + viewController.viewModel = ProfileHeaderViewModel(context: context) + return viewController + }() + private var profileBannerImageViewLayoutConstraint: NSLayoutConstraint! + + private var contentOffsets: [Int: CGFloat] = [:] + var currentPostTimelineTableViewContentSizeObservation: NSKeyValueObservation? + + // title view nested in header + var titleView: DoubleTitleLabelNavigationBarTitleView { + profileHeaderViewController.titleView + } + + deinit { + os_log("%{public}s[%{public}ld], %{public}s: deinit", ((#file as NSString).lastPathComponent), #line, #function) + } + +} + +extension ProfileViewController { + + func observeTableViewContentSize(scrollView: UIScrollView) -> NSKeyValueObservation { + updateOverlayScrollViewContentSize(scrollView: scrollView) + return scrollView.observe(\.contentSize, options: .new) { scrollView, change in + self.updateOverlayScrollViewContentSize(scrollView: scrollView) + } + } + + func updateOverlayScrollViewContentSize(scrollView: UIScrollView) { + let bottomPageHeight = max(scrollView.contentSize.height, self.containerScrollView.frame.height - ProfileHeaderViewController.headerMinHeight - self.containerScrollView.safeAreaInsets.bottom) + let headerViewHeight: CGFloat = profileHeaderViewController.view.frame.height + let contentSize = CGSize( + width: self.containerScrollView.contentSize.width, + height: bottomPageHeight + headerViewHeight + ) + self.overlayScrollView.contentSize = contentSize + // os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: contentSize: %s", ((#file as NSString).lastPathComponent), #line, #function, contentSize.debugDescription) + } + } extension ProfileViewController { + override var preferredStatusBarStyle: UIStatusBarStyle { + return .lightContent + } + + override func viewSafeAreaInsetsDidChange() { + super.viewSafeAreaInsetsDidChange() + + profileHeaderViewController.updateHeaderContainerSafeAreaInset(view.safeAreaInsets) + } + + override var isViewLoaded: Bool { + return super.isViewLoaded + } + override func viewDidLoad() { super.viewDidLoad() + view.backgroundColor = Asset.Colors.Background.systemGroupedBackground.color + + let barAppearance = UINavigationBarAppearance() + barAppearance.configureWithTransparentBackground() + navigationItem.standardAppearance = barAppearance + navigationItem.compactAppearance = barAppearance + navigationItem.scrollEdgeAppearance = barAppearance + + navigationItem.titleView = titleView + + let editingAndUpdatingPublisher = Publishers.CombineLatest( + viewModel.isEditing.eraseToAnyPublisher(), + viewModel.isUpdating.eraseToAnyPublisher() + ) + // note: not add .share() here + + let barButtonItemHiddenPublisher = Publishers.CombineLatest3( + viewModel.isMeBarButtonItemsHidden.eraseToAnyPublisher(), + viewModel.isReplyBarButtonItemHidden.eraseToAnyPublisher(), + viewModel.isMoreMenuBarButtonItemHidden.eraseToAnyPublisher() + ) + + editingAndUpdatingPublisher + .receive(on: DispatchQueue.main) + .sink { [weak self] isEditing, isUpdating in + guard let self = self else { return } + self.cancelEditingBarButtonItem.isEnabled = !isUpdating + } + .store(in: &disposeBag) + + Publishers.CombineLatest4 ( + viewModel.suspended.eraseToAnyPublisher(), + profileHeaderViewController.viewModel.isTitleViewDisplaying.eraseToAnyPublisher(), + editingAndUpdatingPublisher.eraseToAnyPublisher(), + barButtonItemHiddenPublisher.eraseToAnyPublisher() + ) + .receive(on: DispatchQueue.main) + .sink { [weak self] suspended, isTitleViewDisplaying, tuple1, tuple2 in + guard let self = self else { return } + let (isEditing, _) = tuple1 + let (isMeBarButtonItemsHidden, isReplyBarButtonItemHidden, isMoreMenuBarButtonItemHidden) = tuple2 + + var items: [UIBarButtonItem] = [] + defer { + self.navigationItem.rightBarButtonItems = !items.isEmpty ? items : nil + } + + guard !suspended else { + return + } + + guard !isEditing else { + items.append(self.cancelEditingBarButtonItem) + return + } + + guard !isTitleViewDisplaying else { + return + } + + guard isMeBarButtonItemsHidden else { + items.append(self.settingBarButtonItem) + items.append(self.shareBarButtonItem) + items.append(self.favoriteBarButtonItem) + return + } + + if !isReplyBarButtonItemHidden { + items.append(self.replyBarButtonItem) + } + if !isMoreMenuBarButtonItemHidden { + items.append(self.moreMenuBarButtonItem) + } + } + .store(in: &disposeBag) + + overlayScrollView.refreshControl = refreshControl + refreshControl.addTarget(self, action: #selector(ProfileViewController.refreshControlValueChanged(_:)), for: .valueChanged) + + let postsUserTimelineViewModel = UserTimelineViewModel(context: context, domain: viewModel.domain.value, userID: viewModel.userID.value, queryFilter: UserTimelineViewModel.QueryFilter()) + bind(userTimelineViewModel: postsUserTimelineViewModel) + + let repliesUserTimelineViewModel = UserTimelineViewModel(context: context, domain: viewModel.domain.value, userID: viewModel.userID.value, queryFilter: UserTimelineViewModel.QueryFilter(excludeReplies: true)) + bind(userTimelineViewModel: repliesUserTimelineViewModel) + + let mediaUserTimelineViewModel = UserTimelineViewModel(context: context, domain: viewModel.domain.value, userID: viewModel.userID.value, queryFilter: UserTimelineViewModel.QueryFilter(onlyMedia: true)) + bind(userTimelineViewModel: mediaUserTimelineViewModel) + + profileSegmentedViewController.pagingViewController.viewModel = { + let profilePagingViewModel = ProfilePagingViewModel( + postsUserTimelineViewModel: postsUserTimelineViewModel, + repliesUserTimelineViewModel: repliesUserTimelineViewModel, + mediaUserTimelineViewModel: mediaUserTimelineViewModel + ) + profilePagingViewModel.viewControllers.forEach { viewController in + if let viewController = viewController as? NeedsDependency { + viewController.context = context + viewController.coordinator = coordinator + } + } + 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 + + overlayScrollView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(overlayScrollView) + NSLayoutConstraint.activate([ + overlayScrollView.frameLayoutGuide.topAnchor.constraint(equalTo: view.topAnchor), + overlayScrollView.frameLayoutGuide.leadingAnchor.constraint(equalTo: view.leadingAnchor), + view.trailingAnchor.constraint(equalTo: overlayScrollView.frameLayoutGuide.trailingAnchor), + view.bottomAnchor.constraint(equalTo: overlayScrollView.frameLayoutGuide.bottomAnchor), + overlayScrollView.contentLayoutGuide.widthAnchor.constraint(equalTo: view.widthAnchor), + ]) + + containerScrollView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(containerScrollView) + NSLayoutConstraint.activate([ + containerScrollView.frameLayoutGuide.topAnchor.constraint(equalTo: view.topAnchor), + containerScrollView.frameLayoutGuide.leadingAnchor.constraint(equalTo: view.leadingAnchor), + view.trailingAnchor.constraint(equalTo: containerScrollView.frameLayoutGuide.trailingAnchor), + view.bottomAnchor.constraint(equalTo: containerScrollView.frameLayoutGuide.bottomAnchor), + containerScrollView.contentLayoutGuide.widthAnchor.constraint(equalTo: view.widthAnchor), + ]) + + // add segmented list + addChild(profileSegmentedViewController) + profileSegmentedViewController.view.translatesAutoresizingMaskIntoConstraints = false + containerScrollView.addSubview(profileSegmentedViewController.view) + profileSegmentedViewController.didMove(toParent: self) + NSLayoutConstraint.activate([ + profileSegmentedViewController.view.leadingAnchor.constraint(equalTo: containerScrollView.contentLayoutGuide.leadingAnchor), + profileSegmentedViewController.view.trailingAnchor.constraint(equalTo: containerScrollView.contentLayoutGuide.trailingAnchor), + profileSegmentedViewController.view.bottomAnchor.constraint(equalTo: containerScrollView.contentLayoutGuide.bottomAnchor), + profileSegmentedViewController.view.heightAnchor.constraint(equalTo: containerScrollView.frameLayoutGuide.heightAnchor), + ]) + + // add header + addChild(profileHeaderViewController) + profileHeaderViewController.view.translatesAutoresizingMaskIntoConstraints = false + containerScrollView.addSubview(profileHeaderViewController.view) + profileHeaderViewController.didMove(toParent: self) + NSLayoutConstraint.activate([ + profileHeaderViewController.view.topAnchor.constraint(equalTo: containerScrollView.topAnchor), + profileHeaderViewController.view.leadingAnchor.constraint(equalTo: containerScrollView.contentLayoutGuide.leadingAnchor), + containerScrollView.contentLayoutGuide.trailingAnchor.constraint(equalTo: profileHeaderViewController.view.trailingAnchor), + profileSegmentedViewController.view.topAnchor.constraint(equalTo: profileHeaderViewController.view.bottomAnchor), + ]) + + containerScrollView.addGestureRecognizer(overlayScrollView.panGestureRecognizer) + overlayScrollView.layer.zPosition = .greatestFiniteMagnitude // make vision top-most + overlayScrollView.delegate = self + profileHeaderViewController.delegate = self + profileSegmentedViewController.pagingViewController.pagingDelegate = self + + // bind view model + Publishers.CombineLatest( + viewModel.name.eraseToAnyPublisher(), + viewModel.statusesCount.eraseToAnyPublisher() + ) + .receive(on: DispatchQueue.main) + .sink { [weak self] name, 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 + } + let subtitle = L10n.Scene.Profile.subtitle(formattedStatusCount) + self.titleView.update(title: title, subtitle: subtitle) + self.titleView.isHidden = false + } + .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.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) + + 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: isUpdating || 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 } + // 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.pageSegmentedControl.isHidden = 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 + .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.Scene.Profile.Dashboard.Accessibility.countPosts(count ?? 0) + } + .store(in: &disposeBag) + viewModel.followingCount + .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.Scene.Profile.Dashboard.Accessibility.countFollowing(count ?? 0) + } + .store(in: &disposeBag) + viewModel.followersCount + .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.Scene.Profile.Dashboard.Accessibility.countFollowers(count ?? 0) + } + .store(in: &disposeBag) + + profileHeaderViewController.profileHeaderView.delegate = self + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + + // set back button tint color in SceneCoordinator.present(scene:from:transition:) + + // force layout to make banner image tweak take effect + view.layoutIfNeeded() + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + + viewModel.viewDidAppear.send() + + // set overlay scroll view initial content size + guard let currentViewController = profileSegmentedViewController.pagingViewController.currentViewController as? ScrollViewContainer else { return } + currentPostTimelineTableViewContentSizeObservation = observeTableViewContentSize(scrollView: currentViewController.scrollView) + currentViewController.scrollView.panGestureRecognizer.require(toFail: overlayScrollView.panGestureRecognizer) + } + + override func viewDidDisappear(_ animated: Bool) { + super.viewDidDisappear(animated) + + currentPostTimelineTableViewContentSizeObservation = nil } } + +extension ProfileViewController { + + private func bind(userTimelineViewModel: UserTimelineViewModel) { + viewModel.domain.assign(to: \.value, on: userTimelineViewModel.domain).store(in: &disposeBag) + viewModel.userID.assign(to: \.value, on: userTimelineViewModel.userID).store(in: &disposeBag) + viewModel.isBlocking.assign(to: \.value, on: userTimelineViewModel.isBlocking).store(in: &disposeBag) + viewModel.isBlockedBy.assign(to: \.value, on: userTimelineViewModel.isBlockedBy).store(in: &disposeBag) + viewModel.suspended.assign(to: \.value, on: userTimelineViewModel.isSuspended).store(in: &disposeBag) + viewModel.name.assign(to: \.value, on: userTimelineViewModel.userDisplayName).store(in: &disposeBag) + } + +} + +extension ProfileViewController { + + @objc private func cancelEditingBarButtonItemPressed(_ sender: UIBarButtonItem) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + viewModel.isEditing.value = false + } + + @objc private func settingBarButtonItemPressed(_ sender: UIBarButtonItem) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + guard let setting = context.settingService.currentSetting.value else { return } + let settingsViewModel = SettingsViewModel(context: context, setting: setting) + coordinator.present(scene: .settings(viewModel: settingsViewModel), from: self, transition: .modal(animated: true, completion: nil)) + } + + @objc private func 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) + ) + } + + @objc private func favoriteBarButtonItemPressed(_ sender: UIBarButtonItem) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + let favoriteViewModel = FavoriteViewModel(context: context) + coordinator.present(scene: .favorite(viewModel: favoriteViewModel), from: self, transition: .show) + } + + @objc private func replyBarButtonItemPressed(_ sender: UIBarButtonItem) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + guard let mastodonUser = viewModel.mastodonUser.value else { return } + let composeViewModel = ComposeViewModel( + context: context, + composeKind: .mention(mastodonUserObjectID: mastodonUser.objectID) + ) + coordinator.present(scene: .compose(viewModel: composeViewModel), from: self, transition: .modal(animated: true, completion: nil)) + } + + @objc private func refreshControlValueChanged(_ sender: UIRefreshControl) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + + let currentViewController = profileSegmentedViewController.pagingViewController.currentViewController + if let currentViewController = currentViewController as? UserTimelineViewController { + currentViewController.viewModel.stateMachine.enter(UserTimelineViewModel.State.Reloading.self) + } + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + sender.endRefreshing() + } + } + +} + +// MARK: - UIScrollViewDelegate +extension ProfileViewController: UIScrollViewDelegate { + + func scrollViewDidScroll(_ scrollView: UIScrollView) { + contentOffsets[profileSegmentedViewController.pagingViewController.currentIndex!] = scrollView.contentOffset.y + let topMaxContentOffsetY = profileSegmentedViewController.view.frame.minY - ProfileHeaderViewController.headerMinHeight - containerScrollView.safeAreaInsets.top + if scrollView.contentOffset.y < topMaxContentOffsetY { + self.containerScrollView.contentOffset.y = scrollView.contentOffset.y + for postTimelineView in profileSegmentedViewController.pagingViewController.viewModel.viewControllers { + postTimelineView.scrollView.contentOffset.y = 0 + } + contentOffsets.removeAll() + } else { + containerScrollView.contentOffset.y = topMaxContentOffsetY + if viewModel.needsPagePinToTop.value { + // do nothing + } else { + if let customScrollViewContainerController = profileSegmentedViewController.pagingViewController.currentViewController as? ScrollViewContainer { + let contentOffsetY = scrollView.contentOffset.y - containerScrollView.contentOffset.y + customScrollViewContainerController.scrollView.contentOffset.y = contentOffsetY + } + } + + } + + // elastically banner image + let headerScrollProgress = containerScrollView.contentOffset.y / topMaxContentOffsetY + profileHeaderViewController.updateHeaderScrollProgress(headerScrollProgress) + } + +} + +// MARK: - ProfileHeaderViewControllerDelegate +extension ProfileViewController: ProfileHeaderViewControllerDelegate { + + func profileHeaderViewController(_ viewController: ProfileHeaderViewController, viewLayoutDidUpdate view: UIView) { + guard let scrollView = (profileSegmentedViewController.pagingViewController.currentViewController as? UserTimelineViewController)?.scrollView else { + // assertionFailure() + return + } + + updateOverlayScrollViewContentSize(scrollView: scrollView) + } + + func profileHeaderViewController(_ viewController: ProfileHeaderViewController, pageSegmentedControlValueChanged segmentedControl: UISegmentedControl, selectedSegmentIndex index: Int) { + profileSegmentedViewController.pagingViewController.scrollToPage( + .at(index: index), + animated: true + ) + } + +} + +// MARK: - ProfilePagingViewControllerDelegate +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 + } + + // save content offset + overlayScrollView.contentOffset.y = contentOffsets[index] ?? containerScrollView.contentOffset.y + + // setup observer and gesture fallback + currentPostTimelineTableViewContentSizeObservation = observeTableViewContentSize(scrollView: postTimelineViewController.scrollView) + postTimelineViewController.scrollView.panGestureRecognizer.require(toFail: overlayScrollView.panGestureRecognizer) + } + +} + +// 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 } + + 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)) + } + } + + func profileHeaderView(_ profileHeaderView: ProfileHeaderView, bannerImageViewDidPressed imageView: UIImageView) { + // not preview header banner when editing + guard !viewModel.isEditing.value else { return } + + 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)) + } + } + + func profileHeaderView(_ profileHeaderView: ProfileHeaderView, relationshipButtonDidPressed button: ProfileRelationshipActionButton) { + let relationshipActionSet = viewModel.relationshipActionOptionSet.value + if relationshipActionSet.contains(.edit) { + guard !viewModel.isUpdating.value else { return } + + if profileHeaderViewController.viewModel.isProfileInfoEdited() { + viewModel.isUpdating.value = true + profileHeaderViewController.viewModel.updateProfileInfo() + .receive(on: DispatchQueue.main) + .sink { [weak self] completion in + guard let self = self else { return } + 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) + } + self.viewModel.isUpdating.value = false + } receiveValue: { [weak self] _ in + guard let self = self else { return } + self.viewModel.isEditing.value = false + } + .store(in: &disposeBag) + } else { + viewModel.isEditing.value.toggle() + } + } else { + guard let relationshipAction = relationshipActionSet.highPriorityAction(except: .editOptions) else { return } + switch relationshipAction { + case .none: + break + case .follow, .reqeust, .pending, .following: + UserProviderFacade.toggleUserFollowRelationship(provider: self) + .sink { _ in + // TODO: handle error + } receiveValue: { _ in + // do nothing + } + .store(in: &disposeBag) + case .muting: + guard let mastodonUser = viewModel.mastodonUser.value else { return } + 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.Firendship.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) + } + 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 + 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.Firendship.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) + } + 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() + } + } + } + + func profileHeaderView(_ profileHeaderView: ProfileHeaderView, activeLabel: ActiveLabel, entityDidPressed entity: ActiveEntity) { + switch entity.type { + case .url(_, _, let url, _): + guard let url = URL(string: url) else { return } + coordinator.present(scene: .safari(url: url), from: nil, transition: .safariPresent(animated: true, completion: nil)) + default: + // TODO: + break + } + } + + func profileHeaderView(_ profileHeaderView: ProfileHeaderView, profileStatusDashboardView: ProfileStatusDashboardView, postDashboardMeterViewDidPressed dashboardMeterView: ProfileStatusDashboardMeterView) { + + } + + func profileHeaderView(_ profileHeaderView: ProfileHeaderView, profileStatusDashboardView: ProfileStatusDashboardView, followingDashboardMeterViewDidPressed dwingDashboardMeterView: ProfileStatusDashboardMeterView) { + + } + + func profileHeaderView(_ profileHeaderView: ProfileHeaderView, profileStatusDashboardView: ProfileStatusDashboardView, followersDashboardMeterViewDidPressed dwersDashboardMeterView: ProfileStatusDashboardMeterView) { + + } + +} + +// MARK: - ScrollViewContainer +extension ProfileViewController: ScrollViewContainer { + var scrollView: UIScrollView { return overlayScrollView } +} diff --git a/Mastodon/Scene/Profile/ProfileViewModel.swift b/Mastodon/Scene/Profile/ProfileViewModel.swift new file mode 100644 index 000000000..445952e96 --- /dev/null +++ b/Mastodon/Scene/Profile/ProfileViewModel.swift @@ -0,0 +1,411 @@ +// +// ProfileViewModel.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-29. +// + +import os.log +import UIKit +import Combine +import CoreDataStack +import MastodonSDK + +// please override this base class +class ProfileViewModel: NSObject { + + typealias UserID = String + + var disposeBag = Set() + var observations = Set() + private var mastodonUserObserver: AnyCancellable? + private var currentMastodonUserObserver: AnyCancellable? + + // input + let context: AppContext + let mastodonUser: CurrentValueSubject + let currentMastodonUser = CurrentValueSubject(nil) + let viewDidAppear = PassthroughSubject() + + // output + let domain: CurrentValueSubject + let userID: CurrentValueSubject + let bannerImageURL: CurrentValueSubject + let avatarImageURL: CurrentValueSubject + let name: CurrentValueSubject + let username: CurrentValueSubject + let bioDescription: CurrentValueSubject + let url: CurrentValueSubject + let statusesCount: CurrentValueSubject + let followingCount: CurrentValueSubject + let followersCount: CurrentValueSubject + + let protected: CurrentValueSubject + let suspended: CurrentValueSubject + + let isEditing = CurrentValueSubject(false) + let isUpdating = CurrentValueSubject(false) + + let relationshipActionOptionSet = CurrentValueSubject(.none) + let isFollowedBy = CurrentValueSubject(false) + let isMuting = CurrentValueSubject(false) + let isBlocking = CurrentValueSubject(false) + let isBlockedBy = CurrentValueSubject(false) + + let isRelationshipActionButtonHidden = CurrentValueSubject(true) + let isReplyBarButtonItemHidden = CurrentValueSubject(true) + let isMoreMenuBarButtonItemHidden = CurrentValueSubject(true) + let isMeBarButtonItemsHidden = CurrentValueSubject(true) + + let needsPagePinToTop = CurrentValueSubject(false) + + init(context: AppContext, optionalMastodonUser mastodonUser: MastodonUser?) { + self.context = context + self.mastodonUser = CurrentValueSubject(mastodonUser) + self.domain = CurrentValueSubject(context.authenticationService.activeMastodonAuthenticationBox.value?.domain) + self.userID = CurrentValueSubject(mastodonUser?.id) + self.bannerImageURL = CurrentValueSubject(mastodonUser?.headerImageURL()) + self.avatarImageURL = CurrentValueSubject(mastodonUser?.avatarImageURL()) + self.name = CurrentValueSubject(mastodonUser?.displayNameWithFallback) + 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.protected = CurrentValueSubject(mastodonUser?.locked) + self.suspended = CurrentValueSubject(mastodonUser?.suspended ?? false) + super.init() + + relationshipActionOptionSet + .compactMap { $0.highPriorityAction(except: []) } + .map { $0 == .none } + .assign(to: \.value, on: isRelationshipActionButtonHidden) + .store(in: &disposeBag) + + // bind active authentication + context.authenticationService.activeMastodonAuthentication + .sink { [weak self] activeMastodonAuthentication in + guard let self = self else { return } + guard let activeMastodonAuthentication = activeMastodonAuthentication else { + self.domain.value = nil + self.currentMastodonUser.value = nil + return + } + self.domain.value = activeMastodonAuthentication.domain + self.currentMastodonUser.value = activeMastodonAuthentication.user + } + .store(in: &disposeBag) + + // query relationship + let mastodonUserID = self.mastodonUser.map { $0?.id } + let pendingRetryPublisher = CurrentValueSubject(1) + + Publishers.CombineLatest3( + mastodonUserID.removeDuplicates().eraseToAnyPublisher(), + context.authenticationService.activeMastodonAuthenticationBox.eraseToAnyPublisher(), + pendingRetryPublisher.eraseToAnyPublisher() + ) + .compactMap { mastodonUserID, activeMastodonAuthenticationBox, _ -> (String, AuthenticationService.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) + } + } + } + .store(in: &disposeBag) + + setup() + } + +} + +extension ProfileViewModel { + private func setup() { + Publishers.CombineLatest( + mastodonUser.eraseToAnyPublisher(), + currentMastodonUser.eraseToAnyPublisher() + ) + .receive(on: DispatchQueue.main) + .sink { [weak self] mastodonUser, currentMastodonUser in + guard let self = self else { return } + // Update view model attribute + self.update(mastodonUser: mastodonUser) + self.update(mastodonUser: mastodonUser, currentMastodonUser: currentMastodonUser) + + // Setup observer for user + if let mastodonUser = mastodonUser { + // setup observer + self.mastodonUserObserver = ManagedObjectObserver.observe(object: mastodonUser) + .sink { completion in + switch completion { + case .failure(let error): + assertionFailure(error.localizedDescription) + case .finished: + assertionFailure() + } + } receiveValue: { [weak self] change in + guard let self = self else { return } + guard let changeType = change.changeType else { return } + switch changeType { + case .update: + self.update(mastodonUser: mastodonUser) + self.update(mastodonUser: mastodonUser, currentMastodonUser: currentMastodonUser) + case .delete: + // TODO: + break + } + } + + } else { + self.mastodonUserObserver = nil + } + + // Setup observer for user + if let currentMastodonUser = currentMastodonUser { + // setup observer + self.currentMastodonUserObserver = ManagedObjectObserver.observe(object: currentMastodonUser) + .sink { completion in + switch completion { + case .failure(let error): + assertionFailure(error.localizedDescription) + case .finished: + assertionFailure() + } + } receiveValue: { [weak self] change in + guard let self = self else { return } + guard let changeType = change.changeType else { return } + switch changeType { + case .update: + self.update(mastodonUser: mastodonUser, currentMastodonUser: currentMastodonUser) + case .delete: + // TODO: + break + } + } + } else { + self.currentMastodonUserObserver = nil + } + } + .store(in: &disposeBag) + } + + private func update(mastodonUser: MastodonUser?) { + self.userID.value = mastodonUser?.id + self.bannerImageURL.value = mastodonUser?.headerImageURL() + self.avatarImageURL.value = mastodonUser?.avatarImageURL() + self.name.value = mastodonUser?.displayNameWithFallback + 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.protected.value = mastodonUser?.locked + self.suspended.value = mastodonUser?.suspended ?? false + } + + private func update(mastodonUser: MastodonUser?, currentMastodonUser: MastodonUser?) { + guard let mastodonUser = mastodonUser, + let currentMastodonUser = currentMastodonUser else { + // set relationship + self.relationshipActionOptionSet.value = .none + self.isFollowedBy.value = false + self.isMuting.value = false + self.isBlocking.value = false + self.isBlockedBy.value = false + + // set bar button item state + self.isReplyBarButtonItemHidden.value = true + self.isMoreMenuBarButtonItemHidden.value = true + self.isMeBarButtonItemsHidden.value = true + return + } + + if mastodonUser == currentMastodonUser { + self.relationshipActionOptionSet.value = [.edit] + // set bar button item state + self.isReplyBarButtonItemHidden.value = true + self.isMoreMenuBarButtonItemHidden.value = true + self.isMeBarButtonItemsHidden.value = false + } else { + // set with follow action default + var relationshipActionSet = RelationshipActionOptionSet([.follow]) + + if mastodonUser.locked { + relationshipActionSet.insert(.request) + } + + if mastodonUser.suspended { + relationshipActionSet.insert(.suspended) + } + + let isFollowing = mastodonUser.followingBy.flatMap { $0.contains(currentMastodonUser) } ?? false + 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 + 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 + 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 + 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 + 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 + if isBlockedBy { + relationshipActionSet.insert(.blocked) + } + self.isBlockedBy.value = isBlockedBy + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Relationship] update %s isBlockedBy: %s", ((#file as NSString).lastPathComponent), #line, #function, mastodonUser.id, isBlockedBy.description) + + self.relationshipActionOptionSet.value = relationshipActionSet + + // set bar button item state + self.isReplyBarButtonItemHidden.value = isBlocking || isBlockedBy + self.isMoreMenuBarButtonItemHidden.value = false + self.isMeBarButtonItemsHidden.value = true + } + } + +} + +extension ProfileViewModel { + + enum RelationshipAction: Int, CaseIterable { + case none // set hide from UI + case follow + case reqeust + case pending + case following + case muting + case blocked + case blocking + case suspended + case edit + case editing + case updating + + var option: RelationshipActionOptionSet { + return RelationshipActionOptionSet(rawValue: 1 << rawValue) + } + } + + // construct option set on the enum for safe iterator + struct RelationshipActionOptionSet: OptionSet { + let rawValue: Int + + static let none = RelationshipAction.none.option + static let follow = RelationshipAction.follow.option + static let request = RelationshipAction.reqeust.option + static let pending = RelationshipAction.pending.option + static let following = RelationshipAction.following.option + static let muting = RelationshipAction.muting.option + static let blocked = RelationshipAction.blocked.option + static let blocking = RelationshipAction.blocking.option + static let suspended = RelationshipAction.suspended.option + static let edit = RelationshipAction.edit.option + static let editing = RelationshipAction.editing.option + static let updating = RelationshipAction.updating.option + + static let editOptions: RelationshipActionOptionSet = [.edit, .editing, .updating] + + func highPriorityAction(except: RelationshipActionOptionSet) -> RelationshipAction? { + let set = subtracting(except) + for action in RelationshipAction.allCases.reversed() where set.contains(action.option) { + return action + } + + return nil + } + + var title: String { + guard let highPriorityAction = self.highPriorityAction(except: []) else { + assertionFailure() + return " " + } + switch highPriorityAction { + case .none: return " " + case .follow: return L10n.Common.Controls.Firendship.follow + case .reqeust: return L10n.Common.Controls.Firendship.request + case .pending: return L10n.Common.Controls.Firendship.pending + case .following: return L10n.Common.Controls.Firendship.following + case .muting: return L10n.Common.Controls.Firendship.muted + case .blocked: return L10n.Common.Controls.Firendship.follow // blocked by user + case .blocking: return L10n.Common.Controls.Firendship.blocked + case .suspended: return L10n.Common.Controls.Firendship.follow + case .edit: return L10n.Common.Controls.Firendship.editInfo + case .editing: return L10n.Common.Controls.Actions.done + case .updating: return " " + } + } + + var backgroundColor: UIColor { + guard let highPriorityAction = self.highPriorityAction(except: []) else { + assertionFailure() + return Asset.Colors.Button.normal.color + } + switch highPriorityAction { + case .none: return Asset.Colors.Button.normal.color + case .follow: return Asset.Colors.Button.normal.color + case .reqeust: return Asset.Colors.Button.normal.color + case .pending: return Asset.Colors.Button.normal.color + case .following: return Asset.Colors.Button.normal.color + case .muting: return Asset.Colors.Background.alertYellow.color + case .blocked: return Asset.Colors.Button.normal.color + case .blocking: return Asset.Colors.Background.danger.color + case .suspended: return Asset.Colors.Button.normal.color + case .edit: return Asset.Colors.Button.normal.color + case .editing: return Asset.Colors.Button.normal.color + case .updating: return Asset.Colors.Button.normal.color + } + } + + } +} diff --git a/Mastodon/Scene/Profile/RemoteProfileViewModel.swift b/Mastodon/Scene/Profile/RemoteProfileViewModel.swift new file mode 100644 index 000000000..153f50998 --- /dev/null +++ b/Mastodon/Scene/Profile/RemoteProfileViewModel.swift @@ -0,0 +1,52 @@ +// +// RemoteProfileViewModel.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-2. +// + +import os.log +import Foundation +import CoreDataStack +import MastodonSDK + +final class RemoteProfileViewModel: ProfileViewModel { + + init(context: AppContext, userID: Mastodon.Entity.Account.ID) { + super.init(context: context, optionalMastodonUser: nil) + + guard let activeMastodonAuthenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { + return + } + let domain = activeMastodonAuthenticationBox.domain + let authorization = activeMastodonAuthenticationBox.userAuthorization + context.apiService.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) + } + } receiveValue: { [weak self] response in + guard let self = self else { return } + let managedObjectContext = context.managedObjectContext + let request = MastodonUser.sortedFetchRequest + request.fetchLimit = 1 + request.predicate = MastodonUser.predicate(domain: domain, id: response.value.id) + guard let mastodonUser = managedObjectContext.safeFetch(request).first else { + assertionFailure() + return + } + self.mastodonUser.value = mastodonUser + } + .store(in: &disposeBag) + } + +} diff --git a/Mastodon/Scene/Profile/Segmented/Paging/ProfilePagingViewController.swift b/Mastodon/Scene/Profile/Segmented/Paging/ProfilePagingViewController.swift new file mode 100644 index 000000000..3b00b1c6f --- /dev/null +++ b/Mastodon/Scene/Profile/Segmented/Paging/ProfilePagingViewController.swift @@ -0,0 +1,47 @@ +// +// ProfilePagingViewController.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-29. +// + +import os.log +import UIKit +import Pageboy +import Tabman + +protocol ProfilePagingViewControllerDelegate: AnyObject { + func profilePagingViewController(_ viewController: ProfilePagingViewController, didScrollToPostCustomScrollViewContainerController customScrollViewContainerController: ScrollViewContainer, atIndex index: Int) +} + +final class ProfilePagingViewController: TabmanViewController { + + weak var pagingDelegate: ProfilePagingViewControllerDelegate? + var viewModel: ProfilePagingViewModel! + + + // MARK: - PageboyViewControllerDelegate + + override func pageboyViewController(_ pageboyViewController: PageboyViewController, didScrollToPageAt index: TabmanViewController.PageIndex, direction: PageboyViewController.NavigationDirection, animated: Bool) { + super.pageboyViewController(pageboyViewController, didScrollToPageAt: index, direction: direction, animated: animated) + + let viewController = viewModel.viewControllers[index] + pagingDelegate?.profilePagingViewController(self, didScrollToPostCustomScrollViewContainerController: viewController, atIndex: index) + } + + deinit { + os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + } + +} + +extension ProfilePagingViewController { + + override func viewDidLoad() { + super.viewDidLoad() + + view.backgroundColor = .clear + dataSource = viewModel + } + +} diff --git a/Mastodon/Scene/Profile/Segmented/Paging/ProfilePagingViewModel.swift b/Mastodon/Scene/Profile/Segmented/Paging/ProfilePagingViewModel.swift new file mode 100644 index 000000000..252d5e14f --- /dev/null +++ b/Mastodon/Scene/Profile/Segmented/Paging/ProfilePagingViewModel.swift @@ -0,0 +1,68 @@ +// +// ProfilePagingViewModel.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-29. +// + +import os.log +import UIKit +import Pageboy +import Tabman + +final class ProfilePagingViewModel: NSObject { + + let postUserTimelineViewController = UserTimelineViewController() + let repliesUserTimelineViewController = UserTimelineViewController() + let mediaUserTimelineViewController = UserTimelineViewController() + + init( + postsUserTimelineViewModel: UserTimelineViewModel, + repliesUserTimelineViewModel: UserTimelineViewModel, + mediaUserTimelineViewModel: UserTimelineViewModel + ) { + postUserTimelineViewController.viewModel = postsUserTimelineViewModel + repliesUserTimelineViewController.viewModel = repliesUserTimelineViewModel + mediaUserTimelineViewController.viewModel = mediaUserTimelineViewModel + super.init() + } + + var viewControllers: [ScrollViewContainer] { + return [ + postUserTimelineViewController, + repliesUserTimelineViewController, + mediaUserTimelineViewController, + ] + } + + let barItems: [TMBarItemable] = { + let items = [ + TMBarItem(title: L10n.Scene.Profile.SegmentedControl.posts), + TMBarItem(title: L10n.Scene.Profile.SegmentedControl.replies), + TMBarItem(title: L10n.Scene.Profile.SegmentedControl.media), + ] + return items + }() + + deinit { + os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + } + +} + +// MARK: - PageboyViewControllerDataSource +extension ProfilePagingViewModel: 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/Profile/Segmented/ProfileSegmentedViewController.swift b/Mastodon/Scene/Profile/Segmented/ProfileSegmentedViewController.swift new file mode 100644 index 000000000..06eaab3f4 --- /dev/null +++ b/Mastodon/Scene/Profile/Segmented/ProfileSegmentedViewController.swift @@ -0,0 +1,38 @@ +// +// ProfileSegmentedViewController.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-29. +// + +import os.log +import UIKit + +final class ProfileSegmentedViewController: UIViewController { + let pagingViewController = ProfilePagingViewController() + + deinit { + os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + } +} + +extension ProfileSegmentedViewController { + + override func viewDidLoad() { + super.viewDidLoad() + + view.backgroundColor = .clear + + addChild(pagingViewController) + pagingViewController.view.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(pagingViewController.view) + pagingViewController.didMove(toParent: self) + NSLayoutConstraint.activate([ + pagingViewController.view.topAnchor.constraint(equalTo: view.topAnchor), + pagingViewController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), + view.trailingAnchor.constraint(equalTo: pagingViewController.view.trailingAnchor), + view.bottomAnchor.constraint(equalTo: pagingViewController.view.bottomAnchor), + ]) + } + +} diff --git a/Mastodon/Scene/Profile/Timeline/UserTimelineViewController+Provider.swift b/Mastodon/Scene/Profile/Timeline/UserTimelineViewController+Provider.swift new file mode 100644 index 000000000..30029ae5b --- /dev/null +++ b/Mastodon/Scene/Profile/Timeline/UserTimelineViewController+Provider.swift @@ -0,0 +1,89 @@ +// +// 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 + } + +} + +extension UserTimelineViewController: UserProvider {} diff --git a/Mastodon/Scene/Profile/Timeline/UserTimelineViewController.swift b/Mastodon/Scene/Profile/Timeline/UserTimelineViewController.swift new file mode 100644 index 000000000..503ce04c3 --- /dev/null +++ b/Mastodon/Scene/Profile/Timeline/UserTimelineViewController.swift @@ -0,0 +1,187 @@ +// +// UserTimelineViewController.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-29. +// + +import os.log +import UIKit +import AVKit +import Combine +import CoreDataStack +import GameplayKit + +// TODO: adopt MediaPreviewableViewController +final class UserTimelineViewController: UIViewController, NeedsDependency, MediaPreviewableViewController { + + weak var context: AppContext! { willSet { precondition(!isViewLoaded) } } + weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } } + + var disposeBag = Set() + var viewModel: UserTimelineViewModel! + + let mediaPreviewTransitionController = MediaPreviewTransitionController() + + lazy var tableView: UITableView = { + let tableView = UITableView() + tableView.register(StatusTableViewCell.self, forCellReuseIdentifier: String(describing: StatusTableViewCell.self)) + tableView.register(TimelineBottomLoaderTableViewCell.self, forCellReuseIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self)) + tableView.register(TimelineHeaderTableViewCell.self, forCellReuseIdentifier: String(describing: TimelineHeaderTableViewCell.self)) + tableView.rowHeight = UITableView.automaticDimension + tableView.separatorStyle = .none + tableView.backgroundColor = .clear + return tableView + }() + + deinit { + os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + } + +} + +extension UserTimelineViewController { + + override func viewDidLoad() { + super.viewDidLoad() + + view.backgroundColor = Asset.Colors.Background.secondarySystemBackground.color + + 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 + tableView.prefetchDataSource = self + viewModel.setupDiffableDataSource( + for: tableView, + dependency: self, + statusTableViewCellDelegate: self + ) + + // 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) + } + +} + +// MARK: - StatusTableViewControllerAspect +extension UserTimelineViewController: StatusTableViewControllerAspect { } + +// MARK: - UIScrollViewDelegate +extension UserTimelineViewController { + func scrollViewDidScroll(_ scrollView: UIScrollView) { + aspectScrollViewDidScroll(scrollView) + } +} + +// 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) + } + + 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: - 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) + } + +} + +// MARK: - TimelinePostTableViewCellDelegate +extension UserTimelineViewController: StatusTableViewCellDelegate { + weak var playerViewControllerDelegate: AVPlayerViewControllerDelegate? { return self } + func parent() -> UIViewController { return self } +} + +// MARK: - CustomScrollViewContainerController +extension UserTimelineViewController: ScrollViewContainer { + var scrollView: UIScrollView { return tableView } +} + +// MARK: - LoadMoreConfigurableTableViewContainer +extension UserTimelineViewController: LoadMoreConfigurableTableViewContainer { + typealias BottomLoaderTableViewCell = TimelineBottomLoaderTableViewCell + typealias LoadingState = UserTimelineViewModel.State.Loading + + var loadMoreConfigurableTableView: UITableView { return tableView } + var loadMoreConfigurableStateMachine: GKStateMachine { return viewModel.stateMachine } +} diff --git a/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel+Diffable.swift b/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel+Diffable.swift new file mode 100644 index 000000000..7e4ec8728 --- /dev/null +++ b/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel+Diffable.swift @@ -0,0 +1,39 @@ +// +// UserTimelineViewModel+Diffable.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-30. +// + +import UIKit + +extension UserTimelineViewModel { + + func setupDiffableDataSource( + for tableView: UITableView, + dependency: NeedsDependency, + statusTableViewCellDelegate: StatusTableViewCellDelegate + ) { + let timestampUpdatePublisher = Timer.publish(every: 1.0, on: .main, in: .common) + .autoconnect() + .share() + .eraseToAnyPublisher() + + diffableDataSource = StatusSection.tableViewDiffableDataSource( + for: tableView, + dependency: dependency, + managedObjectContext: statusFetchedResultsController.fetchedResultsController.managedObjectContext, + timestampUpdatePublisher: timestampUpdatePublisher, + statusTableViewCellDelegate: statusTableViewCellDelegate, + timelineMiddleLoaderTableViewCellDelegate: nil, + threadReplyLoaderTableViewCellDelegate: nil + ) + + // set empty section to make update animation top-to-bottom style + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([.main]) + snapshot.appendItems([.bottomLoader], toSection: .main) + diffableDataSource?.apply(snapshot) + } + +} diff --git a/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel+State.swift b/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel+State.swift new file mode 100644 index 000000000..be06d781a --- /dev/null +++ b/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel+State.swift @@ -0,0 +1,186 @@ +// +// UserTimelineViewModel+State.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-30. +// + +import os.log +import Foundation +import GameplayKit +import MastodonSDK + +extension UserTimelineViewModel { + class State: GKState { + weak var viewModel: UserTimelineViewModel? + + init(viewModel: UserTimelineViewModel) { + 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 UserTimelineViewModel.State { + class Initial: UserTimelineViewModel.State { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + guard let viewModel = viewModel else { return false } + switch stateClass { + case is Reloading.Type: + return viewModel.userID.value != nil + default: + return false + } + } + } + + class Reloading: UserTimelineViewModel.State { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + switch stateClass { + case is Loading.Type: + return true + default: + return false + } + } + + override func didEnter(from previousState: GKState?) { + super.didEnter(from: previousState) + guard let viewModel = viewModel, let stateMachine = stateMachine else { return } + + // reset + viewModel.statusFetchedResultsController.statusIDs.value = [] + + stateMachine.enter(Loading.self) + } + } + + class Fail: UserTimelineViewModel.State { + + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + switch stateClass { + case is Loading.Type: + return true + default: + return false + } + } + + override func didEnter(from previousState: GKState?) { + super.didEnter(from: previousState) + guard let _ = viewModel, let stateMachine = stateMachine else { return } + + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: retry loading 3s later…", ((#file as NSString).lastPathComponent), #line, #function) + DispatchQueue.main.asyncAfter(deadline: .now() + 3) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: retry loading", ((#file as NSString).lastPathComponent), #line, #function) + stateMachine.enter(Loading.self) + } + } + } + + class Idle: UserTimelineViewModel.State { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + switch stateClass { + case is Reloading.Type, is Loading.Type: + return true + default: + return false + } + } + } + + class Loading: UserTimelineViewModel.State { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + switch stateClass { + case is Fail.Type: + return true + case is Idle.Type: + return true + case is NoMore.Type: + return true + default: + return false + } + } + + override func didEnter(from previousState: GKState?) { + super.didEnter(from: previousState) + guard let viewModel = viewModel, let stateMachine = stateMachine else { return } + + let maxID = viewModel.statusFetchedResultsController.statusIDs.value.last + + guard let userID = viewModel.userID.value, !userID.isEmpty else { + stateMachine.enter(Fail.self) + return + } + + guard let activeMastodonAuthenticationBox = viewModel.context.authenticationService.activeMastodonAuthenticationBox.value else { + stateMachine.enter(Fail.self) + return + } + 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 + } + } 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) + } + } + + class NoMore: UserTimelineViewModel.State { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + switch stateClass { + case is Reloading.Type: + return true + default: + return false + } + } + + override func didEnter(from previousState: GKState?) { + super.didEnter(from: previousState) + guard let viewModel = viewModel, let _ = stateMachine else { return } + + // trigger data source update + viewModel.statusFetchedResultsController.objectIDs.value = viewModel.statusFetchedResultsController.objectIDs.value + } + } +} diff --git a/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel.swift b/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel.swift new file mode 100644 index 000000000..03e5e627d --- /dev/null +++ b/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel.swift @@ -0,0 +1,152 @@ +// +// UserTimelineViewModel.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-29. +// + +import os.log +import UIKit +import GameplayKit +import Combine +import CoreData +import CoreDataStack +import MastodonSDK + +final class UserTimelineViewModel { + + var disposeBag = Set() + + // input + let context: AppContext + let domain: CurrentValueSubject + let userID: CurrentValueSubject + let queryFilter: CurrentValueSubject + let statusFetchedResultsController: StatusFetchedResultsController + var cellFrameCache = NSCache() + + let isBlocking = CurrentValueSubject(false) + let isBlockedBy = CurrentValueSubject(false) + let isSuspended = CurrentValueSubject(false) + let userDisplayName = CurrentValueSubject(nil) // for suspended prompt label + + // output + var diffableDataSource: UITableViewDiffableDataSource? + private(set) lazy var stateMachine: GKStateMachine = { + let stateMachine = GKStateMachine(states: [ + State.Initial(viewModel: self), + State.Reloading(viewModel: self), + State.Fail(viewModel: self), + State.Idle(viewModel: self), + State.Loading(viewModel: self), + State.NoMore(viewModel: self), + ]) + stateMachine.enter(State.Initial.self) + return stateMachine + }() + + init(context: AppContext, domain: String?, userID: String?, 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) + // super.init() + + self.domain + .assign(to: \.value, on: statusFetchedResultsController.domain) + .store(in: &disposeBag) + + Publishers.CombineLatest4( + statusFetchedResultsController.objectIDs.eraseToAnyPublisher(), + isBlocking.eraseToAnyPublisher(), + isBlockedBy.eraseToAnyPublisher(), + isSuspended.eraseToAnyPublisher() + ) + .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]) + + defer { + // not animate when empty items fix loader first appear layout issue + diffableDataSource.apply(snapshot, animatingDifferences: !items.isEmpty) + } + + guard !isBlocking else { + snapshot.appendItems([Item.emptyStateHeader(attribute: Item.EmptyStateHeaderAttribute(reason: .blocking))], toSection: .main) + return + } + + guard !isBlockedBy else { + snapshot.appendItems([Item.emptyStateHeader(attribute: Item.EmptyStateHeaderAttribute(reason: .blocked))], toSection: .main) + return + } + + let name = self.userDisplayName.value + 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: + break + // TODO: handle other states + default: + break + } + } + } + .store(in: &disposeBag) + } + + deinit { + os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + } + +} + +extension UserTimelineViewModel { + struct QueryFilter { + let excludeReplies: Bool? + let excludeReblogs: Bool? + let onlyMedia: Bool? + + init( + excludeReplies: Bool? = nil, + excludeReblogs: Bool? = nil, + onlyMedia: Bool? = nil + ) { + self.excludeReplies = excludeReplies + self.excludeReblogs = excludeReblogs + self.onlyMedia = onlyMedia + } + } + +} diff --git a/Mastodon/Scene/PublicTimeline/PublicTimelineViewController+Provider.swift b/Mastodon/Scene/PublicTimeline/PublicTimelineViewController+Provider.swift new file mode 100644 index 000000000..96963914c --- /dev/null +++ b/Mastodon/Scene/PublicTimeline/PublicTimelineViewController+Provider.swift @@ -0,0 +1,89 @@ +// +// 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 + } + +} + +extension PublicTimelineViewController: UserProvider {} diff --git a/Mastodon/Scene/PublicTimeline/PublicTimelineViewController+StatusProvider.swift b/Mastodon/Scene/PublicTimeline/PublicTimelineViewController+StatusProvider.swift deleted file mode 100644 index 6d83e79af..000000000 --- a/Mastodon/Scene/PublicTimeline/PublicTimelineViewController+StatusProvider.swift +++ /dev/null @@ -1,72 +0,0 @@ -// -// PublicTimelineViewController+StatusProvider.swift -// Mastodon -// -// Created by sxiaojian on 2021/1/27. -// - -import os.log -import UIKit -import Combine -import CoreDataStack -import MastodonSDK - -// MARK: - StatusProvider -extension PublicTimelineViewController: StatusProvider { - - func toot() -> Future { - return Future { promise in promise(.success(nil)) } - } - - func toot(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 ?? self.tableView.indexPath(for: cell), - let item = diffableDataSource.itemIdentifier(for: indexPath) else { - promise(.success(nil)) - return - } - - switch item { - case .toot(let objectID, _): - let managedObjectContext = self.viewModel.fetchedResultsController.managedObjectContext - managedObjectContext.perform { - let toot = managedObjectContext.object(with: objectID) as? Toot - promise(.success(toot)) - } - default: - promise(.success(nil)) - } - } - } - - func toot(for cell: UICollectionViewCell) -> Future { - return Future { promise in promise(.success(nil)) } - } - - var tableViewDiffableDataSource: UITableViewDiffableDataSource? { - return viewModel.diffableDataSource - } - - func item(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 ?? self.tableView.indexPath(for: cell), - let item = diffableDataSource.itemIdentifier(for: indexPath) else { - promise(.success(nil)) - return - } - - promise(.success(item)) - } - } - -} diff --git a/Mastodon/Scene/PublicTimeline/PublicTimelineViewController.swift b/Mastodon/Scene/PublicTimeline/PublicTimelineViewController.swift index dd5ffc84e..781d2ce1b 100644 --- a/Mastodon/Scene/PublicTimeline/PublicTimelineViewController.swift +++ b/Mastodon/Scene/PublicTimeline/PublicTimelineViewController.swift @@ -13,13 +13,15 @@ import GameplayKit import os.log import UIKit -final class PublicTimelineViewController: UIViewController, NeedsDependency { +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 = { @@ -73,14 +75,20 @@ extension PublicTimelineViewController { viewModel.tableView = tableView viewModel.contentOffsetAdjustableTimelineViewControllerDelegate = self tableView.delegate = self + tableView.prefetchDataSource = self viewModel.setupDiffableDataSource( for: tableView, dependency: self, - timelinePostTableViewCellDelegate: 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 @@ -114,8 +122,11 @@ extension PublicTimelineViewController: UITableViewDelegate { } 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 } @@ -125,6 +136,13 @@ extension PublicTimelineViewController: UITableViewDelegate { } } +// MARK: - UITableViewDataSourcePrefetching +extension PublicTimelineViewController: UITableViewDataSourcePrefetching { + func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) { + handleTableView(tableView, prefetchRowsAt: indexPaths) + } +} + // MARK: - ContentOffsetAdjustableTimelineViewControllerDelegate extension PublicTimelineViewController: ContentOffsetAdjustableTimelineViewControllerDelegate { func navigationBar() -> UINavigationBar? { @@ -143,13 +161,13 @@ extension PublicTimelineViewController: LoadMoreConfigurableTableViewContainer { // MARK: - TimelineMiddleLoaderTableViewCellDelegate extension PublicTimelineViewController: TimelineMiddleLoaderTableViewCellDelegate { - func configure(cell: TimelineMiddleLoaderTableViewCell, upperTimelineTootID: String?, timelineIndexobjectID: NSManagedObjectID?) { - guard let upperTimelineTootID = upperTimelineTootID else {return} + 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[upperTimelineTootID] { + if let stateMachine = ids[upperTimelineStatusID] { guard let state = stateMachine.currentState else { assertionFailure() return @@ -157,31 +175,29 @@ extension PublicTimelineViewController: TimelineMiddleLoaderTableViewCellDelegat // make success state same as loading due to snapshot updating delay let isLoading = state is PublicTimelineViewModel.LoadMiddleState.Loading || state is PublicTimelineViewModel.LoadMiddleState.Success - cell.loadMoreButton.isHidden = isLoading if isLoading { - cell.activityIndicatorView.startAnimating() + cell.startAnimating() } else { - cell.activityIndicatorView.stopAnimating() + cell.stopAnimating() } } else { - cell.loadMoreButton.isHidden = false - cell.activityIndicatorView.stopAnimating() + cell.stopAnimating() } } .store(in: &cell.disposeBag) var dict = viewModel.loadMiddleSateMachineList.value - if let _ = dict[upperTimelineTootID] { + if let _ = dict[upperTimelineStatusID] { // do nothing } else { let stateMachine = GKStateMachine(states: [ - PublicTimelineViewModel.LoadMiddleState.Initial(viewModel: viewModel, upperTimelineTootID: upperTimelineTootID), - PublicTimelineViewModel.LoadMiddleState.Loading(viewModel: viewModel, upperTimelineTootID: upperTimelineTootID), - PublicTimelineViewModel.LoadMiddleState.Fail(viewModel: viewModel, upperTimelineTootID: upperTimelineTootID), - PublicTimelineViewModel.LoadMiddleState.Success(viewModel: viewModel, upperTimelineTootID: upperTimelineTootID), + 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[upperTimelineTootID] = stateMachine + dict[upperTimelineStatusID] = stateMachine viewModel.loadMiddleSateMachineList.value = dict } } @@ -204,5 +220,21 @@ extension PublicTimelineViewController: TimelineMiddleLoaderTableViewCellDelegat } } +// 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 { } +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 index f9c92fa0f..27336dc58 100644 --- a/Mastodon/Scene/PublicTimeline/PublicTimelineViewModel+Diffable.swift +++ b/Mastodon/Scene/PublicTimeline/PublicTimelineViewModel+Diffable.swift @@ -14,7 +14,7 @@ extension PublicTimelineViewModel { func setupDiffableDataSource( for tableView: UITableView, dependency: NeedsDependency, - timelinePostTableViewCellDelegate: StatusTableViewCellDelegate, + statusTableViewCellDelegate: StatusTableViewCellDelegate, timelineMiddleLoaderTableViewCellDelegate: TimelineMiddleLoaderTableViewCellDelegate ) { let timestampUpdatePublisher = Timer.publish(every: 1.0, on: .main, in: .common) @@ -27,8 +27,9 @@ extension PublicTimelineViewModel { dependency: dependency, managedObjectContext: fetchedResultsController.managedObjectContext, timestampUpdatePublisher: timestampUpdatePublisher, - timelinePostTableViewCellDelegate: timelinePostTableViewCellDelegate, - timelineMiddleLoaderTableViewCellDelegate: timelineMiddleLoaderTableViewCellDelegate + statusTableViewCellDelegate: statusTableViewCellDelegate, + timelineMiddleLoaderTableViewCellDelegate: timelineMiddleLoaderTableViewCellDelegate, + threadReplyLoaderTableViewCellDelegate: nil ) items.value = [] stateMachine.enter(PublicTimelineViewModel.State.Loading.self) @@ -41,32 +42,32 @@ 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 = tootIDs.value - let toots = fetchedResultsController.fetchedObjects ?? [] - guard toots.count == indexes.count else { return } - let indexTootTuples: [(Int, Toot)] = toots - .compactMap { toot -> (Int, Toot)? in - guard toot.deletedAt == nil else { return nil } - return indexes.firstIndex(of: toot.id).map { index in (index, toot) } + 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.StatusTimelineAttribute] = [:] + var oldSnapshotAttributeDict: [NSManagedObjectID: Item.StatusAttribute] = [:] for item in self.items.value { - guard case let .toot(objectID, attribute) = item else { continue } + guard case let .status(objectID, attribute) = item else { continue } oldSnapshotAttributeDict[objectID] = attribute } var items = [Item]() - for (_, toot) in indexTootTuples { - let targetToot = toot.reblog ?? toot + for (_, status) in indexStatusTuples { + let targetStatus = status.reblog ?? status let isStatusTextSensitive: Bool = { - guard let spoilerText = targetToot.spoilerText, !spoilerText.isEmpty else { return false } + guard let spoilerText = targetStatus.spoilerText, !spoilerText.isEmpty else { return false } return true }() - let attribute = oldSnapshotAttributeDict[toot.objectID] ?? Item.StatusTimelineAttribute(isStatusTextSensitive: isStatusTextSensitive, isStatusSensitive: targetToot.sensitive) - items.append(Item.toot(objectID: toot.objectID, attribute: attribute)) - if tootIDsWhichHasGap.contains(toot.id) { - items.append(Item.publicMiddleLoader(tootID: toot.id)) + 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)) } } diff --git a/Mastodon/Scene/PublicTimeline/PublicTimelineViewModel+LoadMiddleState.swift b/Mastodon/Scene/PublicTimeline/PublicTimelineViewModel+LoadMiddleState.swift index 62334c746..4727072bf 100644 --- a/Mastodon/Scene/PublicTimeline/PublicTimelineViewModel+LoadMiddleState.swift +++ b/Mastodon/Scene/PublicTimeline/PublicTimelineViewModel+LoadMiddleState.swift @@ -14,18 +14,18 @@ import os.log extension PublicTimelineViewModel { class LoadMiddleState: GKState { weak var viewModel: PublicTimelineViewModel? - let upperTimelineTootID: String + let upperTimelineStatusID: String - init(viewModel: PublicTimelineViewModel, upperTimelineTootID: String) { + init(viewModel: PublicTimelineViewModel, upperTimelineStatusID: String) { self.viewModel = viewModel - self.upperTimelineTootID = upperTimelineTootID + 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.upperTimelineTootID] = stateMachine + dict[self.upperTimelineStatusID] = stateMachine viewModel.loadMiddleSateMachineList.value = dict // trigger value change } } @@ -54,42 +54,42 @@ extension PublicTimelineViewModel.LoadMiddleState { } viewModel.context.apiService.publicTimeline( domain: activeMastodonAuthenticationBox.domain, - maxID: upperTimelineTootID + maxID: upperTimelineStatusID ) .receive(on: DispatchQueue.main) .sink { completion in switch completion { case .failure(let error): - os_log("%{public}s[%{public}ld], %{public}s: fetch toots failed. %s", (#file as NSString).lastPathComponent, #line, #function, error.localizedDescription) + 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 toots = response.value - let addedToots = toots.filter { !viewModel.tootIDs.value.contains($0.id) } + let statuses = response.value + let addedStatuses = statuses.filter { !viewModel.statusIDs.value.contains($0.id) } - guard let gapIndex = viewModel.tootIDs.value.firstIndex(of: self.upperTimelineTootID) else { return } - let upToots = Array(viewModel.tootIDs.value[...gapIndex]) - let downToots = Array(viewModel.tootIDs.value[(gapIndex + 1)...]) + 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 newTootIDs - var newTootIDs = upToots - newTootIDs.append(contentsOf: addedToots.map { $0.id }) - newTootIDs.append(contentsOf: downToots) + // 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.tootIDsWhichHasGap.firstIndex(of: self.upperTimelineTootID) { - viewModel.tootIDsWhichHasGap.remove(at: index) + if let index = viewModel.statusIDsWhichHasGap.firstIndex(of: self.upperTimelineStatusID) { + viewModel.statusIDsWhichHasGap.remove(at: index) } // add new gap from viewmodel if need - let intersection = toots.filter { downToots.contains($0.id) } + let intersection = statuses.filter { downStatuses.contains($0.id) } if intersection.isEmpty { - addedToots.last.flatMap { viewModel.tootIDsWhichHasGap.append($0.id) } + addedStatuses.last.flatMap { viewModel.statusIDsWhichHasGap.append($0.id) } } - viewModel.tootIDs.value = newTootIDs - os_log("%{public}s[%{public}ld], %{public}s: load %{public}ld toots, %{public}%ld new toots", (#file as NSString).lastPathComponent, #line, #function, toots.count, addedToots.count) - if addedToots.isEmpty { + 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) diff --git a/Mastodon/Scene/PublicTimeline/PublicTimelineViewModel+State.swift b/Mastodon/Scene/PublicTimeline/PublicTimelineViewModel+State.swift index 258db0cdd..c165adb70 100644 --- a/Mastodon/Scene/PublicTimeline/PublicTimelineViewModel+State.swift +++ b/Mastodon/Scene/PublicTimeline/PublicTimelineViewModel+State.swift @@ -68,21 +68,21 @@ extension PublicTimelineViewModel.State { break } } receiveValue: { response in - let resposeTootIDs = response.value.compactMap { $0.id } - var newTootsIDs = resposeTootIDs - let oldTootsIDs = viewModel.tootIDs.value + let resposeStatusIDs = response.value.compactMap { $0.id } + var newStatusIDs = resposeStatusIDs + let oldStatusIDs = viewModel.statusIDs.value var hasGap = true - for tootID in oldTootsIDs { - if !newTootsIDs.contains(tootID) { - newTootsIDs.append(tootID) + for statusID in oldStatusIDs { + if !newStatusIDs.contains(statusID) { + newStatusIDs.append(statusID) } else { hasGap = false } } - if hasGap && oldTootsIDs.count > 0 { - resposeTootIDs.last.flatMap { viewModel.tootIDsWhichHasGap.append($0) } + if hasGap && oldStatusIDs.count > 0 { + resposeStatusIDs.last.flatMap { viewModel.statusIDsWhichHasGap.append($0) } } - viewModel.tootIDs.value = newTootsIDs + viewModel.statusIDs.value = newStatusIDs stateMachine.enter(Idle.self) } .store(in: &viewModel.disposeBag) @@ -138,7 +138,7 @@ extension PublicTimelineViewModel.State { stateMachine.enter(Fail.self) return } - let maxID = viewModel.tootIDs.value.last + let maxID = viewModel.statusIDs.value.last viewModel.context.apiService.publicTimeline( domain: activeMastodonAuthenticationBox.domain, maxID: maxID @@ -153,14 +153,14 @@ extension PublicTimelineViewModel.State { } } receiveValue: { response in stateMachine.enter(Idle.self) - var oldTootsIDs = viewModel.tootIDs.value - for toot in response.value { - if !oldTootsIDs.contains(toot.id) { - oldTootsIDs.append(toot.id) + var oldStatusIDs = viewModel.statusIDs.value + for status in response.value { + if !oldStatusIDs.contains(status.id) { + oldStatusIDs.append(status.id) } } - viewModel.tootIDs.value = oldTootsIDs + viewModel.statusIDs.value = oldStatusIDs } .store(in: &viewModel.disposeBag) } diff --git a/Mastodon/Scene/PublicTimeline/PublicTimelineViewModel.swift b/Mastodon/Scene/PublicTimeline/PublicTimelineViewModel.swift index d7d6448a5..c3b1a3d4b 100644 --- a/Mastodon/Scene/PublicTimeline/PublicTimelineViewModel.swift +++ b/Mastodon/Scene/PublicTimeline/PublicTimelineViewModel.swift @@ -19,7 +19,7 @@ class PublicTimelineViewModel: NSObject { // input let context: AppContext - let fetchedResultsController: NSFetchedResultsController + let fetchedResultsController: NSFetchedResultsController let isFetchingLatestTimeline = CurrentValueSubject(false) @@ -31,7 +31,7 @@ class PublicTimelineViewModel: NSObject { weak var contentOffsetAdjustableTimelineViewControllerDelegate: ContentOffsetAdjustableTimelineViewControllerDelegate? // - var tootIDsWhichHasGap = [String]() + var statusIDsWhichHasGap = [String]() // output var diffableDataSource: UITableViewDiffableDataSource? @@ -47,15 +47,15 @@ class PublicTimelineViewModel: NSObject { return stateMachine }() - let tootIDs = CurrentValueSubject<[String], Never>([]) + let statusIDs = CurrentValueSubject<[String], Never>([]) let items = CurrentValueSubject<[Item], Never>([]) var cellFrameCache = NSCache() init(context: AppContext) { self.context = context self.fetchedResultsController = { - let fetchRequest = Toot.sortedFetchRequest - fetchRequest.predicate = Toot.predicate(domain: "", ids: []) + let fetchRequest = Status.sortedFetchRequest + fetchRequest.predicate = Status.predicate(domain: "", ids: []) fetchRequest.returnsObjectsAsFaults = false fetchRequest.fetchBatchSize = 20 let controller = NSFetchedResultsController( @@ -111,12 +111,12 @@ class PublicTimelineViewModel: NSObject { } .store(in: &disposeBag) - tootIDs + 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 = Toot.predicate(domain: domain, ids: ids) + self.fetchedResultsController.fetchRequest.predicate = Status.predicate(domain: domain, ids: ids) do { try self.fetchedResultsController.performFetch() } catch { diff --git a/Mastodon/Scene/Report/ReportFooterView.swift b/Mastodon/Scene/Report/ReportFooterView.swift new file mode 100644 index 000000000..a64a556d3 --- /dev/null +++ b/Mastodon/Scene/Report/ReportFooterView.swift @@ -0,0 +1,108 @@ +// +// ReportFooterView.swift +// Mastodon +// +// Created by ihugo on 2021/4/22. +// + +import UIKit + +final class ReportFooterView: UIView { + enum Step: Int { + case one + case two + } + + lazy var stackview: UIStackView = { + let view = UIStackView() + view.axis = .vertical + view.alignment = .fill + view.spacing = 8 + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + + lazy var nextStepButton: PrimaryActionButton = { + let button = PrimaryActionButton() + button.setTitle(L10n.Common.Controls.Actions.continue, for: .normal) + button.translatesAutoresizingMaskIntoConstraints = false + return button + }() + + lazy var skipButton: UIButton = { + let button = UIButton(type: .system) + button.tintColor = Asset.Colors.brandBlue.color + button.setTitle(L10n.Common.Controls.Actions.skip, for: .normal) + button.translatesAutoresizingMaskIntoConstraints = false + return button + }() + + var step: Step = .one { + didSet { + switch step { + case .one: + nextStepButton.setTitle(L10n.Common.Controls.Actions.continue, for: .normal) + skipButton.setTitle(L10n.Common.Controls.Actions.skip, for: .normal) + case .two: + nextStepButton.setTitle(L10n.Scene.Report.send, for: .normal) + skipButton.setTitle(L10n.Scene.Report.skipToSend, for: .normal) + } + } + } + + override init(frame: CGRect) { + super.init(frame: frame) + + self.backgroundColor = Asset.Colors.Background.systemElevatedBackground.color + + stackview.addArrangedSubview(nextStepButton) + stackview.addArrangedSubview(skipButton) + addSubview(stackview) + + NSLayoutConstraint.activate([ + stackview.topAnchor.constraint( + equalTo: self.topAnchor, + constant: ReportView.continuTopMargin + ), + stackview.leadingAnchor.constraint( + equalTo: self.readableContentGuide.leadingAnchor, + constant: ReportView.horizontalMargin + ), + stackview.bottomAnchor.constraint( + equalTo: self.safeAreaLayoutGuide.bottomAnchor, + constant: -1 * ReportView.skipBottomMargin + ), + stackview.trailingAnchor.constraint( + equalTo: self.readableContentGuide.trailingAnchor, + constant: -1 * ReportView.horizontalMargin + ), + nextStepButton.heightAnchor.constraint( + equalToConstant: ReportView.buttonHeight + ), + skipButton.heightAnchor.constraint( + equalTo: nextStepButton.heightAnchor + ) + ]) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +#if canImport(SwiftUI) && DEBUG +import SwiftUI + +struct ReportFooterView_Previews: PreviewProvider { + static var previews: some View { + Group { + UIViewPreview(width: 375) { () -> UIView in + return ReportFooterView(frame: CGRect(origin: .zero, size: CGSize(width: 375, height: 164))) + } + .previewLayout(.fixed(width: 375, height: 164)) + } + } + +} + +#endif diff --git a/Mastodon/Scene/Report/ReportHeaderView.swift b/Mastodon/Scene/Report/ReportHeaderView.swift new file mode 100644 index 000000000..8a6d957c8 --- /dev/null +++ b/Mastodon/Scene/Report/ReportHeaderView.swift @@ -0,0 +1,115 @@ +// +// ReportView.swift +// Mastodon +// +// Created by ihugo on 2021/4/20. +// + +import UIKit + +struct ReportView { + static var horizontalMargin: CGFloat { return 12 } + static var verticalMargin: CGFloat { return 22 } + static var buttonHeight: CGFloat { return 46 } + static var skipBottomMargin: CGFloat { return 8 } + static var continuTopMargin: CGFloat { return 22 } +} + +final class ReportHeaderView: UIView { + enum Step: Int { + case one + case two + } + + lazy var titleLabel: UILabel = { + let label = UILabel() + label.textColor = Asset.Colors.Label.secondary.color + label.font = UIFontMetrics(forTextStyle: .subheadline) + .scaledFont(for: .systemFont(ofSize: 15, weight: .regular)) + label.numberOfLines = 0 + return label + }() + + lazy var contentLabel: UILabel = { + let label = UILabel() + label.textColor = Asset.Colors.Label.primary.color + label.font = UIFontMetrics(forTextStyle: .title3) + .scaledFont(for: .systemFont(ofSize: 20, weight: .semibold)) + label.numberOfLines = 0 + return label + }() + + lazy var stackview: UIStackView = { + let view = UIStackView() + view.axis = .vertical + view.alignment = .leading + view.spacing = 2 + return view + }() + + var step: Step = .one { + didSet { + switch step { + case .one: + titleLabel.text = L10n.Scene.Report.step1 + contentLabel.text = L10n.Scene.Report.content1 + case .two: + titleLabel.text = L10n.Scene.Report.step2 + contentLabel.text = L10n.Scene.Report.content2 + } + } + } + + override init(frame: CGRect) { + super.init(frame: frame) + + self.backgroundColor = Asset.Colors.Background.systemElevatedBackground.color + stackview.addArrangedSubview(titleLabel) + stackview.addArrangedSubview(contentLabel) + addSubview(stackview) + + stackview.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + stackview.topAnchor.constraint( + equalTo: self.topAnchor, + constant: ReportView.verticalMargin + ), + stackview.leadingAnchor.constraint( + equalTo: self.readableContentGuide.leadingAnchor, + constant: ReportView.horizontalMargin + ), + self.bottomAnchor.constraint( + equalTo: stackview.bottomAnchor, + constant: ReportView.verticalMargin + ), + self.readableContentGuide.trailingAnchor.constraint( + equalTo: stackview.trailingAnchor, + constant: ReportView.horizontalMargin + ) + ]) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +#if canImport(SwiftUI) && DEBUG +import SwiftUI + +struct ReportHeaderView_Previews: PreviewProvider { + static var previews: some View { + Group { + UIViewPreview { () -> UIView in + let view = ReportHeaderView() + view.step = .one + view.contentLabel.preferredMaxLayoutWidth = 335 + return view + } + .previewLayout(.fixed(width: 375, height: 110)) + } + } + +} + +#endif diff --git a/Mastodon/Scene/Report/ReportViewController.swift b/Mastodon/Scene/Report/ReportViewController.swift new file mode 100644 index 000000000..b0c6ddcc9 --- /dev/null +++ b/Mastodon/Scene/Report/ReportViewController.swift @@ -0,0 +1,343 @@ +// +// ReportViewController.swift +// Mastodon +// +// Created by ihugo on 2021/4/20. +// + +import AVKit +import Combine +import CoreData +import CoreDataStack +import os.log +import UIKit +import TwitterTextEditor +import MastodonSDK + +class ReportViewController: UIViewController, NeedsDependency { + static let kAnimationDuration: TimeInterval = 0.33 + + weak var context: AppContext! { willSet { precondition(!isViewLoaded) } } + weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } } + + var viewModel: ReportViewModel! { willSet { precondition(!isViewLoaded) } } + var disposeBag = Set() + let didToggleSelected = PassthroughSubject() + let comment = CurrentValueSubject(nil) + let step1Continue = PassthroughSubject() + let step1Skip = PassthroughSubject() + let step2Continue = PassthroughSubject() + let step2Skip = PassthroughSubject() + let cancel = PassthroughSubject() + + // MAKK: - UI + lazy var header: ReportHeaderView = { + let view = ReportHeaderView() + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + + lazy var footer: ReportFooterView = { + let view = ReportFooterView() + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + + lazy var contentView: UIView = { + let view = UIView() + view.translatesAutoresizingMaskIntoConstraints = false + view.setContentHuggingPriority(.defaultLow, for: .vertical) + view.backgroundColor = Asset.Colors.Background.systemElevatedBackground.color + return view + }() + + lazy var stackview: UIStackView = { + let view = UIStackView() + view.axis = .vertical + view.alignment = .fill + view.distribution = .fill + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + + lazy var tableView: UITableView = { + let tableView = ControlContainableTableView() + tableView.register(ReportedStatusTableViewCell.self, forCellReuseIdentifier: String(describing: ReportedStatusTableViewCell.self)) + tableView.rowHeight = UITableView.automaticDimension + tableView.separatorStyle = .none + tableView.backgroundColor = .clear + tableView.translatesAutoresizingMaskIntoConstraints = false + tableView.delegate = self + tableView.prefetchDataSource = self + tableView.allowsMultipleSelection = true + return tableView + }() + + lazy var textView: UITextView = { + let textView = UITextView() + textView.font = .preferredFont(forTextStyle: .body) + textView.isScrollEnabled = false + textView.placeholder = L10n.Scene.Report.textPlaceholder + textView.backgroundColor = .clear + textView.delegate = self + textView.isScrollEnabled = true + textView.keyboardDismissMode = .onDrag + return textView + }() + + lazy var bottomSpacing: UIView = { + let view = UIView() + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + + var bottomConstraint: NSLayoutConstraint! + + override func viewDidLoad() { + super.viewDidLoad() + + setupView() + + viewModel.setupDiffableDataSource( + for: tableView, + dependency: self + ) + + bindViewModel() + bindActions() + } + + // MAKR: - Private methods + private func setupView() { + view.backgroundColor = Asset.Colors.Background.secondarySystemBackground.color + setupNavigation() + + stackview.addArrangedSubview(header) + stackview.addArrangedSubview(contentView) + stackview.addArrangedSubview(footer) + stackview.addArrangedSubview(bottomSpacing) + + contentView.addSubview(tableView) + + view.addSubview(stackview) + NSLayoutConstraint.activate([ + stackview.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), + stackview.leadingAnchor.constraint(equalTo: view.leadingAnchor), + stackview.bottomAnchor.constraint(equalTo: view.bottomAnchor), + stackview.trailingAnchor.constraint(equalTo: view.trailingAnchor), + tableView.topAnchor.constraint(equalTo: contentView.topAnchor), + tableView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + tableView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), + tableView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + ]) + + self.bottomConstraint = bottomSpacing.heightAnchor.constraint(equalToConstant: 0) + bottomConstraint.isActive = true + + header.step = .one + } + + private func bindActions() { + footer.nextStepButton.addTarget(self, action: #selector(continueButtonDidClick), for: .touchUpInside) + footer.skipButton.addTarget(self, action: #selector(skipButtonDidClick), for: .touchUpInside) + } + + private func bindViewModel() { + let input = ReportViewModel.Input( + didToggleSelected: didToggleSelected.eraseToAnyPublisher(), + comment: comment.eraseToAnyPublisher(), + step1Continue: step1Continue.eraseToAnyPublisher(), + step1Skip: step1Skip.eraseToAnyPublisher(), + step2Continue: step2Continue.eraseToAnyPublisher(), + step2Skip: step2Skip.eraseToAnyPublisher(), + cancel: cancel.eraseToAnyPublisher() + ) + let output = viewModel.transform(input: input) + output?.currentStep + .receive(on: DispatchQueue.main) + .sink(receiveValue: { [weak self] (step) in + guard step == .two else { return } + guard let self = self else { return } + + self.header.step = .two + self.footer.step = .two + self.switchToStep2Content() + }) + .store(in: &disposeBag) + + output?.continueEnableSubject + .receive(on: DispatchQueue.main) + .filter { [weak self] _ in + guard let step = self?.viewModel.currentStep.value, step == .one else { return false } + return true + } + .assign(to: \.nextStepButton.isEnabled, on: footer) + .store(in: &disposeBag) + + output?.sendEnableSubject + .receive(on: DispatchQueue.main) + .filter { [weak self] _ in + guard let step = self?.viewModel.currentStep.value, step == .two else { return false } + return true + } + .assign(to: \.nextStepButton.isEnabled, on: footer) + .store(in: &disposeBag) + + output?.reportResult + .print() + .receive(on: DispatchQueue.main) + .sink(receiveCompletion: { _ in + }, receiveValue: { [weak self] data in + let (success, error) = data + if success { + self?.dismiss(animated: true, completion: nil) + } else if let error = error { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: fail to file a report : %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) + + let alertController = UIAlertController(for: error, title: nil, preferredStyle: .alert) + let okAction = UIAlertAction(title: "OK", style: .default, handler: nil) + alertController.addAction(okAction) + self?.coordinator.present( + scene: .alertController(alertController: alertController), + from: nil, + transition: .alertController(animated: true, completion: nil) + ) + } + }) + .store(in: &disposeBag) + + Publishers.CombineLatest( + KeyboardResponderService.shared.state.eraseToAnyPublisher(), + KeyboardResponderService.shared.endFrame.eraseToAnyPublisher() + ) + .sink(receiveValue: { [weak self] state, endFrame in + guard let self = self else { return } + + guard state == .dock else { + self.bottomConstraint.constant = 0.0 + return + } + + let contentFrame = self.view.convert(self.view.frame, to: nil) + let padding = contentFrame.maxY - endFrame.minY + guard padding > 0 else { + self.bottomConstraint.constant = 0.0 + return + } + + self.bottomConstraint.constant = padding + }) + .store(in: &disposeBag) + } + + private func setupNavigation() { + navigationItem.rightBarButtonItem + = UIBarButtonItem(barButtonSystemItem: UIBarButtonItem.SystemItem.cancel, + target: self, + action: #selector(doneButtonDidClick)) + navigationItem.rightBarButtonItem?.tintColor = Asset.Colors.Label.highlight.color + + // fetch old mastodon user + let beReportedUser: MastodonUser? = { + guard let domain = context.authenticationService.activeMastodonAuthenticationBox.value?.domain else { + return nil + } + let request = MastodonUser.sortedFetchRequest + request.predicate = MastodonUser.predicate(domain: domain, id: viewModel.user.id) + request.fetchLimit = 1 + request.returnsObjectsAsFaults = false + do { + return try viewModel.statusFetchedResultsController.fetchedResultsController.managedObjectContext.fetch(request).first + } catch { + assertionFailure(error.localizedDescription) + return nil + } + }() + + navigationItem.title = L10n.Scene.Report.title( + beReportedUser?.displayNameWithFallback ?? "" + ) + } + + private func switchToStep2Content() { + self.contentView.addSubview(self.textView) + self.textView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + self.textView.topAnchor.constraint(equalTo: self.contentView.topAnchor), + self.textView.leadingAnchor.constraint( + equalTo: self.contentView.readableContentGuide.leadingAnchor, + constant: ReportView.horizontalMargin + ), + self.textView.bottomAnchor.constraint(equalTo: self.contentView.bottomAnchor), + self.contentView.trailingAnchor.constraint( + equalTo: self.textView.trailingAnchor, + constant: ReportView.horizontalMargin + ), + ]) + self.textView.layoutIfNeeded() + + UIView.transition( + with: contentView, + duration: ReportViewController.kAnimationDuration, + options: UIView.AnimationOptions.transitionCrossDissolve) { + [weak self] in + guard let self = self else { return } + + self.contentView.addSubview(self.textView) + self.tableView.isHidden = true + } completion: { (_) in + } + } + + // Mark: - Actions + @objc func doneButtonDidClick() { + dismiss(animated: true, completion: nil) + } + + @objc func continueButtonDidClick() { + if viewModel.currentStep.value == .one { + step1Continue.send() + } else { + step2Continue.send() + } + } + + @objc func skipButtonDidClick() { + if viewModel.currentStep.value == .one { + step1Skip.send() + } else { + step2Skip.send() + } + } +} + +// MARK: - UITableViewDelegate +extension ReportViewController: UITableViewDelegate { + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + guard let item = viewModel.diffableDataSource?.itemIdentifier(for: indexPath) else { + return + } + didToggleSelected.send(item) + } + + func tableView(_ tableView: UITableView, didDeselectRowAt indexPath: IndexPath) { + guard let item = viewModel.diffableDataSource?.itemIdentifier(for: indexPath) else { + return + } + didToggleSelected.send(item) + } +} + +// MARK: - UITableViewDataSourcePrefetching +extension ReportViewController: UITableViewDataSourcePrefetching { + func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) { + viewModel.prefetchData(prefetchRowsAt: indexPaths) + } +} + +// MARK: - UITextViewDelegate +extension ReportViewController: UITextViewDelegate { + func textViewDidChange(_ textView: UITextView) { + self.comment.send(textView.text) + } +} diff --git a/Mastodon/Scene/Report/ReportViewModel+Data.swift b/Mastodon/Scene/Report/ReportViewModel+Data.swift new file mode 100644 index 000000000..df95cb002 --- /dev/null +++ b/Mastodon/Scene/Report/ReportViewModel+Data.swift @@ -0,0 +1,138 @@ +// +// ReportViewModel+Data.swift +// Mastodon +// +// Created by ihugo on 2021/4/20. +// + +import Combine +import CoreData +import CoreDataStack +import Foundation +import MastodonSDK +import UIKit +import os.log + +extension ReportViewModel { + func requestRecentStatus( + domain: String, + accountId: String, + authorizationBox: AuthenticationService.MastodonAuthenticationBox + ) { + context.apiService.userTimeline( + domain: domain, + accountID: accountId, + excludeReblogs: true, + authorizationBox: authorizationBox + ) + .receive(on: DispatchQueue.main) + .sink { [weak self] completion in + switch completion { + case .failure(let error): + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: fetch user timeline fail: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) + guard let self = self else { return } + guard let reportStatusId = self.status?.id else { return } + var statusIDs = self.statusFetchedResultsController.statusIDs.value + guard statusIDs.contains(reportStatusId) else { return } + + statusIDs.append(reportStatusId) + self.statusFetchedResultsController.statusIDs.value = statusIDs + case .finished: + break + } + } receiveValue: { [weak self] response in + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + guard let self = self else { return } + + var statusIDs = response.value.map { $0.id } + if let reportStatusId = self.status?.id, !statusIDs.contains(reportStatusId) { + statusIDs.append(reportStatusId) + } + + self.statusFetchedResultsController.statusIDs.value = statusIDs + } + .store(in: &disposeBag) + } + + func fetchStatus() { + let managedObjectContext = self.statusFetchedResultsController.fetchedResultsController.managedObjectContext + statusFetchedResultsController.objectIDs.eraseToAnyPublisher() + .receive(on: DispatchQueue.main) + .debounce(for: .milliseconds(300), scheduler: DispatchQueue.main) + .sink { [weak self] objectIDs in + guard let self = self else { return } + guard let diffableDataSource = self.diffableDataSource else { return } + + var items: [Item] = [] + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([.main]) + + defer { + // not animate when empty items fix loader first appear layout issue + diffableDataSource.apply(snapshot, animatingDifferences: !items.isEmpty) + } + + var oldSnapshotAttributeDict: [NSManagedObjectID : Item.ReportStatusAttribute] = [:] + let oldSnapshot = diffableDataSource.snapshot() + for item in oldSnapshot.itemIdentifiers { + guard case let .reportStatus(objectID, attribute) = item else { continue } + oldSnapshotAttributeDict[objectID] = attribute + } + + for objectID in objectIDs { + let attribute = oldSnapshotAttributeDict[objectID] ?? Item.ReportStatusAttribute() + let item = Item.reportStatus(objectID: objectID, attribute: attribute) + items.append(item) + + guard let status = managedObjectContext.object(with: objectID) as? Status else { + continue + } + if status.id == self.status?.id { + attribute.isSelected = true + self.append(statusID: status.id) + self.continueEnableSubject.send(true) + } + } + snapshot.appendItems(items, toSection: .main) + } + .store(in: &disposeBag) + } + + func prefetchData(prefetchRowsAt indexPaths: [IndexPath]) { + guard let diffableDataSource = diffableDataSource else { return } + + // prefetch reply status + guard let activeMastodonAuthenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { return } + let domain = activeMastodonAuthenticationBox.domain + + var statusObjectIDs: [NSManagedObjectID] = [] + for indexPath in indexPaths { + let item = diffableDataSource.itemIdentifier(for: indexPath) + switch item { + case .reportStatus(let objectID, _): + statusObjectIDs.append(objectID) + default: + continue + } + } + + let backgroundManagedObjectContext = context.backgroundManagedObjectContext + backgroundManagedObjectContext.perform { [weak self] in + guard let self = self else { return } + for objectID in statusObjectIDs { + let status = backgroundManagedObjectContext.object(with: objectID) as! Status + guard let replyToID = status.inReplyToID, status.replyTo == nil else { + // skip + continue + } + self.context.statusPrefetchingService.prefetchReplyTo( + domain: domain, + statusObjectID: status.objectID, + statusID: status.id, + replyToStatusID: replyToID, + authorizationBox: activeMastodonAuthenticationBox + ) + } + } + } +} diff --git a/Mastodon/Scene/Report/ReportViewModel+Diffable.swift b/Mastodon/Scene/Report/ReportViewModel+Diffable.swift new file mode 100644 index 000000000..73d6ffa0d --- /dev/null +++ b/Mastodon/Scene/Report/ReportViewModel+Diffable.swift @@ -0,0 +1,35 @@ +// +// ReportViewModel+Diffable.swift +// Mastodon +// +// Created by ihugo on 2021/4/19. +// + +import UIKit +import Combine +import CoreData +import CoreDataStack + +extension ReportViewModel { + func setupDiffableDataSource( + for tableView: UITableView, + dependency: ReportViewController + ) { + let timestampUpdatePublisher = Timer.publish(every: 1.0, on: .main, in: .common) + .autoconnect() + .share() + .eraseToAnyPublisher() + + diffableDataSource = ReportSection.tableViewDiffableDataSource( + for: tableView, + dependency: dependency, + managedObjectContext: statusFetchedResultsController.fetchedResultsController.managedObjectContext, + timestampUpdatePublisher: timestampUpdatePublisher + ) + + // set empty section to make update animation top-to-bottom style + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([.main]) + diffableDataSource?.apply(snapshot) + } +} diff --git a/Mastodon/Scene/Report/ReportViewModel.swift b/Mastodon/Scene/Report/ReportViewModel.swift new file mode 100644 index 000000000..8631963c5 --- /dev/null +++ b/Mastodon/Scene/Report/ReportViewModel.swift @@ -0,0 +1,215 @@ +// +// ReportViewModel.swift +// Mastodon +// +// Created by ihugo on 2021/4/19. +// + +import Combine +import CoreData +import CoreDataStack +import Foundation +import MastodonSDK +import UIKit +import os.log + +class ReportViewModel: NSObject { + typealias FileReportQuery = Mastodon.API.Reports.FileReportQuery + + enum Step: Int { + case one + case two + } + + // confirm set only once + weak var context: AppContext! { willSet { precondition(context == nil) } } + var user: MastodonUser + var status: Status? + + var statusIDs = [Mastodon.Entity.Status.ID]() + var comment: String? + + var reportQuery: FileReportQuery + var disposeBag = Set() + let currentStep = CurrentValueSubject(.one) + let statusFetchedResultsController: StatusFetchedResultsController + var diffableDataSource: UITableViewDiffableDataSource? + let continueEnableSubject = CurrentValueSubject(false) + let sendEnableSubject = CurrentValueSubject(false) + + struct Input { + let didToggleSelected: AnyPublisher + let comment: AnyPublisher + let step1Continue: AnyPublisher + let step1Skip: AnyPublisher + let step2Continue: AnyPublisher + let step2Skip: AnyPublisher + let cancel: AnyPublisher + } + + struct Output { + let currentStep: AnyPublisher + let continueEnableSubject: AnyPublisher + let sendEnableSubject: AnyPublisher + let reportResult: AnyPublisher<(Bool, Error?), Never> + } + + init(context: AppContext, + domain: String, + user: MastodonUser, + status: Status? + ) { + self.context = context + self.user = user + self.status = status + self.statusFetchedResultsController = StatusFetchedResultsController( + managedObjectContext: context.managedObjectContext, + domain: domain, + additionalTweetPredicate: Status.notDeleted() + ) + + self.reportQuery = FileReportQuery( + accountID: user.id, + statusIDs: [], + comment: nil, + forward: nil + ) + super.init() + } + + func transform(input: Input?) -> Output? { + guard let input = input else { return nil } + guard let activeMastodonAuthenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { + return nil + } + let domain = activeMastodonAuthenticationBox.domain + + // data binding + bindData(input: input) + + // step1 and step2 binding + bindForStep1(input: input) + let reportResult = bindForStep2( + input: input, + domain: domain, + activeMastodonAuthenticationBox: activeMastodonAuthenticationBox + ) + + requestRecentStatus( + domain: domain, + accountId: self.user.id, + authorizationBox: activeMastodonAuthenticationBox + ) + + fetchStatus() + + return Output( + currentStep: currentStep.eraseToAnyPublisher(), + continueEnableSubject: continueEnableSubject.eraseToAnyPublisher(), + sendEnableSubject: sendEnableSubject.eraseToAnyPublisher(), + reportResult: reportResult + ) + } + + // MARK: - Private methods + func bindData(input: Input) { + input.didToggleSelected.sink { [weak self] (item) in + guard let self = self else { return } + guard case let .reportStatus(objectID, attribute) = item else { return } + let managedObjectContext = self.statusFetchedResultsController.fetchedResultsController.managedObjectContext + guard let status = managedObjectContext.object(with: objectID) as? Status else { + return + } + + attribute.isSelected = !attribute.isSelected + if attribute.isSelected { + self.append(statusID: status.id) + } else { + self.remove(statusID: status.id) + } + + let continueEnable = self.statusIDs.count > 0 + self.continueEnableSubject.send(continueEnable) + } + .store(in: &disposeBag) + + input.comment.sink { [weak self] (comment) in + guard let self = self else { return } + + self.comment = comment + + let sendEnable = (comment?.length ?? 0) > 0 + self.sendEnableSubject.send(sendEnable) + } + .store(in: &disposeBag) + } + + func bindForStep1(input: Input) { + let skip = input.step1Skip.map { [weak self] value -> Void in + guard let self = self else { return value } + self.reportQuery.statusIDs?.removeAll() + return value + } + + let step1Continue = input.step1Continue.map { [weak self] value -> Void in + guard let self = self else { return value } + self.reportQuery.statusIDs = self.statusIDs + return value + } + + Publishers.Merge(skip, step1Continue) + .sink { [weak self] _ in + self?.currentStep.value = .two + self?.sendEnableSubject.send(false) + } + .store(in: &disposeBag) + } + + func bindForStep2(input: Input, domain: String, activeMastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox) -> AnyPublisher<(Bool, Error?), Never> { + let skip = input.step2Skip.map { [weak self] value -> Void in + guard let self = self else { return value } + self.reportQuery.comment = nil + return value + } + + let step2Continue = input.step2Continue.map { [weak self] value -> Void in + guard let self = self else { return value } + self.reportQuery.comment = self.comment + return value + } + + return Publishers.Merge(skip, step2Continue) + .flatMap { [weak self] (_) -> AnyPublisher<(Bool, Error?), Never> in + guard let self = self else { + return Empty(completeImmediately: true).eraseToAnyPublisher() + } + + return self.context.apiService.report( + domain: domain, + query: self.reportQuery, + mastodonAuthenticationBox: activeMastodonAuthenticationBox + ) + .map({ (content) -> (Bool, Error?) in + return (true, nil) + }) + .eraseToAnyPublisher() + .tryCatch({ (error) -> AnyPublisher<(Bool, Error?), Never> in + return Just((false, error)).eraseToAnyPublisher() + }) + // to covert to AnyPublisher<(Bool, Error?), Never> + .replaceError(with: (false, nil)) + .eraseToAnyPublisher() + } + .eraseToAnyPublisher() + } + + func append(statusID: Mastodon.Entity.Status.ID) { + guard self.statusIDs.contains(statusID) != true else { return } + self.statusIDs.append(statusID) + } + + func remove(statusID: String) { + guard let index = self.statusIDs.firstIndex(of: statusID) else { return } + self.statusIDs.remove(at: index) + } +} diff --git a/Mastodon/Scene/Report/ReportedStatusTableviewCell.swift b/Mastodon/Scene/Report/ReportedStatusTableviewCell.swift new file mode 100644 index 000000000..b1d0af6b0 --- /dev/null +++ b/Mastodon/Scene/Report/ReportedStatusTableviewCell.swift @@ -0,0 +1,217 @@ +// +// ReportedStatusTableViewCell.swift +// Mastodon +// +// Created by ihugo on 2021/4/20. +// + +import os.log +import UIKit +import AVKit +import Combine +import CoreData +import CoreDataStack +import ActiveLabel + +final class ReportedStatusTableViewCell: UITableViewCell, StatusCell { + + static let bottomPaddingHeight: CGFloat = 10 + + weak var dependency: ReportViewController? + var disposeBag = Set() + var pollCountdownSubscription: AnyCancellable? + var observations = Set() + + let statusView = StatusView() + let separatorLine = UIView.separatorLine + + let checkbox: UIImageView = { + let imageView = UIImageView() + imageView.preferredSymbolConfiguration = UIImage.SymbolConfiguration(textStyle: .body) + imageView.tintColor = Asset.Colors.Label.secondary.color + imageView.contentMode = .scaleAspectFill + imageView.translatesAutoresizingMaskIntoConstraints = false + return imageView + }() + + var separatorLineToEdgeLeadingLayoutConstraint: NSLayoutConstraint! + var separatorLineToEdgeTrailingLayoutConstraint: NSLayoutConstraint! + + var separatorLineToMarginLeadingLayoutConstraint: NSLayoutConstraint! + var separatorLineToMarginTrailingLayoutConstraint: NSLayoutConstraint! + + override func prepareForReuse() { + super.prepareForReuse() + statusView.updateContentWarningDisplay(isHidden: true, animated: false) + statusView.statusMosaicImageViewContainer.contentWarningOverlayView.isUserInteractionEnabled = true + statusView.pollTableView.dataSource = nil + statusView.playerContainerView.reset() + statusView.playerContainerView.contentWarningOverlayView.isUserInteractionEnabled = true + statusView.playerContainerView.isHidden = true + disposeBag.removeAll() + observations.removeAll() + } + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + _init() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + _init() + } + + override func layoutSubviews() { + super.layoutSubviews() + + // precondition: app is active + guard UIApplication.shared.applicationState == .active else { return } + DispatchQueue.main.async { + self.statusView.drawContentWarningImageView() + } + } + + override func setHighlighted(_ highlighted: Bool, animated: Bool) { + super.setHighlighted(highlighted, animated: animated) + if highlighted { + checkbox.image = UIImage(systemName: "checkmark.circle.fill") + checkbox.tintColor = Asset.Colors.Label.highlight.color + } else if !isSelected { + checkbox.image = UIImage(systemName: "circle") + checkbox.tintColor = Asset.Colors.Label.secondary.color + } + } + + override func setSelected(_ selected: Bool, animated: Bool) { + super.setSelected(selected, animated: animated) + + if isSelected { + checkbox.image = UIImage(systemName: "checkmark.circle.fill") + } else { + checkbox.image = UIImage(systemName: "circle") + } + checkbox.tintColor = Asset.Colors.Label.secondary.color + } +} + +extension ReportedStatusTableViewCell { + + private func _init() { + backgroundColor = Asset.Colors.Background.systemBackground.color + + checkbox.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(checkbox) + NSLayoutConstraint.activate([ + checkbox.widthAnchor.constraint(equalToConstant: 23), + checkbox.heightAnchor.constraint(equalToConstant: 22), + checkbox.leadingAnchor.constraint(equalTo: contentView.readableContentGuide.leadingAnchor, constant: 12), + checkbox.centerYAnchor.constraint(equalTo: contentView.centerYAnchor), + ]) + + statusView.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(statusView) + NSLayoutConstraint.activate([ + statusView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 20), + statusView.leadingAnchor.constraint(equalTo: checkbox.trailingAnchor, constant: 20), + contentView.readableContentGuide.trailingAnchor.constraint(equalTo: statusView.trailingAnchor), + contentView.bottomAnchor.constraint(equalTo: statusView.bottomAnchor, constant: 20), + ]) + + separatorLine.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(separatorLine) + separatorLineToEdgeLeadingLayoutConstraint = separatorLine.leadingAnchor.constraint(equalTo: contentView.leadingAnchor) + separatorLineToEdgeTrailingLayoutConstraint = separatorLine.trailingAnchor.constraint(equalTo: contentView.trailingAnchor) + separatorLineToMarginLeadingLayoutConstraint = separatorLine.leadingAnchor.constraint(equalTo: contentView.readableContentGuide.leadingAnchor) + separatorLineToMarginTrailingLayoutConstraint = separatorLine.trailingAnchor.constraint(equalTo: contentView.readableContentGuide.trailingAnchor) + NSLayoutConstraint.activate([ + separatorLine.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), + separatorLine.heightAnchor.constraint(equalToConstant: UIView.separatorLineHeight(of: contentView)), + ]) + resetSeparatorLineLayout() + + selectionStyle = .none + statusView.delegate = self + statusView.statusMosaicImageViewContainer.delegate = self + statusView.actionToolbarContainer.isHidden = true + statusView.contentWarningOverlayView.blurContentImageView.backgroundColor = backgroundColor + } + + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + + resetSeparatorLineLayout() + } +} + +extension ReportedStatusTableViewCell { + private func resetSeparatorLineLayout() { + separatorLineToEdgeLeadingLayoutConstraint.isActive = false + separatorLineToEdgeTrailingLayoutConstraint.isActive = false + separatorLineToMarginLeadingLayoutConstraint.isActive = false + separatorLineToMarginTrailingLayoutConstraint.isActive = false + + if traitCollection.userInterfaceIdiom == .phone { + // to edge + NSLayoutConstraint.activate([ + separatorLineToEdgeLeadingLayoutConstraint, + separatorLineToEdgeTrailingLayoutConstraint, + ]) + } else { + if traitCollection.horizontalSizeClass == .compact { + // to edge + NSLayoutConstraint.activate([ + separatorLineToEdgeLeadingLayoutConstraint, + separatorLineToEdgeTrailingLayoutConstraint, + ]) + } else { + // to margin + NSLayoutConstraint.activate([ + separatorLineToMarginLeadingLayoutConstraint, + separatorLineToMarginTrailingLayoutConstraint, + ]) + } + } + } +} + +extension ReportedStatusTableViewCell: MosaicImageViewContainerDelegate { + func mosaicImageViewContainer(_ mosaicImageViewContainer: MosaicImageViewContainer, didTapImageView imageView: UIImageView, atIndex index: Int) { + + } + + func mosaicImageViewContainer(_ mosaicImageViewContainer: MosaicImageViewContainer, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView) { + + guard let dependency = self.dependency else { return } + StatusProviderFacade.responseToStatusContentWarningRevealAction(dependency: dependency, cell: self) + } +} + +extension ReportedStatusTableViewCell: StatusViewDelegate { + func statusView(_ statusView: StatusView, headerInfoLabelDidPressed label: UILabel) { + } + + func statusView(_ statusView: StatusView, avatarButtonDidPressed button: UIButton) { + } + + 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, activeLabel: ActiveLabel, didSelectActiveEntity entity: ActiveEntity) { + } +} diff --git a/Mastodon/Scene/Search/CollectionViewCell/SearchRecommendAccountsCollectionViewCell.swift b/Mastodon/Scene/Search/CollectionViewCell/SearchRecommendAccountsCollectionViewCell.swift new file mode 100644 index 000000000..289583aec --- /dev/null +++ b/Mastodon/Scene/Search/CollectionViewCell/SearchRecommendAccountsCollectionViewCell.swift @@ -0,0 +1,222 @@ +// +// SearchRecommendAccountsCollectionViewCell.swift +// Mastodon +// +// Created by sxiaojian on 2021/4/1. +// + +import Combine +import CoreDataStack +import Foundation +import MastodonSDK +import UIKit + +protocol SearchRecommendAccountsCollectionViewCellDelegate: NSObject { + func followButtonDidPressed(clickedUser: MastodonUser) + + func configFollowButton(with mastodonUser: MastodonUser, followButton: HighlightDimmableButton) +} + +class SearchRecommendAccountsCollectionViewCell: UICollectionViewCell { + 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: UILabel = { + let label = UILabel() + label.textColor = .white + label.textAlignment = .center + label.font = .systemFont(ofSize: 18, weight: .semibold) + label.translatesAutoresizingMaskIntoConstraints = false + return label + }() + + 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() + visualEffectView.removeFromSuperview() + disposeBag.removeAll() + } + + override init(frame: CGRect) { + super.init(frame: .zero) + configure() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + configure() + } +} + +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) + ]) + + 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) + } + + func config(with mastodonUser: MastodonUser) { + displayNameLabel.text = mastodonUser.displayName.isEmpty ? mastodonUser.username : mastodonUser.displayName + acctLabel.text = "@" + mastodonUser.acct + avatarImageView.af.setImage( + withURL: URL(string: mastodonUser.avatar)!, + placeholderImage: UIImage.placeholder(color: .systemFill), + imageTransition: .crossDissolve(0.2) + ) + headerImageView.af.setImage( + withURL: URL(string: mastodonUser.header)!, + placeholderImage: UIImage.placeholder(color: .systemFill), + imageTransition: .crossDissolve(0.2) + ) { [weak self] _ in + guard let self = self else { return } + self.headerImageView.addSubview(self.visualEffectView) + self.visualEffectView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + self.visualEffectView.topAnchor.constraint(equalTo: self.headerImageView.topAnchor), + self.visualEffectView.leadingAnchor.constraint(equalTo: self.headerImageView.leadingAnchor), + self.visualEffectView.trailingAnchor.constraint(equalTo: self.headerImageView.trailingAnchor), + self.visualEffectView.bottomAnchor.constraint(equalTo: self.headerImageView.bottomAnchor) + ]) + } + delegate?.configFollowButton(with: mastodonUser, followButton: followButton) + followButton.publisher(for: .touchUpInside) + .sink { [weak self] _ in + self?.followButtonDidPressed(mastodonUser: mastodonUser) + } + .store(in: &disposeBag) + } + + func followButtonDidPressed(mastodonUser: MastodonUser) { + delegate?.followButtonDidPressed(clickedUser: mastodonUser) + } +} + +#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/CollectionViewCell/SearchRecommendTagsCollectionViewCell.swift b/Mastodon/Scene/Search/CollectionViewCell/SearchRecommendTagsCollectionViewCell.swift new file mode 100644 index 000000000..002929510 --- /dev/null +++ b/Mastodon/Scene/Search/CollectionViewCell/SearchRecommendTagsCollectionViewCell.swift @@ -0,0 +1,158 @@ +// +// 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) + return label + }() + + let flameIconView: UIImageView = { + let imageView = UIImageView() + let image = UIImage(systemName: "flame.fill", withConfiguration: UIImage.SymbolConfiguration(pointSize: 20, weight: .semibold))!.withRenderingMode(.alwaysTemplate) + imageView.image = image + imageView.tintColor = .white + return imageView + }() + + override func prepareForReuse() { + super.prepareForReuse() + } + + override init(frame: CGRect) { + super.init(frame: .zero) + configure() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + configure() + } +} + +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) + ]) + + + let horizontalStackView = UIStackView() + horizontalStackView.axis = .horizontal + horizontalStackView.translatesAutoresizingMaskIntoConstraints = false + horizontalStackView.distribution = .fill + + hashtagTitleLabel.translatesAutoresizingMaskIntoConstraints = false + hashtagTitleLabel.setContentHuggingPriority(.defaultLow - 1, for: .horizontal) + horizontalStackView.addArrangedSubview(hashtagTitleLabel) + horizontalStackView.setContentHuggingPriority(.required - 1, for: .vertical) + + flameIconView.translatesAutoresizingMaskIntoConstraints = false + horizontalStackView.addArrangedSubview(flameIconView) + + + containerStackView.addArrangedSubview(horizontalStackView) + peopleLabel.translatesAutoresizingMaskIntoConstraints = false + peopleLabel.setContentHuggingPriority(.defaultLow - 1, for: .vertical) + containerStackView.addArrangedSubview(peopleLabel) + containerStackView.setCustomSpacing(SearchViewController.hashtagPeopleTalkingLabelTop, after: horizontalStackView) + } + + func config(with tag: Mastodon.Entity.Tag) { + hashtagTitleLabel.text = "# " + tag.name + guard let historys = tag.history else { + peopleLabel.text = "" + return + } + + let recentHistory = historys.prefix(2) + let peopleAreTalking = recentHistory.compactMap({ Int($0.accounts) }).reduce(0, +) + let string = L10n.Scene.Search.Recommend.HashTag.peopleTalking(String(peopleAreTalking)) + peopleLabel.text = string + + } +} + +#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/SearchViewController+Follow.swift b/Mastodon/Scene/Search/SearchViewController+Follow.swift new file mode 100644 index 000000000..a7f7faf90 --- /dev/null +++ b/Mastodon/Scene/Search/SearchViewController+Follow.swift @@ -0,0 +1,144 @@ +// +// 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(self.viewModel.mastodonUser.value)) + } + } +} + +extension SearchViewController: SearchRecommendAccountsCollectionViewCellDelegate { + func followButtonDidPressed(clickedUser: MastodonUser) { + viewModel.mastodonUser.value = clickedUser + guard let currentMastodonUser = viewModel.currentMastodonUser.value else { + return + } + guard let relationshipAction = relationShipActionSet(mastodonUser: clickedUser, currentMastodonUser: currentMastodonUser).highPriorityAction(except: .editOptions) else { return } + switch relationshipAction { + case .none: + break + case .follow, .following: + UserProviderFacade.toggleUserFollowRelationship(provider: self) + .sink { _ in + + } receiveValue: { _ in + } + .store(in: &disposeBag) + case .pending: + break + case .muting: + guard let mastodonUser = viewModel.mastodonUser.value else { return } + 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.Firendship.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) + } + 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 + 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.Firendship.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) + } + 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() + } + } + + func configFollowButton(with mastodonUser: MastodonUser, followButton: HighlightDimmableButton) { + guard let currentMastodonUser = viewModel.currentMastodonUser.value else { + return + } + _configFollowButton(with: mastodonUser, currentMastodonUser: currentMastodonUser, followButton: followButton) + ManagedObjectObserver.observe(object: currentMastodonUser) + .sink { _ in + + } receiveValue: { change in + guard case .update(let object) = change.changeType, + let newUser = object as? MastodonUser else { return } + self._configFollowButton(with: mastodonUser, currentMastodonUser: newUser, followButton: followButton) + } + .store(in: &disposeBag) + } +} + +extension SearchViewController { + func _configFollowButton(with mastodonUser: MastodonUser, currentMastodonUser: MastodonUser, followButton: HighlightDimmableButton) { + let relationshipActionSet = relationShipActionSet(mastodonUser: mastodonUser, currentMastodonUser: currentMastodonUser) + followButton.setTitle(relationshipActionSet.title, for: .normal) + } + + 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 + } +} diff --git a/Mastodon/Scene/Search/SearchViewController+Recommend.swift b/Mastodon/Scene/Search/SearchViewController+Recommend.swift new file mode 100644 index 000000000..3425ac193 --- /dev/null +++ b/Mastodon/Scene/Search/SearchViewController+Recommend.swift @@ -0,0 +1,111 @@ +// +// 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.addTarget(self, action: #selector(SearchViewController.hashtagSeeAllButtonPressed(_:)), for: .touchUpInside) + 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 user = context.managedObjectContext.object(with: accountObjectID) as! MastodonUser + viewModel.accountCollectionViewItemDidSelected(mastodonUser: user, from: self) + case self.hashtagCollectionView: + guard let diffableDataSource = viewModel.hashtagDiffableDataSource else { return } + guard let hashtag = diffableDataSource.itemIdentifier(for: indexPath) else { return } + viewModel.hashtagCollectionViewItemDidSelected(hashtag: hashtag, from: self) + 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/SearchViewController+Searching.swift b/Mastodon/Scene/Search/SearchViewController+Searching.swift new file mode 100644 index 000000000..0602ac200 --- /dev/null +++ b/Mastodon/Scene/Search/SearchViewController+Searching.swift @@ -0,0 +1,92 @@ +// +// SearchViewController+Searching.swift +// Mastodon +// +// Created by sxiaojian on 2021/4/2. +// + +import Combine +import CoreData +import CoreDataStack +import Foundation +import MastodonSDK +import OSLog +import UIKit + +extension SearchViewController { + func setupSearchingTableView() { + searchingTableView.register(SearchingTableViewCell.self, forCellReuseIdentifier: String(describing: SearchingTableViewCell.self)) + searchingTableView.register(TimelineBottomLoaderTableViewCell.self, forCellReuseIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self)) + searchingTableView.estimatedRowHeight = 66 + searchingTableView.rowHeight = 66 + view.addSubview(searchingTableView) + searchingTableView.delegate = self + searchingTableView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + searchingTableView.topAnchor.constraint(equalTo: searchBar.bottomAnchor), + searchingTableView.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor), + searchingTableView.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor), + searchingTableView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor), + ]) + searchingTableView.tableFooterView = UIView() + viewModel.isSearching + .receive(on: DispatchQueue.main) + .sink { [weak self] isSearching in + self?.searchingTableView.isHidden = !isSearching + } + .store(in: &disposeBag) + + Publishers.CombineLatest( + viewModel.isSearching, + viewModel.searchText + ) + .sink { [weak self] isSearching, text in + guard let self = self else { return } + if isSearching, text.isEmpty { + self.searchingTableView.tableHeaderView = self.searchHeader + } else { + self.searchingTableView.tableHeaderView = nil + } + } + .store(in: &disposeBag) + } + + func setupSearchHeader() { + let containerStackView = UIStackView() + containerStackView.axis = .horizontal + containerStackView.distribution = .fill + containerStackView.translatesAutoresizingMaskIntoConstraints = false + containerStackView.layoutMargins = UIEdgeInsets(top: 0, left: 12, bottom: 0, right: 12) + containerStackView.isLayoutMarginsRelativeArrangement = true + searchHeader.addSubview(containerStackView) + NSLayoutConstraint.activate([ + containerStackView.topAnchor.constraint(equalTo: searchHeader.topAnchor), + containerStackView.leadingAnchor.constraint(equalTo: searchHeader.leadingAnchor), + containerStackView.trailingAnchor.constraint(equalTo: searchHeader.trailingAnchor), + containerStackView.bottomAnchor.constraint(equalTo: searchHeader.bottomAnchor) + ]) + recentSearchesLabel.translatesAutoresizingMaskIntoConstraints = false + containerStackView.addArrangedSubview(recentSearchesLabel) + clearSearchHistoryButton.translatesAutoresizingMaskIntoConstraints = false + containerStackView.addArrangedSubview(clearSearchHistoryButton) + clearSearchHistoryButton.addTarget(self, action: #selector(SearchViewController.clearAction(_:)), for: .touchUpInside) + } +} + +extension SearchViewController { + @objc func clearAction(_ sender: UIButton) { + viewModel.deleteSearchHistory() + } +} + +// MARK: - UITableViewDelegate + +extension SearchViewController: UITableViewDelegate { + + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + tableView.deselectRow(at: indexPath, animated: true) + guard let diffableDataSource = viewModel.searchResultDiffableDataSource else { return } + guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return } + viewModel.searchResultItemDidSelected(item: item, from: self) + } +} diff --git a/Mastodon/Scene/Search/SearchViewController.swift b/Mastodon/Scene/Search/SearchViewController.swift index 084e7b231..3731f118b 100644 --- a/Mastodon/Scene/Search/SearchViewController.swift +++ b/Mastodon/Scene/Search/SearchViewController.swift @@ -2,23 +2,275 @@ // SearchViewController.swift // Mastodon // -// Created by MainasuK Cirno on 2021-2-23. +// Created by sxiaojian on 2021/3/31. // +import Combine +import GameplayKit +import MastodonSDK import UIKit final class SearchViewController: UIViewController, NeedsDependency { + 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 + weak var context: AppContext! { willSet { precondition(!isViewLoaded) } } weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } } + var disposeBag = Set() + private(set) lazy var viewModel = SearchViewModel(context: context, coordinator: coordinator) + + let statusBar: UIView = { + let view = UIView() + view.backgroundColor = Asset.Colors.Background.navigationBar.color + return view + }() + + let searchBar: UISearchBar = { + let searchBar = UISearchBar() + searchBar.placeholder = L10n.Scene.Search.Searchbar.placeholder + searchBar.tintColor = Asset.Colors.brandBlue.color + searchBar.translatesAutoresizingMaskIntoConstraints = false + let micImage = UIImage(systemName: "mic.fill") + searchBar.setImage(micImage, for: .bookmark, state: .normal) + searchBar.showsBookmarkButton = true + searchBar.scopeButtonTitles = [L10n.Scene.Search.Searching.Segment.all, L10n.Scene.Search.Searching.Segment.people, L10n.Scene.Search.Searching.Segment.hashtags] + searchBar.barTintColor = Asset.Colors.Background.navigationBar.color + return searchBar + }() + + // 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 + }() + + // searching + let searchingTableView: UITableView = { + let tableView = UITableView() + tableView.backgroundColor = Asset.Colors.Background.systemBackground.color + tableView.rowHeight = UITableView.automaticDimension + tableView.separatorStyle = .singleLine + tableView.separatorInset = UIEdgeInsets(top: 0, left: 0, bottom: 0, right: 0) + return tableView + }() + + lazy var searchHeader: UIView = { + let view = UIView() + view.backgroundColor = Asset.Colors.Background.systemGroupedBackground.color + view.frame = CGRect(origin: .zero, size: CGSize(width: searchingTableView.frame.width, height: 56)) + return view + }() + + let recentSearchesLabel: UILabel = { + let label = UILabel() + label.font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 20, weight: .semibold)) + label.textColor = Asset.Colors.Label.primary.color + label.text = L10n.Scene.Search.Searching.recentSearch + return label + }() + + let clearSearchHistoryButton: HighlightDimmableButton = { + let button = HighlightDimmableButton(type: .custom) + button.setTitleColor(Asset.Colors.brandBlue.color, for: .normal) + button.setTitle(L10n.Scene.Search.Searching.clear, for: .normal) + return button + }() } extension SearchViewController { - override func viewDidLoad() { super.viewDidLoad() + let barAppearance = UINavigationBarAppearance() + barAppearance.configureWithTransparentBackground() + navigationItem.standardAppearance = barAppearance + navigationItem.compactAppearance = barAppearance + navigationItem.scrollEdgeAppearance = barAppearance + view.backgroundColor = Asset.Colors.Background.systemGroupedBackground.color + navigationItem.hidesBackButton = true + + setupSearchBar() + setupScrollView() + setupHashTagCollectionView() + setupAccountsCollectionView() + setupSearchingTableView() + setupDataSource() + setupSearchHeader() + view.bringSubviewToFront(searchBar) + view.bringSubviewToFront(statusBar) + } + + func setupSearchBar() { + searchBar.delegate = self + view.addSubview(searchBar) + searchBar.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + searchBar.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), + searchBar.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor), + searchBar.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor), + ]) + statusBar.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(statusBar) + NSLayoutConstraint.activate([ + statusBar.topAnchor.constraint(equalTo: view.topAnchor), + statusBar.leadingAnchor.constraint(equalTo: view.leadingAnchor), + statusBar.trailingAnchor.constraint(equalTo: view.trailingAnchor), + statusBar.bottomAnchor.constraint(equalTo: view.layoutMarginsGuide.topAnchor, constant: 3), + ]) + } + + func setupScrollView() { + scrollView.translatesAutoresizingMaskIntoConstraints = false + stackView.translatesAutoresizingMaskIntoConstraints = false + + // scrollView + view.addSubview(scrollView) + NSLayoutConstraint.activate([ + scrollView.frameLayoutGuide.topAnchor.constraint(equalTo: view.layoutMarginsGuide.topAnchor, constant: searchBar.frame.height), + 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), + ]) + + // stackview + scrollView.addSubview(stackView) + stackView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + stackView.topAnchor.constraint(equalTo: scrollView.contentLayoutGuide.topAnchor), + stackView.leadingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.leadingAnchor), + stackView.trailingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.trailingAnchor), + stackView.widthAnchor.constraint(equalTo: scrollView.contentLayoutGuide.widthAnchor), + scrollView.contentLayoutGuide.bottomAnchor.constraint(equalTo: stackView.bottomAnchor), + ]) } + func setupDataSource() { + viewModel.hashtagDiffableDataSource = RecommendHashTagSection.collectionViewDiffableDataSource(for: hashtagCollectionView) + viewModel.accountDiffableDataSource = RecommendAccountSection.collectionViewDiffableDataSource(for: accountsCollectionView, delegate: self, managedObjectContext: context.managedObjectContext) + viewModel.searchResultDiffableDataSource = SearchResultSection.tableViewDiffableDataSource(for: searchingTableView, dependency: self) + } } + +extension SearchViewController: UIScrollViewDelegate { + func scrollViewDidScroll(_ scrollView: UIScrollView) { + if scrollView == searchingTableView { + handleScrollViewDidScroll(scrollView) + } + } +} + +extension SearchViewController: UISearchBarDelegate { + func searchBarTextDidBeginEditing(_ searchBar: UISearchBar) { + searchBar.setShowsCancelButton(true, animated: true) + searchBar.showsScopeBar = true + viewModel.isSearching.value = true + } + + func searchBarTextDidEndEditing(_ searchBar: UISearchBar) { + searchBar.setShowsCancelButton(false, animated: true) + searchBar.showsScopeBar = false + viewModel.isSearching.value = true + } + + func searchBarCancelButtonClicked(_ searchBar: UISearchBar) { + searchBar.setShowsCancelButton(false, animated: true) + searchBar.showsScopeBar = false + searchBar.text = "" + searchBar.resignFirstResponder() + viewModel.isSearching.value = false + } + + func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) { + viewModel.searchText.send(searchText) + } + + func searchBar(_ searchBar: UISearchBar, selectedScopeButtonIndexDidChange selectedScope: Int) { + switch selectedScope { + case 0: + viewModel.searchScope.value = Mastodon.API.V2.Search.SearchType.default + case 1: + viewModel.searchScope.value = Mastodon.API.V2.Search.SearchType.accounts + case 2: + viewModel.searchScope.value = Mastodon.API.V2.Search.SearchType.hashtags + default: + break + } + } + + func searchBarBookmarkButtonClicked(_ searchBar: UISearchBar) {} +} + +extension SearchViewController: LoadMoreConfigurableTableViewContainer { + typealias BottomLoaderTableViewCell = TimelineBottomLoaderTableViewCell + typealias LoadingState = SearchViewModel.LoadOldestState.Loading + var loadMoreConfigurableTableView: UITableView { searchingTableView } + var loadMoreConfigurableStateMachine: GKStateMachine { viewModel.loadoldestStateMachine } +} + +#if canImport(SwiftUI) && DEBUG +import SwiftUI + +struct SearchViewController_Previews: PreviewProvider { + static var previews: some View { + UIViewControllerPreview { + let viewController = SearchViewController() + return viewController + } + .previewLayout(.fixed(width: 375, height: 800)) + } +} + +#endif diff --git a/Mastodon/Scene/Search/SearchViewModel+LoadOldestState.swift b/Mastodon/Scene/Search/SearchViewModel+LoadOldestState.swift new file mode 100644 index 000000000..4fe68e47d --- /dev/null +++ b/Mastodon/Scene/Search/SearchViewModel+LoadOldestState.swift @@ -0,0 +1,146 @@ +// +// SearchViewModel+LoadOldestState.swift +// Mastodon +// +// Created by sxiaojian on 2021/4/6. +// + +import Foundation +import GameplayKit +import MastodonSDK +import os.log + +extension SearchViewModel { + class LoadOldestState: GKState { + weak var viewModel: SearchViewModel? + + init(viewModel: SearchViewModel) { + 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 SearchViewModel.LoadOldestState { + class Initial: SearchViewModel.LoadOldestState { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + guard let viewModel = viewModel else { return false } + guard viewModel.searchResult.value != nil else { return false } + return stateClass == Loading.self + } + } + + class Loading: SearchViewModel.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.context.authenticationService.activeMastodonAuthenticationBox.value else { + assertionFailure() + stateMachine.enter(Fail.self) + return + } + guard let oldSearchResult = viewModel.searchResult.value else { + stateMachine.enter(Fail.self) + return + } + var offset = 0 + switch viewModel.searchScope.value { + case Mastodon.API.V2.Search.SearchType.accounts: + offset = oldSearchResult.accounts.count + case Mastodon.API.V2.Search.SearchType.hashtags: + offset = oldSearchResult.hashtags.count + default: + return + } + let query = Mastodon.API.V2.Search.Query(q: viewModel.searchText.value, + type: viewModel.searchScope.value, + accountID: nil, + maxID: nil, + minID: nil, + excludeUnreviewed: nil, + resolve: nil, + limit: nil, + offset: offset, + following: nil) + + viewModel.context.apiService.search(domain: activeMastodonAuthenticationBox.domain, query: query, mastodonAuthenticationBox: activeMastodonAuthenticationBox) + .sink { completion in + switch completion { + case .failure(let error): + os_log("%{public}s[%{public}ld], %{public}s: load oldest search failed. %s", (#file as NSString).lastPathComponent, #line, #function, error.localizedDescription) + case .finished: + // handle isFetchingLatestTimeline in fetch controller delegate + break + } + } receiveValue: { result in + switch viewModel.searchScope.value { + case Mastodon.API.V2.Search.SearchType.accounts: + if result.value.accounts.isEmpty { + stateMachine.enter(NoMore.self) + } else { + var newAccounts = [Mastodon.Entity.Account]() + newAccounts.append(contentsOf: oldSearchResult.accounts) + newAccounts.append(contentsOf: result.value.accounts) + newAccounts.removeDuplicates() + viewModel.searchResult.value = Mastodon.Entity.SearchResult(accounts: newAccounts, statuses: oldSearchResult.statuses, hashtags: oldSearchResult.hashtags) + stateMachine.enter(Idle.self) + } + case Mastodon.API.V2.Search.SearchType.hashtags: + if result.value.hashtags.isEmpty { + stateMachine.enter(NoMore.self) + } else { + var newTags = [Mastodon.Entity.Tag]() + newTags.append(contentsOf: oldSearchResult.hashtags) + newTags.append(contentsOf: result.value.hashtags) + newTags.removeDuplicates() + viewModel.searchResult.value = Mastodon.Entity.SearchResult(accounts: oldSearchResult.accounts, statuses: oldSearchResult.statuses, hashtags: newTags) + stateMachine.enter(Idle.self) + } + default: + return + } + } + .store(in: &viewModel.disposeBag) + } + } + + class Fail: SearchViewModel.LoadOldestState { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + stateClass == Loading.self || stateClass == Idle.self + } + } + + class Idle: SearchViewModel.LoadOldestState { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + stateClass == Loading.self + } + } + + class NoMore: SearchViewModel.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.searchResultDiffableDataSource else { + assertionFailure() + return + } + DispatchQueue.main.async { + var snapshot = diffableDataSource.snapshot() + snapshot.deleteItems([.bottomLoader]) + diffableDataSource.apply(snapshot) + } + } + } +} diff --git a/Mastodon/Scene/Search/SearchViewModel.swift b/Mastodon/Scene/Search/SearchViewModel.swift new file mode 100644 index 000000000..e10b04c9e --- /dev/null +++ b/Mastodon/Scene/Search/SearchViewModel.swift @@ -0,0 +1,466 @@ +// +// SearchViewModel.swift +// Mastodon +// +// Created by sxiaojian on 2021/3/31. +// + +import Combine +import CoreData +import CoreDataStack +import Foundation +import GameplayKit +import MastodonSDK +import OSLog +import UIKit + +final class SearchViewModel: NSObject { + var disposeBag = Set() + + // input + let context: AppContext + weak var coordinator: SceneCoordinator! + + let mastodonUser = CurrentValueSubject(nil) + let currentMastodonUser = CurrentValueSubject(nil) + + // output + let searchText = CurrentValueSubject("") + let searchScope = CurrentValueSubject(Mastodon.API.V2.Search.SearchType.default) + + let isSearching = CurrentValueSubject(false) + + let searchResult = CurrentValueSubject(nil) + + var recommendHashTags = [Mastodon.Entity.Tag]() + var recommendAccounts = [NSManagedObjectID]() + var recommendAccountsFallback = PassthroughSubject() + + var hashtagDiffableDataSource: UICollectionViewDiffableDataSource? + var accountDiffableDataSource: UICollectionViewDiffableDataSource? + var searchResultDiffableDataSource: UITableViewDiffableDataSource? + + // bottom loader + private(set) lazy var loadoldestStateMachine: GKStateMachine = { + // exclude timeline middle fetcher state + let stateMachine = GKStateMachine(states: [ + LoadOldestState.Initial(viewModel: self), + LoadOldestState.Loading(viewModel: self), + LoadOldestState.Fail(viewModel: self), + LoadOldestState.Idle(viewModel: self), + LoadOldestState.NoMore(viewModel: self), + ]) + stateMachine.enter(LoadOldestState.Initial.self) + return stateMachine + }() + + lazy var loadOldestStateMachinePublisher = CurrentValueSubject(nil) + + init(context: AppContext, coordinator: SceneCoordinator) { + self.coordinator = coordinator + self.context = context + super.init() + + guard let activeMastodonAuthenticationBox = self.context.authenticationService.activeMastodonAuthenticationBox.value else { + return + } + + // bind active authentication + context.authenticationService.activeMastodonAuthentication + .sink { [weak self] activeMastodonAuthentication in + guard let self = self else { return } + guard let activeMastodonAuthentication = activeMastodonAuthentication else { + self.currentMastodonUser.value = nil + return + } + self.currentMastodonUser.value = activeMastodonAuthentication.user + } + .store(in: &disposeBag) + + Publishers.CombineLatest( + searchText + .debounce(for: .milliseconds(300), scheduler: DispatchQueue.main).removeDuplicates(), + searchScope + ) + .filter { text, _ in + !text.isEmpty + } + .flatMap { (text, scope) -> AnyPublisher, Error> in + + let query = Mastodon.API.V2.Search.Query(q: text, + type: scope, + accountID: nil, + maxID: nil, + minID: nil, + excludeUnreviewed: nil, + resolve: nil, + limit: nil, + offset: nil, + following: nil) + return context.apiService.search(domain: activeMastodonAuthenticationBox.domain, query: query, mastodonAuthenticationBox: activeMastodonAuthenticationBox) + } + .sink { _ in + } receiveValue: { [weak self] result in + self?.searchResult.value = result.value + } + .store(in: &disposeBag) + + isSearching + .sink { [weak self] isSearching in + if !isSearching { + self?.searchResult.value = nil + self?.searchText.value = "" + } + } + .store(in: &disposeBag) + + Publishers.CombineLatest3( + isSearching, + searchText, + searchScope + ) + .filter { isSearching, _, _ in + isSearching + } + .sink { [weak self] _, text, scope in + guard text.isEmpty else { return } + guard let self = self else { return } + guard let searchHistories = self.fetchSearchHistory() else { return } + guard let dataSource = self.searchResultDiffableDataSource else { return } + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([.mixed]) + + searchHistories.forEach { searchHistory in + let containsAccount = scope == Mastodon.API.V2.Search.SearchType.accounts || scope == Mastodon.API.V2.Search.SearchType.default + let containsHashTag = scope == Mastodon.API.V2.Search.SearchType.hashtags || scope == Mastodon.API.V2.Search.SearchType.default + if let mastodonUser = searchHistory.account, containsAccount { + let item = SearchResultItem.accountObjectID(accountObjectID: mastodonUser.objectID) + snapshot.appendItems([item], toSection: .mixed) + } + if let tag = searchHistory.hashtag, containsHashTag { + let item = SearchResultItem.hashtagObjectID(hashtagObjectID: tag.objectID) + snapshot.appendItems([item], toSection: .mixed) + } + } + dataSource.apply(snapshot, animatingDifferences: false, completion: nil) + } + .store(in: &disposeBag) + + requestRecommendHashTags() + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + guard let self = self else { return } + if !self.recommendHashTags.isEmpty { + guard let dataSource = self.hashtagDiffableDataSource else { return } + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([.main]) + snapshot.appendItems(self.recommendHashTags, toSection: .main) + dataSource.apply(snapshot, animatingDifferences: false, completion: nil) + } + } receiveValue: { _ in + } + .store(in: &disposeBag) + + requestRecommendAccountsV2() + .sink { [weak self] _ in + guard let self = self else { return } + if !self.recommendAccounts.isEmpty { + self.applyDataSource() + } + } receiveValue: { _ in + } + .store(in: &disposeBag) + + recommendAccountsFallback + .sink { [weak self] _ in + guard let self = self else { return } + self.requestRecommendAccounts() + .sink { [weak self] _ in + guard let self = self else { return } + if !self.recommendAccounts.isEmpty { + self.applyDataSource() + } + } receiveValue: { _ in + } + .store(in: &self.disposeBag) + } + .store(in: &disposeBag) + + searchResult + .receive(on: DispatchQueue.main) + .sink { [weak self] searchResult in + guard let self = self else { return } + guard let dataSource = self.searchResultDiffableDataSource else { return } + var snapshot = NSDiffableDataSourceSnapshot() + if let accounts = searchResult?.accounts { + snapshot.appendSections([.account]) + let items = accounts.compactMap { SearchResultItem.account(account: $0) } + snapshot.appendItems(items, toSection: .account) + if self.searchScope.value == Mastodon.API.V2.Search.SearchType.accounts, !items.isEmpty { + snapshot.appendItems([.bottomLoader], toSection: .account) + } + } + if let tags = searchResult?.hashtags { + snapshot.appendSections([.hashtag]) + let items = tags.compactMap { SearchResultItem.hashtag(tag: $0) } + snapshot.appendItems(items, toSection: .hashtag) + if self.searchScope.value == Mastodon.API.V2.Search.SearchType.hashtags, !items.isEmpty { + snapshot.appendItems([.bottomLoader], toSection: .hashtag) + } + } + dataSource.apply(snapshot, animatingDifferences: false, completion: nil) + } + .store(in: &disposeBag) + } + + func requestRecommendHashTags() -> Future { + Future { promise in + guard let activeMastodonAuthenticationBox = self.context.authenticationService.activeMastodonAuthenticationBox.value else { + promise(.failure(APIService.APIError.implicit(APIService.APIError.ErrorReason.authenticationMissing))) + return + } + self.context.apiService.recommendTrends(domain: activeMastodonAuthenticationBox.domain, query: nil) + .sink { completion in + switch completion { + case .failure(let error): + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: recommendHashTags request fail: %s", (#file as NSString).lastPathComponent, #line, #function, error.localizedDescription) + promise(.failure(error)) + case .finished: + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: recommendHashTags request success", (#file as NSString).lastPathComponent, #line, #function) + promise(.success(())) + } + } receiveValue: { [weak self] tags in + guard let self = self else { return } + self.recommendHashTags = tags.value + } + .store(in: &self.disposeBag) + } + } + + func requestRecommendAccountsV2() -> Future { + Future { promise in + guard let activeMastodonAuthenticationBox = self.context.authenticationService.activeMastodonAuthenticationBox.value else { + promise(.failure(APIService.APIError.implicit(APIService.APIError.ErrorReason.authenticationMissing))) + return + } + self.context.apiService.suggestionAccountV2(domain: activeMastodonAuthenticationBox.domain, query: nil, mastodonAuthenticationBox: activeMastodonAuthenticationBox) + .sink { [weak self] completion in + switch completion { + case .failure(let error): + if let apiError = error as? Mastodon.API.Error { + if apiError.httpResponseStatus == .notFound { + self?.recommendAccountsFallback.send() + } + } + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: recommendAccount request fail: %s", (#file as NSString).lastPathComponent, #line, #function, error.localizedDescription) + promise(.failure(error)) + case .finished: + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: recommendAccount request success", (#file as NSString).lastPathComponent, #line, #function) + promise(.success(())) + } + } receiveValue: { [weak self] accounts in + guard let self = self else { return } + let ids = accounts.value.compactMap({$0.account.id}) + self.receiveAccounts(ids: ids) + } + .store(in: &self.disposeBag) + } + } + + func requestRecommendAccounts() -> Future { + Future { promise in + guard let activeMastodonAuthenticationBox = self.context.authenticationService.activeMastodonAuthenticationBox.value else { + promise(.failure(APIService.APIError.implicit(APIService.APIError.ErrorReason.authenticationMissing))) + return + } + self.context.apiService.suggestionAccount(domain: activeMastodonAuthenticationBox.domain, query: nil, mastodonAuthenticationBox: activeMastodonAuthenticationBox) + .sink { completion in + switch completion { + case .failure(let error): + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: recommendAccount request fail: %s", (#file as NSString).lastPathComponent, #line, #function, error.localizedDescription) + promise(.failure(error)) + case .finished: + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: recommendAccount request success", (#file as NSString).lastPathComponent, #line, #function) + promise(.success(())) + } + } receiveValue: { [weak self] accounts in + guard let self = self else { return } + let ids = accounts.value.compactMap({$0.id}) + self.receiveAccounts(ids: ids) + } + .store(in: &self.disposeBag) + } + } + + func applyDataSource() { + DispatchQueue.main.async { + 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) + } + } + + func receiveAccounts(ids: [String]) { + 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 + } + }() + if let users = mastodonUsers { + let sortedUsers = users.sorted { (user1, user2) -> Bool in + (ids.firstIndex(of: user1.id) ?? 0) < (ids.firstIndex(of: user2.id) ?? 0) + } + recommendAccounts = sortedUsers.map(\.objectID) + } + } + + func accountCollectionViewItemDidSelected(mastodonUser: MastodonUser, from: UIViewController) { + let viewModel = ProfileViewModel(context: context, optionalMastodonUser: mastodonUser) + DispatchQueue.main.async { + self.coordinator.present(scene: .profile(viewModel: viewModel), from: from, transition: .show) + } + } + + func hashtagCollectionViewItemDidSelected(hashtag: Mastodon.Entity.Tag, from: UIViewController) { + 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: from, transition: .show) + } + } + + func searchResultItemDidSelected(item: SearchResultItem, from: UIViewController) { + let searchHistories = fetchSearchHistory() + _ = context.managedObjectContext.performChanges { [weak self] in + guard let self = self else { return } + switch item { + case .account(let account): + guard let activeMastodonAuthenticationBox = self.context.authenticationService.activeMastodonAuthenticationBox.value else { + return + } + // load request mastodon user + let requestMastodonUser: MastodonUser? = { + let request = MastodonUser.sortedFetchRequest + request.predicate = MastodonUser.predicate(domain: activeMastodonAuthenticationBox.domain, id: activeMastodonAuthenticationBox.userID) + request.fetchLimit = 1 + request.returnsObjectsAsFaults = false + do { + return try self.context.managedObjectContext.fetch(request).first + } catch { + assertionFailure(error.localizedDescription) + return nil + } + }() + let (mastodonUser, _) = APIService.CoreData.createOrMergeMastodonUser(into: self.context.managedObjectContext, for: requestMastodonUser, in: activeMastodonAuthenticationBox.domain, entity: account, userCache: nil, networkDate: Date(), log: OSLog.api) + if let searchHistories = searchHistories { + let history = searchHistories.first { history -> Bool in + guard let account = history.account else { return false } + return account.objectID == mastodonUser.objectID + } + if let history = history { + history.update(updatedAt: Date()) + } else { + SearchHistory.insert(into: self.context.managedObjectContext, account: mastodonUser) + } + } else { + SearchHistory.insert(into: self.context.managedObjectContext, account: mastodonUser) + } + let viewModel = ProfileViewModel(context: self.context, optionalMastodonUser: mastodonUser) + DispatchQueue.main.async { + self.coordinator.present(scene: .profile(viewModel: viewModel), from: from, transition: .show) + } + + case .hashtag(let tag): + let (tagInCoreData, _) = APIService.CoreData.createOrMergeTag(into: self.context.managedObjectContext, entity: tag) + if let searchHistories = searchHistories { + let history = searchHistories.first { history -> Bool in + guard let hashtag = history.hashtag else { return false } + return hashtag.objectID == tagInCoreData.objectID + } + if let history = history { + history.update(updatedAt: Date()) + } else { + SearchHistory.insert(into: self.context.managedObjectContext, hashtag: tagInCoreData) + } + } else { + SearchHistory.insert(into: self.context.managedObjectContext, hashtag: tagInCoreData) + } + let viewModel = HashtagTimelineViewModel(context: self.context, hashtag: tagInCoreData.name) + DispatchQueue.main.async { + self.coordinator.present(scene: .hashtagTimeline(viewModel: viewModel), from: from, transition: .show) + } + case .accountObjectID(let accountObjectID): + if let searchHistories = searchHistories { + let history = searchHistories.first { history -> Bool in + guard let account = history.account else { return false } + return account.objectID == accountObjectID + } + if let history = history { + history.update(updatedAt: Date()) + } + } + let mastodonUser = self.context.managedObjectContext.object(with: accountObjectID) as! MastodonUser + let viewModel = ProfileViewModel(context: self.context, optionalMastodonUser: mastodonUser) + DispatchQueue.main.async { + self.coordinator.present(scene: .profile(viewModel: viewModel), from: from, transition: .show) + } + case .hashtagObjectID(let hashtagObjectID): + if let searchHistories = searchHistories { + let history = searchHistories.first { history -> Bool in + guard let hashtag = history.hashtag else { return false } + return hashtag.objectID == hashtagObjectID + } + if let history = history { + history.update(updatedAt: Date()) + } + } + let tagInCoreData = self.context.managedObjectContext.object(with: hashtagObjectID) as! Tag + let viewModel = HashtagTimelineViewModel(context: self.context, hashtag: tagInCoreData.name) + DispatchQueue.main.async { + self.coordinator.present(scene: .hashtagTimeline(viewModel: viewModel), from: from, transition: .show) + } + default: + break + } + } + } + + func fetchSearchHistory() -> [SearchHistory]? { + let searchHistory: [SearchHistory]? = { + let request = SearchHistory.sortedFetchRequest + request.predicate = nil + request.returnsObjectsAsFaults = false + do { + return try context.managedObjectContext.fetch(request) + } catch { + assertionFailure(error.localizedDescription) + return nil + } + + }() + return searchHistory + } + + func deleteSearchHistory() { + let result = fetchSearchHistory() + _ = context.managedObjectContext.performChanges { [weak self] in + result?.forEach { history in + self?.context.managedObjectContext.delete(history) + } + self?.isSearching.value = true + } + } +} diff --git a/Mastodon/Scene/Search/TableViewCell/SearchingTableViewCell.swift b/Mastodon/Scene/Search/TableViewCell/SearchingTableViewCell.swift new file mode 100644 index 000000000..a3a7b58ac --- /dev/null +++ b/Mastodon/Scene/Search/TableViewCell/SearchingTableViewCell.swift @@ -0,0 +1,172 @@ +// +// SearchingTableViewCell.swift +// Mastodon +// +// Created by sxiaojian on 2021/4/2. +// + +import CoreData +import CoreDataStack +import Foundation +import MastodonSDK +import UIKit + +final class SearchingTableViewCell: UITableViewCell { + let _imageView: UIImageView = { + let imageView = UIImageView() + imageView.tintColor = Asset.Colors.Label.primary.color + imageView.layer.cornerRadius = 4 + imageView.clipsToBounds = true + return imageView + }() + + let _titleLabel: UILabel = { + let label = UILabel() + label.textColor = Asset.Colors.brandBlue.color + label.font = .systemFont(ofSize: 17, weight: .semibold) + label.lineBreakMode = .byTruncatingTail + return label + }() + + let _subTitleLabel: UILabel = { + let label = UILabel() + label.textColor = Asset.Colors.Label.secondary.color + label.font = .preferredFont(forTextStyle: .body) + return label + }() + + override func prepareForReuse() { + super.prepareForReuse() + _imageView.af.cancelImageRequest() + _imageView.image = nil + } + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + configure() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + configure() + } +} + +extension SearchingTableViewCell { + private func configure() { + backgroundColor = .clear + + let containerStackView = UIStackView() + containerStackView.axis = .horizontal + containerStackView.distribution = .fill + containerStackView.spacing = 12 + containerStackView.layoutMargins = UIEdgeInsets(top: 12, left: 21, bottom: 12, right: 12) + 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.bottomAnchor.constraint(equalTo: contentView.bottomAnchor) + ]) + + _imageView.translatesAutoresizingMaskIntoConstraints = false + containerStackView.addArrangedSubview(_imageView) + NSLayoutConstraint.activate([ + _imageView.widthAnchor.constraint(equalToConstant: 42), + _imageView.heightAnchor.constraint(equalToConstant: 42), + ]) + + 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) + } + + func config(with account: Mastodon.Entity.Account) { + _imageView.af.setImage( + withURL: URL(string: account.avatar)!, + placeholderImage: UIImage.placeholder(color: .systemFill), + imageTransition: .crossDissolve(0.2) + ) + _titleLabel.text = account.displayName.isEmpty ? account.username : account.displayName + _subTitleLabel.text = account.acct + } + + func config(with account: MastodonUser) { + _imageView.af.setImage( + withURL: URL(string: account.avatar)!, + placeholderImage: UIImage.placeholder(color: .systemFill), + imageTransition: .crossDissolve(0.2) + ) + _titleLabel.text = account.displayName.isEmpty ? account.username : account.displayName + _subTitleLabel.text = account.acct + } + + func config(with tag: Mastodon.Entity.Tag) { + let image = UIImage(systemName: "number.circle.fill", withConfiguration: UIImage.SymbolConfiguration(pointSize: 34, weight: .regular))!.withRenderingMode(.alwaysTemplate) + _imageView.image = image + _titleLabel.text = "# " + tag.name + guard let historys = tag.history else { + _subTitleLabel.text = "" + return + } + let recentHistory = historys.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) { + let image = UIImage(systemName: "number.circle.fill", withConfiguration: UIImage.SymbolConfiguration(pointSize: 34, weight: .regular))!.withRenderingMode(.alwaysTemplate) + _imageView.image = image + _titleLabel.text = "# " + tag.name + guard let historys = tag.histories?.sorted(by: { + $0.createAt.compare($1.createAt) == .orderedAscending + }) else { + _subTitleLabel.text = "" + return + } + let recentHistory = historys.prefix(2) + let peopleAreTalking = recentHistory.compactMap { Int($0.accounts) }.reduce(0, +) + let string = L10n.Scene.Search.Recommend.HashTag.peopleTalking(String(peopleAreTalking)) + _subTitleLabel.text = string + } +} + +#if canImport(SwiftUI) && DEBUG +import SwiftUI + +struct SearchingTableViewCell_Previews: PreviewProvider { + static var controls: some View { + Group { + UIViewPreview { + let cell = SearchingTableViewCell() + cell.backgroundColor = .white + cell._imageView.image = UIImage(systemName: "number.circle.fill") + 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/Search/View/SearchRecommendCollectionHeader.swift b/Mastodon/Scene/Search/View/SearchRecommendCollectionHeader.swift new file mode 100644 index 000000000..3db8c2800 --- /dev/null +++ b/Mastodon/Scene/Search/View/SearchRecommendCollectionHeader.swift @@ -0,0 +1,108 @@ +// +// SearchRecommendCollectionHeader.swift +// Mastodon +// +// Created by sxiaojian on 2021/4/1. +// + +import Foundation +import UIKit + +class SearchRecommendCollectionHeader: UIView { + let titleLabel: UILabel = { + let label = UILabel() + label.textColor = Asset.Colors.Label.primary.color + label.font = .systemFont(ofSize: 20, weight: .semibold) + return label + }() + + let descriptionLabel: UILabel = { + let label = UILabel() + label.textColor = Asset.Colors.Label.secondary.color + label.font = .preferredFont(forTextStyle: .body) + label.numberOfLines = 0 + label.lineBreakMode = .byWordWrapping + return label + }() + + let seeAllButton: HighlightDimmableButton = { + let button = HighlightDimmableButton(type: .custom) + button.setTitleColor(Asset.Colors.brandBlue.color, for: .normal) + button.setTitle(L10n.Scene.Search.Recommend.buttonText, for: .normal) + return button + }() + + override init(frame: CGRect) { + super.init(frame: .zero) + configure() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + configure() + } +} + +extension SearchRecommendCollectionHeader { + private func configure() { + backgroundColor = .clear + translatesAutoresizingMaskIntoConstraints = false + + let containerStackView = UIStackView() + containerStackView.axis = .vertical + containerStackView.layoutMargins = UIEdgeInsets(top: 31, left: 16, bottom: 16, right: 16) + containerStackView.isLayoutMarginsRelativeArrangement = true + containerStackView.translatesAutoresizingMaskIntoConstraints = false + addSubview(containerStackView) + NSLayoutConstraint.activate([ + containerStackView.topAnchor.constraint(equalTo: topAnchor), + containerStackView.leadingAnchor.constraint(equalTo: leadingAnchor), + containerStackView.bottomAnchor.constraint(equalTo: bottomAnchor), + containerStackView.trailingAnchor.constraint(equalTo: trailingAnchor) + ]) + + let horizontalStackView = UIStackView() + horizontalStackView.axis = .horizontal + horizontalStackView.alignment = .center + horizontalStackView.translatesAutoresizingMaskIntoConstraints = false + horizontalStackView.distribution = .fill + titleLabel.translatesAutoresizingMaskIntoConstraints = false + titleLabel.setContentHuggingPriority(.defaultLow - 1, for: .horizontal) + horizontalStackView.addArrangedSubview(titleLabel) + seeAllButton.translatesAutoresizingMaskIntoConstraints = false + horizontalStackView.addArrangedSubview(seeAllButton) + + containerStackView.addArrangedSubview(horizontalStackView) + descriptionLabel.translatesAutoresizingMaskIntoConstraints = false + containerStackView.addArrangedSubview(descriptionLabel) + + } +} + +#if canImport(SwiftUI) && DEBUG +import SwiftUI + +struct SearchRecommendCollectionHeader_Previews: PreviewProvider { + static var controls: some View { + Group { + UIViewPreview { + let cell = SearchRecommendCollectionHeader() + cell.titleLabel.text = "Trending in your timeline" + cell.descriptionLabel.text = "Hashtags that are getting quite a bit of attention among people you follow" + cell.seeAllButton.setTitle("See All", for: .normal) + return cell + } + .previewLayout(.fixed(width: 320, height: 116)) + } + } + + 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 new file mode 100644 index 000000000..1c69ef6c5 --- /dev/null +++ b/Mastodon/Scene/Settings/SettingsViewController.swift @@ -0,0 +1,458 @@ +// +// SettingsViewController.swift +// Mastodon +// +// Created by ihugo on 2021/4/7. +// + +import os.log +import UIKit +import Combine +import ActiveLabel +import CoreData +import CoreDataStack +import MastodonSDK +import AlamofireImage +import Kingfisher + +class SettingsViewController: UIViewController, NeedsDependency { + + weak var context: AppContext! { willSet { precondition(!isViewLoaded) } } + weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } } + + var viewModel: SettingsViewModel! { willSet { precondition(!isViewLoaded) } } + var disposeBag = Set() + var notificationPolicySubscription: AnyCancellable? + + var triggerMenu: UIMenu { + let anyone = L10n.Scene.Settings.Section.Notifications.Trigger.anyone + let follower = L10n.Scene.Settings.Section.Notifications.Trigger.follower + let follow = L10n.Scene.Settings.Section.Notifications.Trigger.follow + let noOne = L10n.Scene.Settings.Section.Notifications.Trigger.noone + let menu = UIMenu( + image: nil, + identifier: nil, + options: .displayInline, + children: [ + UIAction(title: anyone, image: UIImage(systemName: "person.3"), attributes: []) { [weak self] action in + self?.updateTrigger(policy: .all) + }, + UIAction(title: follower, image: UIImage(systemName: "person.crop.circle.badge.plus"), attributes: []) { [weak self] action in + self?.updateTrigger(policy: .follower) + }, + UIAction(title: follow, image: UIImage(systemName: "person.crop.circle.badge.checkmark"), attributes: []) { [weak self] action in + self?.updateTrigger(policy: .followed) + }, + UIAction(title: noOne, image: UIImage(systemName: "nosign"), attributes: []) { [weak self] action in + self?.updateTrigger(policy: .none) + }, + ] + ) + return menu + } + + private let notifySectionHeaderStackView: UIStackView = { + let view = UIStackView() + view.translatesAutoresizingMaskIntoConstraints = false + view.isLayoutMarginsRelativeArrangement = true + view.axis = .horizontal + view.spacing = 4 + return view + }() + + let notifyLabel = UILabel() + private(set) lazy var notifySectionHeader: UIView = { + let view = notifySectionHeaderStackView + notifyLabel.translatesAutoresizingMaskIntoConstraints = false + notifyLabel.adjustsFontForContentSizeCategory = true + notifyLabel.font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: UIFont.systemFont(ofSize: 20, weight: .semibold)) + notifyLabel.textColor = Asset.Colors.Label.primary.color + notifyLabel.text = L10n.Scene.Settings.Section.Notifications.Trigger.title + view.addArrangedSubview(notifyLabel) + view.addArrangedSubview(whoButton) + whoButton.setContentHuggingPriority(.defaultHigh + 1, for: .horizontal) + whoButton.setContentHuggingPriority(.defaultHigh + 1, for: .vertical) + return view + }() + + private(set) lazy var whoButton: UIButton = { + let whoButton = UIButton(type: .roundedRect) + whoButton.menu = triggerMenu + whoButton.showsMenuAsPrimaryAction = true + whoButton.setBackgroundColor(Asset.Colors.battleshipGrey.color, for: .normal) + whoButton.setTitleColor(Asset.Colors.Label.primary.color, for: .normal) + whoButton.titleLabel?.adjustsFontForContentSizeCategory = true + whoButton.titleLabel?.font = UIFontMetrics(forTextStyle: .title3).scaledFont(for: UIFont.systemFont(ofSize: 20, weight: .semibold)) + whoButton.contentEdgeInsets = UIEdgeInsets(top: 5, left: 5, bottom: 5, right: 5) + whoButton.layer.cornerRadius = 10 + whoButton.clipsToBounds = true + return whoButton + }() + + private(set) lazy var tableView: UITableView = { + // init with a frame to fix a conflict ('UIView-Encapsulated-Layout-Width' UIStackView:0x7f8c2b6c0590.width == 0) + let tableView = UITableView(frame: CGRect(x: 0, y: 0, width: 320, height: 320), style: .grouped) + tableView.translatesAutoresizingMaskIntoConstraints = false + tableView.delegate = self + tableView.rowHeight = UITableView.automaticDimension + tableView.backgroundColor = Asset.Colors.Background.systemGroupedBackground.color + + tableView.register(SettingsAppearanceTableViewCell.self, forCellReuseIdentifier: String(describing: SettingsAppearanceTableViewCell.self)) + tableView.register(SettingsToggleTableViewCell.self, forCellReuseIdentifier: String(describing: SettingsToggleTableViewCell.self)) + tableView.register(SettingsLinkTableViewCell.self, forCellReuseIdentifier: String(describing: SettingsLinkTableViewCell.self)) + return tableView + }() + + lazy var tableFooterView: UIView = { + // init with a frame to fix a conflict ('UIView-Encapsulated-Layout-Height' UIStackView:0x7ffe41e47da0.height == 0) + let view = UIStackView(frame: CGRect(x: 0, y: 0, width: 320, height: 320)) + view.isLayoutMarginsRelativeArrangement = true + view.layoutMargins = UIEdgeInsets(top: 20, left: 20, bottom: 20, right: 20) + view.axis = .vertical + view.alignment = .center + + let label = ActiveLabel(style: .default) + label.adjustsFontForContentSizeCategory = true + label.textAlignment = .center + label.configure(content: "Mastodon is open source software. You can contribute or report issues on GitHub at tootsuite/mastodon (v3.3.0).", emojiDict: [:]) + label.delegate = self + + view.addArrangedSubview(label) + return view + }() + + override func viewDidLoad() { + super.viewDidLoad() + + setupView() + bindViewModel() + + viewModel.viewDidLoad.send() + } + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + guard let footerView = self.tableView.tableFooterView else { + return + } + + let width = self.tableView.bounds.size.width + let size = footerView.systemLayoutSizeFitting(CGSize(width: width, height: UIView.layoutFittingCompressedSize.height)) + if footerView.frame.size.height != size.height { + footerView.frame.size.height = size.height + self.tableView.tableFooterView = footerView + } + } + + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + + updateSectionHeaderStackViewLayout() + } + + + // MAKR: - Private methods + private func updateSectionHeaderStackViewLayout() { + // accessibility + if traitCollection.preferredContentSizeCategory < .accessibilityMedium { + notifySectionHeaderStackView.axis = .horizontal + notifyLabel.numberOfLines = 1 + } else { + notifySectionHeaderStackView.axis = .vertical + notifyLabel.numberOfLines = 0 + } + } + + private func bindViewModel() { + self.whoButton.setTitle(viewModel.setting.value.activeSubscription?.policy.title, for: .normal) + viewModel.setting + .sink { [weak self] setting in + guard let self = self else { return } + self.notificationPolicySubscription = ManagedObjectObserver.observe(object: setting) + .sink { _ in + // do nothing + } receiveValue: { [weak self] change in + guard let self = self else { return } + guard case let .update(object) = change.changeType, + let setting = object as? Setting else { return } + if let activeSubscription = setting.activeSubscription { + self.whoButton.setTitle(activeSubscription.policy.title, for: .normal) + } else { + assertionFailure() + } + } + } + .store(in: &disposeBag) + } + + private func setupView() { + view.backgroundColor = Asset.Colors.Background.secondarySystemBackground.color + setupNavigation() + + 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), + ]) + setupTableView() + + updateSectionHeaderStackViewLayout() + } + + private func setupNavigation() { + navigationController?.navigationBar.prefersLargeTitles = true + navigationItem.rightBarButtonItem + = UIBarButtonItem(barButtonSystemItem: UIBarButtonItem.SystemItem.done, + target: self, + action: #selector(doneButtonDidClick)) + navigationItem.title = L10n.Scene.Settings.title + + let barAppearance = UINavigationBarAppearance() + barAppearance.configureWithDefaultBackground() + navigationItem.standardAppearance = barAppearance + navigationItem.compactAppearance = barAppearance + navigationItem.scrollEdgeAppearance = barAppearance + } + + private func setupTableView() { + viewModel.setupDiffableDataSource( + for: tableView, + settingsAppearanceTableViewCellDelegate: self, + settingsToggleCellDelegate: self + ) + tableView.tableFooterView = tableFooterView + } + + func alertToSignout() { + let alertController = UIAlertController( + title: L10n.Common.Alerts.SignOut.title, + message: L10n.Common.Alerts.SignOut.message, + preferredStyle: .alert + ) + + let cancelAction = UIAlertAction(title: L10n.Common.Controls.Actions.cancel, style: .cancel, handler: nil) + let signOutAction = UIAlertAction(title: L10n.Common.Alerts.SignOut.confirm, style: .destructive) { [weak self] _ in + guard let self = self else { return } + self.signOut() + } + alertController.addAction(cancelAction) + alertController.addAction(signOutAction) + self.coordinator.present( + scene: .alertController(alertController: alertController), + from: self, + transition: .alertController(animated: true, completion: nil) + ) + } + + func signOut() { + guard let activeMastodonAuthenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { + return + } + + context.authenticationService.signOutMastodonUser( + domain: activeMastodonAuthenticationBox.domain, + userID: activeMastodonAuthenticationBox.userID + ) + .receive(on: DispatchQueue.main) + .sink { [weak self] result in + guard let self = self else { return } + switch result { + case .failure(let error): + assertionFailure(error.localizedDescription) + case .success(let isSignOut): + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: sign out %s", ((#file as NSString).lastPathComponent), #line, #function, isSignOut ? "success" : "fail") + guard isSignOut else { return } + self.coordinator.setup() + self.coordinator.setupOnboardingIfNeeds(animated: true) + } + } + .store(in: &disposeBag) + } + + deinit { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s:", ((#file as NSString).lastPathComponent), #line, #function) + } + +} + +// Mark: - Actions +extension SettingsViewController { + @objc private func doneButtonDidClick() { + dismiss(animated: true, completion: nil) + } +} + +extension SettingsViewController: UITableViewDelegate { + func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? { + let sections = viewModel.dataSource.snapshot().sectionIdentifiers + guard section < sections.count else { return nil } + + let sectionIdentifier = sections[section] + + let header: SettingsSectionHeader + switch sectionIdentifier { + case .notifications: + header = SettingsSectionHeader( + frame: CGRect(x: 0, y: 0, width: 375, height: 66), + customView: notifySectionHeader) + header.update(title: sectionIdentifier.title) + default: + header = SettingsSectionHeader(frame: CGRect(x: 0, y: 0, width: 375, height: 66)) + header.update(title: sectionIdentifier.title) + } + header.preservesSuperviewLayoutMargins = true + + return header + } + + // remove the gap of table's footer + func tableView(_ tableView: UITableView, viewForFooterInSection section: Int) -> UIView? { + return UIView() + } + + // remove the gap of table's footer + func tableView(_ tableView: UITableView, heightForFooterInSection section: Int) -> CGFloat { + return CGFloat.leastNonzeroMagnitude + } + + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + guard let dataSource = viewModel.dataSource else { return } + let item = dataSource.itemIdentifier(for: indexPath) + + switch item { + case .boringZone: + guard let url = viewModel.privacyURL else { break } + coordinator.present( + scene: .safari(url: url), + from: self, + transition: .safariPresent(animated: true, completion: nil) + ) + case .spicyZone(let link): + // clear media cache + if link.title == L10n.Scene.Settings.Section.Spicyzone.clear { + // clean image cache for AlamofireImage + let diskBytes = ImageDownloader.defaultURLCache().currentDiskUsage + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: diskBytes %d", ((#file as NSString).lastPathComponent), #line, #function, diskBytes) + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: clean image cache", ((#file as NSString).lastPathComponent), #line, #function) + ImageDownloader.defaultURLCache().removeAllCachedResponses() + let cleanedDiskBytes = ImageDownloader.defaultURLCache().currentDiskUsage + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: diskBytes %d", ((#file as NSString).lastPathComponent), #line, #function, cleanedDiskBytes) + + // clean Kingfisher Cache + KingfisherManager.shared.cache.clearDiskCache() + } + // logout + if link.title == L10n.Scene.Settings.Section.Spicyzone.signout { + alertToSignout() + } + default: + break + } + } +} + +// Update setting into core data +extension SettingsViewController { + func updateTrigger(policy: Mastodon.API.Subscriptions.Policy) { + let objectID = self.viewModel.setting.value.objectID + let managedObjectContext = context.backgroundManagedObjectContext + + managedObjectContext.performChanges { + let setting = managedObjectContext.object(with: objectID) as! Setting + let (subscription, _) = APIService.CoreData.createOrFetchSubscription( + into: managedObjectContext, + setting: setting, + policy: policy + ) + let now = Date() + subscription.update(activedAt: now) + setting.didUpdate(at: now) + } + .sink { _ in + // do nothing + } receiveValue: { _ in + // do nohting + } + .store(in: &disposeBag) + } +} + +// MARK: - SettingsAppearanceTableViewCellDelegate +extension SettingsViewController: SettingsAppearanceTableViewCellDelegate { + func settingsAppearanceCell(_ cell: SettingsAppearanceTableViewCell, didSelectAppearanceMode appearanceMode: SettingsItem.AppearanceMode) { + guard let dataSource = viewModel.dataSource else { return } + guard let indexPath = tableView.indexPath(for: cell) else { return } + let item = dataSource.itemIdentifier(for: indexPath) + guard case let .apperance(settingObjectID) = item else { return } + + context.managedObjectContext.performChanges { + let setting = self.context.managedObjectContext.object(with: settingObjectID) as! Setting + setting.update(appearanceRaw: appearanceMode.rawValue) + } + .sink { _ in + // do nothing + }.store(in: &disposeBag) + } +} + +extension SettingsViewController: SettingsToggleCellDelegate { + func settingsToggleCell(_ cell: SettingsToggleTableViewCell, switchValueDidChange switch: UISwitch) { + guard let dataSource = viewModel.dataSource else { return } + guard let indexPath = tableView.indexPath(for: cell) else { return } + let item = dataSource.itemIdentifier(for: indexPath) + switch item { + case .notification(let settingObjectID, let switchMode): + let isOn = `switch`.isOn + let managedObjectContext = context.backgroundManagedObjectContext + managedObjectContext.performChanges { + let setting = managedObjectContext.object(with: settingObjectID) as! Setting + guard let subscription = setting.activeSubscription else { return } + let alert = subscription.alert + switch switchMode { + case .favorite: alert.update(favourite: isOn) + case .follow: alert.update(follow: isOn) + case .reblog: alert.update(reblog: isOn) + case .mention: alert.update(mention: isOn) + } + // trigger setting update + alert.subscription.setting?.didUpdate(at: Date()) + } + .sink { _ in + // do nothing + } + .store(in: &disposeBag) + default: + break + } + } +} + +extension SettingsViewController: ActiveLabelDelegate { + func activeLabel(_ activeLabel: ActiveLabel, didSelectActiveEntity entity: ActiveEntity) { + coordinator.present( + scene: .safari(url: URL(string: "https://github.com/tootsuite/mastodon")!), + from: self, + transition: .safariPresent(animated: true, completion: nil) + ) + } +} + +#if canImport(SwiftUI) && DEBUG +import SwiftUI + +struct SettingsViewController_Previews: PreviewProvider { + + static var previews: some View { + Group { + UIViewControllerPreview { () -> UIViewController in + return SettingsViewController() + } + .previewLayout(.fixed(width: 390, height: 844)) + } + } + +} + +#endif diff --git a/Mastodon/Scene/Settings/SettingsViewModel.swift b/Mastodon/Scene/Settings/SettingsViewModel.swift new file mode 100644 index 000000000..c168b5611 --- /dev/null +++ b/Mastodon/Scene/Settings/SettingsViewModel.swift @@ -0,0 +1,195 @@ +// +// SettingsViewModel.swift +// Mastodon +// +// Created by ihugo on 2021/4/7. +// + +import Combine +import CoreData +import CoreDataStack +import Foundation +import MastodonSDK +import UIKit +import os.log + +class SettingsViewModel { + + var disposeBag = Set() + + let context: AppContext + + // input + let setting: CurrentValueSubject + var updateDisposeBag = Set() + var createDisposeBag = Set() + + let viewDidLoad = PassthroughSubject() + + // output + var dataSource: UITableViewDiffableDataSource! + /// create a subscription when: + /// - does not has one + /// - does not find subscription for selected trigger when change trigger + let createSubscriptionSubject = PassthroughSubject<(triggerBy: String, values: [Bool?]), Never>() + + /// update a subscription when: + /// - change switch for specified alerts + let updateSubscriptionSubject = PassthroughSubject<(triggerBy: String, values: [Bool?]), Never>() + + lazy var privacyURL: URL? = { + guard let box = AppContext.shared.authenticationService.activeMastodonAuthenticationBox.value else { + return nil + } + + return Mastodon.API.privacyURL(domain: box.domain) + }() + + init(context: AppContext, setting: Setting) { + self.context = context + self.setting = CurrentValueSubject(setting) + + self.setting + .sink(receiveValue: { [weak self] setting in + guard let self = self else { return } + self.processDataSource(setting) + }) + .store(in: &disposeBag) + } + + deinit { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s:", ((#file as NSString).lastPathComponent), #line, #function) + } + +} + +extension SettingsViewModel { + + // MARK: - Private methods + private func processDataSource(_ setting: Setting) { + guard let dataSource = self.dataSource else { return } + var snapshot = NSDiffableDataSourceSnapshot() + + // appearance + let appearanceItems = [SettingsItem.apperance(settingObjectID: setting.objectID)] + snapshot.appendSections([.apperance]) + snapshot.appendItems(appearanceItems, toSection: .apperance) + + let notificationItems = SettingsItem.NotificationSwitchMode.allCases.map { mode in + SettingsItem.notification(settingObjectID: setting.objectID, switchMode: mode) + } + snapshot.appendSections([.notifications]) + snapshot.appendItems(notificationItems, toSection: .notifications) + + // boring zone + let boringZoneSettingsItems: [SettingsItem] = { + let links: [SettingsItem.Link] = [ + .termsOfService, + .privacyPolicy + ] + let items = links.map { SettingsItem.boringZone(item: $0) } + return items + }() + snapshot.appendSections([.boringZone]) + snapshot.appendItems(boringZoneSettingsItems, toSection: .boringZone) + + let spicyZoneSettingsItems: [SettingsItem] = { + let links: [SettingsItem.Link] = [ + .clearMediaCache, + .signOut + ] + let items = links.map { SettingsItem.spicyZone(item: $0) } + return items + }() + snapshot.appendSections([.spicyZone]) + snapshot.appendItems(spicyZoneSettingsItems, toSection: .spicyZone) + + dataSource.apply(snapshot, animatingDifferences: false) + } + +} + +extension SettingsViewModel { + func setupDiffableDataSource( + for tableView: UITableView, + settingsAppearanceTableViewCellDelegate: SettingsAppearanceTableViewCellDelegate, + settingsToggleCellDelegate: SettingsToggleCellDelegate + ) { + dataSource = UITableViewDiffableDataSource(tableView: tableView) { [ + weak self, + weak settingsAppearanceTableViewCellDelegate, + weak settingsToggleCellDelegate + ] tableView, indexPath, item -> UITableViewCell? in + guard let self = self else { return nil } + + switch item { + case .apperance(let objectID): + let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: SettingsAppearanceTableViewCell.self), for: indexPath) as! SettingsAppearanceTableViewCell + self.context.managedObjectContext.performAndWait { + let setting = self.context.managedObjectContext.object(with: objectID) as! Setting + cell.update(with: setting.appearance) + ManagedObjectObserver.observe(object: setting) + .sink(receiveCompletion: { _ in + // do nothing + }, receiveValue: { [weak cell] change in + guard let cell = cell else { return } + guard case .update(let object) = change.changeType, + let setting = object as? Setting else { return } + cell.update(with: setting.appearance) + }) + .store(in: &cell.disposeBag) + } + cell.delegate = settingsAppearanceTableViewCellDelegate + return cell + case .notification(let objectID, let switchMode): + let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: SettingsToggleTableViewCell.self), for: indexPath) as! SettingsToggleTableViewCell + self.context.managedObjectContext.performAndWait { + let setting = self.context.managedObjectContext.object(with: objectID) as! Setting + if let subscription = setting.activeSubscription { + SettingsViewModel.configureSettingToggle(cell: cell, switchMode: switchMode, subscription: subscription) + } + ManagedObjectObserver.observe(object: setting) + .sink(receiveCompletion: { _ in + // do nothing + }, receiveValue: { [weak cell] change in + guard let cell = cell else { return } + guard case .update(let object) = change.changeType, + let setting = object as? Setting else { return } + guard let subscription = setting.activeSubscription else { return } + SettingsViewModel.configureSettingToggle(cell: cell, switchMode: switchMode, subscription: subscription) + }) + .store(in: &cell.disposeBag) + } + cell.delegate = settingsToggleCellDelegate + return cell + case .boringZone(let item), .spicyZone(let item): + let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: SettingsLinkTableViewCell.self), for: indexPath) as! SettingsLinkTableViewCell + cell.update(with: item) + return cell + } + } + + processDataSource(self.setting.value) + } +} + +extension SettingsViewModel { + + static func configureSettingToggle( + cell: SettingsToggleTableViewCell, + switchMode: SettingsItem.NotificationSwitchMode, + subscription: NotificationSubscription + ) { + cell.textLabel?.text = switchMode.title + + let enabled: Bool? + switch switchMode { + case .favorite: enabled = subscription.alert.favourite + case .follow: enabled = subscription.alert.follow + case .reblog: enabled = subscription.alert.reblog + case .mention: enabled = subscription.alert.mention + } + cell.update(enabled: enabled) + } + +} diff --git a/Mastodon/Scene/Settings/View/Cell/SettingsAppearanceTableViewCell.swift b/Mastodon/Scene/Settings/View/Cell/SettingsAppearanceTableViewCell.swift new file mode 100644 index 000000000..a58bebf8c --- /dev/null +++ b/Mastodon/Scene/Settings/View/Cell/SettingsAppearanceTableViewCell.swift @@ -0,0 +1,216 @@ +// +// SettingsAppearanceTableViewCell.swift +// Mastodon +// +// Created by ihugo on 2021/4/8. +// + +import UIKit +import Combine + +protocol SettingsAppearanceTableViewCellDelegate: AnyObject { + func settingsAppearanceCell(_ cell: SettingsAppearanceTableViewCell, didSelectAppearanceMode appearanceMode: SettingsItem.AppearanceMode) +} + +class AppearanceView: UIView { + lazy var imageView: UIImageView = { + let view = UIImageView() + // accessibility + view.accessibilityIgnoresInvertColors = true + return view + }() + lazy var titleLabel: UILabel = { + let label = UILabel() + label.font = .systemFont(ofSize: 12, weight: .regular) + label.textColor = Asset.Colors.Label.primary.color + label.textAlignment = .center + return label + }() + lazy var checkBox: UIButton = { + let button = UIButton() + button.isUserInteractionEnabled = false + button.setImage(UIImage(systemName: "circle"), for: .normal) + button.setImage(UIImage(systemName: "checkmark.circle.fill"), for: .selected) + button.imageView?.preferredSymbolConfiguration = UIImage.SymbolConfiguration(textStyle: .body) + button.imageView?.tintColor = Asset.Colors.Label.secondary.color + button.imageView?.contentMode = .scaleAspectFill + return button + }() + lazy var stackView: UIStackView = { + let view = UIStackView() + view.axis = .vertical + view.spacing = 10 + view.distribution = .equalSpacing + return view + }() + + var selected: Bool = false { + didSet { + checkBox.isSelected = selected + if selected { + checkBox.imageView?.tintColor = Asset.Colors.Label.highlight.color + } else { + checkBox.imageView?.tintColor = Asset.Colors.Label.secondary.color + } + } + } + + // MARK: - Methods + init(image: UIImage?, title: String) { + super.init(frame: .zero) + setupUI() + + imageView.image = image + titleLabel.text = title + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Private methods + private func setupUI() { + stackView.addArrangedSubview(imageView) + stackView.addArrangedSubview(titleLabel) + stackView.addArrangedSubview(checkBox) + + addSubview(stackView) + translatesAutoresizingMaskIntoConstraints = false + stackView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + stackView.topAnchor.constraint(equalTo: self.topAnchor), + stackView.leadingAnchor.constraint(equalTo: self.leadingAnchor), + stackView.bottomAnchor.constraint(equalTo: self.bottomAnchor), + stackView.trailingAnchor.constraint(equalTo: self.trailingAnchor), + imageView.heightAnchor.constraint(equalTo: imageView.widthAnchor, multiplier: 218.0 / 100.0), + ]) + } +} + +class SettingsAppearanceTableViewCell: UITableViewCell { + + var disposeBag = Set() + + weak var delegate: SettingsAppearanceTableViewCellDelegate? + var appearance: SettingsItem.AppearanceMode = .automatic + + lazy var stackView: UIStackView = { + let view = UIStackView() + view.isLayoutMarginsRelativeArrangement = true + view.layoutMargins = UIEdgeInsets(top: 0, left: 20, bottom: 0, right: 20) + view.axis = .horizontal + view.distribution = .fillEqually + view.spacing = 18 + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + + let automatic = AppearanceView(image: Asset.Settings.appearanceAutomatic.image, + title: L10n.Scene.Settings.Section.Appearance.automatic) + let light = AppearanceView(image: Asset.Settings.appearanceLight.image, + title: L10n.Scene.Settings.Section.Appearance.light) + let dark = AppearanceView(image: Asset.Settings.appearanceDark.image, + title: L10n.Scene.Settings.Section.Appearance.dark) + + lazy var automaticTap: UITapGestureRecognizer = { + let tapGestureRecognizer = UITapGestureRecognizer.singleTapGestureRecognizer + tapGestureRecognizer.addTarget(self, action: #selector(appearanceDidTap(sender:))) + return tapGestureRecognizer + }() + + lazy var lightTap: UITapGestureRecognizer = { + let tapGestureRecognizer = UITapGestureRecognizer.singleTapGestureRecognizer + tapGestureRecognizer.addTarget(self, action: #selector(appearanceDidTap(sender:))) + return tapGestureRecognizer + }() + + lazy var darkTap: UITapGestureRecognizer = { + let tapGestureRecognizer = UITapGestureRecognizer.singleTapGestureRecognizer + tapGestureRecognizer.addTarget(self, action: #selector(appearanceDidTap(sender:))) + return tapGestureRecognizer + }() + + override func prepareForReuse() { + super.prepareForReuse() + + disposeBag.removeAll() + } + + // MARK: - Methods + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + setupUI() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func layoutSubviews() { + super.layoutSubviews() + + // remove seperator line in section of group tableview + for subview in self.subviews { + if subview != self.contentView && subview.frame.width == self.frame.width { + subview.removeFromSuperview() + } + } + } + + func update(with data: SettingsItem.AppearanceMode) { + appearance = data + + automatic.selected = false + light.selected = false + dark.selected = false + + switch data { + case .automatic: + automatic.selected = true + case .light: + light.selected = true + case .dark: + dark.selected = true + } + } + + // MARK: Private methods + private func setupUI() { + backgroundColor = Asset.Colors.Background.systemGroupedBackground.color + selectionStyle = .none + contentView.addSubview(stackView) + + stackView.addArrangedSubview(automatic) + stackView.addArrangedSubview(light) + stackView.addArrangedSubview(dark) + + automatic.addGestureRecognizer(automaticTap) + light.addGestureRecognizer(lightTap) + dark.addGestureRecognizer(darkTap) + + NSLayoutConstraint.activate([ + stackView.topAnchor.constraint(equalTo: contentView.topAnchor), + stackView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + stackView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), + stackView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + ]) + } + + // MARK: - Actions + @objc func appearanceDidTap(sender: UIGestureRecognizer) { + if sender == automaticTap { + appearance = .automatic + } + + if sender == lightTap { + appearance = .light + } + + if sender == darkTap { + appearance = .dark + } + + guard let delegate = self.delegate else { return } + delegate.settingsAppearanceCell(self, didSelectAppearanceMode: appearance) + } +} diff --git a/Mastodon/Scene/Settings/View/Cell/SettingsLinkTableViewCell.swift b/Mastodon/Scene/Settings/View/Cell/SettingsLinkTableViewCell.swift new file mode 100644 index 000000000..7fdbf7f02 --- /dev/null +++ b/Mastodon/Scene/Settings/View/Cell/SettingsLinkTableViewCell.swift @@ -0,0 +1,35 @@ +// +// SettingsLinkTableViewCell.swift +// Mastodon +// +// Created by ihugo on 2021/4/8. +// + +import UIKit + +class SettingsLinkTableViewCell: UITableViewCell { + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + + selectionStyle = .none + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func setHighlighted(_ highlighted: Bool, animated: Bool) { + super.setHighlighted(highlighted, animated: animated) + textLabel?.alpha = highlighted ? 0.6 : 1.0 + } + +} + +// MARK: - Methods +extension SettingsLinkTableViewCell { + func update(with link: SettingsItem.Link) { + textLabel?.text = link.title + textLabel?.textColor = link.textColor + } +} diff --git a/Mastodon/Scene/Settings/View/Cell/SettingsToggleTableViewCell.swift b/Mastodon/Scene/Settings/View/Cell/SettingsToggleTableViewCell.swift new file mode 100644 index 000000000..86698d840 --- /dev/null +++ b/Mastodon/Scene/Settings/View/Cell/SettingsToggleTableViewCell.swift @@ -0,0 +1,65 @@ +// +// SettingsToggleTableViewCell.swift +// Mastodon +// +// Created by ihugo on 2021/4/8. +// + +import UIKit +import Combine + +protocol SettingsToggleCellDelegate: AnyObject { + func settingsToggleCell(_ cell: SettingsToggleTableViewCell, switchValueDidChange switch: UISwitch) +} + +class SettingsToggleTableViewCell: UITableViewCell { + + var disposeBag = Set() + + private(set) lazy var switchButton: UISwitch = { + let view = UISwitch(frame:.zero) + return view + }() + + weak var delegate: SettingsToggleCellDelegate? + + // MARK: - Methods + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: .default, reuseIdentifier: reuseIdentifier) + setupUI() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + setupUI() + } + + // MARK: Private methods + private func setupUI() { + selectionStyle = .none + accessoryView = switchButton + + switchButton.addTarget(self, action: #selector(switchValueDidChange(sender:)), for: .valueChanged) + } + +} + +// MARK: - Actions +extension SettingsToggleTableViewCell { + + @objc private func switchValueDidChange(sender: UISwitch) { + guard let delegate = delegate else { return } + delegate.settingsToggleCell(self, switchValueDidChange: sender) + } + +} + +extension SettingsToggleTableViewCell { + + func update(enabled: Bool?) { + switchButton.isEnabled = enabled != nil + textLabel?.textColor = enabled != nil ? Asset.Colors.Label.primary.color : Asset.Colors.Label.secondary.color + switchButton.isOn = enabled ?? false + } + +} diff --git a/Mastodon/Scene/Settings/View/SettingsSectionHeader.swift b/Mastodon/Scene/Settings/View/SettingsSectionHeader.swift new file mode 100644 index 000000000..ccd7fd875 --- /dev/null +++ b/Mastodon/Scene/Settings/View/SettingsSectionHeader.swift @@ -0,0 +1,64 @@ +// +// SettingsSectionHeader.swift +// Mastodon +// +// Created by ihugo on 2021/4/8. +// + +import UIKit + +struct GroupedTableViewConstraints { + static let topMargin: CGFloat = 40 + static let bottomMargin: CGFloat = 10 +} + +/// section header which supports add a custom view blelow the title +class SettingsSectionHeader: UIView { + lazy var titleLabel: UILabel = { + let label = UILabel() + label.translatesAutoresizingMaskIntoConstraints = false + label.font = .systemFont(ofSize: 13, weight: .regular) + label.textColor = Asset.Colors.Label.secondary.color + return label + }() + + lazy var stackView: UIStackView = { + let view = UIStackView() + view.translatesAutoresizingMaskIntoConstraints = false + view.isLayoutMarginsRelativeArrangement = true + view.layoutMargins = UIEdgeInsets( + top: GroupedTableViewConstraints.topMargin, + left: 0, + bottom: GroupedTableViewConstraints.bottomMargin, + right: 0 + ) + view.axis = .vertical + return view + }() + + init(frame: CGRect, customView: UIView? = nil) { + super.init(frame: frame) + + backgroundColor = Asset.Colors.Background.systemGroupedBackground.color + stackView.addArrangedSubview(titleLabel) + if let view = customView { + stackView.addArrangedSubview(view) + } + + addSubview(stackView) + NSLayoutConstraint.activate([ + stackView.leadingAnchor.constraint(equalTo: self.readableContentGuide.leadingAnchor), + stackView.trailingAnchor.constraint(lessThanOrEqualTo: self.readableContentGuide.trailingAnchor), + stackView.bottomAnchor.constraint(equalTo: self.bottomAnchor), + stackView.topAnchor.constraint(equalTo: self.topAnchor), + ]) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func update(title: String?) { + titleLabel.text = title?.uppercased() + } +} diff --git a/Mastodon/Scene/Share/ContextMenu/ImagePreview/ContextMenuImagePreviewViewController.swift b/Mastodon/Scene/Share/ContextMenu/ImagePreview/ContextMenuImagePreviewViewController.swift new file mode 100644 index 000000000..2a5ba4923 --- /dev/null +++ b/Mastodon/Scene/Share/ContextMenu/ImagePreview/ContextMenuImagePreviewViewController.swift @@ -0,0 +1,61 @@ +// +// ContextMenuImagePreviewViewController.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-30. +// + +import func AVFoundation.AVMakeRect +import UIKit +import Combine + +final class ContextMenuImagePreviewViewController: UIViewController { + + var disposeBag = Set() + + var viewModel: ContextMenuImagePreviewViewModel! + + let imageView: UIImageView = { + let imageView = UIImageView() + imageView.contentMode = .scaleAspectFill + imageView.layer.masksToBounds = true + return imageView + }() + +} + +extension ContextMenuImagePreviewViewController { + + override func viewDidLoad() { + super.viewDidLoad() + + imageView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(imageView) + NSLayoutConstraint.activate([ + imageView.topAnchor.constraint(equalTo: view.topAnchor), + imageView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + imageView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + imageView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + ]) + + imageView.image = viewModel.thumbnail + + 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) + } + +} diff --git a/Mastodon/Scene/Share/ContextMenu/ImagePreview/ContextMenuImagePreviewViewModel.swift b/Mastodon/Scene/Share/ContextMenu/ImagePreview/ContextMenuImagePreviewViewModel.swift new file mode 100644 index 000000000..f56ff060c --- /dev/null +++ b/Mastodon/Scene/Share/ContextMenu/ImagePreview/ContextMenuImagePreviewViewModel.swift @@ -0,0 +1,25 @@ +// +// ContextMenuImagePreviewViewModel.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-30. +// + +import UIKit +import Combine + +final class ContextMenuImagePreviewViewModel { + + var disposeBag = Set() + + // input + let aspectRatio: CGSize + let thumbnail: UIImage? + let url = CurrentValueSubject(nil) + + init(aspectRatio: CGSize, thumbnail: UIImage?) { + self.aspectRatio = aspectRatio + self.thumbnail = thumbnail + } + +} diff --git a/Mastodon/Scene/Share/ContextMenu/ImagePreview/TimelineTableViewCellContextMenuConfiguration.swift b/Mastodon/Scene/Share/ContextMenu/ImagePreview/TimelineTableViewCellContextMenuConfiguration.swift new file mode 100644 index 000000000..e8e7787f0 --- /dev/null +++ b/Mastodon/Scene/Share/ContextMenu/ImagePreview/TimelineTableViewCellContextMenuConfiguration.swift @@ -0,0 +1,16 @@ +// +// TimelineTableViewCellContextMenuConfiguration.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-30. +// + +import UIKit + +// note: use subclass configuration not custom NSCopying identifier due to identifier cause crash issue +final class TimelineTableViewCellContextMenuConfiguration: UIContextMenuConfiguration { + + var indexPath: IndexPath? + var index: Int? + +} diff --git a/Mastodon/Scene/Share/NavigationController/AdaptiveStatusBarStyleNavigationController.swift b/Mastodon/Scene/Share/NavigationController/AdaptiveStatusBarStyleNavigationController.swift new file mode 100644 index 000000000..5f4302712 --- /dev/null +++ b/Mastodon/Scene/Share/NavigationController/AdaptiveStatusBarStyleNavigationController.swift @@ -0,0 +1,40 @@ +// +// AdaptiveStatusBarStyleNavigationController.swift +// +// +// Created by MainasuK Cirno on 2021-2-26. +// + +import UIKit + +// Make status bar style adptive for child view controller +// SeeAlso: `modalPresentationCapturesStatusBarAppearance` +final class AdaptiveStatusBarStyleNavigationController: UINavigationController { + var viewControllersHiddenNavigationBar: [UIViewController.Type] + + override var childForStatusBarStyle: UIViewController? { + visibleViewController + } + + override init(rootViewController: UIViewController) { + self.viewControllersHiddenNavigationBar = [SearchViewController.self] + super.init(rootViewController: rootViewController) + self.delegate = self + } + + @available(*, unavailable) + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +extension AdaptiveStatusBarStyleNavigationController: UINavigationControllerDelegate { + func navigationController(_ navigationController: UINavigationController, willShow viewController: UIViewController, animated: Bool) { + let isContain = self.viewControllersHiddenNavigationBar.contains { type(of: viewController) == $0 } + if isContain { + self.setNavigationBarHidden(true, animated: animated) + } else { + self.setNavigationBarHidden(false, animated: animated) + } + } +} diff --git a/Mastodon/Scene/Share/NavigationController/DarkContentStatusBarStyleNavigationController.swift b/Mastodon/Scene/Share/NavigationController/DarkContentStatusBarStyleNavigationController.swift deleted file mode 100644 index 0fa4a0e20..000000000 --- a/Mastodon/Scene/Share/NavigationController/DarkContentStatusBarStyleNavigationController.swift +++ /dev/null @@ -1,14 +0,0 @@ -// -// DarkContentStatusBarStyleNavigationController.swift -// -// -// Created by MainasuK Cirno on 2021-2-26. -// - -import UIKit - -final class DarkContentStatusBarStyleNavigationController: UINavigationController { - override var preferredStatusBarStyle: UIStatusBarStyle { - return .darkContent - } -} diff --git a/Mastodon/Scene/Share/View/Button/HighlightDimmableButton.swift b/Mastodon/Scene/Share/View/Button/HighlightDimmableButton.swift index 3eb916f26..5202d376a 100644 --- a/Mastodon/Scene/Share/View/Button/HighlightDimmableButton.swift +++ b/Mastodon/Scene/Share/View/Button/HighlightDimmableButton.swift @@ -9,6 +9,8 @@ import UIKit final class HighlightDimmableButton: UIButton { + var expandEdgeInsets = UIEdgeInsets(top: 0, left: 0, bottom: 0, right: 0) + override init(frame: CGRect) { super.init(frame: frame) _init() @@ -19,6 +21,9 @@ final class HighlightDimmableButton: UIButton { _init() } + override func point(inside point: CGPoint, with event: UIEvent?) -> Bool { + return bounds.inset(by: expandEdgeInsets).contains(point) + } override var isHighlighted: Bool { didSet { diff --git a/Mastodon/Scene/Share/View/Button/PrimaryActionButton.swift b/Mastodon/Scene/Share/View/Button/PrimaryActionButton.swift index 0d68cd74d..8fefc06c8 100644 --- a/Mastodon/Scene/Share/View/Button/PrimaryActionButton.swift +++ b/Mastodon/Scene/Share/View/Button/PrimaryActionButton.swift @@ -38,9 +38,8 @@ extension PrimaryActionButton { private func _init() { titleLabel?.font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 17, weight: .semibold)) setTitleColor(.white, for: .normal) - setBackgroundImage(UIImage.placeholder(color: Asset.Colors.Button.highlight.color), for: .normal) - setBackgroundImage(UIImage.placeholder(color: Asset.Colors.Button.highlight.color.withAlphaComponent(0.5)), for: .highlighted) - setBackgroundImage(UIImage.placeholder(color: Asset.Colors.Button.disabled.color), for: .disabled) + setBackgroundImage(UIImage.placeholder(color: Asset.Colors.brandBlue.color), for: .normal) + setupButtonBackground() applyCornerRadius(radius: 10) } @@ -68,4 +67,21 @@ extension PrimaryActionButton { isEnabled = true self.setTitle(originalButtonTitle, for: .disabled) } + + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + setupButtonBackground() + } + + func setupButtonBackground() { + if UIScreen.main.traitCollection.userInterfaceStyle == .light { + setTitleColor(.white, for: .disabled) + setBackgroundImage(UIImage.placeholder(color: Asset.Colors.Button.normal.color.withAlphaComponent(0.5)), for: .highlighted) + setBackgroundImage(UIImage.placeholder(color: Asset.Colors.Button.disabled.color), for: .disabled) + + } else { + setTitleColor(UIColor.white.withAlphaComponent(0.5), for: .disabled) + setBackgroundImage(UIImage.placeholder(color: Asset.Colors.brandBlue.color.withAlphaComponent(0.5)), for: .highlighted) + setBackgroundImage(UIImage.placeholder(color: Asset.Colors.brandBlue.color.withAlphaComponent(0.5)), for: .disabled) + } + } } diff --git a/Mastodon/Scene/Share/View/Button/RoundedEdgesButton.swift b/Mastodon/Scene/Share/View/Button/RoundedEdgesButton.swift new file mode 100644 index 000000000..266fa6594 --- /dev/null +++ b/Mastodon/Scene/Share/View/Button/RoundedEdgesButton.swift @@ -0,0 +1,19 @@ +// +// RoundedEdgesButton.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-12. +// + +import UIKit + +class RoundedEdgesButton: UIButton { + + override func layoutSubviews() { + super.layoutSubviews() + + layer.masksToBounds = true + layer.cornerRadius = bounds.height * 0.5 + } + +} diff --git a/Mastodon/Scene/Share/View/Container/AudioContainerView.swift b/Mastodon/Scene/Share/View/Container/AudioContainerView.swift new file mode 100644 index 000000000..336fade8f --- /dev/null +++ b/Mastodon/Scene/Share/View/Container/AudioContainerView.swift @@ -0,0 +1,128 @@ +// +// AudioViewContainer.swift +// Mastodon +// +// Created by sxiaojian on 2021/3/8. +// + +import CoreDataStack +import os.log +import UIKit + +final class AudioContainerView: UIView { + static let cornerRadius: CGFloat = 22 + + let container: UIStackView = { + let stackView = UIStackView() + stackView.axis = .horizontal + stackView.distribution = .fill + stackView.alignment = .center + stackView.spacing = 11 + stackView.layoutMargins = UIEdgeInsets(top: 0, left: 8, bottom: 0, right: 8) + stackView.isLayoutMarginsRelativeArrangement = true + stackView.layer.cornerRadius = AudioContainerView.cornerRadius + stackView.clipsToBounds = true + stackView.backgroundColor = Asset.Colors.Background.AudioPlayer.highlight.color + stackView.translatesAutoresizingMaskIntoConstraints = false + return stackView + }() + + let playButtonBackgroundView: UIView = { + let view = UIView() + view.layer.cornerRadius = 16 + view.clipsToBounds = true + view.backgroundColor = Asset.Colors.Background.AudioPlayer.highlight.color + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + + let playButton: UIButton = { + let button = HighlightDimmableButton(type: .custom) + let image = UIImage(systemName: "play.circle.fill", withConfiguration: UIImage.SymbolConfiguration(pointSize: 32, weight: .bold))! + button.setImage(image.withRenderingMode(.alwaysTemplate), for: .normal) + + let pauseImage = UIImage(systemName: "pause.circle.fill", withConfiguration: UIImage.SymbolConfiguration(pointSize: 32, weight: .bold))! + button.setImage(pauseImage.withRenderingMode(.alwaysTemplate), for: .selected) + + button.tintColor = .white + button.translatesAutoresizingMaskIntoConstraints = false + button.isEnabled = true + return button + }() + + let slider: UISlider = { + let slider = UISlider() + slider.translatesAutoresizingMaskIntoConstraints = false + slider.minimumTrackTintColor = Asset.Colors.Slider.bar.color + slider.maximumTrackTintColor = Asset.Colors.Slider.bar.color + if let image = UIImage.placeholder(size: CGSize(width: 22, height: 22), color: .white).withRoundedCorners(radius: 11) { + slider.setThumbImage(image, for: .normal) + } + return slider + }() + + let timeLabel: UILabel = { + let label = UILabel() + label.translatesAutoresizingMaskIntoConstraints = false + label.font = .systemFont(ofSize: 13, weight: .regular) + label.textColor = .white + label.textAlignment = UIApplication.shared.userInterfaceLayoutDirection == .leftToRight ? .right : .left + return label + }() + + override init(frame: CGRect) { + super.init(frame: frame) + _init() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + _init() + } +} + +extension AudioContainerView { + private func _init() { + addSubview(container) + NSLayoutConstraint.activate([ + container.topAnchor.constraint(equalTo: topAnchor), + container.leadingAnchor.constraint(equalTo: leadingAnchor), + trailingAnchor.constraint(equalTo: container.trailingAnchor), + bottomAnchor.constraint(equalTo: container.bottomAnchor), + ]) + + // checkmark + playButtonBackgroundView.addSubview(playButton) + container.addArrangedSubview(playButtonBackgroundView) + NSLayoutConstraint.activate([ + playButton.centerXAnchor.constraint(equalTo: playButtonBackgroundView.centerXAnchor), + playButton.centerYAnchor.constraint(equalTo: playButtonBackgroundView.centerYAnchor), + playButtonBackgroundView.heightAnchor.constraint(equalToConstant: 32), + playButtonBackgroundView.widthAnchor.constraint(equalToConstant: 32), + ]) + + container.addArrangedSubview(slider) + + container.addArrangedSubview(timeLabel) + NSLayoutConstraint.activate([ + timeLabel.widthAnchor.constraint(equalToConstant: 40), + ]) + } +} + +#if canImport(SwiftUI) && DEBUG +import SwiftUI + +struct AudioContainerView_Previews: PreviewProvider { + + static var previews: some View { + UIViewPreview(width: 375) { + AudioContainerView() + } + .previewLayout(.fixed(width: 375, height: 100)) + } + +} + +#endif + diff --git a/Mastodon/Scene/Share/View/Container/MosaicImageViewContainer.swift b/Mastodon/Scene/Share/View/Container/MosaicImageViewContainer.swift index 5240d4e2c..0dccd5930 100644 --- a/Mastodon/Scene/Share/View/Container/MosaicImageViewContainer.swift +++ b/Mastodon/Scene/Share/View/Container/MosaicImageViewContainer.swift @@ -9,21 +9,18 @@ import os.log import func AVFoundation.AVMakeRect import UIKit -protocol MosaicImageViewContainerPresentable: class { +protocol MosaicImageViewContainerPresentable: AnyObject { var mosaicImageViewContainer: MosaicImageViewContainer { get } + var isRevealing: Bool { get } } -protocol MosaicImageViewContainerDelegate: class { +protocol MosaicImageViewContainerDelegate: AnyObject { func mosaicImageViewContainer(_ mosaicImageViewContainer: MosaicImageViewContainer, didTapImageView imageView: UIImageView, atIndex index: Int) - func mosaicImageViewContainer(_ mosaicImageViewContainer: MosaicImageViewContainer, didTapContentWarningVisualEffectView visualEffectView: UIVisualEffectView) - + func mosaicImageViewContainer(_ mosaicImageViewContainer: MosaicImageViewContainer, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView) } final class MosaicImageViewContainer: UIView { - static let cornerRadius: CGFloat = 4 - static let blurVisualEffect = UIBlurEffect(style: .systemUltraThinMaterial) - weak var delegate: MosaicImageViewContainerDelegate? let container = UIStackView() @@ -34,17 +31,16 @@ final class MosaicImageViewContainer: UIView { let tapGesture = UITapGestureRecognizer.singleTapGestureRecognizer tapGesture.addTarget(self, action: #selector(MosaicImageViewContainer.photoTapGestureRecognizerHandler(_:))) imageView.addGestureRecognizer(tapGesture) + imageView.isAccessibilityElement = true } } } - let blurVisualEffectView = UIVisualEffectView(effect: MosaicImageViewContainer.blurVisualEffect) - let vibrancyVisualEffectView = UIVisualEffectView(effect: UIVibrancyEffect(blurEffect: MosaicImageViewContainer.blurVisualEffect)) - let contentWarningLabel: UILabel = { - let label = UILabel() - label.font = UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 15)) - label.text = L10n.Common.Controls.Status.mediaContentWarning - label.textAlignment = .center - return label + var blurhashOverlayImageViews: [UIImageView] = [] + + let contentWarningOverlayView: ContentWarningOverlayView = { + let contentWarningOverlayView = ContentWarningOverlayView() + contentWarningOverlayView.configure(style: .visualEffectView) + return contentWarningOverlayView }() private var containerHeightLayoutConstraint: NSLayoutConstraint! @@ -61,9 +57,18 @@ final class MosaicImageViewContainer: UIView { } +extension MosaicImageViewContainer: ContentWarningOverlayViewDelegate { + func contentWarningOverlayViewDidPressed(_ contentWarningOverlayView: ContentWarningOverlayView) { + self.delegate?.mosaicImageViewContainer(self, contentWarningOverlayViewDidPressed: contentWarningOverlayView) + } +} + extension MosaicImageViewContainer { private func _init() { + // accessibility + accessibilityIgnoresInvertColors = true + container.translatesAutoresizingMaskIntoConstraints = false container.axis = .horizontal container.distribution = .fillEqually @@ -77,32 +82,7 @@ extension MosaicImageViewContainer { containerHeightLayoutConstraint ]) - // add blur visual effect view in the setup method - blurVisualEffectView.layer.masksToBounds = true - blurVisualEffectView.layer.cornerRadius = MosaicImageViewContainer.cornerRadius - blurVisualEffectView.layer.cornerCurve = .continuous - - vibrancyVisualEffectView.translatesAutoresizingMaskIntoConstraints = false - blurVisualEffectView.contentView.addSubview(vibrancyVisualEffectView) - NSLayoutConstraint.activate([ - vibrancyVisualEffectView.topAnchor.constraint(equalTo: blurVisualEffectView.topAnchor), - vibrancyVisualEffectView.leadingAnchor.constraint(equalTo: blurVisualEffectView.leadingAnchor), - vibrancyVisualEffectView.trailingAnchor.constraint(equalTo: blurVisualEffectView.trailingAnchor), - vibrancyVisualEffectView.bottomAnchor.constraint(equalTo: blurVisualEffectView.bottomAnchor), - ]) - - contentWarningLabel.translatesAutoresizingMaskIntoConstraints = false - vibrancyVisualEffectView.contentView.addSubview(contentWarningLabel) - NSLayoutConstraint.activate([ - contentWarningLabel.leadingAnchor.constraint(equalTo: vibrancyVisualEffectView.contentView.layoutMarginsGuide.leadingAnchor), - contentWarningLabel.trailingAnchor.constraint(equalTo: vibrancyVisualEffectView.contentView.layoutMarginsGuide.trailingAnchor), - contentWarningLabel.centerYAnchor.constraint(equalTo: vibrancyVisualEffectView.contentView.centerYAnchor), - ]) - - blurVisualEffectView.isUserInteractionEnabled = true - let tapGesture = UITapGestureRecognizer.singleTapGestureRecognizer - tapGesture.addTarget(self, action: #selector(MosaicImageViewContainer.visualEffectViewTapGestureRecognizerHandler(_:))) - blurVisualEffectView.addGestureRecognizer(tapGesture) + contentWarningOverlayView.delegate = self } } @@ -117,15 +97,19 @@ extension MosaicImageViewContainer { container.subviews.forEach { subview in subview.removeFromSuperview() } - blurVisualEffectView.removeFromSuperview() - blurVisualEffectView.effect = MosaicImageViewContainer.blurVisualEffect - vibrancyVisualEffectView.alpha = 1.0 + contentWarningOverlayView.removeFromSuperview() + contentWarningOverlayView.blurVisualEffectView.effect = ContentWarningOverlayView.blurVisualEffect + contentWarningOverlayView.vibrancyVisualEffectView.alpha = 1.0 + contentWarningOverlayView.isUserInteractionEnabled = true imageViews = [] + blurhashOverlayImageViews = [] container.spacing = 1 } - func setupImageView(aspectRatio: CGSize, maxSize: CGSize) -> UIImageView { + typealias ConfigurableMosaic = (imageView: UIImageView, blurhashOverlayImageView: UIImageView) + + func setupImageView(aspectRatio: CGSize, maxSize: CGSize) -> ConfigurableMosaic { reset() let contentView = UIView() @@ -140,7 +124,7 @@ extension MosaicImageViewContainer { let imageView = UIImageView() imageViews.append(imageView) imageView.layer.masksToBounds = true - imageView.layer.cornerRadius = MosaicImageViewContainer.cornerRadius + imageView.layer.cornerRadius = ContentWarningOverlayView.cornerRadius imageView.layer.cornerCurve = .continuous imageView.contentMode = .scaleAspectFill @@ -155,19 +139,34 @@ extension MosaicImageViewContainer { containerHeightLayoutConstraint.constant = floor(rect.height) containerHeightLayoutConstraint.isActive = true - blurVisualEffectView.translatesAutoresizingMaskIntoConstraints = false - addSubview(blurVisualEffectView) + let blurhashOverlayImageView = UIImageView() + blurhashOverlayImageView.layer.masksToBounds = true + blurhashOverlayImageView.layer.cornerRadius = ContentWarningOverlayView.cornerRadius + blurhashOverlayImageView.layer.cornerCurve = .continuous + blurhashOverlayImageView.contentMode = .scaleAspectFill + blurhashOverlayImageViews.append(blurhashOverlayImageView) + blurhashOverlayImageView.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(blurhashOverlayImageView) NSLayoutConstraint.activate([ - blurVisualEffectView.topAnchor.constraint(equalTo: imageView.topAnchor), - blurVisualEffectView.leadingAnchor.constraint(equalTo: imageView.leadingAnchor), - blurVisualEffectView.trailingAnchor.constraint(equalTo: imageView.trailingAnchor), - blurVisualEffectView.bottomAnchor.constraint(equalTo: imageView.bottomAnchor), + blurhashOverlayImageView.topAnchor.constraint(equalTo: imageView.topAnchor), + blurhashOverlayImageView.leadingAnchor.constraint(equalTo: imageView.leadingAnchor), + blurhashOverlayImageView.trailingAnchor.constraint(equalTo: imageView.trailingAnchor), + blurhashOverlayImageView.bottomAnchor.constraint(equalTo: imageView.bottomAnchor), ]) - - return imageView + + contentWarningOverlayView.translatesAutoresizingMaskIntoConstraints = false + addSubview(contentWarningOverlayView) + NSLayoutConstraint.activate([ + contentWarningOverlayView.topAnchor.constraint(equalTo: imageView.topAnchor), + contentWarningOverlayView.leadingAnchor.constraint(equalTo: imageView.leadingAnchor), + contentWarningOverlayView.trailingAnchor.constraint(equalTo: imageView.trailingAnchor), + contentWarningOverlayView.bottomAnchor.constraint(equalTo: imageView.bottomAnchor), + ]) + + return (imageView, blurhashOverlayImageView) } - func setupImageViews(count: Int, maxHeight: CGFloat) -> [UIImageView] { + func setupImageViews(count: Int, maxHeight: CGFloat) -> [ConfigurableMosaic] { reset() guard count > 1 else { return [] @@ -187,13 +186,22 @@ extension MosaicImageViewContainer { container.addArrangedSubview(contentRightStackView) var imageViews: [UIImageView] = [] + var blurhashOverlayImageViews: [UIImageView] = [] for _ in 0.. UIImage? { + guard blurhashOverlayImageViews.count == imageViews.count else { return nil } + let tuples = Array(zip(blurhashOverlayImageViews, imageViews)) + guard index < tuples.count else { return nil } + let tuple = tuples[index] + return tuple.1.image ?? tuple.0.image + } + + func thumbnails() -> [UIImage?] { + guard blurhashOverlayImageViews.count == imageViews.count else { return [] } + let tuples = Array(zip(blurhashOverlayImageViews, imageViews)) + return tuples.map { blurhashOverlayImageView, imageView -> UIImage? in + return imageView.image ?? blurhashOverlayImageView.image + } } } @@ -260,7 +339,7 @@ extension MosaicImageViewContainer { @objc private func visualEffectViewTapGestureRecognizerHandler(_ sender: UITapGestureRecognizer) { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) - delegate?.mosaicImageViewContainer(self, didTapContentWarningVisualEffectView: blurVisualEffectView) + delegate?.mosaicImageViewContainer(self, contentWarningOverlayViewDidPressed: contentWarningOverlayView) } @objc private func photoTapGestureRecognizerHandler(_ sender: UITapGestureRecognizer) { @@ -287,7 +366,7 @@ struct MosaicImageView_Previews: PreviewProvider { UIViewPreview(width: 375) { let view = MosaicImageViewContainer() let image = images[3] - let imageView = view.setupImageView( + let (imageView, _) = view.setupImageView( aspectRatio: image.size, maxSize: CGSize(width: 375, height: 400) ) @@ -299,7 +378,7 @@ struct MosaicImageView_Previews: PreviewProvider { UIViewPreview(width: 375) { let view = MosaicImageViewContainer() let image = images[1] - let imageView = view.setupImageView( + let (imageView, _) = view.setupImageView( aspectRatio: image.size, maxSize: CGSize(width: 375, height: 400) ) @@ -314,8 +393,9 @@ struct MosaicImageView_Previews: PreviewProvider { UIViewPreview(width: 375) { let view = MosaicImageViewContainer() let images = self.images.prefix(2) - let imageViews = view.setupImageViews(count: images.count, maxHeight: 162) - for (i, imageView) in imageViews.enumerated() { + let mosaics = view.setupImageViews(count: images.count, maxHeight: 162) + for (i, mosiac) in mosaics.enumerated() { + let (imageView, blurhashOverlayImageView) = mosiac imageView.image = images[i] } return view @@ -325,8 +405,9 @@ struct MosaicImageView_Previews: PreviewProvider { UIViewPreview(width: 375) { let view = MosaicImageViewContainer() let images = self.images.prefix(3) - let imageViews = view.setupImageViews(count: images.count, maxHeight: 162) - for (i, imageView) in imageViews.enumerated() { + let mosaics = view.setupImageViews(count: images.count, maxHeight: 162) + for (i, mosiac) in mosaics.enumerated() { + let (imageView, blurhashOverlayImageView) = mosiac imageView.image = images[i] } return view @@ -336,8 +417,9 @@ struct MosaicImageView_Previews: PreviewProvider { UIViewPreview(width: 375) { let view = MosaicImageViewContainer() let images = self.images.prefix(4) - let imageViews = view.setupImageViews(count: images.count, maxHeight: 162) - for (i, imageView) in imageViews.enumerated() { + let mosaics = view.setupImageViews(count: images.count, maxHeight: 162) + for (i, mosiac) in mosaics.enumerated() { + let (imageView, blurhashOverlayImageView) = mosiac imageView.image = images[i] } return view diff --git a/Mastodon/Scene/Share/View/Container/PlayerContainerView+MediaTypeIndicotorView.swift b/Mastodon/Scene/Share/View/Container/PlayerContainerView+MediaTypeIndicotorView.swift new file mode 100644 index 000000000..12f822986 --- /dev/null +++ b/Mastodon/Scene/Share/View/Container/PlayerContainerView+MediaTypeIndicotorView.swift @@ -0,0 +1,138 @@ +// +// PlayerContainerView+MediaTypeIndicotorView.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-15. +// + +import UIKit + +extension PlayerContainerView { + + final class MediaTypeIndicotorView: UIView { + + static let indicatorViewSize = CGSize(width: 47, height: 25) + + let maskLayer = CAShapeLayer() + + let label: UILabel = { + let label = UILabel() + label.textColor = .white + label.textAlignment = .right + label.adjustsFontSizeToFitWidth = true + label.translatesAutoresizingMaskIntoConstraints = false + return label + }() + + override init(frame: CGRect) { + super.init(frame: frame) + _init() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + _init() + } + + override func layoutSubviews() { + super.layoutSubviews() + + let path = UIBezierPath() + path.move(to: CGPoint(x: bounds.width, y: bounds.height)) + path.addLine(to: CGPoint(x: bounds.width, y: 0)) + path.addLine(to: CGPoint(x: bounds.width * 0.5, y: 0)) + path.addCurve( + to: CGPoint(x: 0, y: bounds.height), + controlPoint1: CGPoint(x: bounds.width * 0.2, y: 0), + controlPoint2: CGPoint(x: 0, y: bounds.height * 0.3) + ) + path.close() + + maskLayer.frame = bounds + maskLayer.path = path.cgPath + layer.mask = maskLayer + + layer.cornerRadius = PlayerContainerView.cornerRadius + layer.maskedCorners = [.layerMaxXMaxYCorner] + layer.cornerCurve = .continuous + } + } + +} + +extension PlayerContainerView.MediaTypeIndicotorView { + + private func _init() { + backgroundColor = Asset.Colors.Background.mediaTypeIndicotor.color + layoutMargins = UIEdgeInsets(top: 3, left: 13, bottom: 0, right: 6) + + addSubview(label) + NSLayoutConstraint.activate([ + label.topAnchor.constraint(equalTo: layoutMarginsGuide.topAnchor), + label.leadingAnchor.constraint(equalTo: layoutMarginsGuide.leadingAnchor), + label.trailingAnchor.constraint(equalTo: layoutMarginsGuide.trailingAnchor), + label.bottomAnchor.constraint(equalTo: layoutMarginsGuide.bottomAnchor), + ]) + } + + private static func roundedFont(weight: UIFont.Weight,fontSize: CGFloat) -> UIFont { + let systemFont = UIFont.systemFont(ofSize: fontSize, weight: weight) + guard let descriptor = systemFont.fontDescriptor.withDesign(.rounded) else { return systemFont } + let roundedFont = UIFont(descriptor: descriptor, size: fontSize) + return roundedFont + } + + func setMediaKind(kind: VideoPlayerViewModel.Kind) { + let fontSize: CGFloat = 18 + + switch kind { + case .gif: + label.font = PlayerContainerView.MediaTypeIndicotorView.roundedFont(weight: .heavy, fontSize: fontSize) + label.text = "GIF" + case .video: + let configuration = UIImage.SymbolConfiguration(font: PlayerContainerView.MediaTypeIndicotorView.roundedFont(weight: .regular, fontSize: fontSize)) + let image = UIImage(systemName: "video.fill", withConfiguration: configuration)! + let attachment = NSTextAttachment() + attachment.image = image.withTintColor(.white) + label.attributedText = NSAttributedString(attachment: attachment) + } + } + +} + +#if canImport(SwiftUI) && DEBUG +import SwiftUI + +struct PlayerContainerViewMediaTypeIndicotorView_Previews: PreviewProvider { + + static var previews: some View { + Group { + UIViewPreview(width: 47) { + let view = PlayerContainerView.MediaTypeIndicotorView() + view.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + view.heightAnchor.constraint(equalToConstant: 25), + view.widthAnchor.constraint(equalToConstant: 47), + ]) + view.setMediaKind(kind: .gif) + return view + } + .previewLayout(.fixed(width: 47, height: 25)) + UIViewPreview(width: 47) { + let view = PlayerContainerView.MediaTypeIndicotorView() + view.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + view.heightAnchor.constraint(equalToConstant: 25), + view.widthAnchor.constraint(equalToConstant: 47), + ]) + view.setMediaKind(kind: .video) + return view + } + .previewLayout(.fixed(width: 47, height: 25)) + } + } + +} + +#endif + diff --git a/Mastodon/Scene/Share/View/Container/PlayerContainerView.swift b/Mastodon/Scene/Share/View/Container/PlayerContainerView.swift new file mode 100644 index 000000000..32ee48df9 --- /dev/null +++ b/Mastodon/Scene/Share/View/Container/PlayerContainerView.swift @@ -0,0 +1,167 @@ +// +// PlayerContainerView.swift +// Mastodon +// +// Created by xiaojian sun on 2021/3/10. +// + +import os.log +import AVKit +import UIKit + +protocol PlayerContainerViewDelegate: AnyObject { + func playerContainerView(_ playerContainerView: PlayerContainerView, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView) +} + +final class PlayerContainerView: UIView { + static let cornerRadius: CGFloat = 8 + + private let container = UIView() + private let touchBlockingView = TouchBlockingView() + private var containerHeightLayoutConstraint: NSLayoutConstraint! + + let contentWarningOverlayView: ContentWarningOverlayView = { + let contentWarningOverlayView = ContentWarningOverlayView() + contentWarningOverlayView.update(cornerRadius: PlayerContainerView.cornerRadius) + return contentWarningOverlayView + }() + + let playerViewController = AVPlayerViewController() + + let mediaTypeIndicotorView = MediaTypeIndicotorView() + let mediaTypeIndicotorViewInContentWarningOverlay = MediaTypeIndicotorView() + + weak var delegate: PlayerContainerViewDelegate? + + override init(frame: CGRect) { + super.init(frame: frame) + _init() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + _init() + } +} + +extension PlayerContainerView { + private func _init() { + // accessibility + accessibilityIgnoresInvertColors = true + + container.translatesAutoresizingMaskIntoConstraints = false + addSubview(container) + containerHeightLayoutConstraint = container.heightAnchor.constraint(equalToConstant: 162).priority(.required - 1) + NSLayoutConstraint.activate([ + container.topAnchor.constraint(equalTo: topAnchor), + container.leadingAnchor.constraint(equalTo: leadingAnchor), + trailingAnchor.constraint(equalTo: container.trailingAnchor), + bottomAnchor.constraint(equalTo: container.bottomAnchor), + containerHeightLayoutConstraint, + ]) + + // will not influence full-screen playback + playerViewController.view.layer.masksToBounds = true + playerViewController.view.layer.cornerRadius = PlayerContainerView.cornerRadius + playerViewController.view.layer.cornerCurve = .continuous + + // mediaType + mediaTypeIndicotorView.translatesAutoresizingMaskIntoConstraints = false + playerViewController.contentOverlayView!.addSubview(mediaTypeIndicotorView) + NSLayoutConstraint.activate([ + mediaTypeIndicotorView.bottomAnchor.constraint(equalTo: playerViewController.contentOverlayView!.bottomAnchor), + mediaTypeIndicotorView.rightAnchor.constraint(equalTo: playerViewController.contentOverlayView!.rightAnchor), + mediaTypeIndicotorView.heightAnchor.constraint(equalToConstant: MediaTypeIndicotorView.indicatorViewSize.height).priority(.required - 1), + mediaTypeIndicotorView.widthAnchor.constraint(equalToConstant: MediaTypeIndicotorView.indicatorViewSize.width).priority(.required - 1), + ]) + + mediaTypeIndicotorViewInContentWarningOverlay.translatesAutoresizingMaskIntoConstraints = false + contentWarningOverlayView.addSubview(mediaTypeIndicotorViewInContentWarningOverlay) + NSLayoutConstraint.activate([ + mediaTypeIndicotorViewInContentWarningOverlay.bottomAnchor.constraint(equalTo: contentWarningOverlayView.bottomAnchor), + mediaTypeIndicotorViewInContentWarningOverlay.rightAnchor.constraint(equalTo: contentWarningOverlayView.rightAnchor), + mediaTypeIndicotorViewInContentWarningOverlay.heightAnchor.constraint(equalToConstant: MediaTypeIndicotorView.indicatorViewSize.height).priority(.required - 1), + mediaTypeIndicotorViewInContentWarningOverlay.widthAnchor.constraint(equalToConstant: MediaTypeIndicotorView.indicatorViewSize.width).priority(.required - 1), + ]) + + contentWarningOverlayView.delegate = self + } +} + +// MARK: - ContentWarningOverlayViewDelegate +extension PlayerContainerView: ContentWarningOverlayViewDelegate { + func contentWarningOverlayViewDidPressed(_ contentWarningOverlayView: ContentWarningOverlayView) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + delegate?.playerContainerView(self, contentWarningOverlayViewDidPressed: contentWarningOverlayView) + } +} + +extension PlayerContainerView { + func reset() { + // note: set playerViewController.player pause() and nil in data source configuration process make reloadData not break playing + + playerViewController.willMove(toParent: nil) + playerViewController.view.removeFromSuperview() + playerViewController.removeFromParent() + + container.subviews.forEach { subview in + subview.removeFromSuperview() + } + } + + func setupPlayer(aspectRatio: CGSize, maxSize: CGSize, parent: UIViewController?) -> AVPlayerViewController { + reset() + + touchBlockingView.translatesAutoresizingMaskIntoConstraints = false + container.addSubview(touchBlockingView) + NSLayoutConstraint.activate([ + touchBlockingView.topAnchor.constraint(equalTo: container.topAnchor), + touchBlockingView.leadingAnchor.constraint(equalTo: container.leadingAnchor), + touchBlockingView.bottomAnchor.constraint(equalTo: container.bottomAnchor), + ]) + + let rect = AVMakeRect( + aspectRatio: aspectRatio, + insideRect: CGRect(origin: .zero, size: maxSize) + ) + + parent?.addChild(playerViewController) + playerViewController.view.translatesAutoresizingMaskIntoConstraints = false + touchBlockingView.addSubview(playerViewController.view) + parent.flatMap { playerViewController.didMove(toParent: $0) } + NSLayoutConstraint.activate([ + playerViewController.view.topAnchor.constraint(equalTo: touchBlockingView.topAnchor), + playerViewController.view.leadingAnchor.constraint(equalTo: touchBlockingView.leadingAnchor), + playerViewController.view.trailingAnchor.constraint(equalTo: touchBlockingView.trailingAnchor), + playerViewController.view.bottomAnchor.constraint(equalTo: touchBlockingView.bottomAnchor), + touchBlockingView.widthAnchor.constraint(equalToConstant: floor(rect.width)).priority(.required - 1), + ]) + containerHeightLayoutConstraint.constant = floor(rect.height) + containerHeightLayoutConstraint.isActive = true + + contentWarningOverlayView.removeFromSuperview() + contentWarningOverlayView.translatesAutoresizingMaskIntoConstraints = false + addSubview(contentWarningOverlayView) + NSLayoutConstraint.activate([ + contentWarningOverlayView.topAnchor.constraint(equalTo: touchBlockingView.topAnchor), + contentWarningOverlayView.leadingAnchor.constraint(equalTo: touchBlockingView.leadingAnchor), + contentWarningOverlayView.trailingAnchor.constraint(equalTo: touchBlockingView.trailingAnchor), + contentWarningOverlayView.bottomAnchor.constraint(equalTo: touchBlockingView.bottomAnchor) + ]) + + bringSubviewToFront(mediaTypeIndicotorView) + + return playerViewController + } + + func setMediaKind(kind: VideoPlayerViewModel.Kind) { + mediaTypeIndicotorView.setMediaKind(kind: kind) + mediaTypeIndicotorViewInContentWarningOverlay.setMediaKind(kind: kind) + } + + func setMediaIndicator(isHidden: Bool) { + mediaTypeIndicotorView.alpha = isHidden ? 0 : 1 + mediaTypeIndicotorViewInContentWarningOverlay.alpha = isHidden ? 0 : 1 + } + +} diff --git a/Mastodon/Scene/Share/View/Container/TouchBlockingView.swift b/Mastodon/Scene/Share/View/Container/TouchBlockingView.swift new file mode 100644 index 000000000..b86137f1c --- /dev/null +++ b/Mastodon/Scene/Share/View/Container/TouchBlockingView.swift @@ -0,0 +1,34 @@ +// +// TouchBlockingView.swift +// Mastodon +// +// Created by xiaojian sun on 2021/3/10. +// + +import UIKit + +final class TouchBlockingView: UIView { + + override init(frame: CGRect) { + super.init(frame: frame) + _init() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + _init() + } + +} + +extension TouchBlockingView { + + private func _init() { + isUserInteractionEnabled = true + } + + 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/Mastodon/Scene/Share/View/Content/ContentWarningOverlayView.swift b/Mastodon/Scene/Share/View/Content/ContentWarningOverlayView.swift new file mode 100644 index 000000000..9d9f627dc --- /dev/null +++ b/Mastodon/Scene/Share/View/Content/ContentWarningOverlayView.swift @@ -0,0 +1,198 @@ +// +// ContentWarningOverlayView.swift +// Mastodon +// +// Created by sxiaojian on 2021/3/11. +// + +import os.log +import Foundation +import UIKit + +protocol ContentWarningOverlayViewDelegate: AnyObject { + func contentWarningOverlayViewDidPressed(_ contentWarningOverlayView: ContentWarningOverlayView) +} + +class ContentWarningOverlayView: UIView { + + static let cornerRadius: CGFloat = 4 + static let blurVisualEffect = UIBlurEffect(style: .systemUltraThinMaterial) + + let blurVisualEffectView = UIVisualEffectView(effect: ContentWarningOverlayView.blurVisualEffect) + let vibrancyVisualEffectView = UIVisualEffectView(effect: UIVibrancyEffect(blurEffect: ContentWarningOverlayView.blurVisualEffect)) + let vibrancyContentWarningLabel: UILabel = { + let label = UILabel() + label.font = UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 15)) + label.text = L10n.Common.Controls.Status.mediaContentWarning + label.textAlignment = .center + label.numberOfLines = 0 + label.isAccessibilityElement = false + return label + }() + + let blurContentImageView: UIImageView = { + let imageView = UIImageView() + imageView.layer.masksToBounds = false + return imageView + }() + let blurContentWarningTitleLabel: UILabel = { + let label = UILabel() + label.font = UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 17), maximumPointSize: 23) + label.text = L10n.Common.Controls.Status.mediaContentWarning + label.textColor = Asset.Colors.Label.primary.color + label.textAlignment = .center + label.isAccessibilityElement = false + return label + }() + let blurContentWarningLabel: UILabel = { + let label = UILabel() + label.font = UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 15), maximumPointSize: 20) + label.text = L10n.Common.Controls.Status.mediaContentWarning + label.textColor = Asset.Colors.Label.secondary.color + label.textAlignment = .center + label.layer.setupShadow() + label.isAccessibilityElement = false + return label + }() + + let tapGestureRecognizer = UITapGestureRecognizer.singleTapGestureRecognizer + + weak var delegate: ContentWarningOverlayViewDelegate? + + override init(frame: CGRect) { + super.init(frame: frame) + _init() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + _init() + } +} + +extension ContentWarningOverlayView { + private func _init() { + backgroundColor = .clear + isUserInteractionEnabled = true + + // visual effect style + // add blur visual effect view in the setup method + blurVisualEffectView.layer.masksToBounds = true + blurVisualEffectView.layer.cornerRadius = ContentWarningOverlayView.cornerRadius + blurVisualEffectView.layer.cornerCurve = .continuous + + vibrancyVisualEffectView.translatesAutoresizingMaskIntoConstraints = false + blurVisualEffectView.contentView.addSubview(vibrancyVisualEffectView) + NSLayoutConstraint.activate([ + vibrancyVisualEffectView.topAnchor.constraint(equalTo: blurVisualEffectView.topAnchor), + vibrancyVisualEffectView.leadingAnchor.constraint(equalTo: blurVisualEffectView.leadingAnchor), + vibrancyVisualEffectView.trailingAnchor.constraint(equalTo: blurVisualEffectView.trailingAnchor), + vibrancyVisualEffectView.bottomAnchor.constraint(equalTo: blurVisualEffectView.bottomAnchor), + ]) + + vibrancyContentWarningLabel.translatesAutoresizingMaskIntoConstraints = false + vibrancyVisualEffectView.contentView.addSubview(vibrancyContentWarningLabel) + NSLayoutConstraint.activate([ + vibrancyContentWarningLabel.leadingAnchor.constraint(equalTo: vibrancyVisualEffectView.contentView.layoutMarginsGuide.leadingAnchor), + vibrancyContentWarningLabel.trailingAnchor.constraint(equalTo: vibrancyVisualEffectView.contentView.layoutMarginsGuide.trailingAnchor), + vibrancyContentWarningLabel.centerYAnchor.constraint(equalTo: vibrancyVisualEffectView.contentView.centerYAnchor), + ]) + + 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), + ]) + + // blur image style + blurContentImageView.translatesAutoresizingMaskIntoConstraints = false + addSubview(blurContentImageView) + NSLayoutConstraint.activate([ + blurContentImageView.topAnchor.constraint(equalTo: topAnchor), + blurContentImageView.leadingAnchor.constraint(equalTo: leadingAnchor), + blurContentImageView.trailingAnchor.constraint(equalTo: trailingAnchor), + blurContentImageView.bottomAnchor.constraint(equalTo: bottomAnchor), + ]) + + let blurContentWarningLabelContainer = UIStackView() + blurContentWarningLabelContainer.axis = .vertical + blurContentWarningLabelContainer.spacing = 4 + blurContentWarningLabelContainer.alignment = .center + + blurContentWarningLabelContainer.translatesAutoresizingMaskIntoConstraints = false + blurContentImageView.addSubview(blurContentWarningLabelContainer) + NSLayoutConstraint.activate([ + blurContentWarningLabelContainer.topAnchor.constraint(equalTo: topAnchor), + blurContentWarningLabelContainer.leadingAnchor.constraint(equalTo: leadingAnchor), + blurContentWarningLabelContainer.trailingAnchor.constraint(equalTo: trailingAnchor), + blurContentWarningLabelContainer.bottomAnchor.constraint(equalTo: bottomAnchor), + ]) + + let topPaddingView = UIView() + let bottomPaddingView = UIView() + topPaddingView.translatesAutoresizingMaskIntoConstraints = false + blurContentWarningLabelContainer.addArrangedSubview(topPaddingView) + blurContentWarningLabelContainer.addArrangedSubview(blurContentWarningTitleLabel) + blurContentWarningLabelContainer.addArrangedSubview(blurContentWarningLabel) + bottomPaddingView.translatesAutoresizingMaskIntoConstraints = false + blurContentWarningLabelContainer.addArrangedSubview(bottomPaddingView) + NSLayoutConstraint.activate([ + topPaddingView.heightAnchor.constraint(equalTo: bottomPaddingView.heightAnchor, multiplier: 1.0).priority(.defaultHigh), + ]) + blurContentWarningTitleLabel.setContentHuggingPriority(.defaultHigh + 2, for: .vertical) + blurContentWarningLabel.setContentHuggingPriority(.defaultHigh + 1, for: .vertical) + + tapGestureRecognizer.addTarget(self, action: #selector(ContentWarningOverlayView.tapGestureRecognizerHandler(_:))) + addGestureRecognizer(tapGestureRecognizer) + + configure(style: .visualEffectView) + } +} + +extension ContentWarningOverlayView { + + enum Style { + case visualEffectView + case blurContentImageView + } + + func configure(style: Style) { + switch style { + case .visualEffectView: + blurVisualEffectView.isHidden = false + vibrancyVisualEffectView.isHidden = false + blurContentImageView.isHidden = true + case .blurContentImageView: + blurVisualEffectView.isHidden = true + vibrancyVisualEffectView.isHidden = true + blurContentImageView.isHidden = false + } + } + + func update(isRevealing: Bool, style: Style) { + switch style { + case .visualEffectView: + blurVisualEffectView.effect = isRevealing ? nil : ContentWarningOverlayView.blurVisualEffect + vibrancyVisualEffectView.alpha = isRevealing ? 0 : 1 + isUserInteractionEnabled = !isRevealing + case .blurContentImageView: + assertionFailure("not handle here") + break + } + } + + func update(cornerRadius: CGFloat) { + blurVisualEffectView.layer.cornerRadius = cornerRadius + } + +} + +extension ContentWarningOverlayView { + @objc private func tapGestureRecognizerHandler(_ sender: UITapGestureRecognizer) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + delegate?.contentWarningOverlayViewDidPressed(self) + } +} diff --git a/Mastodon/Scene/Share/View/Content/DoubleTitleLabelNavigationBarTitleView.swift b/Mastodon/Scene/Share/View/Content/DoubleTitleLabelNavigationBarTitleView.swift new file mode 100644 index 000000000..b136859a8 --- /dev/null +++ b/Mastodon/Scene/Share/View/Content/DoubleTitleLabelNavigationBarTitleView.swift @@ -0,0 +1,89 @@ +// +// DoubleTitleLabelNavigationBarTitleView.swift +// Mastodon +// +// Created by BradGao on 2021/4/1. +// + +import UIKit + +final class DoubleTitleLabelNavigationBarTitleView: UIView { + + let containerView = UIStackView() + + let titleLabel: UILabel = { + let label = UILabel() + label.font = .systemFont(ofSize: 17, weight: .semibold) + label.textColor = Asset.Colors.Label.primary.color + label.textAlignment = .center + return label + }() + + let subtitleLabel: UILabel = { + let label = UILabel() + label.font = .systemFont(ofSize: 12) + label.textColor = Asset.Colors.Label.secondary.color + label.textAlignment = .center + label.isHidden = true + return label + }() + + override init(frame: CGRect) { + super.init(frame: frame) + _init() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + _init() + } + +} + +extension DoubleTitleLabelNavigationBarTitleView { + private func _init() { + containerView.axis = .vertical + containerView.alignment = .center + containerView.distribution = .fill + containerView.translatesAutoresizingMaskIntoConstraints = false + addSubview(containerView) + NSLayoutConstraint.activate([ + containerView.topAnchor.constraint(equalTo: topAnchor), + containerView.leadingAnchor.constraint(equalTo: leadingAnchor), + containerView.trailingAnchor.constraint(equalTo: trailingAnchor), + containerView.bottomAnchor.constraint(equalTo: bottomAnchor), + ]) + + containerView.addArrangedSubview(titleLabel) + containerView.addArrangedSubview(subtitleLabel) + } + + func update(title: String, subtitle: String?) { + titleLabel.text = title + if let subtitle = subtitle { + subtitleLabel.text = subtitle + subtitleLabel.isHidden = false + } else { + subtitleLabel.text = nil + subtitleLabel.isHidden = true + } + } +} + +#if canImport(SwiftUI) && DEBUG + +import SwiftUI + +struct DoubleTitleLabelNavigationBarTitleView_Previews: PreviewProvider { + + static var previews: some View { + UIViewPreview(width: 375) { + DoubleTitleLabelNavigationBarTitleView() + } + .previewLayout(.fixed(width: 375, height: 100)) + } + +} + +#endif + diff --git a/Mastodon/Scene/Share/View/Content/NavigationBarProgressView.swift b/Mastodon/Scene/Share/View/Content/NavigationBarProgressView.swift new file mode 100644 index 000000000..3cb1d1d9d --- /dev/null +++ b/Mastodon/Scene/Share/View/Content/NavigationBarProgressView.swift @@ -0,0 +1,56 @@ +// +// NavigationBarProgressView.swift +// Mastodon +// +// Created by sxiaojian on 2021/3/16. +// + +import UIKit + +class NavigationBarProgressView: UIView { + + static let progressAnimationDuration: TimeInterval = 0.3 + + let sliderView: UIView = { + let view = UIView() + view.backgroundColor = Asset.Colors.brandBlue.color + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + + var sliderTrailingAnchor: NSLayoutConstraint! + + var progress: CGFloat = 0 { + willSet(value) { + sliderTrailingAnchor.constant = (1 - progress) * bounds.width + UIView.animate(withDuration: NavigationBarProgressView.progressAnimationDuration) { + self.setNeedsLayout() + } + } + } + + override init(frame: CGRect) { + super.init(frame: frame) + _init() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + _init() + } +} + +extension NavigationBarProgressView { + func _init() { + self.translatesAutoresizingMaskIntoConstraints = false + self.backgroundColor = .clear + addSubview(sliderView) + sliderTrailingAnchor = trailingAnchor.constraint(equalTo: sliderView.trailingAnchor) + NSLayoutConstraint.activate([ + sliderView.topAnchor.constraint(equalTo: topAnchor), + sliderView.leadingAnchor.constraint(equalTo: leadingAnchor), + sliderView.bottomAnchor.constraint(equalTo: bottomAnchor), + sliderTrailingAnchor + ]) + } +} diff --git a/Mastodon/Scene/Share/View/Content/PollOptionView.swift b/Mastodon/Scene/Share/View/Content/PollOptionView.swift new file mode 100644 index 000000000..2a248ec3f --- /dev/null +++ b/Mastodon/Scene/Share/View/Content/PollOptionView.swift @@ -0,0 +1,207 @@ +// +// PollOptionView.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-23. +// + +import UIKit +import Combine + +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 + + private var viewStateDisposeBag = Set() + + let roundedBackgroundView = UIView() + let voteProgressStripView: StripProgressView = { + let view = StripProgressView() + view.tintColor = Asset.Colors.Background.Poll.highlight.color + return view + }() + + let checkmarkBackgroundView: UIView = { + let view = UIView() + view.backgroundColor = Asset.Colors.Background.tertiarySystemBackground.color + return view + }() + + let checkmarkImageView: UIImageView = { + let imageView = UIImageView() + let image = UIImage(systemName: "checkmark", withConfiguration: UIImage.SymbolConfiguration(pointSize: 14, weight: .bold))! + imageView.image = image.withRenderingMode(.alwaysTemplate) + imageView.tintColor = Asset.Colors.Button.normal.color + return imageView + }() + + let plusCircleImageView: UIImageView = { + let imageView = UIImageView() + let image = Asset.Circles.plusCircle.image + imageView.image = image.withRenderingMode(.alwaysTemplate) + imageView.tintColor = Asset.Colors.Button.normal.color + return imageView + }() + + let optionTextField: DeleteBackwardResponseTextField = { + let textField = DeleteBackwardResponseTextField() + textField.font = .systemFont(ofSize: 15, weight: .medium) + textField.textColor = Asset.Colors.Label.primary.color + textField.text = "Option" + textField.textAlignment = UIApplication.shared.userInterfaceLayoutDirection == .leftToRight ? .left : .right + return textField + }() + + let optionLabelMiddlePaddingView = UIView() + + let optionPercentageLabel: UILabel = { + let label = UILabel() + label.font = .systemFont(ofSize: 13, weight: .regular) + label.textColor = Asset.Colors.Label.primary.color + label.text = "50%" + label.textAlignment = UIApplication.shared.userInterfaceLayoutDirection == .leftToRight ? .right : .left + return label + }() + + override init(frame: CGRect) { + super.init(frame: frame) + _init() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + _init() + } + +} + +extension PollOptionView { + private func _init() { + // default color in the timeline + roundedBackgroundView.backgroundColor = Asset.Colors.Background.secondarySystemBackground.color + + roundedBackgroundView.translatesAutoresizingMaskIntoConstraints = false + addSubview(roundedBackgroundView) + NSLayoutConstraint.activate([ + roundedBackgroundView.topAnchor.constraint(equalTo: topAnchor, constant: 5), + roundedBackgroundView.leadingAnchor.constraint(equalTo: leadingAnchor), + roundedBackgroundView.trailingAnchor.constraint(equalTo: trailingAnchor), + bottomAnchor.constraint(equalTo: roundedBackgroundView.bottomAnchor, constant: 5), + roundedBackgroundView.heightAnchor.constraint(equalToConstant: PollOptionView.optionHeight).priority(.defaultHigh), + ]) + + voteProgressStripView.translatesAutoresizingMaskIntoConstraints = false + roundedBackgroundView.addSubview(voteProgressStripView) + NSLayoutConstraint.activate([ + voteProgressStripView.topAnchor.constraint(equalTo: roundedBackgroundView.topAnchor), + voteProgressStripView.leadingAnchor.constraint(equalTo: roundedBackgroundView.leadingAnchor), + voteProgressStripView.trailingAnchor.constraint(equalTo: roundedBackgroundView.trailingAnchor), + voteProgressStripView.bottomAnchor.constraint(equalTo: roundedBackgroundView.bottomAnchor), + ]) + + checkmarkBackgroundView.translatesAutoresizingMaskIntoConstraints = false + roundedBackgroundView.addSubview(checkmarkBackgroundView) + NSLayoutConstraint.activate([ + checkmarkBackgroundView.topAnchor.constraint(equalTo: roundedBackgroundView.topAnchor, constant: 9), + checkmarkBackgroundView.leadingAnchor.constraint(equalTo: roundedBackgroundView.leadingAnchor, constant: PollOptionView.checkmarkBackgroundLeadingMargin), + roundedBackgroundView.bottomAnchor.constraint(equalTo: checkmarkBackgroundView.bottomAnchor, constant: 9), + checkmarkBackgroundView.widthAnchor.constraint(equalToConstant: PollOptionView.checkmarkImageSize.width).priority(.required - 1), + checkmarkBackgroundView.heightAnchor.constraint(equalToConstant: PollOptionView.checkmarkImageSize.height).priority(.required - 1), + ]) + + checkmarkImageView.translatesAutoresizingMaskIntoConstraints = false + checkmarkBackgroundView.addSubview(checkmarkImageView) + NSLayoutConstraint.activate([ + checkmarkImageView.topAnchor.constraint(equalTo: checkmarkBackgroundView.topAnchor, constant: 5), + checkmarkImageView.leadingAnchor.constraint(equalTo: checkmarkBackgroundView.leadingAnchor, constant: 5), + checkmarkBackgroundView.trailingAnchor.constraint(equalTo: checkmarkImageView.trailingAnchor, constant: 5), + checkmarkBackgroundView.bottomAnchor.constraint(equalTo: checkmarkImageView.bottomAnchor, constant: 5), + ]) + + plusCircleImageView.translatesAutoresizingMaskIntoConstraints = false + addSubview(plusCircleImageView) + NSLayoutConstraint.activate([ + plusCircleImageView.topAnchor.constraint(equalTo: checkmarkBackgroundView.topAnchor), + plusCircleImageView.leadingAnchor.constraint(equalTo: checkmarkBackgroundView.leadingAnchor), + plusCircleImageView.trailingAnchor.constraint(equalTo: checkmarkBackgroundView.trailingAnchor), + plusCircleImageView.bottomAnchor.constraint(equalTo: checkmarkBackgroundView.bottomAnchor), + ]) + + optionTextField.translatesAutoresizingMaskIntoConstraints = false + roundedBackgroundView.addSubview(optionTextField) + NSLayoutConstraint.activate([ + optionTextField.leadingAnchor.constraint(equalTo: checkmarkBackgroundView.trailingAnchor, constant: 14), + optionTextField.centerYAnchor.constraint(equalTo: roundedBackgroundView.centerYAnchor), + optionTextField.widthAnchor.constraint(greaterThanOrEqualToConstant: 44).priority(.defaultHigh), + ]) + + optionLabelMiddlePaddingView.translatesAutoresizingMaskIntoConstraints = false + roundedBackgroundView.addSubview(optionLabelMiddlePaddingView) + NSLayoutConstraint.activate([ + optionLabelMiddlePaddingView.leadingAnchor.constraint(equalTo: optionTextField.trailingAnchor), + optionLabelMiddlePaddingView.centerYAnchor.constraint(equalTo: roundedBackgroundView.centerYAnchor), + optionLabelMiddlePaddingView.heightAnchor.constraint(equalToConstant: 4).priority(.defaultHigh), + optionLabelMiddlePaddingView.widthAnchor.constraint(greaterThanOrEqualToConstant: 8).priority(.defaultLow), + ]) + optionLabelMiddlePaddingView.setContentHuggingPriority(.required - 1, for: .horizontal) + optionLabelMiddlePaddingView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + + optionPercentageLabel.translatesAutoresizingMaskIntoConstraints = false + roundedBackgroundView.addSubview(optionPercentageLabel) + NSLayoutConstraint.activate([ + optionPercentageLabel.leadingAnchor.constraint(equalTo: optionLabelMiddlePaddingView.trailingAnchor), + roundedBackgroundView.trailingAnchor.constraint(equalTo: optionPercentageLabel.trailingAnchor, constant: 18), + optionPercentageLabel.centerYAnchor.constraint(equalTo: roundedBackgroundView.centerYAnchor), + ]) + optionPercentageLabel.setContentHuggingPriority(.defaultHigh, for: .horizontal) + optionPercentageLabel.setContentCompressionResistancePriority(.required - 1, for: .horizontal) + + plusCircleImageView.isHidden = true + } + + override func layoutSubviews() { + super.layoutSubviews() + updateCornerRadius() + } + +} + +extension PollOptionView { + private func updateCornerRadius() { + roundedBackgroundView.layer.masksToBounds = true + roundedBackgroundView.layer.cornerRadius = PollOptionView.optionHeight * 0.5 + roundedBackgroundView.layer.cornerCurve = .circular + + checkmarkBackgroundView.layer.masksToBounds = true + checkmarkBackgroundView.layer.cornerRadius = PollOptionView.checkmarkImageSize.width * 0.5 + checkmarkBackgroundView.layer.cornerCurve = .circular + } +} + +#if canImport(SwiftUI) && DEBUG +import SwiftUI + +struct PollOptionView_Previews: PreviewProvider { + + static var previews: some View { + Group { + UIViewPreview(width: 375) { + PollOptionView() + } + .previewLayout(.fixed(width: 375, height: 100)) + UIViewPreview(width: 375) { + PollOptionView() + } + .preferredColorScheme(.dark) + .previewLayout(.fixed(width: 375, height: 100)) + } + } + +} + +#endif + diff --git a/Mastodon/Scene/Share/View/Content/StatusView.swift b/Mastodon/Scene/Share/View/Content/StatusView.swift index be754ed86..1aea1bcc3 100644 --- a/Mastodon/Scene/Share/View/Content/StatusView.swift +++ b/Mastodon/Scene/Share/View/Content/StatusView.swift @@ -11,43 +11,77 @@ import AVKit import ActiveLabel import AlamofireImage -protocol StatusViewDelegate: class { - func statusView(_ statusView: StatusView, contentWarningActionButtonPressed button: UIButton) +protocol StatusViewDelegate: AnyObject { + func statusView(_ statusView: StatusView, headerInfoLabelDidPressed label: UILabel) + func statusView(_ statusView: StatusView, avatarButtonDidPressed button: UIButton) + 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, activeLabel: ActiveLabel, didSelectActiveEntity entity: ActiveEntity) } final class StatusView: UIView { + var statusPollTableViewHeightObservation: NSKeyValueObservation? + 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 isStatusTextSensitive = false + private var needsDrawContentOverlay = false + var pollTableViewDataSource: UITableViewDiffableDataSource? + var pollTableViewHeightLaoutConstraint: NSLayoutConstraint! - let headerContainerStackView = UIStackView() + 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: UILabel = { let label = UILabel() - let attributedString = NSMutableAttributedString() - let imageTextAttachment = NSTextAttachment() - let font = UIFont.systemFont(ofSize: 13, weight: .medium) - let configuration = UIImage.SymbolConfiguration(font: font) - imageTextAttachment.image = UIImage(systemName: "arrow.2.squarepath", withConfiguration: configuration)?.withTintColor(Asset.Colors.Label.secondary.color) - let imageAttribute = NSAttributedString(attachment: imageTextAttachment) - attributedString.append(imageAttribute) - label.attributedText = attributedString + label.attributedText = StatusView.iconAttributedString(image: StatusView.reblogIconImage) return label }() - let headerInfoLabel: UILabel = { - let label = UILabel() - label.font = UIFontMetrics(forTextStyle: .footnote).scaledFont(for: .systemFont(ofSize: 13, weight: .medium)) - label.textColor = Asset.Colors.Label.secondary.color - label.text = "Bob boosted" + let headerInfoLabel: ActiveLabel = { + let label = ActiveLabel(style: .statusHeader) + label.text = "Bob reblogged" return label }() - let avatarView = UIView() + let avatarView: UIView = { + let view = UIView() + view.isAccessibilityElement = true + view.accessibilityTraits = .button + view.accessibilityLabel = L10n.Common.Controls.Status.showUserProfile + return view + }() let avatarButton: UIButton = { let button = HighlightDimmableButton(type: .custom) let placeholderImage = UIImage.placeholder(size: avatarImageSize, color: .systemFill) @@ -55,20 +89,29 @@ final class StatusView: UIView { button.setImage(placeholderImage, for: .normal) return button }() + let avatarStackedContainerButton: AvatarStackContainerButton = AvatarStackContainerButton() - let nameLabel: UILabel = { - let label = UILabel() - label.font = .systemFont(ofSize: 17, weight: .semibold) - label.textColor = Asset.Colors.Label.primary.color + let nameLabel: ActiveLabel = { + let label = ActiveLabel(style: .statusName) label.text = "Alice" 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 }() @@ -80,43 +123,90 @@ final class StatusView: UIView { return label }() - let statusContainerStackView = UIStackView() - let statusTextContainerView = UIView() - let statusContentWarningContainerStackView = UIStackView() - var statusContentWarningContainerStackViewBottomLayoutConstraint: NSLayoutConstraint! - - let contentWarningTitle: UILabel = { - let label = UILabel() - label.font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 15, weight: .regular)) - label.textColor = Asset.Colors.Label.primary.color - label.text = L10n.Common.Controls.Status.statusContentWarning - return label - }() - let contentWarningActionButton: UIButton = { - let button = UIButton() - button.titleLabel?.font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 15, weight: .medium)) - button.setTitleColor(Asset.Colors.Label.highlight.color, for: .normal) - button.setTitle(L10n.Common.Controls.Status.showPost, for: .normal) + let revealContentWarningButton: UIButton = { + let button = HighlightDimmableButton() + button.setImage(UIImage(systemName: "eye", withConfiguration: UIImage.SymbolConfiguration(pointSize: 17, weight: .medium)), for: .normal) + button.tintColor = Asset.Colors.Button.normal.color return button }() - let statusMosaicImageView = MosaicImageViewContainer() - // do not use visual effect view due to we blur text only without background - let contentWarningBlurContentImageView: UIImageView = { + let visibilityImageView: UIImageView = { let imageView = UIImageView() - imageView.backgroundColor = Asset.Colors.Background.secondaryGroupedSystemBackground.color - imageView.layer.masksToBounds = false + 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.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.Common.Controls.Status.Poll.VoteCount.single(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 = L10n.Common.Controls.Status.Poll.timeLeft("6 hours") + 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.Button.normal.color, for: .normal) + button.setTitleColor(Asset.Colors.Button.normal.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.layer.masksToBounds = false + contentWarningOverlayView.configure(style: .blurContentImageView) + 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 }() - let activeTextLabel = ActiveLabel(style: .default) + + private let headerInfoLabelTapGestureRecognizer = UITapGestureRecognizer.singleTapGestureRecognizer + + var isRevealing = true override init(frame: CGRect) { super.init(frame: frame) @@ -136,16 +226,20 @@ final class StatusView: UIView { drawContentWarningImageView() } } + + deinit { + statusPollTableViewHeightObservation = nil + } } extension StatusView { func _init() { - // container: [retoot | author | status | action toolbar] - let containerStackView = UIStackView() + // 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.spacing = 10 containerStackView.translatesAutoresizingMaskIntoConstraints = false addSubview(containerStackView) NSLayoutConstraint.activate([ @@ -154,19 +248,30 @@ extension StatusView { trailingAnchor.constraint(equalTo: containerStackView.trailingAnchor), bottomAnchor.constraint(equalTo: containerStackView.bottomAnchor), ]) + containerStackView.setContentHuggingPriority(.required - 1, for: .vertical) // header container: [icon | info] - containerStackView.addArrangedSubview(headerContainerStackView) - headerContainerStackView.spacing = 4 + let headerContainerStackView = UIStackView() + headerContainerStackView.axis = .horizontal headerContainerStackView.addArrangedSubview(headerIconLabel) headerContainerStackView.addArrangedSubview(headerInfoLabel) headerIconLabel.setContentHuggingPriority(.defaultHigh, for: .horizontal) - // author container: [avatar | author meta container] + 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), + ]) + containerStackView.addArrangedSubview(headerContainerView) + + // author container: [avatar | author meta container | reveal button] let authorContainerStackView = UIStackView() - containerStackView.addArrangedSubview(authorContainerStackView) authorContainerStackView.axis = .horizontal - authorContainerStackView.spacing = 5 + authorContainerStackView.spacing = StatusView.avatarToLabelSpacing + authorContainerStackView.distribution = .fill // avatar avatarView.translatesAutoresizingMaskIntoConstraints = false @@ -183,6 +288,14 @@ extension StatusView { 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() @@ -190,7 +303,7 @@ extension StatusView { authorMetaContainerStackView.axis = .vertical authorMetaContainerStackView.spacing = 4 - // title container: [display name | "·" | date] + // title container: [display name | "·" | date | padding | visibility] let titleContainerStackView = UIStackView() authorMetaContainerStackView.addArrangedSubview(titleContainerStackView) titleContainerStackView.axis = .horizontal @@ -201,125 +314,256 @@ extension StatusView { nameLabel.heightAnchor.constraint(equalToConstant: 22).priority(.defaultHigh), ]) titleContainerStackView.alignment = .firstBaseline - let dotLabel: UILabel = { - let label = UILabel() - label.textColor = Asset.Colors.Label.secondary.color - label.font = .systemFont(ofSize: 17) - label.text = "·" - return label - }() - titleContainerStackView.addArrangedSubview(dotLabel) + titleContainerStackView.addArrangedSubview(nameTrialingDotLabel) titleContainerStackView.addArrangedSubview(dateLabel) + titleContainerStackView.addArrangedSubview(UIView()) // padding + titleContainerStackView.addArrangedSubview(visibilityImageView) nameLabel.setContentHuggingPriority(.defaultHigh + 1, for: .horizontal) - dotLabel.setContentHuggingPriority(.defaultHigh + 2, for: .horizontal) - dotLabel.setContentCompressionResistancePriority(.required - 2, 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) - + visibilityImageView.setContentHuggingPriority(.defaultHigh + 3, for: .horizontal) + // 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) - // status container: [status | image / video | audio] + 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(.defaultHigh), + ]) + 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 - statusContainerStackView.addArrangedSubview(statusTextContainerView) - statusTextContainerView.setContentCompressionResistancePriority(.required - 2, for: .vertical) - activeTextLabel.translatesAutoresizingMaskIntoConstraints = false - statusTextContainerView.addSubview(activeTextLabel) - NSLayoutConstraint.activate([ - activeTextLabel.topAnchor.constraint(equalTo: statusTextContainerView.topAnchor), - activeTextLabel.leadingAnchor.constraint(equalTo: statusTextContainerView.leadingAnchor), - activeTextLabel.trailingAnchor.constraint(equalTo: statusTextContainerView.trailingAnchor), - statusTextContainerView.bottomAnchor.constraint(greaterThanOrEqualTo: activeTextLabel.bottomAnchor), - ]) - contentWarningBlurContentImageView.translatesAutoresizingMaskIntoConstraints = false - statusTextContainerView.addSubview(contentWarningBlurContentImageView) - NSLayoutConstraint.activate([ - activeTextLabel.topAnchor.constraint(equalTo: contentWarningBlurContentImageView.topAnchor, constant: StatusView.contentWarningBlurRadius), - activeTextLabel.leadingAnchor.constraint(equalTo: contentWarningBlurContentImageView.leadingAnchor, constant: StatusView.contentWarningBlurRadius), - - ]) - statusContentWarningContainerStackView.translatesAutoresizingMaskIntoConstraints = false - statusContentWarningContainerStackView.axis = .vertical - statusContentWarningContainerStackView.distribution = .fill - statusContentWarningContainerStackView.alignment = .center - statusTextContainerView.addSubview(statusContentWarningContainerStackView) - statusContentWarningContainerStackViewBottomLayoutConstraint = statusTextContainerView.bottomAnchor.constraint(greaterThanOrEqualTo: statusContentWarningContainerStackView.bottomAnchor) - NSLayoutConstraint.activate([ - statusContentWarningContainerStackView.topAnchor.constraint(equalTo: statusTextContainerView.topAnchor), - statusContentWarningContainerStackView.leadingAnchor.constraint(equalTo: statusTextContainerView.leadingAnchor), - statusContentWarningContainerStackView.trailingAnchor.constraint(equalTo: statusTextContainerView.trailingAnchor), - statusContentWarningContainerStackViewBottomLayoutConstraint, - ]) - statusContentWarningContainerStackView.addArrangedSubview(contentWarningTitle) - statusContentWarningContainerStackView.addArrangedSubview(contentWarningActionButton) - statusContainerStackView.addArrangedSubview(statusMosaicImageView) + // content warning overlay + contentWarningOverlayView.translatesAutoresizingMaskIntoConstraints = false + containerStackView.addSubview(contentWarningOverlayView) + NSLayoutConstraint.activate([ + statusContainerStackView.topAnchor.constraint(equalTo: contentWarningOverlayView.topAnchor, constant: StatusView.contentWarningBlurRadius).priority(.defaultHigh), + statusContainerStackView.leftAnchor.constraint(equalTo: contentWarningOverlayView.leftAnchor, constant: StatusView.contentWarningBlurRadius).priority(.defaultHigh), + contentWarningOverlayView.rightAnchor.constraint(equalTo: statusContainerStackView.rightAnchor, constant: StatusView.contentWarningBlurRadius).priority(.defaultHigh), + // only layout to top and left & right then draw image to fit size + ]) + // avoid overlay clip author view + containerStackView.bringSubviewToFront(authorContainerView) + + // status + statusContainerStackView.addArrangedSubview(activeTextLabel) + activeTextLabel.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) + pollTableViewHeightLaoutConstraint = pollTableView.heightAnchor.constraint(equalToConstant: 44.0).priority(.required - 1) + NSLayoutConstraint.activate([ + pollTableViewHeightLaoutConstraint, + ]) + + 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.pollTableViewHeightLaoutConstraint.constant = 44 + return + } + self.pollTableViewHeightLaoutConstraint.constant = self.pollTableView.contentSize.height + }) + + statusContainerStackView.addArrangedSubview(pollStatusStackView) + 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) actionToolbarContainer.setContentCompressionResistancePriority(.defaultLow, for: .vertical) - headerContainerStackView.isHidden = true - statusMosaicImageView.isHidden = true - contentWarningBlurContentImageView.isHidden = true - statusContentWarningContainerStackView.isHidden = true - statusContentWarningContainerStackViewBottomLayoutConstraint.isActive = false + headerContainerView.isHidden = true + statusMosaicImageViewContainer.isHidden = true + pollTableView.isHidden = true + pollStatusStackView.isHidden = true + audioView.isHidden = true + playerContainerView.isHidden = true - contentWarningActionButton.addTarget(self, action: #selector(StatusView.contentWarningActionButtonPressed(_:)), for: .touchUpInside) + avatarStackedContainerButton.isHidden = true + contentWarningOverlayView.isHidden = true + + activeTextLabel.delegate = 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 cleanUpContentWarning() { - contentWarningBlurContentImageView.image = nil + private func cleanUpContentWarning() { + contentWarningOverlayView.blurContentImageView.image = nil } func drawContentWarningImageView() { - guard activeTextLabel.frame != .zero, - isStatusTextSensitive, - let text = activeTextLabel.text, !text.isEmpty else { - cleanUpContentWarning() + guard window != nil else { return } - let image = UIGraphicsImageRenderer(size: activeTextLabel.frame.size).image { context in - activeTextLabel.draw(activeTextLabel.bounds) + guard needsDrawContentOverlay, statusContainerStackView.frame != .zero else { + cleanUpContentWarning() + return + } + + let format = UIGraphicsImageRendererFormat() + format.opaque = false + let image = UIGraphicsImageRenderer(size: statusContainerStackView.frame.size, format: format).image { context in + statusContainerStackView.drawHierarchy(in: statusContainerStackView.bounds, afterScreenUpdates: true) } .blur(radius: StatusView.contentWarningBlurRadius) - contentWarningBlurContentImageView.contentScaleFactor = traitCollection.displayScale - contentWarningBlurContentImageView.image = image + contentWarningOverlayView.blurContentImageView.contentScaleFactor = traitCollection.displayScale + contentWarningOverlayView.blurContentImageView.image = image } - func updateContentWarningDisplay(isHidden: Bool) { - contentWarningBlurContentImageView.isHidden = isHidden - statusContentWarningContainerStackView.isHidden = isHidden - statusContentWarningContainerStackViewBottomLayoutConstraint.isActive = !isHidden + func updateContentWarningDisplay(isHidden: Bool, animated: Bool) { + needsDrawContentOverlay = !isHidden + + if !isHidden { + drawContentWarningImageView() + } + + if animated { + UIView.animate(withDuration: 0.33, delay: 0, options: .curveEaseInOut) { [weak self] in + guard let self = self else { return } + self.contentWarningOverlayView.alpha = isHidden ? 0 : 1 + } completion: { _ in + // do nothing + } + } else { + contentWarningOverlayView.alpha = isHidden ? 0 : 1 + } + + contentWarningOverlayView.blurContentWarningTitleLabel.isHidden = isHidden + contentWarningOverlayView.blurContentWarningLabel.isHidden = isHidden + } + + 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: String) { + guard let visibility = ComposeToolbarView.VisibilitySelectionType(rawValue: visibility) else { return } + visibilityImageView.image = UIImage(systemName: visibility.imageNameForTimeline(), withConfiguration: UIImage.SymbolConfiguration(pointSize: 13, weight: .regular)) } } extension StatusView { - @objc private func contentWarningActionButtonPressed(_ sender: UIButton) { + + @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, contentWarningActionButtonPressed: sender) + 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, avatarButtonDidPressed: sender) + } + + @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, avatarButtonDidPressed: sender) + } + + @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: - ActiveLabelDelegate +extension StatusView: ActiveLabelDelegate { + func activeLabel(_ activeLabel: ActiveLabel, didSelectActiveEntity entity: ActiveEntity) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: select entity: %s", ((#file as NSString).lastPathComponent), #line, #function, entity.primaryText) + delegate?.statusView(self, activeLabel: activeLabel, didSelectActiveEntity: entity) } } +// 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: UIImageView? { return nil } var configurableAvatarButton: UIButton? { return avatarButton } var configurableVerifiedBadgeImageView: UIImageView? { nil } - - } #if canImport(SwiftUI) && DEBUG @@ -328,6 +572,7 @@ 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 { @@ -342,6 +587,28 @@ struct StatusView_Previews: PreviewProvider { 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( @@ -350,21 +617,43 @@ struct StatusView_Previews: PreviewProvider { placeholderImage: avatarFlora ) ) - statusView.headerContainerStackView.isHidden = false - statusView.isStatusTextSensitive = true - statusView.setNeedsLayout() - statusView.layoutIfNeeded() - statusView.drawContentWarningImageView() - statusView.updateContentWarningDisplay(isHidden: false) + statusView.headerContainerView.isHidden = false let images = MosaicImageView_Previews.images - let imageViews = statusView.statusMosaicImageView.setupImageViews(count: 4, maxHeight: 162) - for (i, imageView) in imageViews.enumerated() { + let mosaics = statusView.statusMosaicImageViewContainer.setupImageViews(count: 4, maxHeight: 162) + for (i, mosaic) in mosaics.enumerated() { + let (imageView, _) = mosaic imageView.image = images[i] } - statusView.statusMosaicImageView.isHidden = false + 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) + statusView.drawContentWarningImageView() + let images = MosaicImageView_Previews.images + let mosaics = statusView.statusMosaicImageViewContainer.setupImageViews(count: 4, maxHeight: 162) + for (i, mosaic) in mosaics.enumerated() { + let (imageView, _) = mosaic + imageView.image = images[i] + } + statusView.statusMosaicImageViewContainer.isHidden = false + return statusView + } + .previewLayout(.fixed(width: 375, height: 380)) + .previewDisplayName("Content Sensitive") } } diff --git a/Mastodon/Scene/Share/View/Content/ThreadMetaView.swift b/Mastodon/Scene/Share/View/Content/ThreadMetaView.swift new file mode 100644 index 000000000..ff5fa58d6 --- /dev/null +++ b/Mastodon/Scene/Share/View/Content/ThreadMetaView.swift @@ -0,0 +1,123 @@ +// +// ThreadMetaView.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-12. +// + +import UIKit + +final class ThreadMetaView: UIView { + + let dateLabel: UILabel = { + let label = UILabel() + label.font = UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 15, weight: .regular)) + label.text = "Date" + return label + }() + + let reblogButton: UIButton = { + let button = UIButton() + button.titleLabel?.font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 15, weight: .semibold)) + button.setTitle("0 reblog", for: .normal) + button.setTitleColor(Asset.Colors.Button.normal.color, for: .normal) + button.setTitleColor(Asset.Colors.Button.normal.color.withAlphaComponent(0.5), for: .highlighted) + return button + }() + + let favoriteButton: UIButton = { + let button = UIButton() + button.titleLabel?.font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 15, weight: .semibold)) + button.setTitle("0 favorite", for: .normal) + button.setTitleColor(Asset.Colors.Button.normal.color, for: .normal) + button.setTitleColor(Asset.Colors.Button.normal.color.withAlphaComponent(0.5), for: .highlighted) + return button + }() + + let containerStackView: UIStackView = { + let stackView = UIStackView() + stackView.axis = .horizontal + stackView.spacing = 20 + return stackView + }() + let actionButtonStackView = UIStackView() + + override init(frame: CGRect) { + super.init(frame: frame) + _init() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + _init() + } + +} + +extension ThreadMetaView { + private func _init() { + + containerStackView.translatesAutoresizingMaskIntoConstraints = false + addSubview(containerStackView) + NSLayoutConstraint.activate([ + containerStackView.topAnchor.constraint(equalTo: topAnchor, constant: 8), + containerStackView.leadingAnchor.constraint(equalTo: leadingAnchor), + containerStackView.trailingAnchor.constraint(equalTo: trailingAnchor), + bottomAnchor.constraint(equalTo: containerStackView.bottomAnchor, constant: 12), + ]) + + containerStackView.addArrangedSubview(dateLabel) + containerStackView.addArrangedSubview(actionButtonStackView) + + actionButtonStackView.axis = .horizontal + actionButtonStackView.spacing = 20 + actionButtonStackView.addArrangedSubview(reblogButton) + actionButtonStackView.addArrangedSubview(favoriteButton) + + dateLabel.setContentHuggingPriority(.defaultLow, for: .horizontal) + reblogButton.setContentHuggingPriority(.required - 2, for: .horizontal) + favoriteButton.setContentHuggingPriority(.required - 1, for: .horizontal) + + updateContainerLayout() + + // TODO: + reblogButton.isAccessibilityElement = false + favoriteButton.isAccessibilityElement = false + } + + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + + updateContainerLayout() + } + + private func updateContainerLayout() { + if traitCollection.preferredContentSizeCategory < .accessibilityMedium { + containerStackView.axis = .horizontal + containerStackView.spacing = 20 + dateLabel.numberOfLines = 1 + } else { + containerStackView.axis = .vertical + containerStackView.spacing = 4 + dateLabel.numberOfLines = 0 + } + } + +} + +#if canImport(SwiftUI) && DEBUG +import SwiftUI + +struct ThreadMetaView_Previews: PreviewProvider { + + static var previews: some View { + UIViewPreview(width: 375) { + ThreadMetaView() + } + .previewLayout(.fixed(width: 375, height: 100)) + } + +} + +#endif + diff --git a/Mastodon/Scene/Share/View/Content/TimelineHeaderView.swift b/Mastodon/Scene/Share/View/Content/TimelineHeaderView.swift new file mode 100644 index 000000000..f095f6f44 --- /dev/null +++ b/Mastodon/Scene/Share/View/Content/TimelineHeaderView.swift @@ -0,0 +1,130 @@ +// +// TimelineHeaderView.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-6. +// + +import UIKit + +final class TimelineHeaderView: UIView { + + let iconImageView: UIImageView = { + let imageView = UIImageView() + imageView.tintColor = Asset.Colors.Label.secondary.color + return imageView + }() + let messageLabel: UILabel = { + let label = UILabel() + label.font = .systemFont(ofSize: 17) + label.textAlignment = .center + label.textColor = Asset.Colors.Label.secondary.color + label.text = "info" + label.numberOfLines = 0 + return label + }() + + override init(frame: CGRect) { + super.init(frame: frame) + _init() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + _init() + } + +} + +extension TimelineHeaderView { + + private func _init() { + backgroundColor = .clear + + let topPaddingView = UIView() + topPaddingView.translatesAutoresizingMaskIntoConstraints = false + addSubview(topPaddingView) + NSLayoutConstraint.activate([ + topPaddingView.topAnchor.constraint(equalTo: topAnchor), + topPaddingView.leadingAnchor.constraint(equalTo: leadingAnchor), + topPaddingView.trailingAnchor.constraint(equalTo: trailingAnchor), + ]) + + let containerStackView = UIStackView() + containerStackView.axis = .vertical + containerStackView.alignment = .center + containerStackView.distribution = .fill + containerStackView.spacing = 16 + containerStackView.translatesAutoresizingMaskIntoConstraints = false + addSubview(containerStackView) + NSLayoutConstraint.activate([ + containerStackView.topAnchor.constraint(equalTo: topPaddingView.bottomAnchor), + containerStackView.leadingAnchor.constraint(equalTo: leadingAnchor), + containerStackView.trailingAnchor.constraint(equalTo: trailingAnchor), + ]) + containerStackView.addArrangedSubview(iconImageView) + containerStackView.addArrangedSubview(messageLabel) + + let bottomPaddingView = UIView() + bottomPaddingView.translatesAutoresizingMaskIntoConstraints = false + addSubview(bottomPaddingView) + NSLayoutConstraint.activate([ + bottomPaddingView.topAnchor.constraint(equalTo: containerStackView.bottomAnchor), + bottomPaddingView.leadingAnchor.constraint(equalTo: leadingAnchor), + bottomPaddingView.trailingAnchor.constraint(equalTo: trailingAnchor), + bottomPaddingView.bottomAnchor.constraint(equalTo: bottomAnchor), + ]) + + NSLayoutConstraint.activate([ + topPaddingView.heightAnchor.constraint(equalToConstant: 100).priority(.defaultHigh), + bottomPaddingView.heightAnchor.constraint(equalTo: topPaddingView.heightAnchor, multiplier: 1.0), + ]) + } + +} + +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: + return L10n.Common.Controls.Timeline.Header.blockingWarning + case .blocked: + return L10n.Common.Controls.Timeline.Header.blockedWarning + case .suspended(let name): + if let name = name { + return L10n.Common.Controls.Timeline.Header.userSuspendedWarning(name) + } else { + return L10n.Common.Controls.Timeline.Header.suspendedWarning + } + } + } +} + +#if DEBUG && canImport(SwiftUI) +import SwiftUI + +struct TimelineHeaderView_Previews: PreviewProvider { + static var previews: some View { + Group { + UIViewPreview(width: 375) { + let headerView = TimelineHeaderView() + headerView.iconImageView.image = Item.EmptyStateHeaderAttribute.Reason.blocking.iconImage + headerView.messageLabel.text = Item.EmptyStateHeaderAttribute.Reason.blocking.message + return headerView + } + .previewLayout(.fixed(width: 375, height: 400)) + } + } +} +#endif diff --git a/Mastodon/Scene/Share/View/Control/AdaptiveUserInterfaceStyleBarButtonItem.swift b/Mastodon/Scene/Share/View/Control/AdaptiveUserInterfaceStyleBarButtonItem.swift new file mode 100644 index 000000000..e801d1756 --- /dev/null +++ b/Mastodon/Scene/Share/View/Control/AdaptiveUserInterfaceStyleBarButtonItem.swift @@ -0,0 +1,76 @@ +// +// AdaptiveUserInterfaceStyleBarButtonItem.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-13. +// + +import UIKit + +final class AdaptiveUserInterfaceStyleBarButtonItem: UIBarButtonItem { + + let button = AdaptiveCustomButton() + + init(lightImage: UIImage, darkImage: UIImage) { + super.init() + button.setImage(light: lightImage, dark: darkImage) + self.customView = button + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + } + +} + +extension AdaptiveUserInterfaceStyleBarButtonItem { + class AdaptiveCustomButton: UIButton { + + var lightImage: UIImage? + var darkImage: UIImage? + + override init(frame: CGRect) { + super.init(frame: frame) + _init() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + _init() + } + + private func _init() { + adjustsImageWhenHighlighted = false + } + + override var isHighlighted: Bool { + didSet { + alpha = isHighlighted ? 0.6 : 1 + } + } + + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + resetImage() + } + + func setImage(light: UIImage, dark: UIImage) { + lightImage = light + darkImage = dark + resetImage() + } + + private func resetImage() { + switch traitCollection.userInterfaceStyle { + case .light: + setImage(lightImage, for: .normal) + case .dark, + .unspecified: + setImage(darkImage, for: .normal) + @unknown default: + assertionFailure() + } + } + + } +} diff --git a/Mastodon/Scene/Share/View/Control/AvatarStackContainerButton.swift b/Mastodon/Scene/Share/View/Control/AvatarStackContainerButton.swift new file mode 100644 index 000000000..1e4bd24fe --- /dev/null +++ b/Mastodon/Scene/Share/View/Control/AvatarStackContainerButton.swift @@ -0,0 +1,177 @@ +// +// AvatarStackContainerButton.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-10. +// + +import os.log +import UIKit +final class AvatarStackedImageView: UIImageView { } + +// MARK: - AvatarConfigurableView +extension AvatarStackedImageView: AvatarConfigurableView { + static var configurableAvatarImageSize: CGSize { CGSize(width: 28, height: 28) } + static var configurableAvatarImageCornerRadius: CGFloat { 4 } + var configurableAvatarImageView: UIImageView? { self } + var configurableAvatarButton: UIButton? { nil } +} + +final class AvatarStackContainerButton: UIControl { + + static let containerSize = CGSize(width: 42, height: 42) + 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: AvatarStackedImageView.configurableAvatarImageSize)) + let mirrorScale: CGFloat = UIApplication.shared.userInterfaceLayoutDirection == .rightToLeft ? -1 : 1 + path.addPath(UIBezierPath( + roundedRect: CGRect( + x: mirrorScale * (AvatarStackContainerButton.containerSize.width - AvatarStackedImageView.configurableAvatarImageSize.width - offset), + y: AvatarStackContainerButton.containerSize.height - AvatarStackedImageView.configurableAvatarImageSize.height - offset, + width: AvatarStackedImageView.configurableAvatarImageSize.width, + height: AvatarStackedImageView.configurableAvatarImageSize.height + ), + cornerRadius: AvatarStackedImageView.configurableAvatarImageCornerRadius + ).cgPath) + return path + }() + let maskShapeLayer = CAShapeLayer() + maskShapeLayer.backgroundColor = UIColor.black.cgColor + maskShapeLayer.fillRule = .evenOdd + maskShapeLayer.path = path + topLeadingAvatarStackedImageView.layer.mask = maskShapeLayer + + topLeadingAvatarStackedImageView.image = UIImage.placeholder(color: .systemFill) + bottomTrailingAvatarStackedImageView.image = UIImage.placeholder(color: .systemFill) + } + + 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/Control/StripProgressView.swift b/Mastodon/Scene/Share/View/Control/StripProgressView.swift new file mode 100644 index 000000000..710d8567d --- /dev/null +++ b/Mastodon/Scene/Share/View/Control/StripProgressView.swift @@ -0,0 +1,174 @@ +// +// StripProgressView.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-3. +// + +import os.log +import UIKit +import Combine + +private final class StripProgressLayer: CALayer { + + static let progressAnimationKey = "progressAnimationKey" + static let progressKey = "progress" + + var tintColor: UIColor = .black + @NSManaged var progress: CGFloat + + override class func needsDisplay(forKey key: String) -> Bool { + switch key { + case StripProgressLayer.progressKey: + return true + default: + return super.needsDisplay(forKey: key) + } + } + + override func display() { + let progress: CGFloat = { + guard animation(forKey: StripProgressLayer.progressAnimationKey) != nil else { + return self.progress + } + + return presentation()?.progress ?? self.progress + }() + // os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: progress: %.2f", ((#file as NSString).lastPathComponent), #line, #function, progress) + + UIGraphicsBeginImageContextWithOptions(bounds.size, false, 0) + guard let context = UIGraphicsGetCurrentContext() else { + assertionFailure() + return + } + context.clear(bounds) + + var rect = bounds + let newWidth = CGFloat(progress) * rect.width + let widthChanged = rect.width - newWidth + rect.size.width = newWidth + switch UIApplication.shared.userInterfaceLayoutDirection { + case .rightToLeft: + rect.origin.x += widthChanged + default: + break + } + let path = UIBezierPath(rect: rect) + context.setFillColor(tintColor.cgColor) + context.addPath(path.cgPath) + context.fillPath() + + contents = UIGraphicsGetImageFromCurrentImageContext()?.cgImage + UIGraphicsEndImageContext() + } + +} + +final class StripProgressView: UIView { + + var disposeBag = Set() + + private let stripProgressLayer: StripProgressLayer = { + let layer = StripProgressLayer() + return layer + }() + + override var tintColor: UIColor! { + didSet { + stripProgressLayer.tintColor = tintColor + setNeedsDisplay() + } + } + + func setProgress(_ progress: CGFloat, animated: Bool) { + stripProgressLayer.removeAnimation(forKey: StripProgressLayer.progressAnimationKey) + if animated { + let animation = CABasicAnimation(keyPath: StripProgressLayer.progressKey) + animation.fromValue = stripProgressLayer.presentation()?.progress ?? stripProgressLayer.progress + animation.toValue = progress + animation.duration = 0.33 + animation.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut) + animation.isRemovedOnCompletion = true + stripProgressLayer.add(animation, forKey: StripProgressLayer.progressAnimationKey) + stripProgressLayer.progress = progress + } else { + stripProgressLayer.progress = progress + stripProgressLayer.setNeedsDisplay() + } + } + + override init(frame: CGRect) { + super.init(frame: frame) + _init() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + _init() + } + +} + +extension StripProgressView { + + private func _init() { + layer.addSublayer(stripProgressLayer) + updateLayerPath() + } + + override func layoutSubviews() { + super.layoutSubviews() + updateLayerPath() + } + +} + +extension StripProgressView { + private func updateLayerPath() { + guard bounds != .zero else { return } + + stripProgressLayer.frame = bounds + stripProgressLayer.tintColor = tintColor + stripProgressLayer.setNeedsDisplay() + } +} + +#if DEBUG +import SwiftUI + +struct VoteProgressStripView_Previews: PreviewProvider { + + static var previews: some View { + Group { + UIViewPreview() { + StripProgressView() + } + .frame(width: 100, height: 44) + .padding() + .background(Color.black) + .previewLayout(.sizeThatFits) + UIViewPreview() { + let bar = StripProgressView() + bar.tintColor = .white + bar.setProgress(0.5, animated: false) + return bar + } + .frame(width: 100, height: 44) + .padding() + .background(Color.black) + .previewLayout(.sizeThatFits) + UIViewPreview() { + let bar = StripProgressView() + bar.tintColor = .white + bar.setProgress(1.0, animated: false) + return bar + } + .frame(width: 100, height: 44) + .padding() + .background(Color.black) + .previewLayout(.sizeThatFits) + } + } + +} +#endif diff --git a/Mastodon/Scene/Share/View/Decoration/SawToothView.swift b/Mastodon/Scene/Share/View/Decoration/SawToothView.swift new file mode 100644 index 000000000..8f41abbb3 --- /dev/null +++ b/Mastodon/Scene/Share/View/Decoration/SawToothView.swift @@ -0,0 +1,46 @@ +// +// SawToothView.swift +// Mastodon +// +// Created by sxiaojian on 2021/3/17. +// + +import Foundation +import UIKit + +final class SawToothView: UIView { + static let widthUint = 8 + + override init(frame: CGRect) { + super.init(frame: frame) + _init() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + _init() + } + + func _init() { + backgroundColor = Asset.Colors.Background.secondarySystemBackground.color + } + + override func draw(_ rect: CGRect) { + let bezierPath = UIBezierPath() + let bottomY = rect.height + let topY = 0 + let count = Int(ceil(rect.width / CGFloat(SawToothView.widthUint))) + bezierPath.move(to: CGPoint(x: 0, y: bottomY)) + for n in 0 ..< count { + bezierPath.addLine(to: CGPoint(x: CGFloat((Double(n) + 0.5) * Double(SawToothView.widthUint)), y: CGFloat(topY))) + bezierPath.addLine(to: CGPoint(x: CGFloat((Double(n) + 1) * Double(SawToothView.widthUint)), y: CGFloat(bottomY))) + } + bezierPath.addLine(to: CGPoint(x: 0, y: bottomY)) + bezierPath.close() + Asset.Colors.Background.systemBackground.color.setFill() + bezierPath.fill() + bezierPath.lineWidth = 0 + bezierPath.stroke() + } + +} diff --git a/Mastodon/Scene/Share/View/TableView/PollTableView.swift b/Mastodon/Scene/Share/View/TableView/PollTableView.swift new file mode 100644 index 000000000..d90be2b09 --- /dev/null +++ b/Mastodon/Scene/Share/View/TableView/PollTableView.swift @@ -0,0 +1,10 @@ +// +// PollTableView.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-3. +// + +import UIKit + +final class PollTableView: UITableView { } diff --git a/Mastodon/Scene/Share/View/TableviewCell/PollOptionTableViewCell.swift b/Mastodon/Scene/Share/View/TableviewCell/PollOptionTableViewCell.swift new file mode 100644 index 000000000..957765e10 --- /dev/null +++ b/Mastodon/Scene/Share/View/TableviewCell/PollOptionTableViewCell.swift @@ -0,0 +1,147 @@ +// +// PollOptionTableViewCell.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-2-25. +// + +import UIKit +import Combine + +final class PollOptionTableViewCell: UITableViewCell { + + 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 = Asset.Colors.Background.systemGroupedBackground.color + 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 = Asset.Colors.Background.systemGroupedBackground.color + 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.swift b/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift index 572f23e01..ca6a9e7eb 100644 --- a/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift +++ b/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift @@ -9,33 +9,79 @@ import os.log import UIKit import AVKit import Combine +import CoreData +import CoreDataStack +import ActiveLabel - -protocol StatusTableViewCellDelegate: class { - func statusTableViewCell(_ cell: StatusTableViewCell, actionToolbarContainer: ActionToolbarContainer, likeButtonDidPressed sender: UIButton) - func statusTableViewCell(_ cell: StatusTableViewCell, statusView: StatusView, contentWarningActionButtonPressed button: UIButton) +protocol StatusTableViewCellDelegate: AnyObject { + var context: AppContext! { get } + var managedObjectContext: NSManagedObjectContext { get } + + func parent() -> UIViewController + var playerViewControllerDelegate: AVPlayerViewControllerDelegate? { get } + + func statusTableViewCell(_ cell: StatusTableViewCell, statusView: StatusView, headerInfoLabelDidPressed label: UILabel) + func statusTableViewCell(_ cell: StatusTableViewCell, statusView: StatusView, avatarButtonDidPressed button: UIButton) + 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, activeLabel: ActiveLabel, didSelectActiveEntity entity: ActiveEntity) + + 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, mosaicImageViewContainer: MosaicImageViewContainer, didTapContentWarningVisualEffectView visualEffectView: UIVisualEffectView) - + + 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) } -final class StatusTableViewCell: UITableViewCell { - +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 { + static let bottomPaddingHeight: CGFloat = 10 weak var delegate: StatusTableViewCellDelegate? var disposeBag = Set() + var pollCountdownSubscription: AnyCancellable? var observations = Set() + private var selectionBackgroundViewObservation: NSKeyValueObservation? 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! + override func prepareForReuse() { super.prepareForReuse() - statusView.isStatusTextSensitive = false - statusView.cleanUpContentWarning() + selectionStyle = .default + 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 } override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { @@ -50,8 +96,11 @@ final class StatusTableViewCell: UITableViewCell { override func layoutSubviews() { super.layoutSubviews() + + // precondition: app is active + guard UIApplication.shared.applicationState == .active else { return } DispatchQueue.main.async { - self.statusView.drawContentWarningImageView() + self.statusView.drawContentWarningImageView() } } @@ -60,9 +109,7 @@ final class StatusTableViewCell: UITableViewCell { extension StatusTableViewCell { private func _init() { - selectionStyle = .none - backgroundColor = Asset.Colors.Background.secondaryGroupedSystemBackground.color - statusView.contentWarningBlurContentImageView.backgroundColor = Asset.Colors.Background.secondaryGroupedSystemBackground.color + backgroundColor = Asset.Colors.Background.systemBackground.color statusView.translatesAutoresizingMaskIntoConstraints = false contentView.addSubview(statusView) @@ -72,30 +119,214 @@ extension StatusTableViewCell { contentView.readableContentGuide.trailingAnchor.constraint(equalTo: statusView.trailingAnchor), ]) - let bottomPaddingView = UIView() - bottomPaddingView.translatesAutoresizingMaskIntoConstraints = false - contentView.addSubview(bottomPaddingView) + threadMetaStackView.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(threadMetaStackView) NSLayoutConstraint.activate([ - bottomPaddingView.topAnchor.constraint(equalTo: statusView.bottomAnchor, constant: 10), - bottomPaddingView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), - bottomPaddingView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), - bottomPaddingView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), - bottomPaddingView.heightAnchor.constraint(equalToConstant: StatusTableViewCell.bottomPaddingHeight).priority(.defaultHigh), + 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), ]) - bottomPaddingView.backgroundColor = Asset.Colors.Background.systemGroupedBackground.color - + threadMetaStackView.addArrangedSubview(threadMetaView) + + 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() + statusView.delegate = self - statusView.statusMosaicImageView.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() + } + + override func setHighlighted(_ highlighted: Bool, animated: Bool) { + super.setHighlighted(highlighted, animated: animated) + + resetContentOverlayBlurImageBackgroundColor(selected: highlighted) + } + + override func setSelected(_ selected: Bool, animated: Bool) { + super.setSelected(selected, animated: animated) + + resetContentOverlayBlurImageBackgroundColor(selected: selected) + } + +} + +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, + ]) + } + } + } + + private func resetContentOverlayBlurImageBackgroundColor(selected: Bool) { + let imageViewBackgroundColor: UIColor? = selected ? selectedBackgroundView?.backgroundColor : backgroundColor + statusView.contentWarningOverlayView.blurContentImageView.backgroundColor = imageViewBackgroundColor + } +} + +// 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 .opion(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 .opion(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: - StatusViewDelegate extension StatusTableViewCell: StatusViewDelegate { - func statusView(_ statusView: StatusView, contentWarningActionButtonPressed button: UIButton) { - delegate?.statusTableViewCell(self, statusView: statusView, contentWarningActionButtonPressed: button) + + func statusView(_ statusView: StatusView, headerInfoLabelDidPressed label: UILabel) { + delegate?.statusTableViewCell(self, statusView: statusView, headerInfoLabelDidPressed: label) } + + func statusView(_ statusView: StatusView, avatarButtonDidPressed button: UIButton) { + delegate?.statusTableViewCell(self, statusView: statusView, avatarButtonDidPressed: button) + } + + 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, activeLabel: ActiveLabel, didSelectActiveEntity entity: ActiveEntity) { + delegate?.statusTableViewCell(self, statusView: statusView, activeLabel: activeLabel, didSelectActiveEntity: entity) + } + } // MARK: - MosaicImageViewDelegate @@ -105,27 +336,32 @@ extension StatusTableViewCell: MosaicImageViewContainerDelegate { delegate?.statusTableViewCell(self, mosaicImageViewContainer: mosaicImageViewContainer, didTapImageView: imageView, atIndex: index) } - func mosaicImageViewContainer(_ mosaicImageViewContainer: MosaicImageViewContainer, didTapContentWarningVisualEffectView visualEffectView: UIVisualEffectView) { - delegate?.statusTableViewCell(self, mosaicImageViewContainer: mosaicImageViewContainer, didTapContentWarningVisualEffectView: visualEffectView) + 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, retootButtonDidPressed sender: UIButton) { - + + 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) } - func actionToolbarContainer(_ actionToolbarContainer: ActionToolbarContainer, bookmarkButtonDidPressed sender: UIButton) { - - } - func actionToolbarContainer(_ actionToolbarContainer: ActionToolbarContainer, moreButtonDidPressed sender: UIButton) { - + +} + +extension StatusTableViewCell { + override var accessibilityActivationPoint: CGPoint { + get { return .zero } + set { } } } diff --git a/Mastodon/Scene/Share/View/TableviewCell/ThreadReplyLoaderTableViewCell.swift b/Mastodon/Scene/Share/View/TableviewCell/ThreadReplyLoaderTableViewCell.swift new file mode 100644 index 000000000..03359df51 --- /dev/null +++ b/Mastodon/Scene/Share/View/TableviewCell/ThreadReplyLoaderTableViewCell.swift @@ -0,0 +1,124 @@ +// +// ThreadReplyLoaderTableViewCell.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-13. +// + +import os.log +import UIKit +import Combine + +protocol ThreadReplyLoaderTableViewCellDelegate: AnyObject { + func threadReplyLoaderTableViewCell(_ cell: ThreadReplyLoaderTableViewCell, loadMoreButtonDidPressed button: UIButton) +} + +final class ThreadReplyLoaderTableViewCell: UITableViewCell { + + static let cellHeight: CGFloat = 44 + + weak var delegate: ThreadReplyLoaderTableViewCellDelegate? + + let loadMoreButton: UIButton = { + let button = HighlightDimmableButton() + button.titleLabel?.font = TimelineLoaderTableViewCell.labelFont + button.backgroundColor = Asset.Colors.Background.secondaryGroupedSystemBackground.color + button.setTitleColor(Asset.Colors.Button.normal.color, for: .normal) + button.setTitle(L10n.Common.Controls.Timeline.Loader.showMoreReplies, for: .normal) + return button + }() + + let separatorLine = UIView.separatorLine + + var separatorLineToEdgeLeadingLayoutConstraint: NSLayoutConstraint! + var separatorLineToEdgeTrailingLayoutConstraint: NSLayoutConstraint! + + var separatorLineToMarginLeadingLayoutConstraint: NSLayoutConstraint! + var separatorLineToMarginTrailingLayoutConstraint: NSLayoutConstraint! + + 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 traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + + resetSeparatorLineLayout() + } + +} + +extension ThreadReplyLoaderTableViewCell { + + func _init() { + selectionStyle = .none + backgroundColor = Asset.Colors.Background.systemGroupedBackground.color + + loadMoreButton.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(loadMoreButton) + NSLayoutConstraint.activate([ + loadMoreButton.topAnchor.constraint(equalTo: contentView.topAnchor), + loadMoreButton.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + contentView.trailingAnchor.constraint(equalTo: loadMoreButton.trailingAnchor), + contentView.bottomAnchor.constraint(equalTo: loadMoreButton.bottomAnchor), + loadMoreButton.heightAnchor.constraint(equalToConstant: ThreadReplyLoaderTableViewCell.cellHeight).priority(.required - 1), + ]) + + 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() + + loadMoreButton.addTarget(self, action: #selector(ThreadReplyLoaderTableViewCell.loadMoreButtonDidPressed(_:)), for: .touchUpInside) + } + + 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 ThreadReplyLoaderTableViewCell { + @objc private func loadMoreButtonDidPressed(_ sender: UIButton) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + delegate?.threadReplyLoaderTableViewCell(self, loadMoreButtonDidPressed: sender) + } +} diff --git a/Mastodon/Scene/Share/View/TableviewCell/TimelineBottomLoaderTableViewCell.swift b/Mastodon/Scene/Share/View/TableviewCell/TimelineBottomLoaderTableViewCell.swift index 7fe4c0a77..8d3589fb1 100644 --- a/Mastodon/Scene/Share/View/TableviewCell/TimelineBottomLoaderTableViewCell.swift +++ b/Mastodon/Scene/Share/View/TableviewCell/TimelineBottomLoaderTableViewCell.swift @@ -11,10 +11,10 @@ import Combine final class TimelineBottomLoaderTableViewCell: TimelineLoaderTableViewCell { override func _init() { super._init() - backgroundColor = .clear - + activityIndicatorView.isHidden = false - activityIndicatorView.startAnimating() + + startAnimating() } } diff --git a/Mastodon/Scene/Share/View/TableviewCell/TimelineHeaderTableViewCell.swift b/Mastodon/Scene/Share/View/TableviewCell/TimelineHeaderTableViewCell.swift new file mode 100644 index 000000000..ba1b6b103 --- /dev/null +++ b/Mastodon/Scene/Share/View/TableviewCell/TimelineHeaderTableViewCell.swift @@ -0,0 +1,42 @@ +// +// TimelineHeaderTableViewCell.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-6. +// + +import UIKit + +final class TimelineHeaderTableViewCell: UITableViewCell { + + let timelineHeaderView = TimelineHeaderView() + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + _init() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + _init() + } + +} + +extension TimelineHeaderTableViewCell { + + private func _init() { + selectionStyle = .none + backgroundColor = .clear + + timelineHeaderView.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(timelineHeaderView) + NSLayoutConstraint.activate([ + timelineHeaderView.topAnchor.constraint(equalTo: contentView.topAnchor), + timelineHeaderView.leadingAnchor.constraint(equalTo: contentView.readableContentGuide.leadingAnchor), + timelineHeaderView.trailingAnchor.constraint(equalTo: contentView.readableContentGuide.trailingAnchor), + timelineHeaderView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), + ]) + } + +} diff --git a/Mastodon/Scene/Share/View/TableviewCell/TimelineLoaderTableViewCell.swift b/Mastodon/Scene/Share/View/TableviewCell/TimelineLoaderTableViewCell.swift index 6aa195241..ded8fa49b 100644 --- a/Mastodon/Scene/Share/View/TableviewCell/TimelineLoaderTableViewCell.swift +++ b/Mastodon/Scene/Share/View/TableviewCell/TimelineLoaderTableViewCell.swift @@ -10,22 +10,34 @@ import Combine class TimelineLoaderTableViewCell: UITableViewCell { - static let cellHeight: CGFloat = 44 + TimelineLoaderTableViewCell.extraTopPadding + TimelineLoaderTableViewCell.bottomPadding - static let extraTopPadding: CGFloat = 0 // the status cell already has 10pt bottom padding - static let bottomPadding: CGFloat = StatusTableViewCell.bottomPaddingHeight + TimelineLoaderTableViewCell.extraTopPadding // make balance + static let buttonHeight: CGFloat = 44 + static let buttonMargin: CGFloat = 12 + static let cellHeight: CGFloat = buttonHeight + 2 * buttonMargin + static let labelFont = UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 17, weight: .medium)) var disposeBag = Set() - + + let stackView = UIStackView() + let loadMoreButton: UIButton = { - let button = UIButton(type: .system) - button.titleLabel?.font = .preferredFont(forTextStyle: .headline) - button.setTitle(L10n.Common.Controls.Timeline.loadMore, for: .normal) + let button = HighlightDimmableButton() + button.titleLabel?.font = TimelineLoaderTableViewCell.labelFont + button.backgroundColor = Asset.Colors.Background.systemBackground.color + button.setTitleColor(Asset.Colors.Button.normal.color, for: .normal) + button.setTitle(L10n.Common.Controls.Timeline.Loader.loadMissingPosts, for: .normal) + button.setTitle("", for: .disabled) return button }() + let loadMoreLabel: UILabel = { + let label = UILabel() + label.font = TimelineLoaderTableViewCell.labelFont + return label + }() + let activityIndicatorView: UIActivityIndicatorView = { let activityIndicatorView = UIActivityIndicatorView(style: .medium) - activityIndicatorView.tintColor = .white + activityIndicatorView.tintColor = Asset.Colors.Label.secondary.color activityIndicatorView.hidesWhenStopped = true return activityIndicatorView }() @@ -45,28 +57,62 @@ class TimelineLoaderTableViewCell: UITableViewCell { _init() } + func startAnimating() { + activityIndicatorView.startAnimating() + self.loadMoreButton.isEnabled = false + self.loadMoreLabel.textColor = Asset.Colors.Label.secondary.color + self.loadMoreLabel.text = L10n.Common.Controls.Timeline.Loader.loadingMissingPosts + } + + func stopAnimating() { + activityIndicatorView.stopAnimating() + self.loadMoreButton.isEnabled = true + self.loadMoreLabel.textColor = Asset.Colors.brandBlue.color + self.loadMoreLabel.text = "" + } + func _init() { selectionStyle = .none - backgroundColor = Asset.Colors.Background.secondarySystemBackground.color + backgroundColor = .clear loadMoreButton.translatesAutoresizingMaskIntoConstraints = false contentView.addSubview(loadMoreButton) NSLayoutConstraint.activate([ - loadMoreButton.topAnchor.constraint(equalTo: contentView.topAnchor, constant: TimelineLoaderTableViewCell.extraTopPadding), - loadMoreButton.leadingAnchor.constraint(equalTo: contentView.readableContentGuide.leadingAnchor), - contentView.readableContentGuide.trailingAnchor.constraint(equalTo: loadMoreButton.trailingAnchor), - contentView.bottomAnchor.constraint(equalTo: loadMoreButton.bottomAnchor, constant: TimelineLoaderTableViewCell.bottomPadding), - loadMoreButton.heightAnchor.constraint(equalToConstant: 44).priority(.defaultHigh), + loadMoreButton.topAnchor.constraint(equalTo: contentView.topAnchor, constant: TimelineLoaderTableViewCell.buttonMargin), + loadMoreButton.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + contentView.trailingAnchor.constraint(equalTo: loadMoreButton.trailingAnchor), + contentView.bottomAnchor.constraint(equalTo: loadMoreButton.bottomAnchor, constant: TimelineLoaderTableViewCell.buttonMargin), + loadMoreButton.heightAnchor.constraint(equalToConstant: TimelineLoaderTableViewCell.buttonHeight).priority(.required - 1), ]) - activityIndicatorView.translatesAutoresizingMaskIntoConstraints = false - addSubview(activityIndicatorView) + // use stack view to alignlment content center + stackView.spacing = 4 + stackView.axis = .horizontal + stackView.alignment = .center + stackView.translatesAutoresizingMaskIntoConstraints = false + stackView.isUserInteractionEnabled = false + contentView.addSubview(stackView) NSLayoutConstraint.activate([ - activityIndicatorView.centerXAnchor.constraint(equalTo: loadMoreButton.centerXAnchor), - activityIndicatorView.centerYAnchor.constraint(equalTo: loadMoreButton.centerYAnchor), + stackView.topAnchor.constraint(equalTo: loadMoreButton.topAnchor), + stackView.leadingAnchor.constraint(equalTo: loadMoreButton.leadingAnchor), + stackView.trailingAnchor.constraint(equalTo: loadMoreButton.trailingAnchor), + stackView.bottomAnchor.constraint(equalTo: loadMoreButton.bottomAnchor), + ]) + let leftPaddingView = UIView() + leftPaddingView.translatesAutoresizingMaskIntoConstraints = false + stackView.addArrangedSubview(leftPaddingView) + stackView.addArrangedSubview(activityIndicatorView) + stackView.addArrangedSubview(loadMoreLabel) + let rightPaddingView = UIView() + rightPaddingView.translatesAutoresizingMaskIntoConstraints = false + stackView.addArrangedSubview(rightPaddingView) + NSLayoutConstraint.activate([ + leftPaddingView.widthAnchor.constraint(equalTo: rightPaddingView.widthAnchor, multiplier: 1.0), ]) + // default set hidden and let subclass override it loadMoreButton.isHidden = true + loadMoreLabel.isHidden = true activityIndicatorView.isHidden = true } diff --git a/Mastodon/Scene/Share/View/TableviewCell/TimelineMiddleLoaderTableViewCell.swift b/Mastodon/Scene/Share/View/TableviewCell/TimelineMiddleLoaderTableViewCell.swift index 16ab241f0..4a0b623ef 100644 --- a/Mastodon/Scene/Share/View/TableviewCell/TimelineMiddleLoaderTableViewCell.swift +++ b/Mastodon/Scene/Share/View/TableviewCell/TimelineMiddleLoaderTableViewCell.swift @@ -10,23 +10,45 @@ import CoreData import os.log import UIKit -protocol TimelineMiddleLoaderTableViewCellDelegate: class { - func configure(cell: TimelineMiddleLoaderTableViewCell, upperTimelineTootID: String?, timelineIndexobjectID:NSManagedObjectID?) +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? + let topSawToothView = SawToothView() + let bottomSawToothView = SawToothView() + override func _init() { super._init() - backgroundColor = .clear - loadMoreButton.isHidden = false - loadMoreButton.setImage(Asset.Arrows.arrowTriangle2Circlepath.image.withRenderingMode(.alwaysTemplate), for: .normal) + loadMoreLabel.isHidden = false + activityIndicatorView.isHidden = false + loadMoreButton.setInsets(forContentPadding: .zero, imageTitlePadding: 4) loadMoreButton.addTarget(self, action: #selector(TimelineMiddleLoaderTableViewCell.loadMoreButtonDidPressed(_:)), for: .touchUpInside) + + topSawToothView.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(topSawToothView) + NSLayoutConstraint.activate([ + topSawToothView.topAnchor.constraint(equalTo: contentView.topAnchor), + topSawToothView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + topSawToothView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + topSawToothView.heightAnchor.constraint(equalToConstant: 3), + ]) + topSawToothView.transform = CGAffineTransform(scaleX: 1, y: -1) // upside down + + bottomSawToothView.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(bottomSawToothView) + NSLayoutConstraint.activate([ + bottomSawToothView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + bottomSawToothView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + bottomSawToothView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), + bottomSawToothView.heightAnchor.constraint(equalToConstant: 3), + ]) } } diff --git a/Mastodon/Scene/Share/View/TableviewCell/TimelineTopLoaderTableViewCell.swift b/Mastodon/Scene/Share/View/TableviewCell/TimelineTopLoaderTableViewCell.swift new file mode 100644 index 000000000..4accee1de --- /dev/null +++ b/Mastodon/Scene/Share/View/TableviewCell/TimelineTopLoaderTableViewCell.swift @@ -0,0 +1,36 @@ +// +// TimelineTopLoaderTableViewCell.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-12. +// + +import UIKit +import Combine + +final class TimelineTopLoaderTableViewCell: TimelineLoaderTableViewCell { + override func _init() { + super._init() + + activityIndicatorView.isHidden = false + + startAnimating() + } +} + +#if canImport(SwiftUI) && DEBUG +import SwiftUI + +struct TimelineTopLoaderTableViewCell_Previews: PreviewProvider { + + static var previews: some View { + UIViewPreview(width: 375) { + TimelineTopLoaderTableViewCell() + } + .previewLayout(.fixed(width: 375, height: 100)) + } + +} + +#endif + diff --git a/Mastodon/Scene/Share/View/TextField/DeleteBackwardResponseTextField.swift b/Mastodon/Scene/Share/View/TextField/DeleteBackwardResponseTextField.swift new file mode 100644 index 000000000..08c085aa9 --- /dev/null +++ b/Mastodon/Scene/Share/View/TextField/DeleteBackwardResponseTextField.swift @@ -0,0 +1,24 @@ +// +// DeleteBackwardResponseTextField.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-23. +// + +import UIKit + +protocol DeleteBackwardResponseTextFieldDelegate: AnyObject { + func deleteBackwardResponseTextField(_ textField: DeleteBackwardResponseTextField, textBeforeDelete: String?) +} + +final class DeleteBackwardResponseTextField: UITextField { + + weak var deleteBackwardDelegate: DeleteBackwardResponseTextFieldDelegate? + + override func deleteBackward() { + let text = self.text + super.deleteBackward() + deleteBackwardDelegate?.deleteBackwardResponseTextField(self, textBeforeDelete: text) + } + +} diff --git a/Mastodon/Scene/Share/View/ToolBar/ActionToolBarContainer.swift b/Mastodon/Scene/Share/View/ToolBar/ActionToolBarContainer.swift index 02f60d518..f10e55941 100644 --- a/Mastodon/Scene/Share/View/ToolBar/ActionToolBarContainer.swift +++ b/Mastodon/Scene/Share/View/ToolBar/ActionToolBarContainer.swift @@ -8,23 +8,26 @@ import os.log import UIKit -protocol ActionToolbarContainerDelegate: class { +protocol ActionToolbarContainerDelegate: AnyObject { func actionToolbarContainer(_ actionToolbarContainer: ActionToolbarContainer, replayButtonDidPressed sender: UIButton) - func actionToolbarContainer(_ actionToolbarContainer: ActionToolbarContainer, retootButtonDidPressed sender: UIButton) + func actionToolbarContainer(_ actionToolbarContainer: ActionToolbarContainer, reblogButtonDidPressed sender: UIButton) func actionToolbarContainer(_ actionToolbarContainer: ActionToolbarContainer, starButtonDidPressed sender: UIButton) - func actionToolbarContainer(_ actionToolbarContainer: ActionToolbarContainer, moreButtonDidPressed sender: UIButton) } final class ActionToolbarContainer: UIView { let replyButton = HitTestExpandedButton() - let retootButton = HitTestExpandedButton() - let starButton = HitTestExpandedButton() + let reblogButton = HitTestExpandedButton() + let favoriteButton = HitTestExpandedButton() let moreButton = HitTestExpandedButton() - var isStarButtonHighlight: Bool = false { - didSet { isStarButtonHighlightStateDidChange(to: isStarButtonHighlight) } + var isReblogButtonHighlight: Bool = false { + didSet { isReblogButtonHighlightStateDidChange(to: isReblogButtonHighlight) } + } + + var isFavoriteButtonHighlight: Bool = false { + didSet { isFavoriteButtonHighlightStateDidChange(to: isFavoriteButtonHighlight) } } weak var delegate: ActionToolbarContainerDelegate? @@ -57,9 +60,8 @@ extension ActionToolbarContainer { ]) replyButton.addTarget(self, action: #selector(ActionToolbarContainer.replyButtonDidPressed(_:)), for: .touchUpInside) - retootButton.addTarget(self, action: #selector(ActionToolbarContainer.retootButtonDidPressed(_:)), for: .touchUpInside) - starButton.addTarget(self, action: #selector(ActionToolbarContainer.starButtonDidPressed(_:)), for: .touchUpInside) - moreButton.addTarget(self, action: #selector(ActionToolbarContainer.moreButtonDidPressed(_:)), for: .touchUpInside) + reblogButton.addTarget(self, action: #selector(ActionToolbarContainer.reblogButtonDidPressed(_:)), for: .touchUpInside) + favoriteButton.addTarget(self, action: #selector(ActionToolbarContainer.favoriteButtonDidPressed(_:)), for: .touchUpInside) } } @@ -89,7 +91,7 @@ extension ActionToolbarContainer { subview.removeFromSuperview() } - let buttons = [replyButton, retootButton, starButton, moreButton] + 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) @@ -103,34 +105,39 @@ extension ActionToolbarContainer { 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) - retootButton.setImage(reblogImage, for: .normal) - starButton.setImage(starImage, 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 - retootButton.translatesAutoresizingMaskIntoConstraints = false - starButton.translatesAutoresizingMaskIntoConstraints = false + reblogButton.translatesAutoresizingMaskIntoConstraints = false + favoriteButton.translatesAutoresizingMaskIntoConstraints = false moreButton.translatesAutoresizingMaskIntoConstraints = false container.addArrangedSubview(replyButton) - container.addArrangedSubview(retootButton) - container.addArrangedSubview(starButton) + container.addArrangedSubview(reblogButton) + container.addArrangedSubview(favoriteButton) container.addArrangedSubview(moreButton) NSLayoutConstraint.activate([ replyButton.heightAnchor.constraint(equalToConstant: 44).priority(.defaultHigh), - replyButton.heightAnchor.constraint(equalTo: retootButton.heightAnchor).priority(.defaultHigh), - replyButton.heightAnchor.constraint(equalTo: starButton.heightAnchor).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: retootButton.widthAnchor).priority(.defaultHigh), - replyButton.widthAnchor.constraint(equalTo: starButton.widthAnchor).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) @@ -140,16 +147,16 @@ extension ActionToolbarContainer { button.contentHorizontalAlignment = .center } replyButton.setImage(replyImage, for: .normal) - retootButton.setImage(reblogImage, for: .normal) - starButton.setImage(starImage, 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(retootButton) - container.addArrangedSubview(starButton) + container.addArrangedSubview(reblogButton) + container.addArrangedSubview(favoriteButton) } } @@ -158,11 +165,18 @@ extension ActionToolbarContainer { return oldStyle != style } - private func isStarButtonHighlightStateDidChange(to isHighlight: Bool) { + 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 - starButton.tintColor = tintColor - starButton.setTitleColor(tintColor, for: .normal) - starButton.setTitleColor(tintColor, for: .highlighted) + favoriteButton.tintColor = tintColor + favoriteButton.setTitleColor(tintColor, for: .normal) + favoriteButton.setTitleColor(tintColor, for: .highlighted) } } @@ -173,21 +187,24 @@ extension ActionToolbarContainer { delegate?.actionToolbarContainer(self, replayButtonDidPressed: sender) } - @objc private func retootButtonDidPressed(_ sender: UIButton) { + @objc private func reblogButtonDidPressed(_ sender: UIButton) { os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) - delegate?.actionToolbarContainer(self, retootButtonDidPressed: sender) + delegate?.actionToolbarContainer(self, reblogButtonDidPressed: sender) } - @objc private func starButtonDidPressed(_ sender: UIButton) { + @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) } - @objc private func moreButtonDidPressed(_ sender: UIButton) { - os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) - delegate?.actionToolbarContainer(self, moreButtonDidPressed: sender) +} + +extension ActionToolbarContainer { + + override var accessibilityElements: [Any]? { + get { [replyButton, reblogButton, favoriteButton, moreButton] } + set { } } - } #if DEBUG diff --git a/Mastodon/Scene/Share/ViewModel/AudioContainerViewModel.swift b/Mastodon/Scene/Share/ViewModel/AudioContainerViewModel.swift new file mode 100644 index 000000000..2bc6db226 --- /dev/null +++ b/Mastodon/Scene/Share/ViewModel/AudioContainerViewModel.swift @@ -0,0 +1,115 @@ +// +// AudioContainerViewModel.swift +// Mastodon +// +// Created by sxiaojian on 2021/3/9. +// + +import CoreDataStack +import Foundation +import UIKit + +class AudioContainerViewModel { + static func configure( + cell: StatusCell, + audioAttachment: Attachment, + audioService: AudioPlaybackService + ) { + guard let duration = audioAttachment.meta?.original?.duration else { return } + let audioView = cell.statusView.audioView + audioView.timeLabel.text = duration.asString(style: .positional) + + audioView.playButton.publisher(for: .touchUpInside) + .sink { [weak audioService] _ in + guard let audioService = audioService else { return } + if audioAttachment === audioService.attachment { + if audioService.isPlaying() { + audioService.pause() + } else { + audioService.resume() + } + if audioService.currentTimeSubject.value == 0 { + audioService.playAudio(audioAttachment: audioAttachment) + } + } else { + audioService.playAudio(audioAttachment: audioAttachment) + } + } + .store(in: &cell.disposeBag) + audioView.slider.publisher(for: .valueChanged) + .sink { [weak audioService] slider in + guard let audioService = audioService else { return } + let slider = slider as! UISlider + let time = Double(slider.value) * duration + 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.33, scheduler: DispatchQueue.main, latest: true) + .compactMap { [weak audioService] time -> (TimeInterval, Float)? 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 (time, Float(time / duration)) + } + .sink(receiveValue: { time, progress in + audioView.timeLabel.text = time.asString(style: .positional) + audioView.slider.setValue(progress, animated: true) + }) + .store(in: &cell.disposeBag) + audioService.playbackState + .receive(on: DispatchQueue.main) + .sink(receiveValue: { playbackState in + if audioAttachment === audioService.attachment { + configureAudioView(audioView: audioView, audioAttachment: audioAttachment, playbackState: playbackState) + } else { + configureAudioView(audioView: audioView, audioAttachment: audioAttachment, playbackState: .stopped) + } + }) + .store(in: &cell.disposeBag) + } + + static func configureAudioView( + audioView: AudioContainerView, + audioAttachment: Attachment, + playbackState: PlaybackState + ) { + switch playbackState { + case .stopped: + audioView.playButton.isSelected = false + audioView.slider.isEnabled = false + audioView.slider.setValue(0, animated: false) + case .paused: + audioView.playButton.isSelected = false + audioView.slider.isEnabled = true + case .playing, .readyToPlay: + audioView.playButton.isSelected = true + audioView.slider.isEnabled = 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 aa9d79c73..9563a19cd 100644 --- a/Mastodon/Scene/Share/ViewModel/MosaicImageViewModel.swift +++ b/Mastodon/Scene/Share/ViewModel/MosaicImageViewModel.swift @@ -6,6 +6,7 @@ // import UIKit +import Combine import CoreDataStack struct MosaicImageViewModel { @@ -16,14 +17,21 @@ struct MosaicImageViewModel { var metas: [MosaicMeta] = [] for element in mediaAttachments where element.type == .image { // Display original on the iPad/Mac - let urlString = UIDevice.current.userInterfaceIdiom == .phone ? element.previewURL : element.url + guard let previewURL = element.previewURL else { continue } + let urlString = UIDevice.current.userInterfaceIdiom == .phone ? previewURL : element.url guard let meta = element.meta, let width = meta.original?.width, let height = meta.original?.height, let url = URL(string: urlString) else { continue } - metas.append(MosaicMeta(url: url, size: CGSize(width: width, height: height))) + let mosaicMeta = MosaicMeta( + url: url, + size: CGSize(width: width, height: height), + blurhash: element.blurhash, + altText: element.descriptionString + ) + metas.append(mosaicMeta) } self.metas = metas } @@ -31,6 +39,46 @@ struct MosaicImageViewModel { } struct MosaicMeta { + static let edgeMaxLength: CGFloat = 20 + let url: URL let size: CGSize + let blurhash: String? + let altText: String? + + let workingQueue = DispatchQueue(label: "org.joinmastodon.Mastodon.MosaicMeta.working-queue", qos: .userInitiated, attributes: .concurrent) + + func blurhashImagePublisher() -> AnyPublisher { + return Future { promise in + workingQueue.async { + let image = self.blurhashImage() + promise(.success(image)) + } + } + .eraseToAnyPublisher() + } + + func blurhashImage() -> UIImage? { + guard let blurhash = blurhash else { + return nil + } + + 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/Scene/Share/ViewModel/VideoPlayerViewModel.swift b/Mastodon/Scene/Share/ViewModel/VideoPlayerViewModel.swift new file mode 100644 index 000000000..d34e73eba --- /dev/null +++ b/Mastodon/Scene/Share/ViewModel/VideoPlayerViewModel.swift @@ -0,0 +1,153 @@ +// +// VideoPlayerViewModel.swift +// Mastodon +// +// Created by xiaojian sun on 2021/3/10. +// + +import AVKit +import Combine +import CoreDataStack +import os.log +import UIKit + +final class VideoPlayerViewModel { + var disposeBag = Set() + + static let appWillPlayVideoNotification = NSNotification.Name(rawValue: "org.joinmastodon.Mastodon.video-playback-service.appWillPlayVideo") + // input + let previewImageURL: URL? + let videoURL: URL + let videoSize: CGSize + let videoKind: Kind + + var isTransitioning = false + var isFullScreenPresentationing = false + var isPlayingWhenEndDisplaying = false + + // prevent player state flick when tableView reload + private typealias Play = Bool + private let debouncePlayingState = PassthroughSubject() + + private var updateDate = Date() + + // output + let player: AVPlayer + private(set) var looper: AVPlayerLooper? // works with AVQueuePlayer (iOS 10+) + + private var timeControlStatusObservation: NSKeyValueObservation? + let timeControlStatus = CurrentValueSubject(.paused) + + init(previewImageURL: URL?, videoURL: URL, videoSize: CGSize, videoKind: VideoPlayerViewModel.Kind) { + self.previewImageURL = previewImageURL + self.videoURL = videoURL + self.videoSize = videoSize + self.videoKind = videoKind + + let playerItem = AVPlayerItem(url: videoURL) + let player = videoKind == .gif ? AVQueuePlayer(playerItem: playerItem) : AVPlayer(playerItem: playerItem) + player.isMuted = true + self.player = player + + if videoKind == .gif { + setupLooper() + } + + timeControlStatusObservation = player.observe(\.timeControlStatus, options: [.initial, .new]) { [weak self] player, _ in + guard let self = self else { return } + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: player state: %s", (#file as NSString).lastPathComponent, #line, #function, player.timeControlStatus.debugDescription) + self.timeControlStatus.value = player.timeControlStatus + } + + // update audio session category for user interactive event stream + timeControlStatus + .sink { [weak self] timeControlStatus in + guard let _ = self else { return } + guard timeControlStatus == .playing else { return } + NotificationCenter.default.post(name: VideoPlayerViewModel.appWillPlayVideoNotification, object: nil) + switch videoKind { + case .gif: + break + case .video: + try? AVAudioSession.sharedInstance().setCategory(.soloAmbient, mode: .default) + } + } + .store(in: &disposeBag) + + debouncePlayingState + .debounce(for: 0.3, scheduler: DispatchQueue.main) + .sink { [weak self] isPlay in + guard let self = self else { return } + isPlay ? self.play() : self.pause() + } + .store(in: &disposeBag) + } + + deinit { + timeControlStatusObservation = nil + } +} + +extension VideoPlayerViewModel { + enum Kind { + case gif + case video + } +} + +extension VideoPlayerViewModel { + func setupLooper() { + guard looper == nil, let queuePlayer = player as? AVQueuePlayer else { return } + guard let templateItem = queuePlayer.items().first else { return } + looper = AVPlayerLooper(player: queuePlayer, templateItem: templateItem) + } + + func play() { + switch videoKind { + case .gif: + break + case .video: + try? AVAudioSession.sharedInstance().setCategory(.soloAmbient, mode: .default) + } + + player.play() + updateDate = Date() + } + + func pause() { + player.pause() + updateDate = Date() + } + + func willDisplay() { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: url: %s", (#file as NSString).lastPathComponent, #line, #function, videoURL.debugDescription) + + switch videoKind { + case .gif: + play() // always auto play GIF + case .video: + guard isPlayingWhenEndDisplaying else { return } + // mute before resume + if updateDate.timeIntervalSinceNow < -3 { + player.isMuted = true + } + debouncePlayingState.send(true) + } + + updateDate = Date() + } + + func didEndDisplaying() { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: url: %s", (#file as NSString).lastPathComponent, #line, #function, videoURL.debugDescription) + + isPlayingWhenEndDisplaying = timeControlStatus.value != .paused + switch videoKind { + case .gif: + pause() // always pause GIF immediately + case .video: + debouncePlayingState.send(false) + } + + updateDate = Date() + } +} diff --git a/Mastodon/Scene/Share/Webview/WebViewController.swift b/Mastodon/Scene/Share/Webview/WebViewController.swift new file mode 100644 index 000000000..bde6e8936 --- /dev/null +++ b/Mastodon/Scene/Share/Webview/WebViewController.swift @@ -0,0 +1,67 @@ +// +// WebViewController.swift +// Mastodon +// +// Created by xiaojian sun on 2021/3/30. +// + +import Foundation +import Combine +import os.log +import UIKit +import WebKit + +final class WebViewController: UIViewController, NeedsDependency { + + weak var context: AppContext! { willSet { precondition(!isViewLoaded) } } + weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } } + + var disposeBag = Set() + var viewModel: WebViewModel! + + let webView: WKWebView = { + let configuration = WKWebViewConfiguration() + configuration.processPool = WKProcessPool() + let webView = WKWebView(frame: .zero, configuration: configuration) + return webView + }() + + deinit { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", (#file as NSString).lastPathComponent, #line, #function) + + // cleanup cookie + let httpCookieStore = webView.configuration.websiteDataStore.httpCookieStore + httpCookieStore.getAllCookies { cookies in + for cookie in cookies { + httpCookieStore.delete(cookie, completionHandler: nil) + } + } + } + +} + +extension WebViewController { + override func viewDidLoad() { + super.viewDidLoad() + + navigationItem.leftBarButtonItem = UIBarButtonItem(barButtonSystemItem: .cancel, target: self, action: #selector(WebViewController.cancelBarButtonItemPressed(_:))) + + webView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(webView) + NSLayoutConstraint.activate([ + webView.topAnchor.constraint(equalTo: view.topAnchor), + webView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + webView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + webView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + ]) + + let request = URLRequest(url: viewModel.url) + webView.load(request) + } +} + +extension WebViewController { + @objc private func cancelBarButtonItemPressed(_ sender: UIBarButtonItem) { + dismiss(animated: true, completion: nil) + } +} diff --git a/Mastodon/Scene/Share/Webview/WebViewModel.swift b/Mastodon/Scene/Share/Webview/WebViewModel.swift new file mode 100644 index 000000000..4e6483c98 --- /dev/null +++ b/Mastodon/Scene/Share/Webview/WebViewModel.swift @@ -0,0 +1,17 @@ +// +// WebViewModel.swift +// Mastodon +// +// Created by xiaojian sun on 2021/3/30. +// + +import Foundation + +final class WebViewModel { + public init(url: URL) { + self.url = url + } + + // input + let url: URL +} diff --git a/Mastodon/Scene/SuggestionAccount/CollectionViewCell/SuggestionAccountCollectionViewCell.swift b/Mastodon/Scene/SuggestionAccount/CollectionViewCell/SuggestionAccountCollectionViewCell.swift new file mode 100644 index 000000000..a973e1c52 --- /dev/null +++ b/Mastodon/Scene/SuggestionAccount/CollectionViewCell/SuggestionAccountCollectionViewCell.swift @@ -0,0 +1,61 @@ +// +// SuggestionAccountCollectionViewCell.swift +// Mastodon +// +// Created by sxiaojian on 2021/4/22. +// + +import CoreDataStack +import Foundation +import UIKit + +class SuggestionAccountCollectionViewCell: UICollectionViewCell { + let imageView: UIImageView = { + let imageView = UIImageView() + imageView.tintColor = Asset.Colors.Label.tertiary.color + imageView.layer.cornerRadius = 4 + imageView.clipsToBounds = true + imageView.image = UIImage.placeholder(color: .systemFill) + return imageView + }() + + func configAsPlaceHolder() { + imageView.tintColor = Asset.Colors.Label.tertiary.color + imageView.image = UIImage.placeholder(color: .systemFill) + } + + func config(with mastodonUser: MastodonUser) { + imageView.af.setImage( + withURL: URL(string: mastodonUser.avatar)!, + placeholderImage: UIImage.placeholder(color: .systemFill), + imageTransition: .crossDissolve(0.2) + ) + } + + override func prepareForReuse() { + super.prepareForReuse() + } + + override init(frame: CGRect) { + super.init(frame: .zero) + configure() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + configure() + } +} + +extension SuggestionAccountCollectionViewCell { + private func configure() { + contentView.addSubview(imageView) + imageView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + imageView.topAnchor.constraint(equalTo: contentView.topAnchor), + imageView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + imageView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + imageView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), + ]) + } +} diff --git a/Mastodon/Scene/SuggestionAccount/SuggestionAccountViewController.swift b/Mastodon/Scene/SuggestionAccount/SuggestionAccountViewController.swift new file mode 100644 index 000000000..80cd73cc1 --- /dev/null +++ b/Mastodon/Scene/SuggestionAccount/SuggestionAccountViewController.swift @@ -0,0 +1,213 @@ +// +// SuggestionAccountViewController.swift +// Mastodon +// +// Created by sxiaojian on 2021/4/21. +// + +import Combine +import CoreData +import CoreDataStack +import Foundation +import OSLog +import UIKit + +class SuggestionAccountViewController: UIViewController, NeedsDependency { + weak var context: AppContext! { willSet { precondition(!isViewLoaded) } } + weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } } + + var disposeBag = Set() + + var viewModel: SuggestionAccountViewModel! + + let tableView: UITableView = { + let tableView = ControlContainableTableView() + tableView.register(SuggestionAccountTableViewCell.self, forCellReuseIdentifier: String(describing: SuggestionAccountTableViewCell.self)) + tableView.rowHeight = UITableView.automaticDimension + tableView.tableFooterView = UIView() + tableView.separatorStyle = .singleLine + tableView.separatorInset = UIEdgeInsets(top: 0, left: 0, bottom: 0, right: 0) + return tableView + }() + + lazy var tableHeader: UIView = { + let view = UIView() + view.backgroundColor = Asset.Colors.Background.systemGroupedBackground.color + view.frame = CGRect(origin: .zero, size: CGSize(width: tableView.frame.width, height: 156)) + return view + }() + + let followExplainLabel: UILabel = { + let label = UILabel() + label.text = L10n.Scene.SuggestionAccount.followExplain + label.textColor = Asset.Colors.Label.primary.color + label.font = UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 17, weight: .regular)) + label.numberOfLines = 0 + return label + }() + + let selectedCollectionView: UICollectionView = { + let flowLayout = UICollectionViewFlowLayout() + flowLayout.scrollDirection = .horizontal + let view = ControlContainableCollectionView(frame: .zero, collectionViewLayout: flowLayout) + view.register(SuggestionAccountCollectionViewCell.self, forCellWithReuseIdentifier: String(describing: SuggestionAccountCollectionViewCell.self)) + view.backgroundColor = .clear + view.showsHorizontalScrollIndicator = false + view.showsVerticalScrollIndicator = false + view.layer.masksToBounds = false + return view + }() + + deinit { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s:", (#file as NSString).lastPathComponent, #line, #function) + } +} + +extension SuggestionAccountViewController { + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = Asset.Colors.Background.systemBackground.color + 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) { + super.viewWillAppear(animated) + tableView.deselectRow(with: transitionCoordinator, animated: animated) + viewModel.checkAccountsFollowState() + } + + override func viewWillLayoutSubviews() { + super.viewWillLayoutSubviews() + let avatarImageViewHeight: Double = 56 + let avatarImageViewCount = Int(floor((Double(view.frame.width) - 20) / (avatarImageViewHeight + 15))) + viewModel.headerPlaceholderCount.value = avatarImageViewCount + } + + func setupHeader(accounts: [NSManagedObjectID]) { + if accounts.isEmpty { + return + } + followExplainLabel.translatesAutoresizingMaskIntoConstraints = false + tableHeader.addSubview(followExplainLabel) + NSLayoutConstraint.activate([ + followExplainLabel.topAnchor.constraint(equalTo: tableHeader.topAnchor, constant: 20), + followExplainLabel.leadingAnchor.constraint(equalTo: tableHeader.leadingAnchor, constant: 20), + tableHeader.trailingAnchor.constraint(equalTo: followExplainLabel.trailingAnchor, constant: 20), + ]) + + selectedCollectionView.translatesAutoresizingMaskIntoConstraints = false + tableHeader.addSubview(selectedCollectionView) + NSLayoutConstraint.activate([ + selectedCollectionView.frameLayoutGuide.topAnchor.constraint(equalTo: followExplainLabel.topAnchor, constant: 20), + selectedCollectionView.frameLayoutGuide.leadingAnchor.constraint(equalTo: tableHeader.leadingAnchor, constant: 20), + selectedCollectionView.frameLayoutGuide.trailingAnchor.constraint(equalTo: tableHeader.trailingAnchor), + selectedCollectionView.frameLayoutGuide.bottomAnchor.constraint(equalTo: tableHeader.bottomAnchor), + ]) + selectedCollectionView.delegate = self + + tableView.tableHeaderView = tableHeader + } +} + +extension SuggestionAccountViewController: UICollectionViewDelegateFlowLayout { + func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, minimumInteritemSpacingForSectionAt section: Int) -> CGFloat { + 15 + } + + func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize { + CGSize(width: 56, height: 56) + } + + func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { + guard let diffableDataSource = viewModel.collectionDiffableDataSource else { return } + guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return } + switch item { + case .accountObjectID(let accountObjectID): + let mastodonUser = context.managedObjectContext.object(with: accountObjectID) as! MastodonUser + let viewModel = ProfileViewModel(context: context, optionalMastodonUser: mastodonUser) + DispatchQueue.main.async { + self.coordinator.present(scene: .profile(viewModel: viewModel), from: self, transition: .show) + } + default: + break + } + } +} + +extension SuggestionAccountViewController: UITableViewDelegate { + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + guard let diffableDataSource = viewModel.diffableDataSource else { return } + guard let objectID = diffableDataSource.itemIdentifier(for: indexPath) else { return } + let mastodonUser = context.managedObjectContext.object(with: objectID) as! MastodonUser + let viewModel = ProfileViewModel(context: context, optionalMastodonUser: mastodonUser) + DispatchQueue.main.async { + self.coordinator.present(scene: .profile(viewModel: viewModel), from: self, transition: .show) + } + } +} + +extension SuggestionAccountViewController: SuggestionAccountTableViewCellDelegate { + func accountButtonPressed(objectID: NSManagedObjectID, cell: SuggestionAccountTableViewCell) { + let selected = !viewModel.selectedAccounts.value.contains(objectID) + cell.startAnimating() + viewModel.followAction(objectID: objectID)? + .sink(receiveCompletion: { [weak self] completion in + guard let self = self else { return } + cell.stopAnimating() + switch completion { + case .failure(let error): + os_log("%{public}s[%{public}ld], %{public}s: follow failed. %s", (#file as NSString).lastPathComponent, #line, #function, error.localizedDescription) + case .finished: + var selectedAccounts = self.viewModel.selectedAccounts.value + if selected { + selectedAccounts.append(objectID) + } else { + selectedAccounts.removeAll { $0 == objectID } + } + cell.button.isSelected = selected + self.viewModel.selectedAccounts.value = selectedAccounts + } + }, receiveValue: { _ in + }) + .store(in: &disposeBag) + } +} + +extension SuggestionAccountViewController { + @objc func doneButtonDidClick(_ sender: UIButton) { + dismiss(animated: true, completion: nil) + if viewModel.selectedAccounts.value.count > 0 { + viewModel.delegate?.homeTimelineNeedRefresh.send() + } + } +} diff --git a/Mastodon/Scene/SuggestionAccount/SuggestionAccountViewModel.swift b/Mastodon/Scene/SuggestionAccount/SuggestionAccountViewModel.swift new file mode 100644 index 000000000..7a508fc75 --- /dev/null +++ b/Mastodon/Scene/SuggestionAccount/SuggestionAccountViewModel.swift @@ -0,0 +1,221 @@ +// +// SuggestionAccountViewModel.swift +// Mastodon +// +// Created by sxiaojian on 2021/4/21. +// + +import Combine +import CoreData +import CoreDataStack +import GameplayKit +import MastodonSDK +import os.log +import UIKit + +protocol SuggestionAccountViewModelDelegate: AnyObject { + var homeTimelineNeedRefresh: PassthroughSubject { get } +} + +final class SuggestionAccountViewModel: NSObject { + var disposeBag = Set() + + // input + let context: AppContext + + let currentMastodonUser = CurrentValueSubject(nil) + weak var delegate: SuggestionAccountViewModelDelegate? + // output + let accounts = CurrentValueSubject<[NSManagedObjectID], Never>([]) + var selectedAccounts = CurrentValueSubject<[NSManagedObjectID], Never>([]) + + var headerPlaceholderCount = CurrentValueSubject(nil) + var suggestionAccountsFallback = PassthroughSubject() + + var viewWillAppear = PassthroughSubject() + + var diffableDataSource: UITableViewDiffableDataSource? { + didSet(value) { + if !accounts.value.isEmpty { + applyTableViewDataSource(accounts: accounts.value) + } + } + } + + var collectionDiffableDataSource: UICollectionViewDiffableDataSource? + + init(context: AppContext, accounts: [NSManagedObjectID]? = nil) { + self.context = context + + super.init() + + Publishers.CombineLatest(self.accounts,self.selectedAccounts) + .sink { [weak self] accounts,selectedAccounts in + self?.applyTableViewDataSource(accounts: accounts) + self?.applySelectedCollectionViewDataSource(accounts: selectedAccounts) + } + .store(in: &disposeBag) + + Publishers.CombineLatest(self.selectedAccounts,self.headerPlaceholderCount) + .sink { [weak self] selectedAccount,count in + self?.applySelectedCollectionViewDataSource(accounts: selectedAccount) + } + .store(in: &disposeBag) + + viewWillAppear + .sink { [weak self] _ in + self?.checkAccountsFollowState() + } + .store(in: &disposeBag) + + if let accounts = accounts { + self.accounts.value = accounts + } + + context.authenticationService.activeMastodonAuthentication + .sink { [weak self] activeMastodonAuthentication in + guard let self = self else { return } + guard let activeMastodonAuthentication = activeMastodonAuthentication else { + self.currentMastodonUser.value = nil + return + } + self.currentMastodonUser.value = activeMastodonAuthentication.user + } + .store(in: &disposeBag) + + if accounts == nil || (accounts ?? []).isEmpty { + guard let activeMastodonAuthenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { return } + + context.apiService.suggestionAccountV2(domain: activeMastodonAuthenticationBox.domain, query: nil, mastodonAuthenticationBox: activeMastodonAuthenticationBox) + .sink { [weak self] completion in + switch completion { + case .failure(let error): + if let apiError = error as? Mastodon.API.Error { + if apiError.httpResponseStatus == .notFound { + self?.suggestionAccountsFallback.send() + } + } + os_log("%{public}s[%{public}ld], %{public}s: fetch recommendAccountV2 failed. %s", (#file as NSString).lastPathComponent, #line, #function, error.localizedDescription) + case .finished: + // handle isFetchingLatestTimeline in fetch controller delegate + break + } + } receiveValue: { [weak self] response in + let ids = response.value.map(\.account.id) + self?.receiveAccounts(ids: ids) + } + .store(in: &disposeBag) + + suggestionAccountsFallback + .sink(receiveValue: { [weak self] _ in + self?.requestSuggestionAccount() + }) + .store(in: &disposeBag) + } + } + + func requestSuggestionAccount() { + guard let activeMastodonAuthenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { return } + context.apiService.suggestionAccount(domain: activeMastodonAuthenticationBox.domain, query: nil, mastodonAuthenticationBox: activeMastodonAuthenticationBox) + .sink { completion in + switch completion { + case .failure(let error): + os_log("%{public}s[%{public}ld], %{public}s: fetch recommendAccount failed. %s", (#file as NSString).lastPathComponent, #line, #function, error.localizedDescription) + case .finished: + // handle isFetchingLatestTimeline in fetch controller delegate + break + } + } receiveValue: { [weak self] response in + let ids = response.value.map(\.id) + self?.receiveAccounts(ids: ids) + } + .store(in: &disposeBag) + } + + func applyTableViewDataSource(accounts: [NSManagedObjectID]) { + guard let dataSource = diffableDataSource else { return } + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([.main]) + snapshot.appendItems(accounts, toSection: .main) + dataSource.apply(snapshot, animatingDifferences: false, completion: nil) + } + + func applySelectedCollectionViewDataSource(accounts: [NSManagedObjectID]) { + guard let count = headerPlaceholderCount.value else { return } + guard let dataSource = collectionDiffableDataSource else { return } + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([.main]) + let placeholderCount = count - accounts.count + let accountItems = accounts.map { SelectedAccountItem.accountObjectID(accountObjectID: $0) } + snapshot.appendItems(accountItems, toSection: .main) + + if placeholderCount > 0 { + for _ in 0 ..< placeholderCount { + snapshot.appendItems([SelectedAccountItem.placeHolder(uuid: UUID())], toSection: .main) + } + } + dataSource.apply(snapshot, animatingDifferences: false, completion: nil) + } + + func receiveAccounts(ids: [String]) { + guard let activeMastodonAuthenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { + 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 + } + }() + if let users = mastodonUsers { + let sortedUsers = users.sorted { (user1, user2) -> Bool in + (ids.firstIndex(of: user1.id) ?? 0) < (ids.firstIndex(of: user2.id) ?? 0) + } + accounts.value = sortedUsers.map(\.objectID) + } + } + + 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 + ) + } + + func checkAccountsFollowState() { + guard let currentMastodonUser = currentMastodonUser.value else { + return + } + let users: [MastodonUser] = accounts.value.compactMap { + guard let user = context.managedObjectContext.object(with: $0) as? MastodonUser else { + return nil + } + let isBlock = user.blockingBy.flatMap { $0.contains(currentMastodonUser) } ?? false + let isDomainBlock = user.domainBlockingBy.flatMap { $0.contains(currentMastodonUser) } ?? false + if isBlock || isDomainBlock { + return nil + } else { + return user + } + } + accounts.value = users.map(\.objectID) + + let followingUsers = users.filter { user -> Bool in + let isFollowing = user.followingBy.flatMap { $0.contains(currentMastodonUser) } ?? false + let isPending = user.followRequestedBy.flatMap { $0.contains(currentMastodonUser) } ?? false + return isFollowing || isPending + }.map(\.objectID) + + selectedAccounts.value = followingUsers + } +} diff --git a/Mastodon/Scene/SuggestionAccount/TableViewCell/SuggestionAccountTableViewCell.swift b/Mastodon/Scene/SuggestionAccount/TableViewCell/SuggestionAccountTableViewCell.swift new file mode 100644 index 000000000..db56d63ca --- /dev/null +++ b/Mastodon/Scene/SuggestionAccount/TableViewCell/SuggestionAccountTableViewCell.swift @@ -0,0 +1,191 @@ +// +// SuggestionAccountTableViewCell.swift +// Mastodon +// +// Created by sxiaojian on 2021/4/21. +// + +import Combine +import CoreData +import CoreDataStack +import Foundation +import MastodonSDK +import UIKit + +protocol SuggestionAccountTableViewCellDelegate: AnyObject { + func accountButtonPressed(objectID: NSManagedObjectID, cell: SuggestionAccountTableViewCell) +} + +final class SuggestionAccountTableViewCell: UITableViewCell { + var disposeBag = Set() + weak var delegate: SuggestionAccountTableViewCellDelegate? + + let _imageView: UIImageView = { + let imageView = UIImageView() + imageView.tintColor = Asset.Colors.Label.primary.color + imageView.layer.cornerRadius = 4 + imageView.clipsToBounds = true + return imageView + }() + + let titleLabel: UILabel = { + let label = UILabel() + label.textColor = Asset.Colors.brandBlue.color + label.font = .systemFont(ofSize: 17, weight: .semibold) + label.lineBreakMode = .byTruncatingTail + return label + }() + + let subTitleLabel: UILabel = { + let label = UILabel() + label.textColor = Asset.Colors.Label.secondary.color + label.font = .preferredFont(forTextStyle: .body) + return label + }() + + let buttonContainer: UIView = { + let view = UIView() + view.backgroundColor = .clear + return view + }() + + let button: HighlightDimmableButton = { + let button = HighlightDimmableButton(type: .custom) + if let plusImage = UIImage(systemName: "plus.circle", withConfiguration: UIImage.SymbolConfiguration(pointSize: 22, weight: .regular))?.withRenderingMode(.alwaysTemplate) { + button.setImage(plusImage, for: .normal) + } + if let minusImage = UIImage(systemName: "minus.circle.fill", withConfiguration: UIImage.SymbolConfiguration(pointSize: 22, weight: .regular))?.withRenderingMode(.alwaysTemplate) { + button.setImage(minusImage, for: .selected) + } + return button + }() + + let activityIndicatorView: UIActivityIndicatorView = { + let activityIndicatorView = UIActivityIndicatorView(style: .medium) + activityIndicatorView.hidesWhenStopped = true + return activityIndicatorView + }() + + override func prepareForReuse() { + super.prepareForReuse() + _imageView.af.cancelImageRequest() + _imageView.image = nil + 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 SuggestionAccountTableViewCell { + private func configure() { + backgroundColor = .clear + + let containerStackView = UIStackView() + containerStackView.axis = .horizontal + containerStackView.distribution = .fill + containerStackView.spacing = 12 + containerStackView.layoutMargins = UIEdgeInsets(top: 12, left: 21, bottom: 12, right: 12) + 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.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), + ]) + + _imageView.translatesAutoresizingMaskIntoConstraints = false + containerStackView.addArrangedSubview(_imageView) + NSLayoutConstraint.activate([ + _imageView.widthAnchor.constraint(equalToConstant: 42).priority(.required - 1), + _imageView.heightAnchor.constraint(equalToConstant: 42).priority(.required - 1), + ]) + + let textStackView = UIStackView() + textStackView.axis = .vertical + textStackView.distribution = .fill + textStackView.alignment = .leading + textStackView.translatesAutoresizingMaskIntoConstraints = false + titleLabel.translatesAutoresizingMaskIntoConstraints = false + textStackView.addArrangedSubview(titleLabel) + subTitleLabel.translatesAutoresizingMaskIntoConstraints = false + textStackView.addArrangedSubview(subTitleLabel) + subTitleLabel.setContentHuggingPriority(.defaultLow - 1, for: .vertical) + + containerStackView.addArrangedSubview(textStackView) + textStackView.setContentHuggingPriority(.defaultLow - 1, for: .horizontal) + + buttonContainer.translatesAutoresizingMaskIntoConstraints = false + containerStackView.addArrangedSubview(buttonContainer) + NSLayoutConstraint.activate([ + buttonContainer.widthAnchor.constraint(equalToConstant: 24).priority(.required - 1), + buttonContainer.heightAnchor.constraint(equalToConstant: 42).priority(.required - 1), + ]) + buttonContainer.setContentHuggingPriority(.required - 1, for: .horizontal) + + activityIndicatorView.translatesAutoresizingMaskIntoConstraints = false + button.translatesAutoresizingMaskIntoConstraints = false + activityIndicatorView.translatesAutoresizingMaskIntoConstraints = false + buttonContainer.addSubview(button) + buttonContainer.addSubview(activityIndicatorView) + NSLayoutConstraint.activate([ + buttonContainer.centerXAnchor.constraint(equalTo: activityIndicatorView.centerXAnchor), + buttonContainer.centerYAnchor.constraint(equalTo: activityIndicatorView.centerYAnchor), + buttonContainer.centerXAnchor.constraint(equalTo: button.centerXAnchor), + buttonContainer.centerYAnchor.constraint(equalTo: button.centerYAnchor), + ]) + } + + func config(with account: MastodonUser, isSelected: Bool) { + if let url = account.avatarImageURL() { + _imageView.af.setImage( + withURL: url, + placeholderImage: UIImage.placeholder(color: .systemFill), + imageTransition: .crossDissolve(0.2) + ) + } + titleLabel.text = account.displayName.isEmpty ? account.username : account.displayName + subTitleLabel.text = account.acct + button.isSelected = isSelected + button.publisher(for: .touchUpInside) + .sink { [weak self] _ in + guard let self = self else { return } + self.delegate?.accountButtonPressed(objectID: account.objectID, cell: self) + } + .store(in: &disposeBag) + button.publisher(for: \.isSelected) + .sink { [weak self] isSelected in + if isSelected { + self?.button.tintColor = Asset.Colors.danger.color + } else { + self?.button.tintColor = Asset.Colors.Label.secondary.color + } + } + .store(in: &disposeBag) + activityIndicatorView.publisher(for: \.isHidden) + .receive(on: DispatchQueue.main) + .sink { [weak self] isHidden in + self?.button.isHidden = !isHidden + } + .store(in: &disposeBag) + } + + func startAnimating() { + activityIndicatorView.isHidden = false + activityIndicatorView.startAnimating() + } + + func stopAnimating() { + activityIndicatorView.stopAnimating() + activityIndicatorView.isHidden = true + } +} diff --git a/Mastodon/Scene/Thread/CachedThreadViewModel.swift b/Mastodon/Scene/Thread/CachedThreadViewModel.swift new file mode 100644 index 000000000..d4866b0bd --- /dev/null +++ b/Mastodon/Scene/Thread/CachedThreadViewModel.swift @@ -0,0 +1,15 @@ +// +// CachedThreadViewModel.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-12. +// + +import Foundation +import CoreDataStack + +final class CachedThreadViewModel: ThreadViewModel { + init(context: AppContext, status: Status) { + super.init(context: context, optionalStatus: status) + } +} diff --git a/Mastodon/Scene/Thread/RemoteThreadViewModel.swift b/Mastodon/Scene/Thread/RemoteThreadViewModel.swift new file mode 100644 index 000000000..e6e111018 --- /dev/null +++ b/Mastodon/Scene/Thread/RemoteThreadViewModel.swift @@ -0,0 +1,89 @@ +// +// RemoteThreadViewModel.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-12. +// + +import os.log +import UIKit +import CoreDataStack +import MastodonSDK + +final class RemoteThreadViewModel: ThreadViewModel { + + init(context: AppContext, statusID: Mastodon.Entity.Status.ID) { + super.init(context: context, optionalStatus: nil) + + guard let activeMastodonAuthenticationBox = 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 } + let managedObjectContext = context.managedObjectContext + let request = Status.sortedFetchRequest + request.fetchLimit = 1 + request.predicate = Status.predicate(domain: domain, id: response.value.id) + guard let status = managedObjectContext.safeFetch(request).first else { + assertionFailure() + return + } + self.rootItem.value = .root(statusObjectID: status.objectID, attribute: Item.StatusAttribute()) + } + .store(in: &disposeBag) + } + + // FIXME: multiple account supports + init(context: AppContext, notificationID: Mastodon.Entity.Notification.ID) { + super.init(context: context, optionalStatus: nil) + + guard let activeMastodonAuthenticationBox = 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 } + guard let statusID = response.value.status?.id else { return } + + let managedObjectContext = context.managedObjectContext + let request = Status.sortedFetchRequest + request.fetchLimit = 1 + request.predicate = Status.predicate(domain: domain, id: statusID) + guard let status = managedObjectContext.safeFetch(request).first else { + assertionFailure() + return + } + self.rootItem.value = .root(statusObjectID: status.objectID, attribute: Item.StatusAttribute()) + } + .store(in: &disposeBag) + } + +} diff --git a/Mastodon/Scene/Thread/ThreadViewController+Provider.swift b/Mastodon/Scene/Thread/ThreadViewController+Provider.swift new file mode 100644 index 000000000..a76a22d0b --- /dev/null +++ b/Mastodon/Scene/Thread/ThreadViewController+Provider.swift @@ -0,0 +1,90 @@ +// +// 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 + } + +} + +extension ThreadViewController: UserProvider {} diff --git a/Mastodon/Scene/Thread/ThreadViewController.swift b/Mastodon/Scene/Thread/ThreadViewController.swift new file mode 100644 index 000000000..6c801ae4f --- /dev/null +++ b/Mastodon/Scene/Thread/ThreadViewController.swift @@ -0,0 +1,229 @@ +// +// ThreadViewController.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-12. +// + +import os.log +import UIKit +import Combine +import CoreData +import AVKit + +final class ThreadViewController: UIViewController, NeedsDependency, MediaPreviewableViewController { + + weak var context: AppContext! { willSet { precondition(!isViewLoaded) } } + weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } } + + var disposeBag = Set() + var viewModel: ThreadViewModel! + + let mediaPreviewTransitionController = MediaPreviewTransitionController() + + let titleView = DoubleTitleLabelNavigationBarTitleView() + + let replyBarButtonItem = AdaptiveUserInterfaceStyleBarButtonItem( + lightImage: UIImage(systemName: "arrowshape.turn.up.left")!, + darkImage: UIImage(systemName: "arrowshape.turn.up.left.fill")! + ) + + let tableView: UITableView = { + let tableView = ControlContainableTableView() + 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.register(ThreadReplyLoaderTableViewCell.self, forCellReuseIdentifier: String(describing: ThreadReplyLoaderTableViewCell.self)) + tableView.rowHeight = UITableView.automaticDimension + tableView.separatorStyle = .none + tableView.backgroundColor = .clear + + return tableView + }() + + deinit { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s:", ((#file as NSString).lastPathComponent), #line, #function) + } + +} + +extension ThreadViewController { + + override func viewDidLoad() { + super.viewDidLoad() + + view.backgroundColor = Asset.Colors.Background.secondarySystemBackground.color + 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 + ) + + 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.navigationBarTitle + .receive(on: DispatchQueue.main) + .sink { [weak self] title in + guard let self = self else { return } + self.titleView.update(title: title ?? L10n.Scene.Thread.backTitle, subtitle: nil) + } + .store(in: &disposeBag) + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + + aspectViewWillAppear(animated) + } + + override func viewDidDisappear(_ animated: Bool) { + super.viewDidDisappear(animated) + + aspectViewDidDisappear(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)) + } +} + +// MARK: - StatusTableViewControllerAspect +extension ThreadViewController: StatusTableViewControllerAspect { } + +// MARK: - TableViewCellHeightCacheableContainer +extension ThreadViewController: TableViewCellHeightCacheableContainer { + var cellFrameCache: NSCache { viewModel.cellFrameCache } +} + +// MARK: - UITableViewDelegate +extension ThreadViewController: UITableViewDelegate { + + func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat { + aspectTableView(tableView, estimatedHeightForRowAt: indexPath) + } + + func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) { + aspectTableView(tableView, willDisplay: cell, forRowAt: indexPath) + } + + func tableView(_ tableView: UITableView, didEndDisplaying cell: UITableViewCell, forRowAt indexPath: IndexPath) { + aspectTableView(tableView, didEndDisplaying: cell, forRowAt: indexPath) + } + + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + aspectTableView(tableView, didSelectRowAt: indexPath) + } + + func tableView(_ tableView: UITableView, 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 + } +} + +// MARK: - AVPlayerViewControllerDelegate +extension ThreadViewController: AVPlayerViewControllerDelegate { + + func playerViewController(_ playerViewController: AVPlayerViewController, willBeginFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator) { + aspectPlayerViewController(playerViewController, willBeginFullScreenPresentationWithAnimationCoordinator: coordinator) + } + + func playerViewController(_ playerViewController: AVPlayerViewController, willEndFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator) { + aspectPlayerViewController(playerViewController, willEndFullScreenPresentationWithAnimationCoordinator: coordinator) + } + +} + +// MARK: - statusTableViewCellDelegate +extension ThreadViewController: StatusTableViewCellDelegate { + weak var playerViewControllerDelegate: AVPlayerViewControllerDelegate? { return self } + func parent() -> UIViewController { return self } +} + +// MARK: - ThreadReplyLoaderTableViewCellDelegate +extension ThreadViewController: ThreadReplyLoaderTableViewCellDelegate { + func threadReplyLoaderTableViewCell(_ cell: ThreadReplyLoaderTableViewCell, loadMoreButtonDidPressed button: UIButton) { + guard let diffableDataSource = viewModel.diffableDataSource else { return } + guard let indexPath = tableView.indexPath(for: cell) else { return } + guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return } + guard case let .leafBottomLoader(statusObjectID) = item else { return } + + 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) + } + } + } +} diff --git a/Mastodon/Scene/Thread/ThreadViewModel+Diffable.swift b/Mastodon/Scene/Thread/ThreadViewModel+Diffable.swift new file mode 100644 index 000000000..323a7a545 --- /dev/null +++ b/Mastodon/Scene/Thread/ThreadViewModel+Diffable.swift @@ -0,0 +1,186 @@ +// +// ThreadViewModel+Diffable.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-12. +// + +import UIKit +import Combine +import CoreData + +extension ThreadViewModel { + + func setupDiffableDataSource( + for tableView: UITableView, + dependency: NeedsDependency, + statusTableViewCellDelegate: StatusTableViewCellDelegate, + threadReplyLoaderTableViewCellDelegate: ThreadReplyLoaderTableViewCellDelegate + ) { + let timestampUpdatePublisher = Timer.publish(every: 1.0, on: .main, in: .common) + .autoconnect() + .share() + .eraseToAnyPublisher() + + diffableDataSource = StatusSection.tableViewDiffableDataSource( + for: tableView, + dependency: dependency, + managedObjectContext: context.managedObjectContext, + timestampUpdatePublisher: timestampUpdatePublisher, + statusTableViewCellDelegate: statusTableViewCellDelegate, + timelineMiddleLoaderTableViewCellDelegate: nil, + threadReplyLoaderTableViewCellDelegate: threadReplyLoaderTableViewCellDelegate + ) + + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([.main]) + if let rootNode = self.rootNode.value, rootNode.replyToID != nil { + snapshot.appendItems([.topLoader], toSection: .main) + } + + diffableDataSource?.apply(snapshot, animatingDifferences: false, completion: nil) + + Publishers.CombineLatest3( + rootItem, + ancestorItems, + descendantItems + ) + .debounce(for: .milliseconds(100), scheduler: DispatchQueue.main) // some magic to avoid jitter + .receive(on: DispatchQueue.main) + .sink { [weak self] rootItem, ancestorItems, descendantItems 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) + } + newSnapshot.appendItems(ancestorItems, toSection: .main) + + // root + if let rootItem = rootItem { + switch rootItem { + case .root: + newSnapshot.appendItems([rootItem], toSection: .main) + default: + break + } + } + + // leaf + if !(currentState is LoadThreadState.NoMore) { + newSnapshot.appendItems([.bottomLoader], toSection: .main) + } + newSnapshot.appendItems(descendantItems, toSection: .main) + + // difference for first visiable item exclude .topLoader + guard let difference = self.calculateReloadSnapshotDifference(navigationBar: navigationBar, tableView: tableView, oldSnapshot: oldSnapshot, newSnapshot: newSnapshot) else { + diffableDataSource.apply(newSnapshot) + return + } + + // addtional 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.apply(newSnapshot, animatingDifferences: false) { + 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 + } + + 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 } + + // find index of the first visible item exclude .topLoader + 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 } + + _index = i + break + } + + 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) + return Difference( + item: item, + sourceIndexPath: sourceIndexPath, + targetIndexPath: targetIndexPath, + offset: offset + ) + } +} diff --git a/Mastodon/Scene/Thread/ThreadViewModel+LoadThreadState.swift b/Mastodon/Scene/Thread/ThreadViewModel+LoadThreadState.swift new file mode 100644 index 000000000..5327edc5c --- /dev/null +++ b/Mastodon/Scene/Thread/ThreadViewModel+LoadThreadState.swift @@ -0,0 +1,127 @@ +// +// ThreadViewModel+LoadThreadState.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-12. +// + +import os.log +import Foundation +import Combine +import GameplayKit +import CoreDataStack +import MastodonSDK + +extension ThreadViewModel { + class LoadThreadState: GKState { + weak var viewModel: ThreadViewModel? + + init(viewModel: ThreadViewModel) { + 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 ThreadViewModel.LoadThreadState { + class Initial: ThreadViewModel.LoadThreadState { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + switch stateClass { + case is Loading.Type: return true + default: return false + } + } + } + + class Loading: ThreadViewModel.LoadThreadState { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + switch stateClass { + case is Fail.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 + ) + } + .store(in: &viewModel.disposeBag) + } + + } + + class Fail: ThreadViewModel.LoadThreadState { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + switch stateClass { + case is Loading.Type: return true + default: return false + } + } + + override func didEnter(from previousState: GKState?) { + super.didEnter(from: previousState) + + guard let viewModel = viewModel, let stateMachine = stateMachine else { return } + DispatchQueue.main.asyncAfter(deadline: .now() + 3) { + stateMachine.enter(Loading.self) + } + } + } + + class NoMore: ThreadViewModel.LoadThreadState { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + return false + } + } + +} diff --git a/Mastodon/Scene/Thread/ThreadViewModel.swift b/Mastodon/Scene/Thread/ThreadViewModel.swift new file mode 100644 index 000000000..50df678c6 --- /dev/null +++ b/Mastodon/Scene/Thread/ThreadViewModel.swift @@ -0,0 +1,279 @@ +// +// ThreadViewModel.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-12. +// + +import os.log +import UIKit +import Combine +import CoreData +import CoreDataStack +import GameplayKit +import MastodonSDK + +class ThreadViewModel { + + var disposeBag = Set() + + // input + let context: AppContext + let rootNode: CurrentValueSubject + let rootItem: CurrentValueSubject + let cellFrameCache = NSCache() + + weak var contentOffsetAdjustableTimelineViewControllerDelegate: ContentOffsetAdjustableTimelineViewControllerDelegate? + weak var tableView: UITableView? + + // output + var diffableDataSource: UITableViewDiffableDataSource? + private(set) lazy var loadThreadStateMachine: GKStateMachine = { + let stateMachine = GKStateMachine(states: [ + LoadThreadState.Initial(viewModel: self), + LoadThreadState.Loading(viewModel: self), + LoadThreadState.Fail(viewModel: self), + LoadThreadState.NoMore(viewModel: self), + + ]) + 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 + + init(context: AppContext, optionalStatus: Status?) { + 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.navigationBarTitle = CurrentValueSubject( + optionalStatus.flatMap { L10n.Scene.Thread.title($0.author.displayNameWithFallback) } + ) + + 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) + + 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) + } + } + .store(in: &disposeBag) + } + + // descendantNodes + + 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 { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + } + +} + +extension ThreadViewModel { + + struct RootNode { + 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.reversed() + } + } + + 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 + ) + } + } + +} + diff --git a/Mastodon/Scene/Transition/MediaPreview/MediaHostToMediaPreviewViewControllerAnimatedTransitioning.swift b/Mastodon/Scene/Transition/MediaPreview/MediaHostToMediaPreviewViewControllerAnimatedTransitioning.swift new file mode 100644 index 000000000..eb4a43c1c --- /dev/null +++ b/Mastodon/Scene/Transition/MediaPreview/MediaHostToMediaPreviewViewControllerAnimatedTransitioning.swift @@ -0,0 +1,522 @@ +// +// MediaHostToMediaPreviewViewControllerAnimatedTransitioning.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-28. +// + +import os.log +import UIKit +import func AVFoundation.AVMakeRect + +final class MediaHostToMediaPreviewViewControllerAnimatedTransitioning: ViewControllerAnimatedTransitioning { + + let transitionItem: MediaPreviewTransitionItem + let panGestureRecognizer: UIPanGestureRecognizer + + private var isTransitionContextFinish = false + + private var popInteractiveTransitionAnimator = MediaHostToMediaPreviewViewControllerAnimatedTransitioning.animator(initialVelocity: .zero) + private var itemInteractiveTransitionAnimator = MediaHostToMediaPreviewViewControllerAnimatedTransitioning.animator(initialVelocity: .zero) + + init(operation: UINavigationController.Operation, transitionItem: MediaPreviewTransitionItem, panGestureRecognizer: UIPanGestureRecognizer) { + self.transitionItem = transitionItem + self.panGestureRecognizer = panGestureRecognizer + super.init(operation: operation) + } + + class func animator(initialVelocity: CGVector = .zero) -> UIViewPropertyAnimator { + let timingParameters = UISpringTimingParameters(mass: 4.0, stiffness: 1300, damping: 180, initialVelocity: initialVelocity) + return UIViewPropertyAnimator(duration: 0.5, timingParameters: timingParameters) + } + +} + +// MARK: - UIViewControllerAnimatedTransitioning +extension MediaHostToMediaPreviewViewControllerAnimatedTransitioning { + + override func animateTransition(using transitionContext: UIViewControllerContextTransitioning) { + super.animateTransition(using: transitionContext) + + switch operation { + case .push: pushTransition(using: transitionContext).startAnimation() + case .pop: popTransition(using: transitionContext).startAnimation() + default: return + } + } + + private func pushTransition(using transitionContext: UIViewControllerContextTransitioning, curve: UIView.AnimationCurve = .easeInOut) -> UIViewPropertyAnimator { + guard let toVC = transitionContext.viewController(forKey: .to) as? MediaPreviewViewController, + let toView = transitionContext.view(forKey: .to) else { + fatalError() + } + + let toViewEndFrame = transitionContext.finalFrame(for: toVC) + toView.frame = toViewEndFrame + toView.alpha = 0 + transitionContext.containerView.addSubview(toView) + // set to image hidden + toVC.pagingViewConttroller.view.alpha = 0 + // set from image hidden. update hidden when paging. seealso: `MediaPreviewViewController` + transitionItem.source.updateAppearance(position: .start, index: toVC.viewModel.currentPage.value) + + // Set transition image view + assert(transitionItem.initialFrame != nil) + let initialFrame = transitionItem.initialFrame ?? toViewEndFrame + let transitionTargetFrame: CGRect = { + let aspectRatio = transitionItem.aspectRatio ?? CGSize(width: initialFrame.width, height: initialFrame.height) + return AVMakeRect(aspectRatio: aspectRatio, insideRect: toView.bounds) + }() + let transitionImageView: UIImageView = { + let imageView = UIImageView(frame: transitionContext.containerView.convert(initialFrame, from: nil)) + imageView.clipsToBounds = true + imageView.contentMode = .scaleAspectFill + imageView.isUserInteractionEnabled = false + imageView.image = transitionItem.image + // accessibility + imageView.accessibilityIgnoresInvertColors = true + return imageView + }() + transitionItem.targetFrame = transitionTargetFrame + transitionItem.imageView = transitionImageView + transitionContext.containerView.addSubview(transitionImageView) + + toVC.closeButtonBackground.alpha = 0 + + if UIAccessibility.isReduceTransparencyEnabled { + toVC.visualEffectView.alpha = 0 + } + + let animator = MediaHostToMediaPreviewViewControllerAnimatedTransitioning.animator(initialVelocity: .zero) + + animator.addAnimations { + transitionImageView.frame = transitionTargetFrame + toView.alpha = 1 + if UIAccessibility.isReduceTransparencyEnabled { + toVC.visualEffectView.alpha = 1 + } + } + + animator.addCompletion { position in + toVC.pagingViewConttroller.view.alpha = 1 + transitionImageView.removeFromSuperview() + UIView.animate(withDuration: 0.33, delay: 0, options: [.curveEaseInOut]) { + toVC.closeButtonBackground.alpha = 1 + } + transitionContext.completeTransition(position == .end) + } + + return animator + } + + private func popTransition(using transitionContext: UIViewControllerContextTransitioning, curve: UIView.AnimationCurve = .easeInOut) -> UIViewPropertyAnimator { + guard let fromVC = transitionContext.viewController(forKey: .from) as? MediaPreviewViewController, + let fromView = transitionContext.view(forKey: .from), + let mediaPreviewImageViewController = fromVC.pagingViewConttroller.currentViewController as? MediaPreviewImageViewController, + let index = fromVC.pagingViewConttroller.currentIndex else { + fatalError() + } + + // assert view hierarchy not change + let toVC = transitionItem.previewableViewController + let targetFrame = toVC.sourceFrame(transitionItem: transitionItem, index: index) + + let imageView = mediaPreviewImageViewController.previewImageView.imageView + let _snapshot: UIView? = { + transitionItem.snapshotRaw = imageView + let snapshot = imageView.snapshotView(afterScreenUpdates: false) + snapshot?.clipsToBounds = true + snapshot?.contentMode = .scaleAspectFill + return snapshot + }() + guard let snapshot = _snapshot else { + transitionContext.completeTransition(false) + fatalError() + } + + let transitionMaskView = UIView(frame: transitionContext.containerView.bounds) + transitionMaskView.autoresizingMask = [.flexibleWidth, .flexibleHeight] + transitionContext.containerView.addSubview(transitionMaskView) + + let maskLayer = CAShapeLayer() + maskLayer.frame = transitionMaskView.bounds + let maskLayerFromPath = UIBezierPath(rect: maskLayer.bounds).cgPath + maskLayer.path = maskLayerFromPath + transitionMaskView.layer.mask = maskLayer + + transitionMaskView.addSubview(snapshot) + snapshot.center = transitionMaskView.center + fromVC.view.bringSubviewToFront(fromVC.closeButtonBackground) + + transitionItem.imageView = imageView + transitionItem.snapshotTransitioning = snapshot + transitionItem.initialFrame = snapshot.frame + transitionItem.targetFrame = targetFrame + + // disable interaction + fromVC.pagingViewConttroller.isUserInteractionEnabled = false + + let animator = popInteractiveTransitionAnimator + + self.transitionItem.snapshotRaw?.alpha = 0.0 + + var needsMaskWithAnimation = true + let maskLayerToRect: CGRect? = { + guard case .mosaic = transitionItem.source else { return nil } + guard let navigationBar = toVC.navigationController?.navigationBar else { return nil } + let navigationBarFrameInWindow = toVC.view.convert(navigationBar.frame, to: nil) + var rect = transitionMaskView.frame + rect.origin.y = navigationBarFrameInWindow.maxY + UIView.separatorLineHeight(of: toVC.view) // extra hairline + + if rect.minY < snapshot.frame.minY { + needsMaskWithAnimation = false + } + + return rect + }() + let maskLayerToPath = maskLayerToRect.flatMap { UIBezierPath(rect: $0) }?.cgPath + let maskLayerToFinalRect: CGRect? = { + guard case .mosaic = transitionItem.source else { return nil } + guard let tabBarController = toVC.tabBarController else { return nil } + let tabBarFrameInWindow = toVC.view.convert(tabBarController.tabBar.frame, to: nil) + var rect = maskLayerToRect ?? transitionMaskView.frame + let offset = rect.maxY - tabBarFrameInWindow.minY + guard offset > 0 else { return rect } + rect.size.height -= offset + return rect + }() + let maskLayerToFinalPath = maskLayerToFinalRect.flatMap { UIBezierPath(rect: $0) }?.cgPath + + if !needsMaskWithAnimation, let maskLayerToPath = maskLayerToPath { + maskLayer.path = maskLayerToPath + } + + animator.addAnimations { + if let targetFrame = targetFrame { + self.transitionItem.snapshotTransitioning?.frame = targetFrame + } else { + fromView.alpha = 0 + } + self.transitionItem.sourceImageViewCornerRadius.flatMap { self.transitionItem.snapshotTransitioning?.layer.cornerRadius = $0 } + fromVC.closeButtonBackground.alpha = 0 + fromVC.visualEffectView.effect = nil + if let maskLayerToFinalPath = maskLayerToFinalPath { + maskLayer.path = maskLayerToFinalPath + } + if UIAccessibility.isReduceTransparencyEnabled { + fromVC.visualEffectView.alpha = 0 + } + } + + animator.addCompletion { position in + self.transitionItem.snapshotTransitioning?.removeFromSuperview() + self.transitionItem.source.updateAppearance(position: position, index: nil) + transitionContext.completeTransition(position == .end) + } + + return animator + } + +} + +// MARK: - UIViewControllerInteractiveTransitioning +extension MediaHostToMediaPreviewViewControllerAnimatedTransitioning { + + override func startInteractiveTransition(_ transitionContext: UIViewControllerContextTransitioning) { + super.startInteractiveTransition(transitionContext) + + switch operation { + case .pop: + // Note: change item.imageView transform via pan gesture + panGestureRecognizer.addTarget(self, action: #selector(MediaHostToMediaPreviewViewControllerAnimatedTransitioning.updatePanGestureInteractive(_:))) + popInteractiveTransition(using: transitionContext) + default: + assertionFailure() + return + } + } + + private func popInteractiveTransition(using transitionContext: UIViewControllerContextTransitioning) { + guard let fromVC = transitionContext.viewController(forKey: .from) as? MediaPreviewViewController, + let fromView = transitionContext.view(forKey: .from), + let mediaPreviewImageViewController = fromVC.pagingViewConttroller.currentViewController as? MediaPreviewImageViewController, + let index = fromVC.pagingViewConttroller.currentIndex else { + fatalError() + } + + // assert view hierarchy not change + let toVC = transitionItem.previewableViewController + let targetFrame = toVC.sourceFrame(transitionItem: transitionItem, index: index) + + let imageView = mediaPreviewImageViewController.previewImageView.imageView + let _snapshot: UIView? = { + transitionItem.snapshotRaw = imageView + let snapshot = imageView.snapshotView(afterScreenUpdates: false) + snapshot?.clipsToBounds = true + snapshot?.contentMode = .scaleAspectFill + return snapshot + }() + guard let snapshot = _snapshot else { + transitionContext.completeTransition(false) + return + } + + let transitionMaskView = UIView(frame: transitionContext.containerView.bounds) + transitionMaskView.autoresizingMask = [.flexibleWidth, .flexibleHeight] + transitionContext.containerView.addSubview(transitionMaskView) + transitionItem.interactiveTransitionMaskView = transitionMaskView + + let maskLayer = CAShapeLayer() + maskLayer.frame = transitionMaskView.bounds + maskLayer.path = UIBezierPath(rect: maskLayer.bounds).cgPath + transitionMaskView.layer.mask = maskLayer + transitionItem.interactiveTransitionMaskLayer = maskLayer + + transitionMaskView.addSubview(snapshot) + snapshot.center = transitionMaskView.center + fromVC.view.bringSubviewToFront(fromVC.closeButtonBackground) + + transitionItem.imageView = imageView + transitionItem.snapshotTransitioning = snapshot + transitionItem.initialFrame = snapshot.frame + transitionItem.targetFrame = targetFrame ?? snapshot.frame + + // disable interaction + fromVC.pagingViewConttroller.isUserInteractionEnabled = false + + let animator = popInteractiveTransitionAnimator + + let blurEffect = fromVC.visualEffectView.effect + self.transitionItem.snapshotRaw?.alpha = 0.0 + + UIView.animate(withDuration: 0.33, delay: 0, options: [.curveEaseInOut]) { + fromVC.closeButtonBackground.alpha = 0 + } + + animator.addAnimations { + switch self.transitionItem.source { + case .profileBanner: + self.transitionItem.snapshotTransitioning?.alpha = 0.4 + default: + break + } + fromVC.visualEffectView.effect = nil + self.transitionItem.sourceImageViewCornerRadius.flatMap { self.transitionItem.snapshotTransitioning?.layer.cornerRadius = $0 } + if UIAccessibility.isReduceTransparencyEnabled { + fromVC.visualEffectView.alpha = 0 + } + } + + animator.addCompletion { position in + fromVC.pagingViewConttroller.isUserInteractionEnabled = true + fromVC.closeButtonBackground.alpha = position == .end ? 0 : 1 + self.transitionItem.imageView?.isHidden = position == .end + self.transitionItem.snapshotRaw?.alpha = position == .start ? 1.0 : 0.0 + self.transitionItem.snapshotTransitioning?.removeFromSuperview() + if position == .end { + // reset appearance + self.transitionItem.source.updateAppearance(position: position, index: nil) + } + fromVC.visualEffectView.effect = position == .end ? nil : blurEffect + transitionMaskView.removeFromSuperview() + UIView.animate(withDuration: 0.33, delay: 0, options: [.curveEaseInOut]) { + fromVC.closeButtonBackground.alpha = position == .end ? 0 : 1 + } + if UIAccessibility.isReduceTransparencyEnabled { + fromVC.visualEffectView.alpha = position == .end ? 0 : 1 + } + transitionContext.completeTransition(position == .end) + } + } + +} + +extension MediaHostToMediaPreviewViewControllerAnimatedTransitioning { + + @objc func updatePanGestureInteractive(_ sender: UIPanGestureRecognizer) { + guard !isTransitionContextFinish else { return } // do not accept transition abort + + switch sender.state { + case .began, .changed: + let translation = sender.translation(in: transitionContext.containerView) + let percent = popInteractiveTransitionAnimator.fractionComplete + progressStep(for: translation) + popInteractiveTransitionAnimator.fractionComplete = percent + transitionContext.updateInteractiveTransition(percent) + updateTransitionItemPosition(of: translation) + + // Reset translation to zero + sender.setTranslation(CGPoint.zero, in: transitionContext.containerView) + case .ended, .cancelled: + let targetPosition = completionPosition() + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: target position: %s", ((#file as NSString).lastPathComponent), #line, #function, targetPosition == .end ? "end" : "start") + isTransitionContextFinish = true + animate(targetPosition) + + targetPosition == .end ? transitionContext.finishInteractiveTransition() : transitionContext.cancelInteractiveTransition() + default: + return + } + } + + private func convert(_ velocity: CGPoint, for item: MediaPreviewTransitionItem?) -> CGVector { + guard let currentFrame = item?.imageView?.frame, let targetFrame = item?.targetFrame else { + return CGVector.zero + } + + let dx = abs(targetFrame.midX - currentFrame.midX) + let dy = abs(targetFrame.midY - currentFrame.midY) + + guard dx > 0.0 && dy > 0.0 else { + return CGVector.zero + } + + let range = CGFloat(35.0) + let clippedVx = clip(-range, range, velocity.x / dx) + let clippedVy = clip(-range, range, velocity.y / dy) + return CGVector(dx: clippedVx, dy: clippedVy) + } + + private func completionPosition() -> UIViewAnimatingPosition { + let completionThreshold: CGFloat = 0.33 + let flickMagnitude: CGFloat = 1200 // pts/sec + let velocity = panGestureRecognizer.velocity(in: transitionContext.containerView).vector + let isFlick = (velocity.magnitude > flickMagnitude) + let isFlickDown = isFlick && (velocity.dy > 0.0) + let isFlickUp = isFlick && (velocity.dy < 0.0) + + if (operation == .push && isFlickUp) || (operation == .pop && isFlickDown) { + return .end + } else if (operation == .push && isFlickDown) || (operation == .pop && isFlickUp) { + return .start + } else if popInteractiveTransitionAnimator.fractionComplete > completionThreshold { + return .end + } else { + return .start + } + } + + // Create item animator and start it + func animate(_ toPosition: UIViewAnimatingPosition) { + // Create a property animator to animate each image's frame change + let gestureVelocity = panGestureRecognizer.velocity(in: transitionContext.containerView) + let velocity = convert(gestureVelocity, for: transitionItem) + let itemAnimator = MediaHostToMediaPreviewViewControllerAnimatedTransitioning.animator(initialVelocity: velocity) + + var maskLayerToFinalPath: CGPath? + if toPosition == .end, + let transitionMaskView = transitionItem.interactiveTransitionMaskView, + let snapshot = transitionItem.snapshotTransitioning { + let toVC = transitionItem.previewableViewController + + var needsMaskWithAnimation = true + let maskLayerToRect: CGRect? = { + guard case .mosaic = transitionItem.source else { return nil } + guard let navigationBar = toVC.navigationController?.navigationBar else { return nil } + let navigationBarFrameInWindow = toVC.view.convert(navigationBar.frame, to: nil) + var rect = transitionMaskView.frame + rect.origin.y = navigationBarFrameInWindow.maxY + + if rect.minY < snapshot.frame.minY { + needsMaskWithAnimation = false + } + + return rect + }() + let maskLayerToPath = maskLayerToRect.flatMap { UIBezierPath(rect: $0) }?.cgPath + + if let maskLayer = transitionItem.interactiveTransitionMaskLayer, !needsMaskWithAnimation { + maskLayer.path = maskLayerToPath + } + + let maskLayerToFinalRect: CGRect? = { + guard case .mosaic = transitionItem.source else { return nil } + guard let tabBarController = toVC.tabBarController else { return nil } + let tabBarFrameInWindow = toVC.view.convert(tabBarController.tabBar.frame, to: nil) + var rect = maskLayerToRect ?? transitionMaskView.frame + let offset = rect.maxY - tabBarFrameInWindow.minY + guard offset > 0 else { return rect } + rect.size.height -= offset + return rect + }() + maskLayerToFinalPath = maskLayerToFinalRect.flatMap { UIBezierPath(rect: $0) }?.cgPath + } + + itemAnimator.addAnimations { + if let maskLayer = self.transitionItem.interactiveTransitionMaskLayer, + let maskLayerToFinalPath = maskLayerToFinalPath { + maskLayer.path = maskLayerToFinalPath + } + if toPosition == .end { + switch self.transitionItem.source { + case .profileBanner where toPosition == .end: + // fade transition for banner + self.transitionItem.snapshotTransitioning?.alpha = 0 + default: + if let targetFrame = self.transitionItem.targetFrame { + self.transitionItem.snapshotTransitioning?.frame = targetFrame + } else { + self.transitionItem.snapshotTransitioning?.alpha = 0 + } + } + + } else { + if let initialFrame = self.transitionItem.initialFrame { + self.transitionItem.snapshotTransitioning?.frame = initialFrame + } else { + self.transitionItem.snapshotTransitioning?.alpha = 1 + } + } + } + + // Start the property animator and keep track of it + self.itemInteractiveTransitionAnimator = itemAnimator + itemAnimator.startAnimation() + + // Reverse the transition animator if we are returning to the start position + popInteractiveTransitionAnimator.isReversed = (toPosition == .start) + + if popInteractiveTransitionAnimator.state == .inactive { + popInteractiveTransitionAnimator.startAnimation() + } else { + let durationFactor = CGFloat(itemAnimator.duration / popInteractiveTransitionAnimator.duration) + popInteractiveTransitionAnimator.continueAnimation(withTimingParameters: nil, durationFactor: durationFactor) + } + } + + private func progressStep(for translation: CGPoint) -> CGFloat { + return (operation == .push ? -1.0 : 1.0) * translation.y / transitionContext.containerView.bounds.midY + } + + private func updateTransitionItemPosition(of translation: CGPoint) { + let progress = progressStep(for: translation) + + let initialSize = transitionItem.initialFrame!.size + guard initialSize != .zero else { return } + // assert(initialSize != .zero) + + guard let snapshot = transitionItem.snapshotTransitioning, + let finalSize = transitionItem.targetFrame?.size else { + return + } + + if snapshot.frame.size == .zero { + snapshot.frame.size = initialSize + } + + let currentSize = snapshot.frame.size + + let itemPercentComplete = clip(-0.05, 1.05, (currentSize.width - initialSize.width) / (finalSize.width - initialSize.width) + progress) + let itemWidth = lerp(initialSize.width, finalSize.width, itemPercentComplete) + let itemHeight = lerp(initialSize.height, finalSize.height, itemPercentComplete) + assert(currentSize.width != 0.0) + assert(currentSize.height != 0.0) + let scaleTransform = CGAffineTransform(scaleX: (itemWidth / currentSize.width), y: (itemHeight / currentSize.height)) + let scaledOffset = transitionItem.touchOffset.apply(transform: scaleTransform) + + snapshot.center = (snapshot.center + (translation + (transitionItem.touchOffset - scaledOffset))).point + snapshot.bounds = CGRect(origin: CGPoint.zero, size: CGSize(width: itemWidth, height: itemHeight)) + transitionItem.touchOffset = scaledOffset + } + +} + diff --git a/Mastodon/Scene/Transition/MediaPreview/MediaPreviewTransitionController.swift b/Mastodon/Scene/Transition/MediaPreview/MediaPreviewTransitionController.swift new file mode 100644 index 000000000..225a83209 --- /dev/null +++ b/Mastodon/Scene/Transition/MediaPreview/MediaPreviewTransitionController.swift @@ -0,0 +1,125 @@ +// +// MediaPreviewTransitionController.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-28. +// + +import os.log +import UIKit + +final class MediaPreviewTransitionController: NSObject { + + weak var mediaPreviewViewController: MediaPreviewViewController? + + var wantsInteractiveStart = false + private var panGestureRecognizer: UIPanGestureRecognizer = { + let gestureRecognizer = UIPanGestureRecognizer() + gestureRecognizer.maximumNumberOfTouches = 1 + return gestureRecognizer + }() + private var dismissInteractiveTransitioning: MediaHostToMediaPreviewViewControllerAnimatedTransitioning? + + override init() { + super.init() + + panGestureRecognizer.delegate = self + panGestureRecognizer.addTarget(self, action: #selector(MediaPreviewTransitionController.panGestureRecognizerHandler(_:))) + } + +} + +extension MediaPreviewTransitionController { + + @objc private func panGestureRecognizerHandler(_ sender: UIPanGestureRecognizer) { + guard dismissInteractiveTransitioning == nil else { return } + + guard let mediaPreviewViewController = self.mediaPreviewViewController else { return } + wantsInteractiveStart = true + mediaPreviewViewController.dismiss(animated: true, completion: nil) + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: start interactive dismiss", ((#file as NSString).lastPathComponent), #line, #function) + } + +} + +// MARK: - UIGestureRecognizerDelegate +extension MediaPreviewTransitionController: UIGestureRecognizerDelegate { + + func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool { + if gestureRecognizer === panGestureRecognizer || otherGestureRecognizer === panGestureRecognizer { + // FIXME: should enable zoom up pan dismiss + return false + } + return true + } + + func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { + if gestureRecognizer === panGestureRecognizer { + guard let mediaPreviewViewController = self.mediaPreviewViewController else { return false } + return mediaPreviewViewController.isInteractiveDismissable() + } + + return false + } +} + +// MARK: - UIViewControllerTransitioningDelegate +extension MediaPreviewTransitionController: UIViewControllerTransitioningDelegate { + + func animationController(forPresented presented: UIViewController, presenting: UIViewController, source: UIViewController) -> UIViewControllerAnimatedTransitioning? { + guard let mediaPreviewViewController = presented as? MediaPreviewViewController else { + assertionFailure() + return nil + } + self.mediaPreviewViewController = mediaPreviewViewController + self.mediaPreviewViewController?.view.addGestureRecognizer(panGestureRecognizer) + + return MediaHostToMediaPreviewViewControllerAnimatedTransitioning( + operation: .push, + transitionItem: mediaPreviewViewController.viewModel.pushTransitionItem, + panGestureRecognizer: panGestureRecognizer + ) + } + + func interactionControllerForPresentation(using animator: UIViewControllerAnimatedTransitioning) -> UIViewControllerInteractiveTransitioning? { + // not support interactive present + return nil + } + + func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? { + guard let mediaPreviewViewController = dismissed as? MediaPreviewViewController else { + assertionFailure() + return nil + } + + return MediaHostToMediaPreviewViewControllerAnimatedTransitioning( + operation: .pop, + transitionItem: mediaPreviewViewController.viewModel.pushTransitionItem, + panGestureRecognizer: panGestureRecognizer + ) + } + + func interactionControllerForDismissal(using animator: UIViewControllerAnimatedTransitioning) -> UIViewControllerInteractiveTransitioning? { + guard let transitioning = animator as? MediaHostToMediaPreviewViewControllerAnimatedTransitioning, + transitioning.operation == .pop, wantsInteractiveStart else { + return nil + } + + dismissInteractiveTransitioning = transitioning + transitioning.delegate = self + return transitioning + } + +} + +// MARK: - ViewControllerAnimatedTransitioningDelegate +extension MediaPreviewTransitionController: ViewControllerAnimatedTransitioningDelegate { + + func animationEnded(_ transitionCompleted: Bool) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: completed: %s", ((#file as NSString).lastPathComponent), #line, #function, transitionCompleted.description) + + dismissInteractiveTransitioning = nil + wantsInteractiveStart = false + } + +} diff --git a/Mastodon/Scene/Transition/MediaPreview/MediaPreviewTransitionItem.swift b/Mastodon/Scene/Transition/MediaPreview/MediaPreviewTransitionItem.swift new file mode 100644 index 000000000..7024d3056 --- /dev/null +++ b/Mastodon/Scene/Transition/MediaPreview/MediaPreviewTransitionItem.swift @@ -0,0 +1,66 @@ +// +// MediaPreviewTransitionItem.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-28. +// + +import UIKit +import CoreData + +class MediaPreviewTransitionItem: Identifiable { + + let id: UUID + let source: Source + var previewableViewController: MediaPreviewableViewController + + // source + // value maybe invalid when preview paging + var image: UIImage? + var aspectRatio: CGSize? + var initialFrame: CGRect? = nil + var sourceImageView: UIImageView? + var sourceImageViewCornerRadius: CGFloat? + + // target + var targetFrame: CGRect? = nil + + // transitioning + var imageView: UIImageView? + var snapshotRaw: UIView? + var snapshotTransitioning: UIView? + var touchOffset: CGVector = CGVector.zero + var interactiveTransitionMaskView: UIView? + var interactiveTransitionMaskLayer: CAShapeLayer? + + init(id: UUID = UUID(), source: Source, previewableViewController: MediaPreviewableViewController) { + self.id = id + self.source = source + self.previewableViewController = previewableViewController + } + +} + +extension MediaPreviewTransitionItem { + enum Source { + case mosaic(MosaicImageViewContainer) + 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): + if let index = index { + mosaicImageViewContainer.setImageView(alpha: 0, index: index) + } else { + mosaicImageViewContainer.setImageViews(alpha: alpha) + } + case .profileAvatar(let profileHeaderView): + profileHeaderView.avatarImageView.alpha = alpha + case .profileBanner: + break // keep source + } + } + } +} diff --git a/Mastodon/Scene/Transition/MediaPreview/MediaPreviewableViewController.swift b/Mastodon/Scene/Transition/MediaPreview/MediaPreviewableViewController.swift new file mode 100644 index 000000000..63cf10c3e --- /dev/null +++ b/Mastodon/Scene/Transition/MediaPreview/MediaPreviewableViewController.swift @@ -0,0 +1,28 @@ +// +// MediaPreviewableViewController.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-28. +// + +import UIKit + +protocol MediaPreviewableViewController: UIViewController { + var mediaPreviewTransitionController: MediaPreviewTransitionController { get } + func sourceFrame(transitionItem: MediaPreviewTransitionItem, index: Int) -> CGRect? +} + +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 .profileAvatar(let profileHeaderView): + return profileHeaderView.avatarImageView.superview!.convert(profileHeaderView.avatarImageView.frame, to: nil) + case .profileBanner: + return nil // fallback to snapshot.frame + } + } +} diff --git a/Mastodon/Scene/Transition/MediaPreview/MediaPreviewingViewController.swift b/Mastodon/Scene/Transition/MediaPreview/MediaPreviewingViewController.swift new file mode 100644 index 000000000..9c6e56d05 --- /dev/null +++ b/Mastodon/Scene/Transition/MediaPreview/MediaPreviewingViewController.swift @@ -0,0 +1,12 @@ +// +// MediaPreviewingViewController.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-28. +// + +import Foundation + +protocol MediaPreviewingViewController: AnyObject { + func isInteractiveDismissable() -> Bool +} diff --git a/Mastodon/Scene/Transition/ViewControllerAnimatedTransitioning.swift b/Mastodon/Scene/Transition/ViewControllerAnimatedTransitioning.swift new file mode 100644 index 000000000..078bf6565 --- /dev/null +++ b/Mastodon/Scene/Transition/ViewControllerAnimatedTransitioning.swift @@ -0,0 +1,65 @@ +// +// ViewControllerAnimatedTransitioning.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-28. +// + +import os.log +import UIKit + +protocol ViewControllerAnimatedTransitioningDelegate: AnyObject { + var wantsInteractiveStart: Bool { get } + func animationEnded(_ transitionCompleted: Bool) +} + +class ViewControllerAnimatedTransitioning: NSObject { + + let operation: UINavigationController.Operation + + var transitionContext: UIViewControllerContextTransitioning! + var isInteractive: Bool { return transitionContext.isInteractive } + + weak var delegate: ViewControllerAnimatedTransitioningDelegate? + + init(operation: UINavigationController.Operation) { + assert(operation != .none) + self.operation = operation + super.init() + } + + deinit { + os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + } + +} + +// MARK: - UIViewControllerAnimatedTransitioning +extension ViewControllerAnimatedTransitioning: UIViewControllerAnimatedTransitioning { + + func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval { + return 0.3 + } + + func animateTransition(using transitionContext: UIViewControllerContextTransitioning) { + self.transitionContext = transitionContext + } + + func animationEnded(_ transitionCompleted: Bool) { + delegate?.animationEnded(transitionCompleted) + } + +} + +// MARK: - UIViewControllerInteractiveTransitioning +extension ViewControllerAnimatedTransitioning: UIViewControllerInteractiveTransitioning { + + func startInteractiveTransition(_ transitionContext: UIViewControllerContextTransitioning) { + self.transitionContext = transitionContext + } + + var wantsInteractiveStart: Bool { + return delegate?.wantsInteractiveStart ?? false + } + +} diff --git a/Mastodon/Service/APIService/APIService+APIError.swift b/Mastodon/Service/APIService/APIService+APIError.swift index 7fd29b6b3..37235c2cb 100644 --- a/Mastodon/Service/APIService/APIService+APIError.swift +++ b/Mastodon/Service/APIService/APIService+APIError.swift @@ -21,6 +21,8 @@ extension APIService { case badResponse case requestThrottle + case voteExpiredPoll + // Server API error case mastodonAPIError(Mastodon.API.Error) } @@ -44,6 +46,7 @@ extension APIService.APIError: LocalizedError { case .badRequest: return "Bad Request" case .badResponse: return "Bad Response" case .requestThrottle: return "Request Throttled" + case .voteExpiredPoll: return L10n.Common.Alerts.VoteFailure.title case .mastodonAPIError(let error): guard let responseError = error.mastodonError else { guard error.httpResponseStatus != .ok else { @@ -62,6 +65,7 @@ extension APIService.APIError: LocalizedError { case .badRequest: return "Request invalid." case .badResponse: return "Response invalid." case .requestThrottle: return "Request too frequency." + case .voteExpiredPoll: return L10n.Common.Alerts.VoteFailure.pollExpired case .mastodonAPIError(let error): guard let responseError = error.mastodonError else { return nil @@ -73,9 +77,10 @@ extension APIService.APIError: LocalizedError { var helpAnchor: String? { switch errorReason { case .authenticationMissing: return "Please request after authenticated." - case .badRequest: return "Please try again." - case .badResponse: return "Please try again." - case .requestThrottle: return "Please try again later." + case .badRequest: return L10n.Common.Alerts.Common.pleaseTryAgain + case .badResponse: return L10n.Common.Alerts.Common.pleaseTryAgain + case .requestThrottle: return L10n.Common.Alerts.Common.pleaseTryAgainLater + case .voteExpiredPoll: return nil case .mastodonAPIError(let error): guard let responseError = error.mastodonError else { return nil diff --git a/Mastodon/Service/APIService/APIService+Account.swift b/Mastodon/Service/APIService/APIService+Account.swift index 6e26dbf83..7638f2444 100644 --- a/Mastodon/Service/APIService/APIService+Account.swift +++ b/Mastodon/Service/APIService/APIService+Account.swift @@ -10,6 +10,52 @@ import Combine import CommonOSLog import MastodonSDK +extension APIService { + + func accountInfo( + domain: String, + userID: Mastodon.Entity.Account.ID, + authorization: Mastodon.API.OAuth.Authorization + ) -> AnyPublisher, Error> { + return 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 + ) + 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() + } + +} + extension APIService { func accountVerifyCredentials( @@ -31,13 +77,63 @@ extension APIService { 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() + } + .eraseToAnyPublisher() + } + + func accountUpdateCredentials( + domain: String, + query: Mastodon.API.Account.UpdateCredentialQuery, + authorization: Mastodon.API.OAuth.Authorization + ) -> AnyPublisher, Error> { + return 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) - .map { _ in return response } + .tryMap { result -> Mastodon.Response.Content in + switch result { + case .success: + return response + case .failure(let error): + throw error + } + } .eraseToAnyPublisher() } .eraseToAnyPublisher() @@ -56,4 +152,17 @@ extension APIService { ) } + func accountLookup( + domain: String, + query: Mastodon.API.Account.AccountLookupQuery, + authorization: Mastodon.API.OAuth.Authorization + ) -> AnyPublisher, Error> { + return Mastodon.API.Account.lookupAccount( + session: session, + domain: domain, + query: query, + authorization: authorization + ) + } + } diff --git a/Mastodon/Service/APIService/APIService+Block.swift b/Mastodon/Service/APIService/APIService+Block.swift new file mode 100644 index 000000000..124b65155 --- /dev/null +++ b/Mastodon/Service/APIService/APIService+Block.swift @@ -0,0 +1,168 @@ +// +// APIService+Block.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-2. +// + +import UIKit +import Combine +import CoreData +import CoreDataStack +import CommonOSLog +import MastodonSDK + +extension APIService { + + func toggleBlock( + for mastodonUser: MastodonUser, + activeMastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox + ) -> AnyPublisher, Error> { + let impactFeedbackGenerator = UIImpactFeedbackGenerator(style: .light) + let notificationFeedbackGenerator = UINotificationFeedbackGenerator() + + 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 + } + } + .flatMap { blockQueryType, mastodonUserID -> AnyPublisher, Error> in + return self.blockUpdateRemote( + blockQueryType: blockQueryType, + 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.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: AuthenticationService.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 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) + } + .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 blockUpdateRemote( + blockQueryType: Mastodon.API.Account.BlockQueryType, + mastodonUserID: MastodonUser.ID, + mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox + ) -> AnyPublisher, Error> { + let domain = mastodonAuthenticationBox.domain + let authorization = mastodonAuthenticationBox.userAuthorization + + return Mastodon.API.Account.block( + session: session, + domain: domain, + accountID: mastodonUserID, + blockQueryType: blockQueryType, + 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] 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() + } + +} + diff --git a/Mastodon/Service/APIService/APIService+CustomEmoji.swift b/Mastodon/Service/APIService/APIService+CustomEmoji.swift new file mode 100644 index 000000000..2a80eca4c --- /dev/null +++ b/Mastodon/Service/APIService/APIService+CustomEmoji.swift @@ -0,0 +1,22 @@ +// +// APIService+CustomEmojiViewModel.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-15. +// + +import Foundation +import Combine +import CoreData +import CoreDataStack +import CommonOSLog +import DateToolsSwift +import MastodonSDK + +extension APIService { + + func customEmoji(domain: String) -> AnyPublisher, Error> { + return Mastodon.API.CustomEmojis.customEmojis(session: session, domain: domain) + } + +} diff --git a/Mastodon/Service/APIService/APIService+DomainBlock.swift b/Mastodon/Service/APIService/APIService+DomainBlock.swift new file mode 100644 index 000000000..887c3f074 --- /dev/null +++ b/Mastodon/Service/APIService/APIService+DomainBlock.swift @@ -0,0 +1,139 @@ +// +// APIService+DomainBlock.swift +// Mastodon +// +// Created by sxiaojian on 2021/4/29. +// + +import Combine +import CommonOSLog +import CoreData +import CoreDataStack +import DateToolsSwift +import Foundation +import MastodonSDK + +extension APIService { + func getDomainblocks( + domain: String, + limit: Int = onceRequestDomainBlocksMaxCount, + authorizationBox: AuthenticationService.MastodonAuthenticationBox + ) -> AnyPublisher, Error> { + let authorization = authorizationBox.userAuthorization + + let query = Mastodon.API.DomainBlock.Query( + maxID: nil, sinceID: nil, limit: limit + ) + return Mastodon.API.DomainBlock.getDomainblocks( + domain: domain, + session: session, + authorization: authorization, + query: query + ) + .flatMap { response -> AnyPublisher, Error> in + self.backgroundManagedObjectContext.performChanges { + let blockedDomains: [DomainBlock] = { + let request = DomainBlock.sortedFetchRequest + request.predicate = DomainBlock.predicate(domain: authorizationBox.domain, userID: authorizationBox.userID) + request.returnsObjectsAsFaults = false + do { + return try self.backgroundManagedObjectContext.fetch(request) + } catch { + assertionFailure(error.localizedDescription) + return [] + } + }() + blockedDomains.forEach { self.backgroundManagedObjectContext.delete($0) } + + response.value.forEach { domain in + // use constrain to avoid repeated save + _ = DomainBlock.insert( + into: self.backgroundManagedObjectContext, + blockedDomain: domain, + domain: authorizationBox.domain, + userID: authorizationBox.userID + ) + } + } + .setFailureType(to: Error.self) + .tryMap { result -> Mastodon.Response.Content<[String]> in + switch result { + case .success: + return response + case .failure(let error): + throw error + } + } + .eraseToAnyPublisher() + } + .eraseToAnyPublisher() + } + + func blockDomain( + user: MastodonUser, + authorizationBox: AuthenticationService.MastodonAuthenticationBox + ) -> AnyPublisher, Error> { + let authorization = authorizationBox.userAuthorization + + return Mastodon.API.DomainBlock.blockDomain( + domain: authorizationBox.domain, + blockDomain: user.domainFromAcct, + session: session, + authorization: authorization + ) + .flatMap { response -> AnyPublisher, Error> in + self.backgroundManagedObjectContext.performChanges { + let requestMastodonUserRequest = MastodonUser.sortedFetchRequest + requestMastodonUserRequest.predicate = MastodonUser.predicate(domain: authorizationBox.domain, id: authorizationBox.userID) + requestMastodonUserRequest.fetchLimit = 1 + guard let requestMastodonUser = self.backgroundManagedObjectContext.safeFetch(requestMastodonUserRequest).first else { return } + user.update(isDomainBlocking: true, by: requestMastodonUser) + } + .setFailureType(to: Error.self) + .tryMap { result -> Mastodon.Response.Content in + switch result { + case .success: + return response + case .failure(let error): + throw error + } + } + .eraseToAnyPublisher() + } + .eraseToAnyPublisher() + } + + func unblockDomain( + user: MastodonUser, + authorizationBox: AuthenticationService.MastodonAuthenticationBox + ) -> AnyPublisher, Error> { + let authorization = authorizationBox.userAuthorization + + return Mastodon.API.DomainBlock.unblockDomain( + domain: authorizationBox.domain, + blockDomain: user.domainFromAcct, + session: session, + authorization: authorization + ) + .flatMap { response -> AnyPublisher, Error> in + self.backgroundManagedObjectContext.performChanges { + let requestMastodonUserRequest = MastodonUser.sortedFetchRequest + requestMastodonUserRequest.predicate = MastodonUser.predicate(domain: authorizationBox.domain, id: authorizationBox.userID) + requestMastodonUserRequest.fetchLimit = 1 + guard let requestMastodonUser = self.backgroundManagedObjectContext.safeFetch(requestMastodonUserRequest).first else { return } + user.update(isDomainBlocking: false, by: requestMastodonUser) + } + .setFailureType(to: Error.self) + .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+Favorite.swift b/Mastodon/Service/APIService/APIService+Favorite.swift index 34bd3f0e4..23206494e 100644 --- a/Mastodon/Service/APIService/APIService+Favorite.swift +++ b/Mastodon/Service/APIService/APIService+Favorite.swift @@ -16,30 +16,30 @@ import CommonOSLog extension APIService { // make local state change only - func like( - tootObjectID: NSManagedObjectID, + func favorite( + statusObjectID: NSManagedObjectID, mastodonUserObjectID: NSManagedObjectID, favoriteKind: Mastodon.API.Favorites.FavoriteKind - ) -> AnyPublisher { - var _targetTootID: Toot.ID? + ) -> AnyPublisher { + var _targetStatusID: Status.ID? let managedObjectContext = backgroundManagedObjectContext return managedObjectContext.performChanges { - let toot = managedObjectContext.object(with: tootObjectID) as! Toot + let status = managedObjectContext.object(with: statusObjectID) as! Status let mastodonUser = managedObjectContext.object(with: mastodonUserObjectID) as! MastodonUser - let targetToot = toot.reblog ?? toot - let targetTootID = targetToot.id - _targetTootID = targetTootID + let targetStatus = status.reblog ?? status + let targetStatusID = targetStatus.id + _targetStatusID = targetStatusID - targetToot.update(liked: favoriteKind == .create, mastodonUser: mastodonUser) + targetStatus.update(liked: favoriteKind == .create, by: mastodonUser) } .tryMap { result in switch result { case .success: - guard let targetTootID = _targetTootID else { + guard let targetStatusID = _targetStatusID else { throw APIError.implicit(.badRequest) } - return targetTootID + return targetStatusID case .failure(let error): assertionFailure(error.localizedDescription) @@ -50,7 +50,7 @@ extension APIService { } // send favorite request to remote - func like( + func favorite( statusID: Mastodon.Entity.Status.ID, favoriteKind: Mastodon.API.Favorites.FavoriteKind, mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox @@ -76,11 +76,12 @@ extension APIService { return nil } }() - let _oldToot: Toot? = { - let request = Toot.sortedFetchRequest - request.predicate = Toot.predicate(domain: mastodonAuthenticationBox.domain, id: entity.id) + 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(Toot.reblog)] + request.relationshipKeyPathsForPrefetching = [#keyPath(Status.reblog)] do { return try managedObjectContext.fetch(request).first } catch { @@ -90,12 +91,15 @@ extension APIService { }() guard let requestMastodonUser = _requestMastodonUser, - let oldToot = _oldToot else { + let oldStatus = _oldStatus else { assertionFailure() return } - APIService.CoreData.mergeToot(for: requestMastodonUser, old: oldToot, in: mastodonAuthenticationBox.domain, entity: entity, networkDate: response.networkDate) - os_log(.info, log: log, "%{public}s[%{public}ld], %{public}s: did update toot %{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 ) + 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 @@ -112,7 +116,8 @@ extension APIService { .handleEvents(receiveCompletion: { completion in switch completion { case .failure(let error): - print(error) + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: error:", ((#file as NSString).lastPathComponent), #line, #function) + debugPrint(error) case .finished: break } @@ -123,20 +128,24 @@ extension APIService { } extension APIService { - func likeList( - limit: Int = onceRequestTootMaxCount, - userID: String, + func favoritedStatuses( + limit: Int = onceRequestStatusMaxCount, maxID: String? = nil, mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox ) -> AnyPublisher, Error> { let requestMastodonUserID = mastodonAuthenticationBox.userID - let query = Mastodon.API.Favorites.ListQuery(limit: limit, minID: nil, maxID: maxID) - return Mastodon.API.Favorites.getFavoriteStatus(domain: mastodonAuthenticationBox.domain, session: session, authorization: mastodonAuthenticationBox.userAuthorization, query: query) + let query = Mastodon.API.Favorites.FavoriteStatusesQuery(limit: limit, minID: nil, maxID: maxID) + return Mastodon.API.Favorites.favoritedStatus( + domain: mastodonAuthenticationBox.domain, + session: session, + authorization: mastodonAuthenticationBox.userAuthorization, + query: query + ) .map { response -> AnyPublisher, Error> in let log = OSLog.api - return APIService.Persist.persistTimeline( + return APIService.Persist.persistStatus( managedObjectContext: self.backgroundManagedObjectContext, domain: mastodonAuthenticationBox.domain, query: query, diff --git a/Mastodon/Service/APIService/APIService+Follow.swift b/Mastodon/Service/APIService/APIService+Follow.swift new file mode 100644 index 000000000..53634ab4b --- /dev/null +++ b/Mastodon/Service/APIService/APIService+Follow.swift @@ -0,0 +1,219 @@ +// +// APIService+Follow.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-2. +// + +import UIKit +import Combine +import CoreData +import CoreDataStack +import CommonOSLog +import MastodonSDK + +extension APIService { + + /// Toggle friendship between target MastodonUser and current MastodonUser + /// + /// Following / Following pending <-> Unfollow + /// + /// - Parameters: + /// - mastodonUser: target MastodonUser + /// - activeMastodonAuthenticationBox: `AuthenticationService.MastodonAuthenticationBox` + /// - Returns: publisher for `Relationship` + func toggleFollow( + for mastodonUser: MastodonUser, + activeMastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox + ) -> AnyPublisher, Error> { + + 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: AuthenticationService.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 mastodonUser = managedObjectContext.object(with: mastodonUserObjectID) as! MastodonUser + _targetMastodonUserID = mastodonUser.id + + 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) + } else { + _queryType = .follow(query: Mastodon.API.Account.FollowQuery()) + if mastodonUser.locked { + mastodonUser.update(isFollowing: false, by: _requestMastodonUser) + mastodonUser.update(isFollowRequested: true, by: _requestMastodonUser) + } else { + mastodonUser.update(isFollowing: true, by: _requestMastodonUser) + mastodonUser.update(isFollowRequested: false, by: _requestMastodonUser) + } + } + } + .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: AuthenticationService.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() + } + .eraseToAnyPublisher() + } + +} diff --git a/Mastodon/Service/APIService/APIService+FollowRequest.swift b/Mastodon/Service/APIService/APIService+FollowRequest.swift new file mode 100644 index 000000000..c40fcad52 --- /dev/null +++ b/Mastodon/Service/APIService/APIService+FollowRequest.swift @@ -0,0 +1,105 @@ +// +// APIService+FollowRequest.swift +// Mastodon +// +// Created by sxiaojian on 2021/4/27. +// + +import Foundation + +import UIKit +import Combine +import CoreData +import CoreDataStack +import CommonOSLog +import MastodonSDK + +extension APIService { + func acceptFollowRequest( + mastodonUserID: MastodonUser.ID, + mastodonAuthenticationBox: AuthenticationService.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: AuthenticationService.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+HashtagTimeline.swift b/Mastodon/Service/APIService/APIService+HashtagTimeline.swift new file mode 100644 index 000000000..69c2c7486 --- /dev/null +++ b/Mastodon/Service/APIService/APIService+HashtagTimeline.swift @@ -0,0 +1,70 @@ +// +// APIService+HashtagTimeline.swift +// Mastodon +// +// Created by BradGao on 2021/3/30. +// + +import Foundation +import Combine +import CoreData +import CoreDataStack +import CommonOSLog +import DateToolsSwift +import MastodonSDK + +extension APIService { + + func hashtagTimeline( + domain: String, + sinceID: Mastodon.Entity.Status.ID? = nil, + maxID: Mastodon.Entity.Status.ID? = nil, + limit: Int = onceRequestStatusMaxCount, + local: Bool? = nil, + hashtag: String, + authorizationBox: AuthenticationService.MastodonAuthenticationBox + ) -> AnyPublisher, Error> { + let authorization = authorizationBox.userAuthorization + let requestMastodonUserID = authorizationBox.userID + let query = Mastodon.API.Timeline.HashtagTimelineQuery( + maxID: maxID, + sinceID: sinceID, + minID: nil, // prefer sinceID + limit: limit, + local: local, + onlyMedia: false + ) + + return 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 + } + } + .eraseToAnyPublisher() + } + .eraseToAnyPublisher() + } + +} + diff --git a/Mastodon/Service/APIService/APIService+HomeTimeline.swift b/Mastodon/Service/APIService/APIService+HomeTimeline.swift index 0112a9da7..d4cbe69c2 100644 --- a/Mastodon/Service/APIService/APIService+HomeTimeline.swift +++ b/Mastodon/Service/APIService/APIService+HomeTimeline.swift @@ -19,7 +19,7 @@ extension APIService { domain: String, sinceID: Mastodon.Entity.Status.ID? = nil, maxID: Mastodon.Entity.Status.ID? = nil, - limit: Int = onceRequestTootMaxCount, + limit: Int = onceRequestStatusMaxCount, local: Bool? = nil, authorizationBox: AuthenticationService.MastodonAuthenticationBox ) -> AnyPublisher, Error> { @@ -40,7 +40,7 @@ extension APIService { authorization: authorization ) .flatMap { response -> AnyPublisher, Error> in - return APIService.Persist.persistTimeline( + return APIService.Persist.persistStatus( managedObjectContext: self.backgroundManagedObjectContext, domain: domain, query: query, diff --git a/Mastodon/Service/APIService/APIService+Media.swift b/Mastodon/Service/APIService/APIService+Media.swift new file mode 100644 index 000000000..03e333424 --- /dev/null +++ b/Mastodon/Service/APIService/APIService+Media.swift @@ -0,0 +1,46 @@ +// +// APIService+Media.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-18. +// + +import Foundation +import Combine +import MastodonSDK + +extension APIService { + + func uploadMedia( + domain: String, + query: Mastodon.API.Media.UploadMeidaQuery, + mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox + ) -> AnyPublisher, Error> { + let authorization = mastodonAuthenticationBox.userAuthorization + + return Mastodon.API.Media.uploadMedia( + session: session, + domain: domain, + query: query, + authorization: authorization + ) + } + + func updateMedia( + domain: String, + attachmentID: Mastodon.Entity.Attachment.ID, + query: Mastodon.API.Media.UpdateMediaQuery, + mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox + ) -> AnyPublisher, Error> { + let authorization = mastodonAuthenticationBox.userAuthorization + + return Mastodon.API.Media.updateMedia( + session: session, + domain: domain, + attachmentID: attachmentID, + query: query, + authorization: authorization + ) + } + +} diff --git a/Mastodon/Service/APIService/APIService+Mute.swift b/Mastodon/Service/APIService/APIService+Mute.swift new file mode 100644 index 000000000..9d992ab6a --- /dev/null +++ b/Mastodon/Service/APIService/APIService+Mute.swift @@ -0,0 +1,167 @@ +// +// APIService+Mute.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-2. +// + +import UIKit +import Combine +import CoreData +import CoreDataStack +import CommonOSLog +import MastodonSDK + +extension APIService { + + func toggleMute( + for mastodonUser: MastodonUser, + activeMastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox + ) -> AnyPublisher, Error> { + let impactFeedbackGenerator = UIImpactFeedbackGenerator(style: .light) + let notificationFeedbackGenerator = UINotificationFeedbackGenerator() + + 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 + } + } + .flatMap { muteQueryType, mastodonUserID -> AnyPublisher, Error> in + return self.muteUpdateRemote( + muteQueryType: muteQueryType, + 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.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: AuthenticationService.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 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) + } + .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 muteUpdateRemote( + muteQueryType: Mastodon.API.Account.MuteQueryType, + mastodonUserID: MastodonUser.ID, + mastodonAuthenticationBox: AuthenticationService.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() + } + +} + diff --git a/Mastodon/Service/APIService/APIService+Notification.swift b/Mastodon/Service/APIService/APIService+Notification.swift new file mode 100644 index 000000000..dfd87bc15 --- /dev/null +++ b/Mastodon/Service/APIService/APIService+Notification.swift @@ -0,0 +1,119 @@ +// +// APIService+Notification.swift +// Mastodon +// +// Created by sxiaojian on 2021/4/13. +// + +import Combine +import CoreData +import CoreDataStack +import Foundation +import MastodonSDK +import OSLog + +extension APIService { + func allNotifications( + domain: String, + query: Mastodon.API.Notifications.Query, + mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox + ) -> AnyPublisher, Error> { + let authorization = mastodonAuthenticationBox.userAuthorization + let userID = mastodonAuthenticationBox.userID + return Mastodon.API.Notifications.getNotifications( + session: session, + domain: 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) + } + } + 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() + } + + func notification( + notificationID: Mastodon.Entity.Notification.ID, + mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox + ) -> AnyPublisher, Error> { + let domain = mastodonAuthenticationBox.domain + let authorization = mastodonAuthenticationBox.userAuthorization + + return 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 + ) + .setFailureType(to: Error.self) + .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+Onboarding.swift b/Mastodon/Service/APIService/APIService+Onboarding.swift index 450dc141c..5cbf455a0 100644 --- a/Mastodon/Service/APIService/APIService+Onboarding.swift +++ b/Mastodon/Service/APIService/APIService+Onboarding.swift @@ -23,7 +23,7 @@ extension APIService { return Mastodon.API.Onboarding.categories(session: session) } - func stubCategories() -> [Mastodon.Entity.Category] { + static func stubCategories() -> [Mastodon.Entity.Category] { return Mastodon.Entity.Category.Kind.allCases.map { kind in return Mastodon.Entity.Category(category: kind.rawValue, serversCount: 0) } diff --git a/Mastodon/Service/APIService/APIService+Poll.swift b/Mastodon/Service/APIService/APIService+Poll.swift new file mode 100644 index 000000000..0b240466a --- /dev/null +++ b/Mastodon/Service/APIService/APIService+Poll.swift @@ -0,0 +1,197 @@ +// +// APIService+Poll.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-3. +// + +import Foundation +import Combine +import CoreData +import CoreDataStack +import CommonOSLog +import DateToolsSwift +import MastodonSDK + +extension APIService { + + func poll( + domain: String, + pollID: Mastodon.Entity.Poll.ID, + pollObjectID: NSManagedObjectID, + mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox + ) -> AnyPublisher, Error> { + let authorization = mastodonAuthenticationBox.userAuthorization + let requestMastodonUserID = mastodonAuthenticationBox.userID + + return Mastodon.API.Polls.poll( + session: session, + domain: 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() + } + .eraseToAnyPublisher() + } + +} + +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, + choices: [Int], + mastodonAuthenticationBox: AuthenticationService.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() + } + .eraseToAnyPublisher() + } + +} diff --git a/Mastodon/Service/APIService/APIService+PublicTimeline.swift b/Mastodon/Service/APIService/APIService+PublicTimeline.swift index bfa3bb26e..bd176f311 100644 --- a/Mastodon/Service/APIService/APIService+PublicTimeline.swift +++ b/Mastodon/Service/APIService/APIService+PublicTimeline.swift @@ -21,7 +21,7 @@ extension APIService { domain: String, sinceID: Mastodon.Entity.Status.ID? = nil, maxID: Mastodon.Entity.Status.ID? = nil, - limit: Int = onceRequestTootMaxCount + limit: Int = onceRequestStatusMaxCount ) -> AnyPublisher, Error> { let query = Mastodon.API.Timeline.PublicTimelineQuery( local: nil, @@ -39,7 +39,7 @@ extension APIService { query: query ) .flatMap { response -> AnyPublisher, Error> in - return APIService.Persist.persistTimeline( + return APIService.Persist.persistStatus( managedObjectContext: self.backgroundManagedObjectContext, domain: domain, query: query, diff --git a/Mastodon/Service/APIService/APIService+Reblog.swift b/Mastodon/Service/APIService/APIService+Reblog.swift new file mode 100644 index 000000000..6dc9f189b --- /dev/null +++ b/Mastodon/Service/APIService/APIService+Reblog.swift @@ -0,0 +1,132 @@ +// +// APIService+Reblog.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-9. +// + +import Foundation +import Combine +import MastodonSDK +import CoreData +import CoreDataStack +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 + + switch reblogKind { + case .reblog: + targetStatus.update(reblogged: true, by: mastodonUser) + case .undoReblog: + targetStatus.update(reblogged: false, 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() + } + + // send reblog request to remote + func reblog( + statusID: Mastodon.Entity.Status.ID, + reblogKind: Mastodon.API.Reblog.ReblogKind, + mastodonAuthenticationBox: AuthenticationService.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: + oldStatus.update(reblogsCount: NSNumber(value: max(0, oldStatus.reblogsCount.intValue - 1))) + 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() + } + .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 + } + }) + .eraseToAnyPublisher() + } + +} diff --git a/Mastodon/Service/APIService/APIService+Recommend.swift b/Mastodon/Service/APIService/APIService+Recommend.swift new file mode 100644 index 000000000..a3bcb3e35 --- /dev/null +++ b/Mastodon/Service/APIService/APIService+Recommend.swift @@ -0,0 +1,85 @@ +// +// APIService+Recommend.swift +// Mastodon +// +// Created by sxiaojian on 2021/3/31. +// + +import Combine +import Foundation +import MastodonSDK +import CoreData +import CoreDataStack +import OSLog + +extension APIService { + func suggestionAccount( + domain: String, + query: Mastodon.API.Suggestions.Query?, + mastodonAuthenticationBox: AuthenticationService.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() + } + + func suggestionAccountV2( + domain: String, + query: Mastodon.API.Suggestions.Query?, + mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox + ) -> AnyPublisher, Error> { + 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() + } + + 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 new file mode 100644 index 000000000..b0ef29267 --- /dev/null +++ b/Mastodon/Service/APIService/APIService+Relationship.swift @@ -0,0 +1,65 @@ +// +// APIService+Relationship.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-1. +// + +import UIKit +import Combine +import CoreData +import CoreDataStack +import CommonOSLog +import MastodonSDK + +extension APIService { + + func relationship( + domain: String, + accountIDs: [Mastodon.Entity.Account.ID], + authorizationBox: AuthenticationService.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) + } + } + .tryMap { result -> Mastodon.Response.Content<[Mastodon.Entity.Relationship]> in + switch result { + case .success: + return response + case .failure(let error): + throw error + } + } + .eraseToAnyPublisher() + } + .eraseToAnyPublisher() + } + +} diff --git a/Mastodon/Service/APIService/APIService+Report.swift b/Mastodon/Service/APIService/APIService+Report.swift new file mode 100644 index 000000000..3c170c625 --- /dev/null +++ b/Mastodon/Service/APIService/APIService+Report.swift @@ -0,0 +1,23 @@ +// +// APIService+Report.swift +// Mastodon +// +// Created by ihugo on 2021/4/19. +// + +import Foundation +import MastodonSDK +import Combine + +extension APIService { + + func report( + domain: String, + query: Mastodon.API.Reports.FileReportQuery, + mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox + ) -> AnyPublisher, Error> { + let authorization = mastodonAuthenticationBox.userAuthorization + + return Mastodon.API.Reports.fileReport(session: session, domain: domain, query: query, authorization: authorization) + } +} diff --git a/Mastodon/Service/APIService/APIService+Search.swift b/Mastodon/Service/APIService/APIService+Search.swift new file mode 100644 index 000000000..986e3d931 --- /dev/null +++ b/Mastodon/Service/APIService/APIService+Search.swift @@ -0,0 +1,23 @@ +// +// APIService+Search.swift +// Mastodon +// +// Created by sxiaojian on 2021/3/31. +// + +import Foundation +import MastodonSDK +import Combine + +extension APIService { + + func search( + domain: String, + query: Mastodon.API.V2.Search.Query, + mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox + ) -> AnyPublisher, Error> { + let authorization = mastodonAuthenticationBox.userAuthorization + + return Mastodon.API.V2.Search.search(session: session, domain: domain, query: query, authorization: authorization) + } +} diff --git a/Mastodon/Service/APIService/APIService+Status.swift b/Mastodon/Service/APIService/APIService+Status.swift new file mode 100644 index 000000000..c927b05a8 --- /dev/null +++ b/Mastodon/Service/APIService/APIService+Status.swift @@ -0,0 +1,137 @@ +// +// APIService+Status.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-10. +// + +import Foundation +import Combine +import CoreData +import CoreDataStack +import CommonOSLog +import DateToolsSwift +import MastodonSDK + +extension APIService { + + func publishStatus( + domain: String, + query: Mastodon.API.Statuses.PublishStatusQuery, + mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox + ) -> AnyPublisher, Error> { + let authorization = mastodonAuthenticationBox.userAuthorization + + return Mastodon.API.Statuses.publishStatus( + session: session, + domain: domain, + query: query, + 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 + ) + .setFailureType(to: Error.self) + .tryMap { result -> Mastodon.Response.Content in + switch result { + case .success: + return response + case .failure(let error): + throw error + } + } + .eraseToAnyPublisher() + } + .eraseToAnyPublisher() + } + + func status( + domain: String, + statusID: Mastodon.Entity.Status.ID, + authorizationBox: AuthenticationService.MastodonAuthenticationBox + ) -> AnyPublisher, Error> { + let authorization = authorizationBox.userAuthorization + return 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 + ) + .setFailureType(to: Error.self) + .tryMap { result -> Mastodon.Response.Content in + switch result { + case .success: + return response + case .failure(let error): + throw error + } + } + .eraseToAnyPublisher() + } + .eraseToAnyPublisher() + } + + func deleteStatus( + domain: String, + statusID: Mastodon.Entity.Status.ID, + authorizationBox: AuthenticationService.MastodonAuthenticationBox + ) -> AnyPublisher, Error> { + let authorization = authorizationBox.userAuthorization + let query = Mastodon.API.Statuses.DeleteStatusQuery(id: statusID) + return Mastodon.API.Statuses.deleteStatus( + session: session, + domain: 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 { + 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() + } + .eraseToAnyPublisher() + } + +} diff --git a/Mastodon/Service/APIService/APIService+Subscriptions.swift b/Mastodon/Service/APIService/APIService+Subscriptions.swift new file mode 100644 index 000000000..ceaff45fa --- /dev/null +++ b/Mastodon/Service/APIService/APIService+Subscriptions.swift @@ -0,0 +1,70 @@ +// +// APIService+Settings.swift +// Mastodon +// +// Created by ihugo on 2021/4/9. +// + +import os.log +import Combine +import CoreData +import CoreDataStack +import Foundation +import MastodonSDK + +extension APIService { + + func createSubscription( + subscriptionObjectID: NSManagedObjectID, + query: Mastodon.API.Subscriptions.CreateSubscriptionQuery, + mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox + ) -> AnyPublisher, Error> { + let authorization = mastodonAuthenticationBox.userAuthorization + let domain = mastodonAuthenticationBox.domain + + return Mastodon.API.Subscriptions.createSubscription( + session: session, + domain: domain, + authorization: authorization, + query: query + ) + .flatMap { response -> AnyPublisher, Error> in + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: create subscription successful %s", ((#file as NSString).lastPathComponent), #line, #function, response.value.endpoint) + + let managedObjectContext = self.backgroundManagedObjectContext + return managedObjectContext.performChanges { + guard let subscription = managedObjectContext.object(with: subscriptionObjectID) as? NotificationSubscription else { + assertionFailure() + return + } + subscription.endpoint = response.value.endpoint + subscription.serverKey = response.value.serverKey + subscription.userToken = authorization.accessToken + subscription.didUpdate(at: response.networkDate) + } + .setFailureType(to: Error.self) + .map { _ in return response } + .eraseToAnyPublisher() + } + .eraseToAnyPublisher() + } + + func cancelSubscription( + mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox + ) -> AnyPublisher, Error> { + let authorization = mastodonAuthenticationBox.userAuthorization + let domain = mastodonAuthenticationBox.domain + + return Mastodon.API.Subscriptions.removeSubscription( + session: session, + domain: domain, + authorization: authorization + ) + .handleEvents(receiveOutput: { _ in + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: cancel subscription successful", ((#file as NSString).lastPathComponent), #line, #function) + }) + .eraseToAnyPublisher() + } + +} + diff --git a/Mastodon/Service/APIService/APIService+Thread.swift b/Mastodon/Service/APIService/APIService+Thread.swift new file mode 100644 index 000000000..2633518ca --- /dev/null +++ b/Mastodon/Service/APIService/APIService+Thread.swift @@ -0,0 +1,57 @@ +// +// APIService+Thread.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-12. +// + +import os.log +import Foundation +import Combine +import CoreData +import CoreDataStack +import MastodonSDK + +extension APIService { + + func statusContext( + domain: String, + statusID: Mastodon.Entity.Status.ID, + mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox + ) -> AnyPublisher, Error> { + let authorization = mastodonAuthenticationBox.userAuthorization + guard domain == mastodonAuthenticationBox.domain else { + return Fail(error: APIError.implicit(.badRequest)).eraseToAnyPublisher() + } + + return 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 + } + } + .eraseToAnyPublisher() + } + .eraseToAnyPublisher() + } + +} diff --git a/Mastodon/Service/APIService/APIService+UserTimeline.swift b/Mastodon/Service/APIService/APIService+UserTimeline.swift new file mode 100644 index 000000000..cb20c85ef --- /dev/null +++ b/Mastodon/Service/APIService/APIService+UserTimeline.swift @@ -0,0 +1,70 @@ +// +// APIService+UserTimeline.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-30. +// + +import Foundation +import Combine +import CoreData +import CoreDataStack +import CommonOSLog +import MastodonSDK + +extension APIService { + + func userTimeline( + domain: String, + accountID: String, + maxID: Mastodon.Entity.Status.ID? = nil, + sinceID: Mastodon.Entity.Status.ID? = nil, + limit: Int = onceRequestStatusMaxCount, + excludeReplies: Bool? = nil, + excludeReblogs: Bool? = nil, + onlyMedia: Bool? = nil, + authorizationBox: AuthenticationService.MastodonAuthenticationBox + ) -> AnyPublisher, Error> { + let authorization = authorizationBox.userAuthorization + let requestMastodonUserID = authorizationBox.userID + let query = Mastodon.API.Account.AccountStatuseseQuery( + maxID: maxID, + sinceID: sinceID, + excludeReplies: excludeReplies, + excludeReblogs: excludeReblogs, + onlyMedia: onlyMedia, + limit: limit + ) + + return 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 + } + } + .eraseToAnyPublisher() + } + .eraseToAnyPublisher() + } + +} diff --git a/Mastodon/Service/APIService/APIService.swift b/Mastodon/Service/APIService/APIService.swift index 655684cc5..b38e2e059 100644 --- a/Mastodon/Service/APIService/APIService.swift +++ b/Mastodon/Service/APIService/APIService.swift @@ -44,8 +44,9 @@ final class APIService { } extension APIService { - public static let onceRequestTootMaxCount = 100 + public static let onceRequestStatusMaxCount = 100 public static let onceRequestUserMaxCount = 100 + public static let onceRequestDomainBlocksMaxCount = 100 } extension APIService { diff --git a/Mastodon/Service/APIService/CoreData/APIService+CoreData+MastodonUser.swift b/Mastodon/Service/APIService/CoreData/APIService+CoreData+MastodonUser.swift index 4f35a54c1..4a1237051 100644 --- a/Mastodon/Service/APIService/CoreData/APIService+CoreData+MastodonUser.swift +++ b/Mastodon/Service/APIService/CoreData/APIService+CoreData+MastodonUser.swift @@ -18,6 +18,7 @@ extension APIService.CoreData { for requestMastodonUser: MastodonUser?, in domain: String, entity: Mastodon.Entity.Account, + userCache: APIService.Persist.PersistCache?, networkDate: Date, log: OSLog ) -> (user: MastodonUser, isCreated: Bool) { @@ -29,25 +30,29 @@ extension APIService.CoreData { // fetch old mastodon user let oldMastodonUser: MastodonUser? = { - 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 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.mergeMastodonUser( - for: requestMastodonUser, - old: oldMastodonUser, - in: domain, + APIService.CoreData.merge( + user: oldMastodonUser, entity: entity, + requestMastodonUser: requestMastodonUser, + domain: domain, networkDate: networkDate ) return (oldMastodonUser, false) @@ -57,17 +62,21 @@ extension APIService.CoreData { 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) } } - static func mergeMastodonUser( - for requestMastodonUser: MastodonUser?, - old user: MastodonUser, - in domain: String, +} + +extension APIService.CoreData { + + static func merge( + user: MastodonUser, entity: Mastodon.Entity.Account, + requestMastodonUser: MastodonUser?, + domain: String, networkDate: Date ) { guard networkDate > user.updatedAt else { return } @@ -79,6 +88,42 @@ extension APIService.CoreData { 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) } + + 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+Setting.swift b/Mastodon/Service/APIService/CoreData/APIService+CoreData+Setting.swift new file mode 100644 index 000000000..0c23eab6e --- /dev/null +++ b/Mastodon/Service/APIService/CoreData/APIService+CoreData+Setting.swift @@ -0,0 +1,76 @@ +// +// APIService+CoreData+Setting.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-25. +// + + +import os.log +import Foundation +import CoreData +import CoreDataStack +import MastodonSDK + +extension APIService.CoreData { + + static func createOrMergeSetting( + into managedObjectContext: NSManagedObjectContext, + property: Setting.Property + ) -> (Subscription: Setting, isCreated: Bool) { + let oldSetting: Setting? = { + let request = Setting.sortedFetchRequest + request.predicate = Setting.predicate(domain: property.domain, userID: property.userID) + request.fetchLimit = 1 + request.returnsObjectsAsFaults = false + return managedObjectContext.safeFetch(request).first + }() + + if let oldSetting = oldSetting { + setupSettingSubscriptions(managedObjectContext: managedObjectContext, setting: oldSetting) + return (oldSetting, false) + } else { + let setting = Setting.insert( + into: managedObjectContext, + property: property + ) + setupSettingSubscriptions(managedObjectContext: managedObjectContext, setting: setting) + return (setting, true) + } + } + +} + +extension APIService.CoreData { + + static func setupSettingSubscriptions( + managedObjectContext: NSManagedObjectContext, + setting: Setting + ) { + guard (setting.subscriptions ?? Set()).isEmpty else { return } + + let now = Date() + let policies: [Mastodon.API.Subscriptions.Policy] = [ + .all, + .followed, + .follower, + .none + ] + policies.forEach { policy in + let (subscription, _) = createOrFetchSubscription( + into: managedObjectContext, + setting: setting, + policy: policy + ) + if policy == .all { + subscription.update(activedAt: now) + } else { + subscription.update(activedAt: now.addingTimeInterval(-10)) + } + } + + // trigger setting update + setting.didUpdate(at: now) + } + +} diff --git a/Mastodon/Service/APIService/CoreData/APIService+CoreData+Status.swift b/Mastodon/Service/APIService/CoreData/APIService+CoreData+Status.swift new file mode 100644 index 000000000..32628a203 --- /dev/null +++ b/Mastodon/Service/APIService/CoreData/APIService+CoreData+Status.swift @@ -0,0 +1,232 @@ +// +// 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 metions = entity.mentions?.enumerated().compactMap { index, mention -> Mention in + Mention.insert(into: managedObjectContext, property: Mention.Property(id: mention.id, username: mention.username, acct: mention.acct, url: mention.url), index: index) + } + let tags = entity.tags?.compactMap { tag -> Tag in + let histories = tag.history?.compactMap { history -> History in + History.insert(into: managedObjectContext, property: History.Property(day: history.day, uses: history.uses, accounts: history.accounts)) + } + return Tag.insert(into: managedObjectContext, property: Tag.Property(name: tag.name, url: tag.url, histories: histories)) + } + let mediaAttachments: [Attachment]? = { + let encoder = JSONEncoder() + var attachments: [Attachment] = [] + 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: metions, + tags: tags, + 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+Subscriptions.swift b/Mastodon/Service/APIService/CoreData/APIService+CoreData+Subscriptions.swift new file mode 100644 index 000000000..6eebc9e56 --- /dev/null +++ b/Mastodon/Service/APIService/CoreData/APIService+CoreData+Subscriptions.swift @@ -0,0 +1,62 @@ +// +// APIService+CoreData+Notification.swift +// Mastodon +// +// Created by ihugo on 2021/4/11. +// + +import os.log +import Foundation +import CoreData +import CoreDataStack +import MastodonSDK + +extension APIService.CoreData { + + static func createOrFetchSubscription( + into managedObjectContext: NSManagedObjectContext, + setting: Setting, + policy: Mastodon.API.Subscriptions.Policy + ) -> (subscription: Subscription, isCreated: Bool) { + let oldSubscription: Subscription? = { + let request = Subscription.sortedFetchRequest + request.predicate = Subscription.predicate(policyRaw: policy.rawValue) + request.fetchLimit = 1 + request.returnsObjectsAsFaults = false + return managedObjectContext.safeFetch(request).first + }() + + if let oldSubscription = oldSubscription { + oldSubscription.setting = setting + return (oldSubscription, false) + } else { + let subscriptionProperty = Subscription.Property(policyRaw: policy.rawValue) + let subscription = Subscription.insert( + into: managedObjectContext, + property: subscriptionProperty, + setting: setting + ) + let alertProperty = SubscriptionAlerts.Property(policy: policy) + subscription.alert = SubscriptionAlerts.insert( + into: managedObjectContext, + property: alertProperty, + subscription: subscription + ) + + return (subscription, true) + } + } + +} + +extension APIService.CoreData { + + static func merge( + subscription: Subscription, + property: Subscription.Property, + networkDate: Date + ) { + // TODO: + } + +} diff --git a/Mastodon/Service/APIService/CoreData/APIService+CoreData+Tag.swift b/Mastodon/Service/APIService/CoreData/APIService+CoreData+Tag.swift new file mode 100644 index 000000000..9b4319572 --- /dev/null +++ b/Mastodon/Service/APIService/CoreData/APIService+CoreData+Tag.swift @@ -0,0 +1,66 @@ +// +// 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 mastodon user + 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/CoreData/APIService+CoreData+Toot.swift b/Mastodon/Service/APIService/CoreData/APIService+CoreData+Toot.swift deleted file mode 100644 index bbf814e66..000000000 --- a/Mastodon/Service/APIService/CoreData/APIService+CoreData+Toot.swift +++ /dev/null @@ -1,145 +0,0 @@ -// -// APIService+CoreData+Toot.swift -// Mastodon -// -// Created by sxiaojian on 2021/2/3. -// - -import Foundation -import CoreData -import CoreDataStack -import CommonOSLog -import MastodonSDK - -extension APIService.CoreData { - - static func createOrMergeToot( - into managedObjectContext: NSManagedObjectContext, - for requestMastodonUser: MastodonUser?, - entity: Mastodon.Entity.Status, - domain: String, - networkDate: Date, - log: OSLog - ) -> (Toot: Toot, isTootCreated: Bool, isMastodonUserCreated: Bool) { - - // build tree - let reblog = entity.reblog.flatMap { entity -> Toot in - let (toot, _, _) = createOrMergeToot(into: managedObjectContext, for: requestMastodonUser, entity: entity, domain: domain, networkDate: networkDate, log: log) - return toot - } - - // fetch old Toot - let oldToot: Toot? = { - let request = Toot.sortedFetchRequest - request.predicate = Toot.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 oldToot = oldToot { - // merge old Toot - APIService.CoreData.mergeToot(for: requestMastodonUser, old: oldToot,in: domain, entity: entity, networkDate: networkDate) - return (oldToot, false, false) - } else { - let (mastodonUser, isMastodonUserCreated) = createOrMergeMastodonUser(into: managedObjectContext, for: requestMastodonUser,in: domain, entity: entity.account, 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 metions = entity.mentions?.compactMap { mention -> Mention in - Mention.insert(into: managedObjectContext, property: Mention.Property(id: mention.id, username: mention.username, acct: mention.acct, url: mention.url)) - } - let emojis = entity.emojis?.compactMap { emoji -> Emoji in - Emoji.insert(into: managedObjectContext, property: Emoji.Property(shortcode: emoji.shortcode, url: emoji.url, staticURL: emoji.staticURL, visibleInPicker: emoji.visibleInPicker, category: emoji.category)) - } - let tags = entity.tags?.compactMap { tag -> Tag in - let histories = tag.history?.compactMap { history -> History in - History.insert(into: managedObjectContext, property: History.Property(day: history.day, uses: history.uses, accounts: history.accounts)) - } - return Tag.insert(into: managedObjectContext, property: Tag.Property(name: tag.name, url: tag.url, histories: histories)) - } - let mediaAttachments: [Attachment]? = { - let encoder = JSONEncoder() - var attachments: [Attachment] = [] - 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 tootProperty = Toot.Property(entity: entity, domain: domain, networkDate: networkDate) - let toot = Toot.insert( - into: managedObjectContext, - property: tootProperty, - author: mastodonUser, - reblog: reblog, - application: application, - mentions: metions, - emojis: emojis, - tags: tags, - 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 - ) - return (toot, true, isMastodonUserCreated) - } - } - - static func mergeToot(for requestMastodonUser: MastodonUser?, old toot: Toot,in domain: String, entity: Mastodon.Entity.Status, networkDate: Date) { - guard networkDate > toot.updatedAt else { return } - - // merge - if entity.favouritesCount != toot.favouritesCount.intValue { - toot.update(favouritesCount:NSNumber(value: entity.favouritesCount)) - } - if let repliesCount = entity.repliesCount { - if (repliesCount != toot.repliesCount?.intValue) { - toot.update(repliesCount:NSNumber(value: repliesCount)) - } - } - if entity.reblogsCount != toot.reblogsCount.intValue { - toot.update(reblogsCount:NSNumber(value: entity.reblogsCount)) - } - - if let mastodonUser = requestMastodonUser { - if let favourited = entity.favourited { - toot.update(liked: favourited, mastodonUser: mastodonUser) - } - if let reblogged = entity.reblogged { - toot.update(reblogged: reblogged, mastodonUser: mastodonUser) - } - if let muted = entity.muted { - toot.update(muted: muted, mastodonUser: mastodonUser) - } - if let bookmarked = entity.bookmarked { - toot.update(bookmarked: bookmarked, mastodonUser: mastodonUser) - } - } - - - - - // set updateAt - toot.didUpdate(at: networkDate) - - // merge user - mergeMastodonUser(for: requestMastodonUser, old: toot.author, in: domain, entity: entity.account, networkDate: networkDate) - // merge indirect reblog & quote - if let reblog = toot.reblog, let reblogEntity = entity.reblog { - mergeToot(for: requestMastodonUser, old: reblog,in: domain, entity: reblogEntity, networkDate: networkDate) - } - } - -} diff --git a/Mastodon/Service/APIService/Persist/APIService+Persist+PersistCache.swift b/Mastodon/Service/APIService/Persist/APIService+Persist+PersistCache.swift new file mode 100644 index 000000000..eb354035f --- /dev/null +++ b/Mastodon/Service/APIService/Persist/APIService+Persist+PersistCache.swift @@ -0,0 +1,66 @@ +// +// 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 new file mode 100644 index 000000000..dab4ba6ad --- /dev/null +++ b/Mastodon/Service/APIService/Persist/APIService+Persist+PersistMemo.swift @@ -0,0 +1,226 @@ +// +// 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 new file mode 100644 index 000000000..f5bb4ea3d --- /dev/null +++ b/Mastodon/Service/APIService/Persist/APIService+Persist+Status.swift @@ -0,0 +1,263 @@ +// +// 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/APIService/Persist/APIService+Persist+Timeline.swift b/Mastodon/Service/APIService/Persist/APIService+Persist+Timeline.swift deleted file mode 100644 index 460cab023..000000000 --- a/Mastodon/Service/APIService/Persist/APIService+Persist+Timeline.swift +++ /dev/null @@ -1,446 +0,0 @@ -// -// APIService+Persist+Timeline.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 likeList - } - - static func persistTimeline( - 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> { - let toots = response.value - os_log(.info, log: log, "%{public}s[%{public}ld], %{public}s: persist %{public}ld toots…", ((#file as NSString).lastPathComponent), #line, #function, toots.count) - - return managedObjectContext.performChanges { - 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 toots into cache", signpostID: cacheTaskSignpostID) - let workingIDRecord = APIService.Persist.WorkingIDRecord.workingID(entities: toots) - - // contains toots and reblogs - let _tootCache: [Toot] = { - let request = Toot.sortedFetchRequest - let idSet = workingIDRecord.statusIDSet - .union(workingIDRecord.reblogIDSet) - let ids = Array(idSet) - request.predicate = Toot.predicate(domain: domain, ids: ids) - request.returnsObjectsAsFaults = false - request.relationshipKeyPathsForPrefetching = [#keyPath(Toot.reblog)] - do { - return try managedObjectContext.fetch(request) - } catch { - assertionFailure(error.localizedDescription) - return [] - } - }() - os_signpost(.event, log: log, name: "load toots into cache", signpostID: cacheTaskSignpostID, "cached %{public}ld toots", _tootCache.count) - os_signpost(.end, log: log, name: "load toots into cache", signpostID: cacheTaskSignpostID) - - // remote timeline merge local timeline record set - // declare it before do working - let mergedOldTootsInTimeline = _tootCache.filter { - return $0.homeTimelineIndexes?.contains(where: { $0.userID == requestMastodonUserID }) ?? false - } - - let updateDatabaseTaskSignpostID = OSSignpostID(log: log) - let recordType: WorkingRecord.RecordType = { - switch persistType { - case .public: return .publicTimeline - case .home: return .homeTimeline - case .likeList: return .favoriteTimeline - } - }() - - var workingRecords: [WorkingRecord] = [] - os_signpost(.begin, log: log, name: "update database", signpostID: updateDatabaseTaskSignpostID) - for entity in toots { - 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 record = WorkingRecord.createOrMergeToot( - into: managedObjectContext, - for: requestMastodonUser, - domain: domain, - entity: entity, - recordType: recordType, - networkDate: response.networkDate, - log: log - ) - workingRecords.append(record) - } // end for… - os_signpost(.end, log: log, name: "update database", signpostID: updateDatabaseTaskSignpostID) - - // home & mention timeline tasks - switch persistType { - case .home: - // Task 1: update anchor hasMore - // update maxID anchor hasMore attribute when fetching on timeline - // do not use working records due to anchor toot is removable on the remote - var anchorToot: Toot? - if let maxID = query.maxID { - do { - // load anchor toot from database - let request = Toot.sortedFetchRequest - request.predicate = Toot.predicate(domain: domain, id: maxID) - request.returnsObjectsAsFaults = false - request.fetchLimit = 1 - anchorToot = try managedObjectContext.fetch(request).first - if persistType == .home { - let timelineIndex = anchorToot.flatMap { toot in - toot.homeTimelineIndexes?.first(where: { $0.userID == requestMastodonUserID }) - } - timelineIndex?.update(hasMore: false) - } else { - assertionFailure() - } - } catch { - assertionFailure(error.localizedDescription) - } - } - - // Task 2: set last toot hasMore when fetched toots not overlap with the timeline in the local database - let _oldestRecord = workingRecords - .sorted(by: { $0.status.createdAt < $1.status.createdAt }) - .first - if let oldestRecord = _oldestRecord { - if let anchorToot = anchorToot { - // using anchor. set hasMore when (overlap itself OR no overlap) AND oldest record NOT anchor - let isNoOverlap = mergedOldTootsInTimeline.isEmpty - let isOnlyOverlapItself = mergedOldTootsInTimeline.count == 1 && mergedOldTootsInTimeline.first?.id == anchorToot.id - let isAnchorEqualOldestRecord = oldestRecord.status.id == anchorToot.id - if (isNoOverlap || isOnlyOverlapItself) && !isAnchorEqualOldestRecord { - if persistType == .home { - let timelineIndex = oldestRecord.status.homeTimelineIndexes? - .first(where: { $0.userID == requestMastodonUserID }) - timelineIndex?.update(hasMore: true) - } else { - assertionFailure() - } - } - - } else if mergedOldTootsInTimeline.isEmpty { - // no anchor. set hasMore when no overlap - if persistType == .home { - let timelineIndex = oldestRecord.status.homeTimelineIndexes? - .first(where: { $0.userID == requestMastodonUserID }) - timelineIndex?.update(hasMore: true) - } - } - } else { - // empty working record. mark anchor hasMore in the task 1 - } - default: - break - } - - // print working record tree map - #if DEBUG - DispatchQueue.global(qos: .utility).async { - let logs = workingRecords - .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 = workingRecords - .map { record in record.count() } - .reduce(into: WorkingRecord.Counting(), { result, next in result = result + next }) - let newTootsInTimeLineCount = workingRecords.reduce(0, { result, next in - return next.statusProcessType == .create ? result + 1 : result - }) - os_log(.info, log: log, "%{public}s[%{public}ld], %{public}s: toot: insert %{public}ldT(%{public}ldTRQ), merge %{public}ldT(%{public}ldTRQ)", ((#file as NSString).lastPathComponent), #line, #function, newTootsInTimeLineCount, counting.status.create, mergedOldTootsInTimeline.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() - } -} - -extension APIService.Persist { - - struct WorkingIDRecord { - var statusIDSet: Set - var reblogIDSet: Set - var userIDSet: Set - - enum RecordType { - case timeline - case reblog - } - - init(statusIDSet: Set = Set(), reblogIDSet: Set = Set(), userIDSet: Set = Set()) { - self.statusIDSet = statusIDSet - self.reblogIDSet = reblogIDSet - self.userIDSet = userIDSet - } - - mutating func union(record: WorkingIDRecord) { - statusIDSet = statusIDSet.union(record.statusIDSet) - reblogIDSet = reblogIDSet.union(record.reblogIDSet) - userIDSet = userIDSet.union(record.userIDSet) - } - - static func workingID(entities: [Mastodon.Entity.Status]) -> WorkingIDRecord { - var value = WorkingIDRecord() - for entity in entities { - let child = workingID(entity: entity, recordType: .timeline) - value.union(record: child) - } - return value - } - - private static func workingID(entity: Mastodon.Entity.Status, recordType: RecordType) -> WorkingIDRecord { - var value = WorkingIDRecord() - switch recordType { - case .timeline: value.statusIDSet = Set([entity.id]) - case .reblog: value.reblogIDSet = Set([entity.id]) - } - value.userIDSet = Set([entity.account.id]) - - if let reblog = entity.reblog { - let child = workingID(entity: reblog, recordType: .reblog) - value.union(record: child) - } - return value - } - } - - class WorkingRecord { - - let status: Toot - let children: [WorkingRecord] - let recordType: RecordType - let statusProcessType: ProcessType - let userProcessType: ProcessType - - init( - status: Toot, - children: [APIService.Persist.WorkingRecord], - recordType: APIService.Persist.WorkingRecord.RecordType, - tootProcessType: ProcessType, - userProcessType: ProcessType - ) { - self.status = status - self.children = children - self.recordType = recordType - self.statusProcessType = tootProcessType - self.userProcessType = userProcessType - } - - enum RecordType { - case publicTimeline - case homeTimeline - case mentionTimeline - case userTimeline - case favoriteTimeline - case searchTimeline - - case reblog - - var flag: String { - switch self { - case .publicTimeline: return "P" - case .homeTimeline: return "H" - case .mentionTimeline: return "M" - case .userTimeline: return "U" - case .favoriteTimeline: return "F" - case .searchTimeline: return "S" - case .reblog: return "R" - } - } - } - - enum ProcessType { - case create - case merge - - var flag: String { - switch self { - case .create: return "+" - case .merge: return "-" - } - } - } - - func log(indentLevel: Int = 0) -> String { - let indent = Array(repeating: " ", count: indentLevel).joined() - let tootPreview = status.content.prefix(32).replacingOccurrences(of: "\n", with: " ") - let message = "\(indent)[\(statusProcessType.flag)\(recordType.flag)](\(status.id)) [\(userProcessType.flag)](\(status.author.id))@\(status.author.username) ~> \(tootPreview)" - - 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 - } - - 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 userProcessType { - 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 - } - - // handle timelineIndex insert with APIService.Persist.createOrMergeToot - static func createOrMergeToot( - into managedObjectContext: NSManagedObjectContext, - for requestMastodonUser: MastodonUser?, - domain: String, - entity: Mastodon.Entity.Status, - recordType: RecordType, - networkDate: Date, - log: OSLog - ) -> WorkingRecord { - let processEntityTaskSignpostID = OSSignpostID(log: log) - os_signpost(.begin, log: log, name: "update database - process entity: createorMergeToot", signpostID: processEntityTaskSignpostID, "process toot %{public}s", entity.id) - defer { - os_signpost(.end, log: log, name: "update database - process entity: createorMergeToot", signpostID: processEntityTaskSignpostID, "finish process toot %{public}s", entity.id) - } - - // build tree - let reblogRecord: WorkingRecord? = entity.reblog.flatMap { entity -> WorkingRecord in - createOrMergeToot(into: managedObjectContext, for: requestMastodonUser, domain: domain, entity: entity, recordType: .reblog, networkDate: networkDate, log: log) - } - let children = [reblogRecord].compactMap { $0 } - - let (status, isTootCreated, isTootUserCreated) = APIService.CoreData.createOrMergeToot(into: managedObjectContext, for: requestMastodonUser, entity: entity, domain: domain, networkDate: networkDate, log: log) - - let result = WorkingRecord( - status: status, - children: children, - recordType: recordType, - tootProcessType: isTootCreated ? .create : .merge, - userProcessType: isTootUserCreated ? .create : .merge - ) - - switch (result.statusProcessType, recordType) { - case (.create, .homeTimeline), (.merge, .homeTimeline): - guard let requestMastodonUserID = requestMastodonUser?.id else { - assertionFailure("Request user is required for home timeline") - break - } - let timelineIndex = status.homeTimelineIndexes? - .first { $0.userID == requestMastodonUserID } - if timelineIndex == nil { - let timelineIndexProperty = HomeTimelineIndex.Property(domain: domain, userID: requestMastodonUserID) - - let _ = HomeTimelineIndex.insert( - into: managedObjectContext, - property: timelineIndexProperty, - toot: status - ) - } else { - // enity already in home timeline - } - default: - break - } - - return result - } - - } - -} - diff --git a/Mastodon/Service/AudioPlaybackService.swift b/Mastodon/Service/AudioPlaybackService.swift new file mode 100644 index 000000000..34ceb3bbe --- /dev/null +++ b/Mastodon/Service/AudioPlaybackService.swift @@ -0,0 +1,161 @@ +// +// AudioPlayer.swift +// Mastodon +// +// Created by sxiaojian on 2021/3/8. +// + +import AVFoundation +import Combine +import CoreDataStack +import Foundation +import UIKit +import os.log + +final class AudioPlaybackService: NSObject { + + static let appWillPlayAudioNotification = NSNotification.Name(rawValue: "org.joinmastodon.Mastodon.audio-playback-service.appWillPlayAudio") + + var disposeBag = Set() + + var player = AVPlayer() + var timeObserver: Any? + var statusObserver: Any? + var attachment: Attachment? + + let session = AVAudioSession.sharedInstance() + let playbackState = CurrentValueSubject(PlaybackState.unknown) + + let currentTimeSubject = CurrentValueSubject(0) + + override init() { + super.init() + addObserver() + } +} + +extension AudioPlaybackService { + func playAudio(audioAttachment: Attachment) { + guard let url = URL(string: audioAttachment.url) else { + return + } + do { + try session.setCategory(.playback) + } catch { + print(error) + return + } + + notifyWillPlayAudioNotification() + if audioAttachment == attachment { + if self.playbackState.value == .stopped { + self.seekToTime(time: .zero) + } + player.play() + self.playbackState.value = .playing + return + } + player.pause() + let playerItem = AVPlayerItem(url: url) + player.replaceCurrentItem(with: playerItem) + attachment = audioAttachment + player.play() + playbackState.value = .playing + } + + func addObserver() { + UIDevice.current.isProximityMonitoringEnabled = true + NotificationCenter.default.publisher(for: UIDevice.proximityStateDidChangeNotification, object: nil) + .sink { [weak self] _ in + guard let self = self else { return } + if UIDevice.current.proximityState == true { + do { + try self.session.setCategory(.playAndRecord) + } catch { + print(error) + return + } + } else { + do { + try self.session.setCategory(.playback) + } catch { + print(error) + return + } + } + } + .store(in: &disposeBag) + NotificationCenter.default.publisher(for: VideoPlayerViewModel.appWillPlayVideoNotification) + .sink { [weak self] _ in + guard let self = self else { return } + self.pauseIfNeed() + } + .store(in: &disposeBag) + + timeObserver = player.addPeriodicTimeObserver(forInterval: CMTimeMake(value: 1, timescale: CMTimeScale(NSEC_PER_SEC)), queue: DispatchQueue.main, using: { [weak self] time in + guard let self = self else { return } + self.currentTimeSubject.value = time.seconds + }) + player.publisher(for: \.status, options: .new) + .sink(receiveValue: { [weak self] status in + guard let self = self else { return } + switch status { + case .failed: + self.playbackState.value = .failed + case .readyToPlay: + self.playbackState.value = .readyToPlay + case .unknown: + self.playbackState.value = .unknown + @unknown default: + assertionFailure() + } + }) + .store(in: &disposeBag) + NotificationCenter.default.publisher(for: .AVPlayerItemDidPlayToEndTime, object: nil) + .sink { [weak self] _ in + guard let self = self else { return } + self.player.seek(to: .zero) + self.playbackState.value = .stopped + self.currentTimeSubject.value = 0 + } + .store(in: &disposeBag) + NotificationCenter.default.publisher(for: AVAudioSession.interruptionNotification, object: nil) + .sink { [weak self] _ in + guard let self = self else { return } + self.pause() + } + .store(in: &disposeBag) + } + + func notifyWillPlayAudioNotification() { + NotificationCenter.default.post(name: AudioPlaybackService.appWillPlayAudioNotification, object: nil) + } + func isPlaying() -> Bool { + return playbackState.value == .readyToPlay || playbackState.value == .playing + } + func resume() { + notifyWillPlayAudioNotification() + player.play() + playbackState.value = .playing + } + + func pause() { + player.pause() + playbackState.value = .paused + } + func pauseIfNeed() { + if isPlaying() { + pause() + } + } + func seekToTime(time: TimeInterval) { + player.seek(to: CMTimeMake(value:Int64(time), timescale: 1)) + } +} + +extension AudioPlaybackService { + func viewDidDisappear(from viewController: UIViewController?) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", (#file as NSString).lastPathComponent, #line, #function) + pause() + } +} diff --git a/Mastodon/Service/AuthenticationService.swift b/Mastodon/Service/AuthenticationService.swift index 9fa411f22..6b35486d6 100644 --- a/Mastodon/Service/AuthenticationService.swift +++ b/Mastodon/Service/AuthenticationService.swift @@ -12,9 +12,10 @@ import CoreData import CoreDataStack import MastodonSDK -class AuthenticationService: NSObject { +final class AuthenticationService: NSObject { var disposeBag = Set() + // input weak var apiService: APIService? let managedObjectContext: NSManagedObjectContext // read-only @@ -23,6 +24,7 @@ class AuthenticationService: NSObject { // output let mastodonAuthentications = CurrentValueSubject<[MastodonAuthentication], Never>([]) + let mastodonAuthenticationBoxes = CurrentValueSubject<[AuthenticationService.MastodonAuthenticationBox], Never>([]) let activeMastodonAuthentication = CurrentValueSubject(nil) let activeMastodonAuthenticationBox = CurrentValueSubject(nil) @@ -58,16 +60,24 @@ class AuthenticationService: NSObject { .assign(to: \.value, on: activeMastodonAuthentication) .store(in: &disposeBag) - activeMastodonAuthentication - .map { authentication -> AuthenticationService.MastodonAuthenticationBox? in - guard let authentication = authentication else { return nil } - return AuthenticationService.MastodonAuthenticationBox( - domain: authentication.domain, - userID: authentication.userID, - appAuthorization: Mastodon.API.OAuth.Authorization(accessToken: authentication.appAccessToken), - userAuthorization: Mastodon.API.OAuth.Authorization(accessToken: authentication.userAccessToken) - ) + mastodonAuthentications + .map { authentications -> [AuthenticationService.MastodonAuthenticationBox] in + return authentications + .sorted(by: { $0.activedAt > $1.activedAt }) + .compactMap { authentication -> AuthenticationService.MastodonAuthenticationBox? in + return AuthenticationService.MastodonAuthenticationBox( + domain: authentication.domain, + userID: authentication.userID, + appAuthorization: Mastodon.API.OAuth.Authorization(accessToken: authentication.appAccessToken), + userAuthorization: Mastodon.API.OAuth.Authorization(accessToken: authentication.userAccessToken) + ) + } } + .assign(to: \.value, on: mastodonAuthenticationBoxes) + .store(in: &disposeBag) + + mastodonAuthenticationBoxes + .map { $0.first } .assign(to: \.value, on: activeMastodonAuthenticationBox) .store(in: &disposeBag) @@ -114,16 +124,37 @@ extension AuthenticationService { func signOutMastodonUser(domain: String, userID: MastodonUser.ID) -> AnyPublisher, Never> { var isSignOut = false - return backgroundManagedObjectContext.performChanges { + var _mastodonAutenticationBox: MastodonAuthenticationBox? + let managedObjectContext = backgroundManagedObjectContext + return managedObjectContext.performChanges { let request = MastodonAuthentication.sortedFetchRequest request.predicate = MastodonAuthentication.predicate(domain: domain, userID: userID) request.fetchLimit = 1 - guard let mastodonAutentication = try? self.backgroundManagedObjectContext.fetch(request).first else { + guard let mastodonAutentication = try? managedObjectContext.fetch(request).first else { return } - self.backgroundManagedObjectContext.delete(mastodonAutentication) + _mastodonAutenticationBox = AuthenticationService.MastodonAuthenticationBox( + domain: mastodonAutentication.domain, + userID: mastodonAutentication.userID, + appAuthorization: Mastodon.API.OAuth.Authorization(accessToken: mastodonAutentication.appAccessToken), + userAuthorization: Mastodon.API.OAuth.Authorization(accessToken: mastodonAutentication.userAccessToken) + ) + managedObjectContext.delete(mastodonAutentication) isSignOut = true } + .flatMap { result -> AnyPublisher, Never> in + guard let apiService = self.apiService, + let mastodonAuthenticationBox = _mastodonAutenticationBox else { + return Just(result).eraseToAnyPublisher() + } + + return apiService.cancelSubscription( + mastodonAuthenticationBox: mastodonAuthenticationBox + ) + .map { _ in result } + .catch { _ in Just(result).eraseToAnyPublisher() } + .eraseToAnyPublisher() + } .map { result in return result.map { isSignOut } } diff --git a/Mastodon/Service/BlockDomainService.swift b/Mastodon/Service/BlockDomainService.swift new file mode 100644 index 000000000..036083e60 --- /dev/null +++ b/Mastodon/Service/BlockDomainService.swift @@ -0,0 +1,122 @@ +// +// BlockDomainService.swift +// Mastodon +// +// Created by sxiaojian on 2021/4/29. +// + +import Combine +import CoreData +import CoreDataStack +import Foundation +import MastodonSDK +import OSLog +import UIKit + +final class BlockDomainService { + // input + weak var backgroundManagedObjectContext: NSManagedObjectContext? + weak var authenticationService: AuthenticationService? + + // output + let blockedDomains = CurrentValueSubject<[String], Never>([]) + + init( + backgroundManagedObjectContext: NSManagedObjectContext, + authenticationService: AuthenticationService + ) { + self.backgroundManagedObjectContext = backgroundManagedObjectContext + self.authenticationService = authenticationService + guard let authorizationBox = authenticationService.activeMastodonAuthenticationBox.value else { return } + backgroundManagedObjectContext.perform { + let _blockedDomains: [DomainBlock] = { + let request = DomainBlock.sortedFetchRequest + request.predicate = DomainBlock.predicate(domain: authorizationBox.domain, userID: authorizationBox.userID) + request.returnsObjectsAsFaults = false + do { + return try backgroundManagedObjectContext.fetch(request) + } catch { + assertionFailure(error.localizedDescription) + return [] + } + }() + self.blockedDomains.value = _blockedDomains.map(\.blockedDomain) + } + } + + 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/EmojiService/EmojiService+CustomEmojiViewModel+LoadState.swift b/Mastodon/Service/EmojiService/EmojiService+CustomEmojiViewModel+LoadState.swift new file mode 100644 index 000000000..a03af9bd1 --- /dev/null +++ b/Mastodon/Service/EmojiService/EmojiService+CustomEmojiViewModel+LoadState.swift @@ -0,0 +1,86 @@ +// +// EmojiService+CustomEmojiViewModel+LoadState.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-15. +// + +import os.log +import Foundation +import GameplayKit + +extension EmojiService.CustomEmojiViewModel { + class LoadState: GKState { + weak var viewModel: EmojiService.CustomEmojiViewModel? + + init(viewModel: EmojiService.CustomEmojiViewModel) { + 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 EmojiService.CustomEmojiViewModel.LoadState { + + class Initial: EmojiService.CustomEmojiViewModel.LoadState { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + return stateClass == Loading.self + } + } + + class Loading: EmojiService.CustomEmojiViewModel.LoadState { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + return stateClass == Fail.self || stateClass == Finish.self + } + + override func didEnter(from previousState: GKState?) { + super.didEnter(from: previousState) + guard let viewModel = viewModel, let apiService = viewModel.service?.apiService, let stateMachine = stateMachine else { return } + + apiService.customEmoji(domain: viewModel.domain) + // .receive(on: DispatchQueue.main) + .sink { completion in + switch completion { + case .failure(let error): + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: failed to load custom emojis for %s: %s. Retry 10s later", ((#file as NSString).lastPathComponent), #line, #function, viewModel.domain, error.localizedDescription) + stateMachine.enter(Fail.self) + case .finished: + break + } + } receiveValue: { response in + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: load %ld custom emojis for %s", ((#file as NSString).lastPathComponent), #line, #function, response.value.count, viewModel.domain) + stateMachine.enter(Finish.self) + viewModel.emojis.value = response.value + } + .store(in: &viewModel.disposeBag) + } + } + + class Fail: EmojiService.CustomEmojiViewModel.LoadState { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + return stateClass == Loading.self || stateClass == Finish.self + } + + override func didEnter(from previousState: GKState?) { + super.didEnter(from: previousState) + + guard let stateMachine = stateMachine else { return } + + // retry 10s later + DispatchQueue.main.asyncAfter(deadline: .now() + 10) { + stateMachine.enter(Loading.self) + } + } + } + + class Finish: EmojiService.CustomEmojiViewModel.LoadState { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + // one time task + return false + } + } + +} diff --git a/Mastodon/Service/EmojiService/EmojiService+CustomEmojiViewModel.swift b/Mastodon/Service/EmojiService/EmojiService+CustomEmojiViewModel.swift new file mode 100644 index 000000000..d1b8494d7 --- /dev/null +++ b/Mastodon/Service/EmojiService/EmojiService+CustomEmojiViewModel.swift @@ -0,0 +1,63 @@ +// +// EmojiService+CustomEmojiViewModel.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-15. +// + +import Foundation +import Combine +import GameplayKit +import MastodonSDK + +extension EmojiService { + final class CustomEmojiViewModel { + + var disposeBag = Set() + + // input + let domain: String + weak var service: EmojiService? + + // output + private(set) lazy var stateMachine: GKStateMachine = { + // exclude timeline middle fetcher state + let stateMachine = GKStateMachine(states: [ + LoadState.Initial(viewModel: self), + LoadState.Loading(viewModel: self), + LoadState.Fail(viewModel: self), + LoadState.Finish(viewModel: self), + ]) + stateMachine.enter(LoadState.Initial.self) + return stateMachine + }() + let emojis = CurrentValueSubject<[Mastodon.Entity.Emoji], Never>([]) + let emojiDict = CurrentValueSubject<[String: [Mastodon.Entity.Emoji]], Never>([:]) + + private var learnedEmoji: Set = Set() + + init(domain: String, service: EmojiService) { + self.domain = domain + self.service = service + + emojis + .map { Dictionary(grouping: $0, by: { $0.shortcode }) } + .assign(to: \.value, on: emojiDict) + .store(in: &disposeBag) + } + + func emoji(shortcode: String) -> Mastodon.Entity.Emoji? { + if !learnedEmoji.contains(shortcode) { + learnedEmoji.insert(shortcode) + + DispatchQueue.global().async { + UITextChecker.learnWord(shortcode) + UITextChecker.learnWord(":" + shortcode + ":") + } + } + + return emojiDict.value[shortcode]?.first + } + + } +} diff --git a/Mastodon/Service/EmojiService/EmojiService.swift b/Mastodon/Service/EmojiService/EmojiService.swift new file mode 100644 index 000000000..3883d4bab --- /dev/null +++ b/Mastodon/Service/EmojiService/EmojiService.swift @@ -0,0 +1,46 @@ +// +// EmojiService.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-15. +// + +import os.log +import Foundation +import Combine +import MastodonSDK + +final class EmojiService { + + + weak var apiService: APIService? + + let workingQueue = DispatchQueue(label: "org.joinmastodon.Mastodon.EmojiService.working-queue") + private(set) var customEmojiViewModelDict: [String: CustomEmojiViewModel] = [:] + + init(apiService: APIService) { + self.apiService = apiService + } + +} + +extension EmojiService { + + func dequeueCustomEmojiViewModel(for domain: String) -> CustomEmojiViewModel? { + var _customEmojiViewModel: CustomEmojiViewModel? + workingQueue.sync { + if let viewModel = customEmojiViewModelDict[domain] { + _customEmojiViewModel = viewModel + } else { + let viewModel = CustomEmojiViewModel(domain: domain, service: self) + _customEmojiViewModel = viewModel + + // trigger loading + viewModel.stateMachine.enter(CustomEmojiViewModel.LoadState.Loading.self) + } + } + return _customEmojiViewModel + } + +} + diff --git a/Mastodon/Service/KeyboardResponderService.swift b/Mastodon/Service/KeyboardResponderService.swift index b21737963..d4bf9b58b 100644 --- a/Mastodon/Service/KeyboardResponderService.swift +++ b/Mastodon/Service/KeyboardResponderService.swift @@ -18,9 +18,8 @@ final class KeyboardResponderService { // output let isShow = CurrentValueSubject(false) let state = CurrentValueSubject(.none) - let didEndFrame = CurrentValueSubject(.zero) - let willEndFrame = CurrentValueSubject(.zero) - + let endFrame = CurrentValueSubject(.zero) + private init() { NotificationCenter.default.publisher(for: UIResponder.keyboardWillShowNotification, object: nil) .sink { notification in @@ -38,15 +37,11 @@ final class KeyboardResponderService { NotificationCenter.default.publisher(for: UIResponder.keyboardDidChangeFrameNotification, object: nil) .sink { notification in - guard let endFrame = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect else { return } - self.didEndFrame.value = endFrame self.updateInternalStatus(notification: notification) } .store(in: &disposeBag) NotificationCenter.default.publisher(for: UIResponder.keyboardWillChangeFrameNotification, object: nil) .sink { notification in - guard let endFrame = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect else { return } - self.willEndFrame.value = endFrame self.updateInternalStatus(notification: notification) } .store(in: &disposeBag) @@ -62,6 +57,8 @@ extension KeyboardResponderService { return } + self.endFrame.value = endFrame + guard isLocal else { self.state.value = .notLocal return diff --git a/Mastodon/Service/MastodonAttachmentService/MastodonAttachmentService+UploadState.swift b/Mastodon/Service/MastodonAttachmentService/MastodonAttachmentService+UploadState.swift new file mode 100644 index 000000000..9fd4b1298 --- /dev/null +++ b/Mastodon/Service/MastodonAttachmentService/MastodonAttachmentService+UploadState.swift @@ -0,0 +1,111 @@ +// +// MastodonAttachmentService+UploadState.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-18. +// + +import os.log +import Foundation +import GameplayKit +import Kingfisher +import MastodonSDK + +extension MastodonAttachmentService { + class UploadState: GKState { + weak var service: MastodonAttachmentService? + + init(service: MastodonAttachmentService) { + self.service = service + } + + 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) + service?.uploadStateMachineSubject.send(self) + } + } +} + +extension MastodonAttachmentService.UploadState { + + class Initial: MastodonAttachmentService.UploadState { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + guard service?.authenticationBox != nil else { return false } + if stateClass == Initial.self { + return true + } + + if service?.imageData.value != nil { + return stateClass == Uploading.self + } else { + return stateClass == Fail.self + } + } + } + + class Uploading: MastodonAttachmentService.UploadState { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + return stateClass == Fail.self || stateClass == Finish.self + } + + override func didEnter(from previousState: GKState?) { + super.didEnter(from: previousState) + + guard let service = service, let stateMachine = stateMachine else { return } + guard let authenticationBox = service.authenticationBox else { return } + guard let imageData = service.imageData.value else { return } + + let file: Mastodon.Query.MediaAttachment = { + if imageData.kf.imageFormat == .PNG { + return .png(imageData) + } else { + return .jpeg(imageData) + } + }() + let description = service.description.value + let query = Mastodon.API.Media.UploadMeidaQuery( + file: file, + thumbnail: nil, + description: description, + focus: nil + ) + + service.context.apiService.uploadMedia( + domain: authenticationBox.domain, + query: query, + mastodonAuthenticationBox: authenticationBox + ) + .receive(on: DispatchQueue.main) + .sink { completion in + switch completion { + case .failure(let error): + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: upload attachment fail: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) + service.error.send(error) + case .finished: + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: upload attachment success", ((#file as NSString).lastPathComponent), #line, #function) + break + } + } receiveValue: { response in + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: upload attachment %s success: %s", ((#file as NSString).lastPathComponent), #line, #function, response.value.id, response.value.url) + service.attachment.value = response.value + stateMachine.enter(Finish.self) + } + .store(in: &service.disposeBag) + } + } + + class Fail: MastodonAttachmentService.UploadState { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + // allow discard publishing + return stateClass == Uploading.self || stateClass == Finish.self + } + } + + class Finish: MastodonAttachmentService.UploadState { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + return false + } + } + +} + diff --git a/Mastodon/Service/MastodonAttachmentService/MastodonAttachmentService.swift b/Mastodon/Service/MastodonAttachmentService/MastodonAttachmentService.swift new file mode 100644 index 000000000..fd95d2634 --- /dev/null +++ b/Mastodon/Service/MastodonAttachmentService/MastodonAttachmentService.swift @@ -0,0 +1,138 @@ +// +// MastodonAttachmentService.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-17. +// + +import UIKit +import Combine +import PhotosUI +import Kingfisher +import GameplayKit +import MastodonSDK + +protocol MastodonAttachmentServiceDelegate: AnyObject { + func mastodonAttachmentService(_ service: MastodonAttachmentService, uploadStateDidChange state: MastodonAttachmentService.UploadState?) +} + +final class MastodonAttachmentService { + + var disposeBag = Set() + weak var delegate: MastodonAttachmentServiceDelegate? + + let identifier = UUID() + + // input + let context: AppContext + var authenticationBox: AuthenticationService.MastodonAuthenticationBox? + + // output + // TODO: handle video/GIF/Audio data + let imageData = CurrentValueSubject(nil) + let attachment = CurrentValueSubject(nil) + let description = CurrentValueSubject(nil) + let error = CurrentValueSubject(nil) + + private(set) lazy var uploadStateMachine: GKStateMachine = { + // exclude timeline middle fetcher state + let stateMachine = GKStateMachine(states: [ + UploadState.Initial(service: self), + UploadState.Uploading(service: self), + UploadState.Fail(service: self), + UploadState.Finish(service: self), + ]) + stateMachine.enter(UploadState.Initial.self) + return stateMachine + }() + lazy var uploadStateMachineSubject = CurrentValueSubject(nil) + + init( + context: AppContext, + pickerResult: PHPickerResult, + initalAuthenticationBox: AuthenticationService.MastodonAuthenticationBox? + ) { + self.context = context + self.authenticationBox = initalAuthenticationBox + // end init + + setupServiceObserver() + + PHPickerResultLoader.loadImageData(from: pickerResult) + .sink { [weak self] completion in + guard let self = self else { return } + switch completion { + case .failure(let error): + self.error.value = error + self.uploadStateMachine.enter(UploadState.Fail.self) + case .finished: + break + } + } receiveValue: { [weak self] imageData in + guard let self = self else { return } + self.imageData.value = imageData + self.uploadStateMachine.enter(UploadState.Initial.self) + } + .store(in: &disposeBag) + } + + init( + context: AppContext, + image: UIImage, + initalAuthenticationBox: AuthenticationService.MastodonAuthenticationBox? + ) { + self.context = context + self.authenticationBox = initalAuthenticationBox + // end init + + setupServiceObserver() + + imageData.value = image.jpegData(compressionQuality: 0.75) + uploadStateMachine.enter(UploadState.Initial.self) + } + + init( + context: AppContext, + imageData: Data, + initalAuthenticationBox: AuthenticationService.MastodonAuthenticationBox? + ) { + self.context = context + self.authenticationBox = initalAuthenticationBox + // end init + + setupServiceObserver() + + self.imageData.value = imageData + uploadStateMachine.enter(UploadState.Initial.self) + } + + private func setupServiceObserver() { + uploadStateMachineSubject + .sink { [weak self] state in + guard let self = self else { return } + self.delegate?.mastodonAttachmentService(self, uploadStateDidChange: state) + } + .store(in: &disposeBag) + } + +} + +extension MastodonAttachmentService { + // FIXME: needs reset state for multiple account posting support + func uploading(mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox) -> Bool { + authenticationBox = mastodonAuthenticationBox + return uploadStateMachine.enter(UploadState.self) + } +} + +extension MastodonAttachmentService: Equatable, Hashable { + + static func == (lhs: MastodonAttachmentService, rhs: MastodonAttachmentService) -> Bool { + return lhs.identifier == rhs.identifier + } + + func hash(into hasher: inout Hasher) { + hasher.combine(identifier) + } + +} diff --git a/Mastodon/Service/NotificationService.swift b/Mastodon/Service/NotificationService.swift new file mode 100644 index 000000000..e21a3cff8 --- /dev/null +++ b/Mastodon/Service/NotificationService.swift @@ -0,0 +1,204 @@ +// +// NotificationService.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-22. +// + +import os.log +import UIKit +import Combine +import CoreData +import CoreDataStack +import MastodonSDK +import AppShared + +final class NotificationService { + + var disposeBag = Set() + + let workingQueue = DispatchQueue(label: "org.joinmastodon.Mastodon.NotificationService.working-queue") + + // input + weak var apiService: APIService? + weak var authenticationService: AuthenticationService? + let isNotificationPermissionGranted = CurrentValueSubject(false) + let deviceToken = CurrentValueSubject(nil) + + // output + /// [Token: UserID] + let notificationSubscriptionDict: [String: NotificationViewModel] = [:] + let hasUnreadPushNotification = CurrentValueSubject(false) + let requestRevealNotificationPublisher = PassthroughSubject() + + init( + apiService: APIService, + authenticationService: AuthenticationService + ) { + self.apiService = apiService + self.authenticationService = authenticationService + + authenticationService.mastodonAuthentications + .sink(receiveValue: { [weak self] mastodonAuthentications in + guard let self = self else { return } + + // request permission when sign-in + guard !mastodonAuthentications.isEmpty else { return } + self.requestNotificationPermission() + }) + .store(in: &disposeBag) + + deviceToken + .receive(on: DispatchQueue.main) + .sink { [weak self] deviceToken in + guard let _ = self else { return } + guard let deviceToken = deviceToken else { return } + let token = [UInt8](deviceToken).toHexString() + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: deviceToken: %s", ((#file as NSString).lastPathComponent), #line, #function, token) + } + .store(in: &disposeBag) + } + +} + +extension NotificationService { + private func requestNotificationPermission() { + let center = UNUserNotificationCenter.current() + center.requestAuthorization(options: [.alert, .sound, .badge]) { [weak self] granted, error in + guard let self = self else { return } + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: request notification permission: %s", ((#file as NSString).lastPathComponent), #line, #function, granted ? "granted" : "fail") + + self.isNotificationPermissionGranted.value = granted + + if let _ = error { + // Handle the error here. + } + + // Enable or disable features based on the authorization. + } + } +} + +extension NotificationService { + + func dequeueNotificationViewModel( + mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox + ) -> NotificationViewModel? { + var _notificationSubscription: NotificationViewModel? + workingQueue.sync { + let domain = mastodonAuthenticationBox.domain + let userID = mastodonAuthenticationBox.userID + let key = [domain, userID].joined(separator: "@") + + if let notificationSubscription = notificationSubscriptionDict[key] { + _notificationSubscription = notificationSubscription + } else { + let notificationSubscription = NotificationViewModel(domain: domain, userID: userID) + _notificationSubscription = notificationSubscription + } + } + return _notificationSubscription + } + + func handle(mastodonPushNotification: MastodonPushNotification) { + hasUnreadPushNotification.value = true + + // Subscription maybe failed to cancel when sign-out + // Try cancel again if receive that kind push notification + guard let managedObjectContext = authenticationService?.managedObjectContext else { return } + guard let apiService = apiService else { return } + + managedObjectContext.perform { + let subscriptionRequest = NotificationSubscription.sortedFetchRequest + subscriptionRequest.predicate = NotificationSubscription.predicate(userToken: mastodonPushNotification.accessToken) + let subscriptions = managedObjectContext.safeFetch(subscriptionRequest) + + // note: assert setting remove after cancel subscription + guard let subscription = subscriptions.first else { return } + guard let setting = subscription.setting else { return } + let domain = setting.domain + let userID = setting.userID + + let authenticationRequest = MastodonAuthentication.sortedFetchRequest + authenticationRequest.predicate = MastodonAuthentication.predicate(domain: domain, userID: userID) + let authentication = managedObjectContext.safeFetch(authenticationRequest).first + + guard authentication == nil else { + // do nothing if still sign-in + return + } + + // cancel subscription if sign-out + let accessToken = mastodonPushNotification.accessToken + let mastodonAuthenticationBox = AuthenticationService.MastodonAuthenticationBox( + domain: domain, + userID: userID, + appAuthorization: .init(accessToken: accessToken), + userAuthorization: .init(accessToken: accessToken) + ) + apiService + .cancelSubscription(mastodonAuthenticationBox: mastodonAuthenticationBox) + .sink { completion in + switch completion { + case .failure(let error): + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Push Notification] failed to cancel sign-out user subscription: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) + case .finished: + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Push Notification] cancel sign-out user subscription", ((#file as NSString).lastPathComponent), #line, #function) + } + } receiveValue: { _ in + // do nothing + } + .store(in: &self.disposeBag) + } + } + +} + +// MARK: - NotificationViewModel + +extension NotificationService { + final class NotificationViewModel { + + var disposeBag = Set() + + // input + let domain: String + let userID: Mastodon.Entity.Account.ID + + // output + + init(domain: String, userID: Mastodon.Entity.Account.ID) { + self.domain = domain + self.userID = userID + } + } +} + +extension NotificationService.NotificationViewModel { + func createSubscribeQuery( + deviceToken: Data, + queryData: Mastodon.API.Subscriptions.QueryData, + mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox + ) -> Mastodon.API.Subscriptions.CreateSubscriptionQuery { + let deviceToken = [UInt8](deviceToken).toHexString() + + let appSecret = AppSecret.default + let endpoint = appSecret.notificationEndpoint + "/" + deviceToken + let p256dh = appSecret.notificationPublicKey.x963Representation + let auth = appSecret.notificationAuth + + let query = Mastodon.API.Subscriptions.CreateSubscriptionQuery( + subscription: Mastodon.API.Subscriptions.QuerySubscription( + endpoint: endpoint, + keys: Mastodon.API.Subscriptions.QuerySubscription.Keys( + p256dh: p256dh, + auth: auth + ) + ), + data: queryData + ) + + return query + } + +} diff --git a/Mastodon/Service/PhotoLibraryService.swift b/Mastodon/Service/PhotoLibraryService.swift new file mode 100644 index 000000000..2dcc8f990 --- /dev/null +++ b/Mastodon/Service/PhotoLibraryService.swift @@ -0,0 +1,83 @@ +// +// PhotoLibraryService.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-29. +// + +import os.log +import UIKit +import Combine +import Photos +import AlamofireImage + +final class PhotoLibraryService: NSObject { + +} + +extension PhotoLibraryService { + + enum PhotoLibraryError: Error { + case noPermission + } + +} + +extension PhotoLibraryService { + + func saveImage(url: URL) -> AnyPublisher { + let impactFeedbackGenerator = UIImpactFeedbackGenerator(style: .light) + let notificationFeedbackGenerator = UINotificationFeedbackGenerator() + + return Future { promise in + guard PHPhotoLibrary.authorizationStatus(for: .addOnly) != .denied else { + promise(.failure(PhotoLibraryError.noPermission)) + return + } + + ImageDownloader.default.download(URLRequest(url: url), completion: { [weak self] response in + guard let self = self else { return } + switch response.result { + 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) + promise(.failure(error)) + case .success(let image): + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: download image %s success", ((#file as NSString).lastPathComponent), #line, #function, url.debugDescription) + self.save(image: image) + promise(.success(image)) + } + }) + } + .handleEvents(receiveSubscription: { _ in + impactFeedbackGenerator.impactOccurred() + }, receiveCompletion: { completion in + switch completion { + case .failure: + notificationFeedbackGenerator.notificationOccurred(.error) + case .finished: + notificationFeedbackGenerator.notificationOccurred(.success) + } + }) + .eraseToAnyPublisher() + } + + func save(image: UIImage, withNotificationFeedback: Bool = false) { + UIImageWriteToSavedPhotosAlbum( + image, + self, + #selector(PhotoLibraryService.image(_:didFinishSavingWithError:contextInfo:)), + nil + ) + + // assert no error + if withNotificationFeedback { + let notificationFeedbackGenerator = UINotificationFeedbackGenerator() + notificationFeedbackGenerator.notificationOccurred(.success) + } + } + + @objc private func image(_ image: UIImage, didFinishSavingWithError error: Error?, contextInfo: UnsafeRawPointer) { + // TODO: notify banner + } + +} diff --git a/Mastodon/Service/PlaybackState.swift b/Mastodon/Service/PlaybackState.swift new file mode 100644 index 000000000..75fced7bb --- /dev/null +++ b/Mastodon/Service/PlaybackState.swift @@ -0,0 +1,25 @@ +// +// PlaybackState.swift +// Mastodon +// +// Created by sxiaojian on 2021/3/9. +// + +import Foundation + +public enum PlaybackState : Int { + + case unknown = 0 + + case buffering = 1 + + case readyToPlay = 2 + + case playing = 3 + + case paused = 4 + + case stopped = 5 + + case failed = 6 +} diff --git a/Mastodon/Service/SettingService.swift b/Mastodon/Service/SettingService.swift new file mode 100644 index 000000000..f0375bad2 --- /dev/null +++ b/Mastodon/Service/SettingService.swift @@ -0,0 +1,189 @@ +// +// SettingService.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-25. +// + +import os.log +import UIKit +import Combine +import CoreDataStack +import MastodonSDK + +final class SettingService { + + var disposeBag = Set() + + private var currentSettingUpdateSubscription: AnyCancellable? + + // input + weak var apiService: APIService? + weak var authenticationService: AuthenticationService? + weak var notificationService: NotificationService? + + // output + let settingFetchedResultController: SettingFetchedResultController + let currentSetting = CurrentValueSubject(nil) + + init( + apiService: APIService, + authenticationService: AuthenticationService, + notificationService: NotificationService + ) { + self.apiService = apiService + self.authenticationService = authenticationService + self.notificationService = notificationService + self.settingFetchedResultController = SettingFetchedResultController( + managedObjectContext: authenticationService.managedObjectContext, + additionalPredicate: nil + ) + + // create setting (if non-exist) for authenticated users + authenticationService.mastodonAuthenticationBoxes + .compactMap { [weak self] mastodonAuthenticationBoxes -> AnyPublisher<[AuthenticationService.MastodonAuthenticationBox], Never>? in + guard let self = self else { return nil } + guard let authenticationService = self.authenticationService else { return nil } + guard let activeMastodonAuthenticationBox = mastodonAuthenticationBoxes.first else { return nil } + + let domain = activeMastodonAuthenticationBox.domain + let userID = activeMastodonAuthenticationBox.userID + return authenticationService.backgroundManagedObjectContext.performChanges { + _ = APIService.CoreData.createOrMergeSetting( + into: authenticationService.backgroundManagedObjectContext, + property: Setting.Property( + domain: domain, + userID: userID, + appearanceRaw: SettingsItem.AppearanceMode.automatic.rawValue + ) + ) + } + .map { _ in mastodonAuthenticationBoxes } + .eraseToAnyPublisher() + } + .sink { _ in + // do nothing + } + .store(in: &disposeBag) + + // bind current setting + Publishers.CombineLatest( + authenticationService.activeMastodonAuthenticationBox, + settingFetchedResultController.settings + ) + .sink { [weak self] activeMastodonAuthenticationBox, settings in + guard let self = self else { return } + guard let activeMastodonAuthenticationBox = activeMastodonAuthenticationBox else { return } + let currentSetting = settings.first(where: { setting in + return setting.domain == activeMastodonAuthenticationBox.domain && + setting.userID == activeMastodonAuthenticationBox.userID + }) + self.currentSetting.value = currentSetting + } + .store(in: &disposeBag) + + // observe current setting + currentSetting + .receive(on: DispatchQueue.main) + .sink { [weak self] setting in + guard let self = self else { return } + guard let setting = setting else { + self.currentSettingUpdateSubscription = nil + return + } + + self.currentSettingUpdateSubscription = ManagedObjectObserver.observe(object: setting) + .sink(receiveCompletion: { _ in + // do nothing + }, receiveValue: { change in + guard case .update(let object) = change.changeType, + let setting = object as? Setting else { return } + + // observe apparance mode + switch setting.appearance { + case .automatic: UserDefaults.shared.customUserInterfaceStyle = .unspecified + case .light: UserDefaults.shared.customUserInterfaceStyle = .light + case .dark: UserDefaults.shared.customUserInterfaceStyle = .dark + } + }) + } + .store(in: &disposeBag) + + Publishers.CombineLatest3( + notificationService.deviceToken, + currentSetting.eraseToAnyPublisher(), + authenticationService.activeMastodonAuthenticationBox + ) + .compactMap { [weak self] deviceToken, setting, activeMastodonAuthenticationBox -> AnyPublisher, Error>? in + guard let self = self else { return nil } + guard let deviceToken = deviceToken else { return nil } + guard let setting = setting else { return nil } + guard let authenticationBox = activeMastodonAuthenticationBox else { return nil } + + guard let subscription = setting.activeSubscription else { return nil } + + guard setting.domain == authenticationBox.domain, + setting.userID == authenticationBox.userID else { return nil } + + let _viewModel = self.notificationService?.dequeueNotificationViewModel( + mastodonAuthenticationBox: authenticationBox + ) + guard let viewModel = _viewModel else { return nil } + let queryData = Mastodon.API.Subscriptions.QueryData( + policy: subscription.policy, + alerts: Mastodon.API.Subscriptions.QueryData.Alerts( + favourite: subscription.alert.favourite, + follow: subscription.alert.follow, + reblog: subscription.alert.reblog, + mention: subscription.alert.mention, + poll: subscription.alert.poll + ) + ) + let query = viewModel.createSubscribeQuery( + deviceToken: deviceToken, + queryData: queryData, + mastodonAuthenticationBox: authenticationBox + ) + + return apiService.createSubscription( + subscriptionObjectID: subscription.objectID, + query: query, + mastodonAuthenticationBox: authenticationBox + ) + } + .debounce(for: .seconds(3), scheduler: DispatchQueue.main) // limit subscribe request emit time interval + .sink(receiveValue: { [weak self] publisher in + guard let self = self else { return } + publisher + .sink { completion in + switch completion { + case .failure(let error): + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Push Notification] subscribe failure: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) + case .finished: + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Push Notification] subscribe success", ((#file as NSString).lastPathComponent), #line, #function) + } + } receiveValue: { _ in + // do nothing + } + .store(in: &self.disposeBag) + }) + .store(in: &disposeBag) + } + +} + +extension SettingService { + + static func openSettingsAlertController(title: String, message: String) -> UIAlertController { + let alertController = UIAlertController(title: title, message: message, preferredStyle: .alert) + let settingAction = UIAlertAction(title: L10n.Common.Controls.Actions.settings, style: .default) { _ in + guard let url = URL(string: UIApplication.openSettingsURLString) else { return } + UIApplication.shared.open(url, options: [:], completionHandler: nil) + } + alertController.addAction(settingAction) + let cancelAction = UIAlertAction(title: L10n.Common.Controls.Actions.cancel, style: .cancel, handler: nil) + alertController.addAction(cancelAction) + return alertController + } + +} diff --git a/Mastodon/Service/StatusPrefetchingService.swift b/Mastodon/Service/StatusPrefetchingService.swift new file mode 100644 index 000000000..e1337204b --- /dev/null +++ b/Mastodon/Service/StatusPrefetchingService.swift @@ -0,0 +1,84 @@ +// +// StatusPrefetchingService.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-10. +// + +import os.log +import Foundation +import Combine +import CoreData +import CoreDataStack +import MastodonSDK + +final class StatusPrefetchingService { + + typealias TaskID = String + + let workingQueue = DispatchQueue(label: "org.joinmastodon.Mastodon.StatusPrefetchingService.working-queue") + + var disposeBag = Set() + private(set) var statusPrefetchingDisposeBagDict: [TaskID: AnyCancellable] = [:] + + weak var apiService: APIService? + + init(apiService: APIService) { + self.apiService = apiService + } + +} + +extension StatusPrefetchingService { + + func prefetchReplyTo( + domain: String, + statusObjectID: NSManagedObjectID, + statusID: Mastodon.Entity.Status.ID, + replyToStatusID: Mastodon.Entity.Status.ID, + authorizationBox: AuthenticationService.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/StatusPublishService.swift b/Mastodon/Service/StatusPublishService.swift new file mode 100644 index 000000000..4728af8c1 --- /dev/null +++ b/Mastodon/Service/StatusPublishService.swift @@ -0,0 +1,78 @@ +// +// StatusPublishService.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-26. +// + +import os.log +import Foundation +import Combine +import CoreData +import CoreDataStack +import MastodonSDK + +final class StatusPublishService { + + var disposeBag = Set() + + let workingQueue = DispatchQueue(label: "org.joinmastodon.Mastodon.StatusPublishService.working-queue") + + // input + var viewModels = CurrentValueSubject<[ComposeViewModel], Never>([]) // use strong reference to retain the view models + + // output + let composeViewModelDidUpdatePublisher = PassthroughSubject() + let latestPublishingComposeViewModel = CurrentValueSubject(nil) + + init() { + Publishers.CombineLatest( + viewModels.eraseToAnyPublisher(), + composeViewModelDidUpdatePublisher.eraseToAnyPublisher() + ) + .map { viewModels, _ in viewModels.last } + .assign(to: \.value, on: latestPublishingComposeViewModel) + .store(in: &disposeBag) + } + +} + +extension StatusPublishService { + + func publish(composeViewModel: ComposeViewModel) { + workingQueue.sync { + guard !self.viewModels.value.contains(where: { $0 === composeViewModel }) else { return } + self.viewModels.value = self.viewModels.value + [composeViewModel] + + composeViewModel.publishStateMachinePublisher + .receive(on: DispatchQueue.main) + .sink { [weak self, weak composeViewModel] state in + guard let self = self else { return } + guard let composeViewModel = composeViewModel else { return } + + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: composeViewModelDidUpdate", ((#file as NSString).lastPathComponent), #line, #function) + self.composeViewModelDidUpdatePublisher.send() + + switch state { + case is ComposeViewModel.PublishState.Finish: + self.remove(composeViewModel: composeViewModel) + default: + break + } + } + .store(in: &composeViewModel.disposeBag) // cancel subscription when viewModel dealloc + } + } + + func remove(composeViewModel: ComposeViewModel) { + workingQueue.async { + var viewModels = self.viewModels.value + viewModels.removeAll(where: { $0 === composeViewModel }) + self.viewModels.value = viewModels + + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: composeViewModel removed", ((#file as NSString).lastPathComponent), #line, #function) + + } + } + +} diff --git a/Mastodon/Service/ViedeoPlaybackService.swift b/Mastodon/Service/ViedeoPlaybackService.swift new file mode 100644 index 000000000..523af3103 --- /dev/null +++ b/Mastodon/Service/ViedeoPlaybackService.swift @@ -0,0 +1,142 @@ +// +// ViedeoPlaybackService.swift +// Mastodon +// +// Created by xiaojian sun on 2021/3/10. +// + +import AVKit +import Combine +import CoreDataStack +import Foundation +import os.log + +final class VideoPlaybackService { + var disposeBag = Set() + + let workingQueue = DispatchQueue(label: "org.joinmastodon.Mastodon.VideoPlaybackService.working-queue") + private(set) var viewPlayerViewModelDict: [URL: VideoPlayerViewModel] = [:] + + // only for video kind + weak var latestPlayingVideoPlayerViewModel: VideoPlayerViewModel? +} + +extension VideoPlaybackService { + private func playerViewModel(_ playerViewModel: VideoPlayerViewModel, didUpdateTimeControlStatus: AVPlayer.TimeControlStatus) { + switch playerViewModel.videoKind { + case .gif: + // do nothing + return + case .video: + if playerViewModel.timeControlStatus.value != .paused { + latestPlayingVideoPlayerViewModel = playerViewModel + + // pause other player + for viewModel in viewPlayerViewModelDict.values { + guard viewModel.timeControlStatus.value != .paused else { continue } + guard viewModel !== playerViewModel else { continue } + viewModel.pause() + } + } else { + if latestPlayingVideoPlayerViewModel === playerViewModel { + latestPlayingVideoPlayerViewModel = nil + try? AVAudioSession.sharedInstance().setCategory(.soloAmbient, mode: .default) + } + } + } + } +} + +extension VideoPlaybackService { + func dequeueVideoPlayerViewModel(for media: Attachment) -> VideoPlayerViewModel? { + // Core Data entity not thread-safe. Save attribute before enter working queue + guard let height = media.meta?.original?.height, + let width = media.meta?.original?.width, + let url = URL(string: media.url), + media.type == .gifv || media.type == .video + else { return nil } + + let previewImageURL = media.previewURL.flatMap { URL(string: $0) } + let videoKind: VideoPlayerViewModel.Kind = media.type == .gifv ? .gif : .video + + var _viewModel: VideoPlayerViewModel? + workingQueue.sync { + if let viewModel = viewPlayerViewModelDict[url] { + _viewModel = viewModel + } else { + let viewModel = VideoPlayerViewModel( + previewImageURL: previewImageURL, + videoURL: url, + videoSize: CGSize(width: width, height: height), + videoKind: videoKind + ) + viewPlayerViewModelDict[url] = viewModel + setupListener(for: viewModel) + _viewModel = viewModel + } + } + return _viewModel + } + + func playerViewModel(for playerViewController: AVPlayerViewController) -> VideoPlayerViewModel? { + guard let url = (playerViewController.player?.currentItem?.asset as? AVURLAsset)?.url else { return nil } + return viewPlayerViewModelDict[url] + } + + private func setupListener(for viewModel: VideoPlayerViewModel) { + viewModel.timeControlStatus + .sink { [weak self] timeControlStatus in + guard let self = self else { return } + self.playerViewModel(viewModel, didUpdateTimeControlStatus: timeControlStatus) + } + .store(in: &disposeBag) + + NotificationCenter.default.publisher(for: AudioPlaybackService.appWillPlayAudioNotification) + .sink { [weak self] _ in + guard let self = self else { return } + self.pauseWhenPlayAudio() + } + .store(in: &disposeBag) + } +} + +extension VideoPlaybackService { + func markTransitioning(for status: Status) { + guard let videoAttachment = status.mediaAttachments?.filter({ $0.type == .gifv || $0.type == .video }).first else { return } + guard let videoPlayerViewModel = dequeueVideoPlayerViewModel(for: videoAttachment) else { return } + videoPlayerViewModel.isTransitioning = true + } + + func viewDidDisappear(from viewController: UIViewController?) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", (#file as NSString).lastPathComponent, #line, #function) + + // note: do not retain view controller + // pause all player when view disppear exclude full screen player and other transitioning scene + for viewModel in viewPlayerViewModelDict.values { + guard !viewModel.isTransitioning else { + viewModel.isTransitioning = false + continue + } + guard !viewModel.isFullScreenPresentationing else { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: isFullScreenPresentationing", (#file as NSString).lastPathComponent, #line, #function) + continue + } + guard viewModel.videoKind == .video else { continue } + viewModel.pause() + } + } + + func pauseWhenPlayAudio() { + for viewModel in viewPlayerViewModelDict.values { + guard !viewModel.isTransitioning else { + viewModel.isTransitioning = false + continue + } + guard !viewModel.isFullScreenPresentationing else { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: isFullScreenPresentationing", (#file as NSString).lastPathComponent, #line, #function) + continue + } + viewModel.pause() + } + } +} diff --git a/Mastodon/State/AppContext.swift b/Mastodon/State/AppContext.swift index 08918496b..55d5841f7 100644 --- a/Mastodon/State/AppContext.swift +++ b/Mastodon/State/AppContext.swift @@ -23,7 +23,17 @@ class AppContext: ObservableObject { let apiService: APIService let authenticationService: AuthenticationService - + let emojiService: EmojiService + let audioPlaybackService = AudioPlaybackService() + let videoPlaybackService = VideoPlaybackService() + let statusPrefetchingService: StatusPrefetchingService + let statusPublishService = StatusPublishService() + let notificationService: NotificationService + let settingService: SettingService + + let blockDomainService: BlockDomainService + let photoLibraryService = PhotoLibraryService() + let documentStore: DocumentStore private var documentStoreSubscription: AnyCancellable! @@ -40,11 +50,35 @@ class AppContext: ObservableObject { let _apiService = APIService(backgroundManagedObjectContext: _backgroundManagedObjectContext) apiService = _apiService - authenticationService = AuthenticationService( + let _authenticationService = AuthenticationService( managedObjectContext: _managedObjectContext, backgroundManagedObjectContext: _backgroundManagedObjectContext, apiService: _apiService ) + authenticationService = _authenticationService + + emojiService = EmojiService( + apiService: apiService + ) + statusPrefetchingService = StatusPrefetchingService( + apiService: _apiService + ) + let _notificationService = NotificationService( + apiService: _apiService, + authenticationService: _authenticationService + ) + notificationService = _notificationService + + settingService = SettingService( + apiService: _apiService, + authenticationService: _authenticationService, + notificationService: _notificationService + ) + + blockDomainService = BlockDomainService( + backgroundManagedObjectContext: _backgroundManagedObjectContext, + authenticationService: _authenticationService + ) documentStore = DocumentStore() documentStoreSubscription = documentStore.objectWillChange diff --git a/Mastodon/State/DocumentStore.swift b/Mastodon/State/DocumentStore.swift index b39a29245..8b3f88eb7 100644 --- a/Mastodon/State/DocumentStore.swift +++ b/Mastodon/State/DocumentStore.swift @@ -7,5 +7,10 @@ import UIKit import Combine +import MastodonSDK -class DocumentStore: ObservableObject { } +class DocumentStore: ObservableObject { + let blurhashImageCache = NSCache() + let appStartUpTimestamp = Date() + var defaultRevealStatusDict: [Mastodon.Entity.Status.ID: Bool] = [:] +} diff --git a/Mastodon/Supporting Files/AppDelegate.swift b/Mastodon/Supporting Files/AppDelegate.swift index cfac7f1a5..6c49638da 100644 --- a/Mastodon/Supporting Files/AppDelegate.swift +++ b/Mastodon/Supporting Files/AppDelegate.swift @@ -5,7 +5,10 @@ // Created by MainasuK Cirno on 2021/1/22. // +import os.log import UIKit +import UserNotifications +import AppShared @main class AppDelegate: UIResponder, UIApplicationDelegate { @@ -13,11 +16,17 @@ class AppDelegate: UIResponder, UIApplicationDelegate { let appContext = AppContext() func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { - return true + + AppSecret.default.register() // Update app version info. See: `Settings.bundle` UserDefaults.standard.setValue(UIApplication.appVersion(), forKey: "Mastodon.appVersion") UserDefaults.standard.setValue(UIApplication.appBuild(), forKey: "Mastodon.appBundle") + + UNUserNotificationCenter.current().delegate = self + application.registerForRemoteNotifications() + + return true } // MARK: UISceneSession Lifecycle @@ -37,17 +46,70 @@ class AppDelegate: UIResponder, UIApplicationDelegate { } - extension AppDelegate { - func application(_ application: UIApplication, supportedInterfaceOrientationsFor window: UIWindow?) -> UIInterfaceOrientationMask { - #if DEBUG - return .all - #else - return UIDevice.current.userInterfaceIdiom == .pad ? .all : .portrait - #endif + func application(_ application: UIApplication, supportedInterfaceOrientationsFor window: UIWindow?) -> UIInterfaceOrientationMask { + return UIDevice.current.userInterfaceIdiom == .phone ? .portrait : .all } } +extension AppDelegate { + + func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) { + appContext.notificationService.deviceToken.value = deviceToken + } +} + +// MARK: - UNUserNotificationCenterDelegate +extension AppDelegate: UNUserNotificationCenterDelegate { + + // notification present in the foreground + func userNotificationCenter( + _ center: UNUserNotificationCenter, + willPresent notification: UNNotification, + withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void + ) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Push Notification]", ((#file as NSString).lastPathComponent), #line, #function) + guard let mastodonPushNotification = AppDelegate.mastodonPushNotification(from: notification) else { + completionHandler([]) + return + } + + let notificationID = String(mastodonPushNotification.notificationID) + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Push Notification] notification %s", ((#file as NSString).lastPathComponent), #line, #function, notificationID) + appContext.notificationService.handle(mastodonPushNotification: mastodonPushNotification) + completionHandler([.sound]) + } + + // response to user action for notification + func userNotificationCenter( + _ center: UNUserNotificationCenter, + didReceive response: UNNotificationResponse, + withCompletionHandler completionHandler: @escaping () -> Void + ) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Push Notification]", ((#file as NSString).lastPathComponent), #line, #function) + + guard let mastodonPushNotification = AppDelegate.mastodonPushNotification(from: response.notification) else { + completionHandler() + return + } + + let notificationID = String(mastodonPushNotification.notificationID) + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Push Notification] notification %s", ((#file as NSString).lastPathComponent), #line, #function, notificationID) + appContext.notificationService.handle(mastodonPushNotification: mastodonPushNotification) + appContext.notificationService.requestRevealNotificationPublisher.send(notificationID) + completionHandler() + } + + private static func mastodonPushNotification(from notification: UNNotification) -> MastodonPushNotification? { + guard let plaintext = notification.request.content.userInfo["plaintext"] as? Data, + let mastodonPushNotification = try? JSONDecoder().decode(MastodonPushNotification.self, from: plaintext) else { + return nil + } + + return mastodonPushNotification + } + +} extension AppContext { static var shared: AppContext { diff --git a/Mastodon/Supporting Files/SceneDelegate.swift b/Mastodon/Supporting Files/SceneDelegate.swift index e13395ccd..8dd978a8c 100644 --- a/Mastodon/Supporting Files/SceneDelegate.swift +++ b/Mastodon/Supporting Files/SceneDelegate.swift @@ -6,10 +6,13 @@ // import UIKit +import Combine import CoreDataStack class SceneDelegate: UIResponder, UIWindowSceneDelegate { + var observations = Set() + var window: UIWindow? var coordinator: SceneCoordinator? @@ -27,6 +30,12 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { sceneCoordinator.setup() sceneCoordinator.setupOnboardingIfNeeds(animated: false) window.makeKeyAndVisible() + + UserDefaults.shared.observe(\.customUserInterfaceStyle, options: [.initial, .new]) { [weak self] defaults, _ in + guard let self = self else { return } + self.window?.overrideUserInterfaceStyle = defaults.customUserInterfaceStyle + } + .store(in: &observations) } func sceneDidDisconnect(_ scene: UIScene) { @@ -39,6 +48,10 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { func sceneDidBecomeActive(_ scene: UIScene) { // Called when the scene has moved from an inactive state to an active state. // Use this method to restart any tasks that were paused (or not yet started) when the scene was inactive. + + // reset notification badge + UserDefaults.shared.notificationBadgeCount = 0 + UIApplication.shared.applicationIconBadgeNumber = 0 } func sceneWillResignActive(_ scene: UIScene) { diff --git a/Mastodon/Vender/BlurHashDecode.swift b/Mastodon/Vender/BlurHashDecode.swift new file mode 100644 index 000000000..7fe3b3985 --- /dev/null +++ b/Mastodon/Vender/BlurHashDecode.swift @@ -0,0 +1,146 @@ +import UIKit + +extension UIImage { + public convenience init?(blurHash: String, size: CGSize, punch: Float = 1) { + guard blurHash.count >= 6 else { return nil } + + let sizeFlag = String(blurHash[0]).decode83() + let numY = (sizeFlag / 9) + 1 + let numX = (sizeFlag % 9) + 1 + + let quantisedMaximumValue = String(blurHash[1]).decode83() + let maximumValue = Float(quantisedMaximumValue + 1) / 166 + + guard blurHash.count == 4 + 2 * numX * numY else { return nil } + + let colours: [(Float, Float, Float)] = (0 ..< numX * numY).map { i in + if i == 0 { + let value = String(blurHash[2 ..< 6]).decode83() + return decodeDC(value) + } else { + let value = String(blurHash[4 + i * 2 ..< 4 + i * 2 + 2]).decode83() + return decodeAC(value, maximumValue: maximumValue * punch) + } + } + + let width = Int(size.width) + let height = Int(size.height) + let bytesPerRow = width * 3 + guard let data = CFDataCreateMutable(kCFAllocatorDefault, bytesPerRow * height) else { return nil } + CFDataSetLength(data, bytesPerRow * height) + guard let pixels = CFDataGetMutableBytePtr(data) else { return nil } + + for y in 0 ..< height { + for x in 0 ..< width { + var r: Float = 0 + var g: Float = 0 + var b: Float = 0 + + for j in 0 ..< numY { + for i in 0 ..< numX { + let basis = cos(Float.pi * Float(x) * Float(i) / Float(width)) * cos(Float.pi * Float(y) * Float(j) / Float(height)) + let colour = colours[i + j * numX] + r += colour.0 * basis + g += colour.1 * basis + b += colour.2 * basis + } + } + + let intR = UInt8(linearTosRGB(r)) + let intG = UInt8(linearTosRGB(g)) + let intB = UInt8(linearTosRGB(b)) + + pixels[3 * x + 0 + y * bytesPerRow] = intR + pixels[3 * x + 1 + y * bytesPerRow] = intG + pixels[3 * x + 2 + y * bytesPerRow] = intB + } + } + + let bitmapInfo = CGBitmapInfo(rawValue: CGImageAlphaInfo.none.rawValue) + + guard let provider = CGDataProvider(data: data) else { return nil } + guard let cgImage = CGImage(width: width, height: height, bitsPerComponent: 8, bitsPerPixel: 24, bytesPerRow: bytesPerRow, + space: CGColorSpaceCreateDeviceRGB(), bitmapInfo: bitmapInfo, provider: provider, decode: nil, shouldInterpolate: true, intent: .defaultIntent) else { return nil } + + self.init(cgImage: cgImage) + } +} + +private func decodeDC(_ value: Int) -> (Float, Float, Float) { + let intR = value >> 16 + let intG = (value >> 8) & 255 + let intB = value & 255 + return (sRGBToLinear(intR), sRGBToLinear(intG), sRGBToLinear(intB)) +} + +private func decodeAC(_ value: Int, maximumValue: Float) -> (Float, Float, Float) { + let quantR = value / (19 * 19) + let quantG = (value / 19) % 19 + let quantB = value % 19 + + let rgb = ( + signPow((Float(quantR) - 9) / 9, 2) * maximumValue, + signPow((Float(quantG) - 9) / 9, 2) * maximumValue, + signPow((Float(quantB) - 9) / 9, 2) * maximumValue + ) + + return rgb +} + +private func signPow(_ value: Float, _ exp: Float) -> Float { + return copysign(pow(abs(value), exp), value) +} + +private func linearTosRGB(_ value: Float) -> Int { + let v = max(0, min(1, value)) + if v <= 0.0031308 { return Int(v * 12.92 * 255 + 0.5) } + else { return Int((1.055 * pow(v, 1 / 2.4) - 0.055) * 255 + 0.5) } +} + +private func sRGBToLinear(_ value: Type) -> Float { + let v = Float(Int64(value)) / 255 + if v <= 0.04045 { return v / 12.92 } + else { return pow((v + 0.055) / 1.055, 2.4) } +} + +private let encodeCharacters: [String] = { + return "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#$%*+,-.:;=?@[]^_{|}~".map { String($0) } +}() + +private let decodeCharacters: [String: Int] = { + var dict: [String: Int] = [:] + for (index, character) in encodeCharacters.enumerated() { + dict[character] = index + } + return dict +}() + +extension String { + func decode83() -> Int { + var value: Int = 0 + for character in self { + if let digit = decodeCharacters[String(character)] { + value = value * 83 + digit + } + } + return value + } +} + +private extension String { + subscript (offset: Int) -> Character { + return self[index(startIndex, offsetBy: offset)] + } + + subscript (bounds: CountableClosedRange) -> Substring { + let start = index(startIndex, offsetBy: bounds.lowerBound) + let end = index(startIndex, offsetBy: bounds.upperBound) + return self[start...end] + } + + subscript (bounds: CountableRange) -> Substring { + let start = index(startIndex, offsetBy: bounds.lowerBound) + let end = index(startIndex, offsetBy: bounds.upperBound) + return self[start.. String? { + let pixelWidth = Int(round(size.width * scale)) + let pixelHeight = Int(round(size.height * scale)) + + let context = CGContext( + data: nil, + width: pixelWidth, + height: pixelHeight, + bitsPerComponent: 8, + bytesPerRow: pixelWidth * 4, + space: CGColorSpace(name: CGColorSpace.sRGB)!, + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue + )! + context.scaleBy(x: scale, y: -scale) + context.translateBy(x: 0, y: -size.height) + + UIGraphicsPushContext(context) + draw(at: .zero) + UIGraphicsPopContext() + + guard let cgImage = context.makeImage(), + let dataProvider = cgImage.dataProvider, + let data = dataProvider.data, + let pixels = CFDataGetBytePtr(data) else { + assertionFailure("Unexpected error!") + return nil + } + + let width = cgImage.width + let height = cgImage.height + let bytesPerRow = cgImage.bytesPerRow + + var factors: [(Float, Float, Float)] = [] + for y in 0 ..< components.1 { + for x in 0 ..< components.0 { + let normalisation: Float = (x == 0 && y == 0) ? 1 : 2 + let factor = multiplyBasisFunction(pixels: pixels, width: width, height: height, bytesPerRow: bytesPerRow, bytesPerPixel: cgImage.bitsPerPixel / 8, pixelOffset: 0) { + normalisation * cos(Float.pi * Float(x) * $0 / Float(width)) as Float * cos(Float.pi * Float(y) * $1 / Float(height)) as Float + } + factors.append(factor) + } + } + + let dc = factors.first! + let ac = factors.dropFirst() + + var hash = "" + + let sizeFlag = (components.0 - 1) + (components.1 - 1) * 9 + hash += sizeFlag.encode83(length: 1) + + let maximumValue: Float + if ac.count > 0 { + let actualMaximumValue = ac.map({ max(abs($0.0), abs($0.1), abs($0.2)) }).max()! + let quantisedMaximumValue = Int(max(0, min(82, floor(actualMaximumValue * 166 - 0.5)))) + maximumValue = Float(quantisedMaximumValue + 1) / 166 + hash += quantisedMaximumValue.encode83(length: 1) + } else { + maximumValue = 1 + hash += 0.encode83(length: 1) + } + + hash += encodeDC(dc).encode83(length: 4) + + for factor in ac { + hash += encodeAC(factor, maximumValue: maximumValue).encode83(length: 2) + } + + return hash + } + + private func multiplyBasisFunction(pixels: UnsafePointer, width: Int, height: Int, bytesPerRow: Int, bytesPerPixel: Int, pixelOffset: Int, basisFunction: (Float, Float) -> Float) -> (Float, Float, Float) { + var r: Float = 0 + var g: Float = 0 + var b: Float = 0 + + let buffer = UnsafeBufferPointer(start: pixels, count: height * bytesPerRow) + + for x in 0 ..< width { + for y in 0 ..< height { + let basis = basisFunction(Float(x), Float(y)) + r += basis * sRGBToLinear(buffer[bytesPerPixel * x + pixelOffset + 0 + y * bytesPerRow]) + g += basis * sRGBToLinear(buffer[bytesPerPixel * x + pixelOffset + 1 + y * bytesPerRow]) + b += basis * sRGBToLinear(buffer[bytesPerPixel * x + pixelOffset + 2 + y * bytesPerRow]) + } + } + + let scale = 1 / Float(width * height) + + return (r * scale, g * scale, b * scale) + } +} + +private func encodeDC(_ value: (Float, Float, Float)) -> Int { + let roundedR = linearTosRGB(value.0) + let roundedG = linearTosRGB(value.1) + let roundedB = linearTosRGB(value.2) + return (roundedR << 16) + (roundedG << 8) + roundedB +} + +private func encodeAC(_ value: (Float, Float, Float), maximumValue: Float) -> Int { + let quantR = Int(max(0, min(18, floor(signPow(value.0 / maximumValue, 0.5) * 9 + 9.5)))) + let quantG = Int(max(0, min(18, floor(signPow(value.1 / maximumValue, 0.5) * 9 + 9.5)))) + let quantB = Int(max(0, min(18, floor(signPow(value.2 / maximumValue, 0.5) * 9 + 9.5)))) + + return quantR * 19 * 19 + quantG * 19 + quantB +} + +private func signPow(_ value: Float, _ exp: Float) -> Float { + return copysign(pow(abs(value), exp), value) +} + +private func linearTosRGB(_ value: Float) -> Int { + let v = max(0, min(1, value)) + if v <= 0.0031308 { return Int(v * 12.92 * 255 + 0.5) } + else { return Int((1.055 * pow(v, 1 / 2.4) - 0.055) * 255 + 0.5) } +} + +private func sRGBToLinear(_ value: Type) -> Float { + let v = Float(Int64(value)) / 255 + if v <= 0.04045 { return v / 12.92 } + else { return pow((v + 0.055) / 1.055, 2.4) } +} + +private let encodeCharacters: [String] = { + return "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#$%*+,-.:;=?@[]^_{|}~".map { String($0) } +}() + +extension BinaryInteger { + func encode83(length: Int) -> String { + var result = "" + for i in 1 ... length { + let digit = (Int(self) / pow(83, length - i)) % 83 + result += encodeCharacters[Int(digit)] + } + return result + } +} + +private func pow(_ base: Int, _ exponent: Int) -> Int { + return (0 ..< exponent).reduce(1) { value, _ in value * base } +} diff --git a/Mastodon/Vender/CustomScheduler.swift b/Mastodon/Vender/CustomScheduler.swift new file mode 100644 index 000000000..bf87ce053 --- /dev/null +++ b/Mastodon/Vender/CustomScheduler.swift @@ -0,0 +1,50 @@ +// +// CustomScheduler.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-30. +// + +import Foundation +import Combine + +// Ref: https://stackoverflow.com/a/59069315/3797903 +struct CustomScheduler: Scheduler { + var runLoop: RunLoop + var modes: [RunLoop.Mode] = [.default] + + func schedule(after date: RunLoop.SchedulerTimeType, interval: RunLoop.SchedulerTimeType.Stride, + tolerance: RunLoop.SchedulerTimeType.Stride, options: Never?, + _ action: @escaping () -> Void) -> Cancellable { + let timer = Timer(fire: date.date, interval: interval.magnitude, repeats: true) { timer in + action() + } + for mode in modes { + runLoop.add(timer, forMode: mode) + } + return AnyCancellable { + timer.invalidate() + } + } + + func schedule(after date: RunLoop.SchedulerTimeType, tolerance: RunLoop.SchedulerTimeType.Stride, + options: Never?, _ action: @escaping () -> Void) { + let timer = Timer(fire: date.date, interval: 0, repeats: false) { timer in + timer.invalidate() + action() + } + for mode in modes { + runLoop.add(timer, forMode: mode) + } + } + + func schedule(options: Never?, _ action: @escaping () -> Void) { + runLoop.perform(inModes: modes, block: action) + } + + var now: RunLoop.SchedulerTimeType { RunLoop.SchedulerTimeType(Date()) } + var minimumTolerance: RunLoop.SchedulerTimeType.Stride { RunLoop.SchedulerTimeType.Stride(0.1) } + + typealias SchedulerTimeType = RunLoop.SchedulerTimeType + typealias SchedulerOptions = Never +} diff --git a/Mastodon/Vender/PHPickerResultLoader.swift b/Mastodon/Vender/PHPickerResultLoader.swift new file mode 100644 index 000000000..7e083001c --- /dev/null +++ b/Mastodon/Vender/PHPickerResultLoader.swift @@ -0,0 +1,72 @@ +// +// PHPickerResultLoader.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-18. +// + +import os.log +import Foundation +import Combine +import MobileCoreServices +import PhotosUI + +// load image with low memory usage +// Refs: https://christianselig.com/2020/09/phpickerviewcontroller-efficiently/ +enum PHPickerResultLoader { + + static func loadImageData(from result: PHPickerResult) -> Future { + Future { promise in + result.itemProvider.loadFileRepresentation(forTypeIdentifier: UTType.image.identifier) { url, error in + if let error = error { + promise(.failure(error)) + return + } + + guard let url = url else { + promise(.success(nil)) + return + } + + let sourceOptions = [kCGImageSourceShouldCache: false] as CFDictionary + guard let source = CGImageSourceCreateWithURL(url as CFURL, sourceOptions) else { + return + } + + let downsampleOptions = [ + kCGImageSourceCreateThumbnailFromImageAlways: true, + kCGImageSourceCreateThumbnailWithTransform: true, + kCGImageSourceThumbnailMaxPixelSize: 4096, + ] as CFDictionary + + guard let cgImage = CGImageSourceCreateThumbnailAtIndex(source, 0, downsampleOptions) else { + return + } + + let data = NSMutableData() + guard let imageDestination = CGImageDestinationCreateWithData(data, kUTTypeJPEG, 1, nil) else { + promise(.success(nil)) + return + } + + let isPNG: Bool = { + guard let utType = cgImage.utType else { return false } + return (utType as String) == UTType.png.identifier + }() + + let destinationProperties = [ + kCGImageDestinationLossyCompressionQuality: isPNG ? 1.0 : 0.75 + ] as CFDictionary + + CGImageDestinationAddImage(imageDestination, cgImage, destinationProperties) + CGImageDestinationFinalize(imageDestination) + + let dataSize = ByteCountFormatter.string(fromByteCount: Int64(data.length), countStyle: .memory) + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: load image %s", ((#file as NSString).lastPathComponent), #line, #function, dataSize) + + promise(.success(data as Data)) + } + } + } + +} diff --git a/Mastodon/Vender/TransitioningMath.swift b/Mastodon/Vender/TransitioningMath.swift new file mode 100644 index 000000000..6639b4dd8 --- /dev/null +++ b/Mastodon/Vender/TransitioningMath.swift @@ -0,0 +1,66 @@ +/* + Copyright (C) 2016 Apple Inc. All Rights Reserved. + See LICENSE.txt for this sample’s licensing information + + Abstract: + Convenience math operators + */ + +import QuartzCore + +func clip(_ x0: T, _ x1: T, _ v: T) -> T { + return max(x0, min(x1, v)) +} + +func lerp(_ v0: T, _ v1: T, _ t: T) -> T { + return v0 + (v1 - v0) * t +} + + +func -(lhs: CGPoint, rhs: CGPoint) -> CGVector { + return CGVector(dx: lhs.x - rhs.x, dy: lhs.y - rhs.y) +} + +func -(lhs: CGPoint, rhs: CGVector) -> CGPoint { + return CGPoint(x: lhs.x - rhs.dx, y: lhs.y - rhs.dy) +} + +func -(lhs: CGVector, rhs: CGVector) -> CGVector { + return CGVector(dx: lhs.dx - rhs.dx, dy: lhs.dy - rhs.dy) +} + +func +(lhs: CGPoint, rhs: CGPoint) -> CGVector { + return CGVector(dx: lhs.x + rhs.x, dy: lhs.y + rhs.y) +} + +func +(lhs: CGPoint, rhs: CGVector) -> CGPoint { + return CGPoint(x: lhs.x + rhs.dx, y: lhs.y + rhs.dy) +} + +func +(lhs: CGVector, rhs: CGVector) -> CGVector { + return CGVector(dx: lhs.dx + rhs.dx, dy: lhs.dy + rhs.dy) +} + +func *(left: CGVector, right:CGFloat) -> CGVector { + return CGVector(dx: left.dx * right, dy: left.dy * right) +} + +extension CGPoint { + var vector: CGVector { + return CGVector(dx: x, dy: y) + } +} + +extension CGVector { + var magnitude: CGFloat { + return sqrt(dx*dx + dy*dy) + } + + var point: CGPoint { + return CGPoint(x: dx, y: dy) + } + + func apply(transform t: CGAffineTransform) -> CGVector { + return point.applying(t).vector + } +} diff --git a/Mastodon/Vender/TwitterTextEditor+String.swift b/Mastodon/Vender/TwitterTextEditor+String.swift new file mode 100644 index 000000000..7abdba3a3 --- /dev/null +++ b/Mastodon/Vender/TwitterTextEditor+String.swift @@ -0,0 +1,54 @@ +// +// String.swift +// Example +// +// Copyright 2021 Twitter, Inc. +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +extension String { + @inlinable + var length: Int { + (self as NSString).length + } + + @inlinable + func substring(with range: NSRange) -> String { + (self as NSString).substring(with: range) + } + + func substring(with result: NSTextCheckingResult, at index: Int) -> String? { + guard index < result.numberOfRanges else { + return nil + } + let range = result.range(at: index) + guard range.location != NSNotFound else { + return nil + } + return substring(with: result.range(at: index)) + } + + func firstMatch(pattern: String, + options: NSRegularExpression.Options = [], + range: NSRange? = nil) -> NSTextCheckingResult? + { + guard let regularExpression = try? NSRegularExpression(pattern: pattern, options: options) else { + return nil + } + let range = range ?? NSRange(location: 0, length: length) + return regularExpression.firstMatch(in: self, options: [], range: range) + } + + func matches(pattern: String, + options: NSRegularExpression.Options = [], + range: NSRange? = nil) -> [NSTextCheckingResult] + { + guard let regularExpression = try? NSRegularExpression(pattern: pattern, options: options) else { + return [] + } + let range = range ?? NSRange(location: 0, length: length) + return regularExpression.matches(in: self, options: [], range: range) + } +} diff --git a/MastodonSDK/Sources/MastodonSDK/API/Error/Mastodon+API+Error.swift b/MastodonSDK/Sources/MastodonSDK/API/Error/Mastodon+API+Error.swift index 05540feda..94d063c40 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Error/Mastodon+API+Error.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Error/Mastodon+API+Error.swift @@ -34,27 +34,3 @@ extension Mastodon.API { } } - -extension Mastodon.API.Error: LocalizedError { - - public var errorDescription: String? { - guard let mastodonError = mastodonError else { - return "HTTP \(httpResponseStatus.code)" - } - switch mastodonError { - case .generic(let error): - return error.error - } - } - - public var failureReason: String? { - guard let mastodonError = mastodonError else { - return httpResponseStatus.reasonPhrase - } - switch mastodonError { - case .generic(let error): - return error.errorDescription - } - } - -} diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Account+Credentials.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Account+Credentials.swift new file mode 100644 index 000000000..6f324627b --- /dev/null +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Account+Credentials.swift @@ -0,0 +1,232 @@ +// +// Mastodon+API+Account+Credentials.swift +// +// +// Created by MainasuK Cirno on 2021-3-8. +// + +import Foundation +import Combine + +// MARK: - Account credentials +extension Mastodon.API.Account { + + static func accountsEndpointURL(domain: String) -> URL { + return Mastodon.API.endpointURL(domain: domain).appendingPathComponent("accounts") + } + + /// Register an account + /// + /// Creates a user and account records. + /// + /// - Since: 2.7.0 + /// - Version: 3.3.0 + /// # Last Update + /// 2021/2/9 + /// # Reference + /// [Document](https://docs.joinmastodon.org/methods/accounts/) + /// - Parameters: + /// - session: `URLSession` + /// - domain: Mastodon instance domain. e.g. "example.com" + /// - query: `RegisterQuery` with account registration information + /// - authorization: App token + /// - Returns: `AnyPublisher` contains `Token` nested in the response + public static func register( + session: URLSession, + domain: String, + query: RegisterQuery, + authorization: Mastodon.API.OAuth.Authorization + ) -> AnyPublisher, Error> { + let request = Mastodon.API.post( + url: accountsEndpointURL(domain: domain), + query: query, + authorization: authorization + ) + return session.dataTaskPublisher(for: request) + .tryMap { data, response in + let value = try Mastodon.API.decode(type: Mastodon.Entity.Token.self, from: data, response: response) + return Mastodon.Response.Content(value: value, response: response) + } + .eraseToAnyPublisher() + } + + public struct RegisterQuery: Codable, PostQuery { + public let reason: String? + public let username: String + public let email: String + public let password: String + public let agreement: Bool + public let locale: String + + public init(reason: String? = nil, username: String, email: String, password: String, agreement: Bool, locale: String) { + self.reason = reason + self.username = username + self.email = email + self.password = password + self.agreement = agreement + self.locale = locale + } + } + +} + +extension Mastodon.API.Account { + + static func verifyCredentialsEndpointURL(domain: String) -> URL { + return Mastodon.API.endpointURL(domain: domain).appendingPathComponent("accounts/verify_credentials") + } + + /// Verify account credentials + /// + /// Test to make sure that the user token works. + /// + /// - Since: 0.0.0 + /// - Version: 3.3.0 + /// # Last Update + /// 2021/2/9 + /// # Reference + /// [Document](https://docs.joinmastodon.org/methods/accounts/) + /// - Parameters: + /// - session: `URLSession` + /// - domain: Mastodon instance domain. e.g. "example.com" + /// - authorization: App token + /// - Returns: `AnyPublisher` contains `Account` nested in the response + public static func verifyCredentials( + session: URLSession, + domain: String, + authorization: Mastodon.API.OAuth.Authorization + ) -> AnyPublisher, Error> { + let request = Mastodon.API.get( + url: verifyCredentialsEndpointURL(domain: domain), + query: nil, + authorization: authorization + ) + return session.dataTaskPublisher(for: request) + .tryMap { data, response in + let value = try Mastodon.API.decode(type: Mastodon.Entity.Account.self, from: data, response: response) + return Mastodon.Response.Content(value: value, response: response) + } + .eraseToAnyPublisher() + } + + static func updateCredentialsEndpointURL(domain: String) -> URL { + return Mastodon.API.endpointURL(domain: domain).appendingPathComponent("accounts/update_credentials") + } + + /// Update account credentials + /// + /// Update the user's display and preferences. + /// + /// - Since: 1.1.1 + /// - Version: 3.3.0 + /// # Last Update + /// 2021/2/9 + /// # Reference + /// [Document](https://docs.joinmastodon.org/methods/accounts/) + /// - Parameters: + /// - session: `URLSession` + /// - domain: Mastodon instance domain. e.g. "example.com" + /// - query: `CredentialQuery` with update credential information + /// - authorization: user token + /// - Returns: `AnyPublisher` contains updated `Account` nested in the response + public static func updateCredentials( + session: URLSession, + domain: String, + query: UpdateCredentialQuery, + authorization: Mastodon.API.OAuth.Authorization + ) -> AnyPublisher, Error> { + let request = Mastodon.API.patch( + url: updateCredentialsEndpointURL(domain: domain), + query: query, + authorization: authorization + ) + return session.dataTaskPublisher(for: request) + .tryMap { data, response in + let value = try Mastodon.API.decode(type: Mastodon.Entity.Account.self, from: data, response: response) + return Mastodon.Response.Content(value: value, response: response) + } + .eraseToAnyPublisher() + } + + public struct UpdateCredentialQuery: PatchQuery { + public let discoverable: Bool? + public let bot: Bool? + public let displayName: String? + public let note: String? + public let avatar: Mastodon.Query.MediaAttachment? + public let header: Mastodon.Query.MediaAttachment? + public let locked: Bool? + public let source: Mastodon.Entity.Source? + public let fieldsAttributes: [Mastodon.Entity.Field]? + + enum CodingKeys: String, CodingKey { + case discoverable + case bot + case displayName = "display_name" + case note + + case avatar + case header + case locked + case source + case fieldsAttributes = "fields_attributes" + } + + public init( + discoverable: Bool? = nil, + bot: Bool? = nil, + displayName: String? = nil, + note: String? = nil, + avatar: Mastodon.Query.MediaAttachment? = nil, + header: Mastodon.Query.MediaAttachment? = nil, + locked: Bool? = nil, + source: Mastodon.Entity.Source? = nil, + fieldsAttributes: [Mastodon.Entity.Field]? = nil + ) { + self.discoverable = discoverable + self.bot = bot + self.displayName = displayName + self.note = note + self.avatar = avatar + self.header = header + self.locked = locked + self.source = source + self.fieldsAttributes = fieldsAttributes + } + + var contentType: String? { + return Self.multipartContentType() + } + + var queryItems: [URLQueryItem]? { + return nil + } + + var body: Data? { + var data = Data() + + discoverable.flatMap { data.append(Data.multipart(key: "discoverable", value: $0)) } + bot.flatMap { data.append(Data.multipart(key: "bot", value: $0)) } + displayName.flatMap { data.append(Data.multipart(key: "display_name", value: $0)) } + note.flatMap { data.append(Data.multipart(key: "note", value: $0)) } + avatar.flatMap { data.append(Data.multipart(key: "avatar", value: $0)) } + header.flatMap { data.append(Data.multipart(key: "header", value: $0)) } + locked.flatMap { data.append(Data.multipart(key: "locked", value: $0)) } + if let source = source { + source.privacy.flatMap { data.append(Data.multipart(key: "source[privacy]", value: $0.rawValue)) } + source.sensitive.flatMap { data.append(Data.multipart(key: "source[privacy]", value: $0)) } + source.language.flatMap { data.append(Data.multipart(key: "source[privacy]", value: $0)) } + } + fieldsAttributes.flatMap { fieldsAttributes in + for fieldsAttribute in fieldsAttributes { + data.append(Data.multipart(key: "fields_attributes[name][]", value: fieldsAttribute.name)) + data.append(Data.multipart(key: "fields_attributes[value][]", value: fieldsAttribute.value)) + } + } + + data.append(Data.multipartEnd()) + return data + } + } + +} diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Account+FollowRequest.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Account+FollowRequest.swift new file mode 100644 index 000000000..87c879ea0 --- /dev/null +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Account+FollowRequest.swift @@ -0,0 +1,89 @@ +// +// Mastodon+API+Account+FollowRequest.swift +// +// +// Created by sxiaojian on 2021/4/27. +// + +import Foundation +import Combine + +// MARK: - Account credentials +extension Mastodon.API.Account { + + static func acceptFollowRequestEndpointURL(domain: String, userID: Mastodon.Entity.Account.ID) -> URL { + return Mastodon.API.endpointURL(domain: domain).appendingPathComponent("follow_requests") + .appendingPathComponent(userID) + .appendingPathComponent("authorize") + } + + static func rejectFollowRequestEndpointURL(domain: String, userID: Mastodon.Entity.Account.ID) -> URL { + return Mastodon.API.endpointURL(domain: domain).appendingPathComponent("follow_requests") + .appendingPathComponent(userID) + .appendingPathComponent("reject") + } + + /// Accept Follow + /// + /// + /// - Since: 0.0.0 + /// - Version: 3.3.0 + /// # Reference + /// [Document](https://docs.joinmastodon.org/methods/accounts/follow_requests/) + /// - Parameters: + /// - session: `URLSession` + /// - domain: Mastodon instance domain. e.g. "example.com" + /// - userID: ID of the account in the database + /// - authorization: User token + /// - Returns: `AnyPublisher` contains `Relationship` nested in the response + public static func acceptFollowRequest( + session: URLSession, + domain: String, + userID: Mastodon.Entity.Account.ID, + authorization: Mastodon.API.OAuth.Authorization + ) -> AnyPublisher, Error> { + let request = Mastodon.API.post( + url: acceptFollowRequestEndpointURL(domain: domain, userID: userID), + query: nil, + authorization: authorization + ) + return session.dataTaskPublisher(for: request) + .tryMap { data, response in + let value = try Mastodon.API.decode(type: Mastodon.Entity.Relationship.self, from: data, response: response) + return Mastodon.Response.Content(value: value, response: response) + } + .eraseToAnyPublisher() + } + + /// Reject Follow + /// + /// + /// - Since: 0.0.0 + /// - Version: 3.3.0 + /// # Reference + /// [Document](https://docs.joinmastodon.org/methods/accounts/follow_requests/) + /// - Parameters: + /// - session: `URLSession` + /// - domain: Mastodon instance domain. e.g. "example.com" + /// - userID: ID of the account in the database + /// - authorization: User token + /// - Returns: `AnyPublisher` contains `Relationship` nested in the response + public static func rejectFollowRequest( + session: URLSession, + domain: String, + userID: Mastodon.Entity.Account.ID, + authorization: Mastodon.API.OAuth.Authorization + ) -> AnyPublisher, Error> { + let request = Mastodon.API.post( + url: rejectFollowRequestEndpointURL(domain: domain, userID: userID), + query: nil, + authorization: authorization + ) + return session.dataTaskPublisher(for: request) + .tryMap { data, response in + let value = try Mastodon.API.decode(type: Mastodon.Entity.Relationship.self, from: data, response: response) + return Mastodon.Response.Content(value: value, response: response) + } + .eraseToAnyPublisher() + } +} diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Account+Friendship.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Account+Friendship.swift new file mode 100644 index 000000000..2c0c39b97 --- /dev/null +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Account+Friendship.swift @@ -0,0 +1,416 @@ +// +// Mastodon+API+Account+Friendship.swift +// +// +// Created by MainasuK Cirno on 2021-4-1. +// + +import Foundation +import Combine + +extension Mastodon.API.Account { + + static func accountsRelationshipsEndpointURL(domain: String) -> URL { + return Mastodon.API.endpointURL(domain: domain).appendingPathComponent("accounts/relationships") + } + + /// Check relationships to other accounts + /// + /// Find out whether a given account is followed, blocked, muted, etc. + /// + /// - Since: 0.0.0 + /// - Version: 3.3.0 + /// # Last Update + /// 2021/4/1 + /// # Reference + /// [Document](https://docs.joinmastodon.org/methods/accounts/#perform-actions-on-an-account/) + /// - Parameters: + /// - session: `URLSession` + /// - domain: Mastodon instance domain. e.g. "example.com" + /// - query: `RelationshipQuery` + /// - authorization: User token + /// - Returns: `AnyPublisher` contains `[Relationship]` nested in the response + public static func relationships( + session: URLSession, + domain: String, + query: RelationshipQuery, + authorization: Mastodon.API.OAuth.Authorization + ) -> AnyPublisher, Error> { + let request = Mastodon.API.get( + url: accountsRelationshipsEndpointURL(domain: domain), + query: query, + authorization: authorization + ) + return session.dataTaskPublisher(for: request) + .tryMap { data, response in + let value = try Mastodon.API.decode(type: [Mastodon.Entity.Relationship].self, from: data, response: response) + return Mastodon.Response.Content(value: value, response: response) + } + .eraseToAnyPublisher() + } + + public struct RelationshipQuery: GetQuery { + public let ids: [Mastodon.Entity.Account.ID] + + public init(ids: [Mastodon.Entity.Account.ID]) { + self.ids = ids + } + + var queryItems: [URLQueryItem]? { + var items: [URLQueryItem] = [] + for id in ids { + items.append(URLQueryItem(name: "id[]", value: id)) + } + guard !items.isEmpty else { return nil } + return items + } + } + +} + +extension Mastodon.API.Account { + + public enum FollowQueryType { + case follow(query: FollowQuery) + case unfollow + } + + public static func follow( + session: URLSession, + domain: String, + accountID: Mastodon.Entity.Account.ID, + followQueryType: FollowQueryType, + authorization: Mastodon.API.OAuth.Authorization + ) -> AnyPublisher, Error> { + switch followQueryType { + case .follow(let query): + return follow(session: session, domain: domain, accountID: accountID, query: query, authorization: authorization) + case .unfollow: + return unfollow(session: session, domain: domain, accountID: accountID, authorization: authorization) + } + } + +} + +extension Mastodon.API.Account { + + static func followEndpointURL(domain: String, accountID: Mastodon.Entity.Account.ID) -> URL { + let pathComponent = "accounts/" + accountID + "/follow" + return Mastodon.API.endpointURL(domain: domain).appendingPathComponent(pathComponent) + } + + /// Follow + /// + /// Follow the given account. Can also be used to update whether to show reblogs or enable notifications. + /// + /// - Since: 0.0.0 + /// - Version: 3.3.0 + /// # Last Update + /// 2021/4/1 + /// # Reference + /// [Document](https://docs.joinmastodon.org/methods/accounts/) + /// - Parameters: + /// - session: `URLSession` + /// - domain: Mastodon instance domain. e.g. "example.com" + /// - accountID: id for account + /// - authorization: User token. + /// - Returns: `AnyPublisher` contains `Relationship` nested in the response + public static func follow( + session: URLSession, + domain: String, + accountID: Mastodon.Entity.Account.ID, + query: FollowQuery, + authorization: Mastodon.API.OAuth.Authorization + ) -> AnyPublisher, Error> { + let request = Mastodon.API.post( + url: followEndpointURL(domain: domain, accountID: accountID), + query: query, + authorization: authorization + ) + return session.dataTaskPublisher(for: request) + .tryMap { data, response in + let value = try Mastodon.API.decode(type: Mastodon.Entity.Relationship.self, from: data, response: response) + return Mastodon.Response.Content(value: value, response: response) + } + .eraseToAnyPublisher() + } + + public struct FollowQuery: Codable, PostQuery { + public let reblogs: Bool? + public let notify: Bool? + + public init(reblogs: Bool? = nil , notify: Bool? = nil) { + self.reblogs = reblogs + self.notify = notify + } + } + +} + +extension Mastodon.API.Account { + + static func unfollowEndpointURL(domain: String, accountID: Mastodon.Entity.Account.ID) -> URL { + let pathComponent = "accounts/" + accountID + "/unfollow" + return Mastodon.API.endpointURL(domain: domain).appendingPathComponent(pathComponent) + } + + /// Unfollow + /// + /// Unfollow the given account. + /// + /// - Since: 0.0.0 + /// - Version: 3.3.0 + /// # Last Update + /// 2021/4/1 + /// # Reference + /// [Document](https://docs.joinmastodon.org/methods/accounts/) + /// - Parameters: + /// - session: `URLSession` + /// - domain: Mastodon instance domain. e.g. "example.com" + /// - accountID: id for account + /// - authorization: User token. + /// - Returns: `AnyPublisher` contains `Relationship` nested in the response + public static func unfollow( + session: URLSession, + domain: String, + accountID: Mastodon.Entity.Account.ID, + authorization: Mastodon.API.OAuth.Authorization + ) -> AnyPublisher, Error> { + let request = Mastodon.API.post( + url: unfollowEndpointURL(domain: domain, accountID: accountID), + query: nil, + authorization: authorization + ) + return session.dataTaskPublisher(for: request) + .tryMap { data, response in + let value = try Mastodon.API.decode(type: Mastodon.Entity.Relationship.self, from: data, response: response) + return Mastodon.Response.Content(value: value, response: response) + } + .eraseToAnyPublisher() + } + +} + +extension Mastodon.API.Account { + + public enum BlockQueryType { + case block + case unblock + } + + public static func block( + session: URLSession, + domain: String, + accountID: Mastodon.Entity.Account.ID, + blockQueryType: BlockQueryType, + authorization: Mastodon.API.OAuth.Authorization + ) -> AnyPublisher, Error> { + switch blockQueryType { + case .block: + return block(session: session, domain: domain, accountID: accountID, authorization: authorization) + case .unblock: + return unblock(session: session, domain: domain, accountID: accountID, authorization: authorization) + } + } + +} + +extension Mastodon.API.Account { + + static func blockEndpointURL(domain: String, accountID: Mastodon.Entity.Account.ID) -> URL { + let pathComponent = "accounts/" + accountID + "/block" + return Mastodon.API.endpointURL(domain: domain).appendingPathComponent(pathComponent) + } + + /// Block + /// + /// Block the given account. Clients should filter statuses from this account if received (e.g. due to a boost in the Home timeline). + /// + /// - Since: 0.0.0 + /// - Version: 3.3.0 + /// # Last Update + /// 2021/4/1 + /// # Reference + /// [Document](https://docs.joinmastodon.org/methods/accounts/) + /// - Parameters: + /// - session: `URLSession` + /// - domain: Mastodon instance domain. e.g. "example.com" + /// - accountID: id for account + /// - authorization: User token. + /// - Returns: `AnyPublisher` contains `Relationship` nested in the response + public static func block( + session: URLSession, + domain: String, + accountID: Mastodon.Entity.Account.ID, + authorization: Mastodon.API.OAuth.Authorization + ) -> AnyPublisher, Error> { + let request = Mastodon.API.post( + url: blockEndpointURL(domain: domain, accountID: accountID), + query: nil, + authorization: authorization + ) + return session.dataTaskPublisher(for: request) + .tryMap { data, response in + let value = try Mastodon.API.decode(type: Mastodon.Entity.Relationship.self, from: data, response: response) + return Mastodon.Response.Content(value: value, response: response) + } + .eraseToAnyPublisher() + } + +} + +extension Mastodon.API.Account { + + static func unblockEndpointURL(domain: String, accountID: Mastodon.Entity.Account.ID) -> URL { + let pathComponent = "accounts/" + accountID + "/unblock" + return Mastodon.API.endpointURL(domain: domain).appendingPathComponent(pathComponent) + } + + /// Unblock + /// + /// Unblock the given account. + /// + /// - Since: 0.0.0 + /// - Version: 3.3.0 + /// # Last Update + /// 2021/4/1 + /// # Reference + /// [Document](https://docs.joinmastodon.org/methods/accounts/) + /// - Parameters: + /// - session: `URLSession` + /// - domain: Mastodon instance domain. e.g. "example.com" + /// - accountID: id for account + /// - authorization: User token. + /// - Returns: `AnyPublisher` contains `Relationship` nested in the response + public static func unblock( + session: URLSession, + domain: String, + accountID: Mastodon.Entity.Account.ID, + authorization: Mastodon.API.OAuth.Authorization + ) -> AnyPublisher, Error> { + let request = Mastodon.API.post( + url: unblockEndpointURL(domain: domain, accountID: accountID), + query: nil, + authorization: authorization + ) + return session.dataTaskPublisher(for: request) + .tryMap { data, response in + let value = try Mastodon.API.decode(type: Mastodon.Entity.Relationship.self, from: data, response: response) + return Mastodon.Response.Content(value: value, response: response) + } + .eraseToAnyPublisher() + } + +} + +extension Mastodon.API.Account { + + public enum MuteQueryType { + case mute + case unmute + } + + public static func mute( + session: URLSession, + domain: String, + accountID: Mastodon.Entity.Account.ID, + muteQueryType: MuteQueryType, + authorization: Mastodon.API.OAuth.Authorization + ) -> AnyPublisher, Error> { + switch muteQueryType { + case .mute: + return mute(session: session, domain: domain, accountID: accountID, authorization: authorization) + case .unmute: + return unmute(session: session, domain: domain, accountID: accountID, authorization: authorization) + } + } + +} + +extension Mastodon.API.Account { + + static func mutekEndpointURL(domain: String, accountID: Mastodon.Entity.Account.ID) -> URL { + let pathComponent = "accounts/" + accountID + "/mute" + return Mastodon.API.endpointURL(domain: domain).appendingPathComponent(pathComponent) + } + + /// Mute + /// + /// Mute the given account. Clients should filter statuses and notifications from this account, if received (e.g. due to a boost in the Home timeline). + /// + /// - Since: 0.0.0 + /// - Version: 3.3.0 + /// # Last Update + /// 2021/4/1 + /// # Reference + /// [Document](https://docs.joinmastodon.org/methods/accounts/) + /// - Parameters: + /// - session: `URLSession` + /// - domain: Mastodon instance domain. e.g. "example.com" + /// - accountID: id for account + /// - authorization: User token. + /// - Returns: `AnyPublisher` contains `Relationship` nested in the response + public static func mute( + session: URLSession, + domain: String, + accountID: Mastodon.Entity.Account.ID, + authorization: Mastodon.API.OAuth.Authorization + ) -> AnyPublisher, Error> { + let request = Mastodon.API.post( + url: mutekEndpointURL(domain: domain, accountID: accountID), + query: nil, + authorization: authorization + ) + return session.dataTaskPublisher(for: request) + .tryMap { data, response in + let value = try Mastodon.API.decode(type: Mastodon.Entity.Relationship.self, from: data, response: response) + return Mastodon.Response.Content(value: value, response: response) + } + .eraseToAnyPublisher() + } + +} + +extension Mastodon.API.Account { + + static func unmutekEndpointURL(domain: String, accountID: Mastodon.Entity.Account.ID) -> URL { + let pathComponent = "accounts/" + accountID + "/unmute" + return Mastodon.API.endpointURL(domain: domain).appendingPathComponent(pathComponent) + } + + /// Unmute + /// + /// Unmute the given account. + /// + /// - Since: 0.0.0 + /// - Version: 3.3.0 + /// # Last Update + /// 2021/4/1 + /// # Reference + /// [Document](https://docs.joinmastodon.org/methods/accounts/) + /// - Parameters: + /// - session: `URLSession` + /// - domain: Mastodon instance domain. e.g. "example.com" + /// - accountID: id for account + /// - authorization: User token. + /// - Returns: `AnyPublisher` contains `Relationship` nested in the response + public static func unmute( + session: URLSession, + domain: String, + accountID: Mastodon.Entity.Account.ID, + authorization: Mastodon.API.OAuth.Authorization + ) -> AnyPublisher, Error> { + let request = Mastodon.API.post( + url: unmutekEndpointURL(domain: domain, accountID: accountID), + query: nil, + authorization: authorization + ) + return session.dataTaskPublisher(for: request) + .tryMap { data, response in + let value = try Mastodon.API.decode(type: Mastodon.Entity.Relationship.self, from: data, response: response) + return Mastodon.Response.Content(value: value, response: response) + } + .eraseToAnyPublisher() + } + +} diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Account.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Account.swift index d9b2a4448..d1c5458c4 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Account.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Account.swift @@ -8,119 +8,17 @@ import Foundation import Combine +// MARK: - Retrieve information extension Mastodon.API.Account { - - static func verifyCredentialsEndpointURL(domain: String) -> URL { - return Mastodon.API.endpointURL(domain: domain).appendingPathComponent("accounts/verify_credentials") - } - static func accountsEndpointURL(domain: String) -> URL { - return Mastodon.API.endpointURL(domain: domain).appendingPathComponent("accounts") - } + static func accountsInfoEndpointURL(domain: String, id: String) -> URL { - return Mastodon.API.endpointURL(domain: domain).appendingPathComponent("accounts") + return Mastodon.API.endpointURL(domain: domain) + .appendingPathComponent("accounts") .appendingPathComponent(id) } - static func updateCredentialsEndpointURL(domain: String) -> URL { - return Mastodon.API.endpointURL(domain: domain).appendingPathComponent("accounts/update_credentials") - } - /// Test to make sure that the user token works. + /// Retrieve information /// - /// - Since: 0.0.0 - /// - Version: 3.3.0 - /// # Last Update - /// 2021/2/9 - /// # Reference - /// [Document](https://docs.joinmastodon.org/methods/accounts/) - /// - Parameters: - /// - session: `URLSession` - /// - domain: Mastodon instance domain. e.g. "example.com" - /// - authorization: App token - /// - Returns: `AnyPublisher` contains `Account` nested in the response - public static func verifyCredentials( - session: URLSession, - domain: String, - authorization: Mastodon.API.OAuth.Authorization - ) -> AnyPublisher, Error> { - let request = Mastodon.API.get( - url: verifyCredentialsEndpointURL(domain: domain), - query: nil, - authorization: authorization - ) - return session.dataTaskPublisher(for: request) - .tryMap { data, response in - let value = try Mastodon.API.decode(type: Mastodon.Entity.Account.self, from: data, response: response) - return Mastodon.Response.Content(value: value, response: response) - } - .eraseToAnyPublisher() - } - - /// Creates a user and account records. - /// - /// - Since: 2.7.0 - /// - Version: 3.3.0 - /// # Last Update - /// 2021/2/9 - /// # Reference - /// [Document](https://docs.joinmastodon.org/methods/accounts/) - /// - Parameters: - /// - session: `URLSession` - /// - domain: Mastodon instance domain. e.g. "example.com" - /// - query: `RegisterQuery` with account registration information - /// - authorization: App token - /// - Returns: `AnyPublisher` contains `Token` nested in the response - public static func register( - session: URLSession, - domain: String, - query: RegisterQuery, - authorization: Mastodon.API.OAuth.Authorization - ) -> AnyPublisher, Error> { - let request = Mastodon.API.post( - url: accountsEndpointURL(domain: domain), - query: query, - authorization: authorization - ) - return session.dataTaskPublisher(for: request) - .tryMap { data, response in - let value = try Mastodon.API.decode(type: Mastodon.Entity.Token.self, from: data, response: response) - return Mastodon.Response.Content(value: value, response: response) - } - .eraseToAnyPublisher() - } - - /// Update the user's display and preferences. - /// - /// - Since: 1.1.1 - /// - Version: 3.3.0 - /// # Last Update - /// 2021/2/9 - /// # Reference - /// [Document](https://docs.joinmastodon.org/methods/accounts/) - /// - Parameters: - /// - session: `URLSession` - /// - domain: Mastodon instance domain. e.g. "example.com" - /// - query: `CredentialQuery` with update credential information - /// - authorization: user token - /// - Returns: `AnyPublisher` contains updated `Account` nested in the response - public static func updateCredentials( - session: URLSession, - domain: String, - query: UpdateCredentialQuery, - authorization: Mastodon.API.OAuth.Authorization - ) -> AnyPublisher, Error> { - let request = Mastodon.API.patch( - url: updateCredentialsEndpointURL(domain: domain), - query: query, - authorization: authorization - ) - return session.dataTaskPublisher(for: request) - .tryMap { data, response in - let value = try Mastodon.API.decode(type: Mastodon.Entity.Account.self, from: data, response: response) - return Mastodon.Response.Content(value: value, response: response) - } - .eraseToAnyPublisher() - } - /// View information about a profile. /// /// - Since: 0.0.0 @@ -138,11 +36,11 @@ extension Mastodon.API.Account { public static func accountInfo( session: URLSession, domain: String, - query: AccountInfoQuery, + userID: Mastodon.Entity.Account.ID, authorization: Mastodon.API.OAuth.Authorization? ) -> AnyPublisher, Error> { let request = Mastodon.API.get( - url: accountsInfoEndpointURL(domain: domain, id: query.id), + url: accountsInfoEndpointURL(domain: domain, id: userID), query: nil, authorization: authorization ) @@ -158,76 +56,130 @@ extension Mastodon.API.Account { extension Mastodon.API.Account { - public struct RegisterQuery: Codable, PostQuery { - public let reason: String? - public let username: String - public let email: String - public let password: String - public let agreement: Bool - public let locale: String + static func accountStatusesEndpointURL(domain: String, accountID: Mastodon.Entity.Account.ID) -> URL { + return Mastodon.API.endpointURL(domain: domain).appendingPathComponent("accounts/\(accountID)/statuses") + } + + /// View statuses from followed users. + /// + /// - Since: 0.0.0 + /// - Version: 3.3.0 + /// # Last Update + /// 2021/3/30 + /// # Reference + /// [Document](https://docs.joinmastodon.org/methods/accounts/) + /// - Parameters: + /// - session: `URLSession` + /// - domain: Mastodon instance domain. e.g. "example.com" + /// - query: `AccountStatuseseQuery` with query parameters + /// - authorization: User token + /// - Returns: `AnyPublisher` contains `Token` nested in the response + public static func statuses( + session: URLSession, + domain: String, + accountID: Mastodon.Entity.Account.ID, + query: AccountStatuseseQuery, + authorization: Mastodon.API.OAuth.Authorization + ) -> AnyPublisher, Error> { + let request = Mastodon.API.get( + url: accountStatusesEndpointURL(domain: domain, accountID: accountID), + query: query, + authorization: authorization + ) + return session.dataTaskPublisher(for: request) + .tryMap { data, response in + let value = try Mastodon.API.decode(type: [Mastodon.Entity.Status].self, from: data, response: response) + return Mastodon.Response.Content(value: value, response: response) + } + .eraseToAnyPublisher() + } + + public struct AccountStatuseseQuery: GetQuery { + public let maxID: Mastodon.Entity.Status.ID? + public let sinceID: Mastodon.Entity.Status.ID? + public let excludeReplies: Bool? // undocumented + public let excludeReblogs: Bool? + public let onlyMedia: Bool? + public let limit: Int? - public init(reason: String? = nil, username: String, email: String, password: String, agreement: Bool, locale: String) { - self.reason = reason - self.username = username - self.email = email - self.password = password - self.agreement = agreement - self.locale = locale - } - } - - public struct UpdateCredentialQuery: Codable, PatchQuery { - - public var discoverable: Bool? - public var bot: Bool? - public var displayName: String? - public var note: String? - public var avatar: String? - public var header: String? - public var locked: Bool? - public var source: Mastodon.Entity.Source? - public var fieldsAttributes: [Mastodon.Entity.Field]? - - enum CodingKeys: String, CodingKey { - case discoverable - case bot - case displayName = "display_name" - case note - - case avatar - case header - case locked - case source - case fieldsAttributes = "fields_attributes" - } - public init( - discoverable: Bool? = nil, - bot: Bool? = nil, - displayName: String? = nil, - note: String? = nil, - avatar: Mastodon.Entity.MediaAttachment? = nil, - header: Mastodon.Entity.MediaAttachment? = nil, - locked: Bool? = nil, - source: Mastodon.Entity.Source? = nil, - fieldsAttributes: [Mastodon.Entity.Field]? = nil + maxID: Mastodon.Entity.Status.ID?, + sinceID: Mastodon.Entity.Status.ID?, + excludeReplies: Bool?, + excludeReblogs: Bool?, + onlyMedia: Bool?, + limit: Int? ) { - self.discoverable = discoverable - self.bot = bot - self.displayName = displayName - self.note = note - self.avatar = avatar?.base64EncondedString - self.header = header?.base64EncondedString - self.locked = locked - self.source = source - self.fieldsAttributes = fieldsAttributes + self.maxID = maxID + self.sinceID = sinceID + self.excludeReplies = excludeReplies + self.excludeReblogs = excludeReblogs + self.onlyMedia = onlyMedia + self.limit = limit + } + + var queryItems: [URLQueryItem]? { + var items: [URLQueryItem] = [] + maxID.flatMap { items.append(URLQueryItem(name: "max_id", value: $0)) } + sinceID.flatMap { items.append(URLQueryItem(name: "since_id", value: $0)) } + excludeReplies.flatMap { items.append(URLQueryItem(name: "exclude_replies", value: $0.queryItemValue)) } + excludeReblogs.flatMap { items.append(URLQueryItem(name: "exclude_reblogs", value: $0.queryItemValue)) } + onlyMedia.flatMap { items.append(URLQueryItem(name: "only_media", value: $0.queryItemValue)) } + limit.flatMap { items.append(URLQueryItem(name: "limit", value: String($0))) } + guard !items.isEmpty else { return nil } + return items } } - - public struct AccountInfoQuery: GetQuery { - - public let id: String - - var queryItems: [URLQueryItem]? { nil } - } + +} + +extension Mastodon.API.Account { + static func accountsLookupEndpointURL(domain: String) -> URL { + return Mastodon.API.endpointURL(domain: domain).appendingPathComponent("accounts/lookup") + } + + public struct AccountLookupQuery: GetQuery { + + public var acct: String + + public init(acct: String) { + self.acct = acct + } + + var queryItems: [URLQueryItem]? { + var items: [URLQueryItem] = [] + items.append(URLQueryItem(name: "acct", value: acct)) + return items + } + } + + /// lookup account by acct. + /// + /// - Version: 3.3.1 + + /// - Parameters: + /// - session: `URLSession` + /// - domain: Mastodon instance domain. e.g. "example.com" + /// - query: `AccountInfoQuery` with account query information, + /// - authorization: app token + /// - Returns: `AnyPublisher` contains `Account` nested in the response + public static func lookupAccount( + session: URLSession, + domain: String, + query: AccountLookupQuery, + authorization: Mastodon.API.OAuth.Authorization? + ) -> AnyPublisher, Error> { + let request = Mastodon.API.get( + url: accountsLookupEndpointURL(domain: domain), + query: query, + authorization: authorization + ) + return session.dataTaskPublisher(for: request) + .tryMap { data, response in + let value = try Mastodon.API.decode(type: Mastodon.Entity.Account.self, from: data, response: response) + return Mastodon.Response.Content(value: value, response: response) + } + .eraseToAnyPublisher() + } + } diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+CustomEmojis.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+CustomEmojis.swift new file mode 100644 index 000000000..091e12d11 --- /dev/null +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+CustomEmojis.swift @@ -0,0 +1,48 @@ +// +// Mastodon+API+CustomEmojis.swift +// +// +// Created by MainasuK Cirno on 2021-3-15. +// + +import Foundation +import Combine + +extension Mastodon.API.CustomEmojis { + + static func customEmojisEndpointURL(domain: String) -> URL { + return Mastodon.API.endpointURL(domain: domain).appendingPathComponent("custom_emojis") + } + + /// Custom emoji + /// + /// Returns custom emojis that are available on the server. + /// + /// - Since: 2.0.0 + /// - Version: 3.3.0 + /// # Last Update + /// 2021/3/15 + /// # Reference + /// [Document](https://docs.joinmastodon.org/methods/instance/custom_emojis/) + /// - Parameters: + /// - session: `URLSession` + /// - domain: Mastodon instance domain. e.g. "example.com" + /// - Returns: `AnyPublisher` contains [`Emoji`] nested in the response + public static func customEmojis( + session: URLSession, + domain: String + ) -> AnyPublisher, Error> { + let request = Mastodon.API.get( + url: customEmojisEndpointURL(domain: domain), + query: nil, + authorization: nil + ) + return session.dataTaskPublisher(for: request) + .tryMap { data, response in + let value = try Mastodon.API.decode(type: [Mastodon.Entity.Emoji].self, from: data, response: response) + return Mastodon.Response.Content(value: value, response: response) + } + .eraseToAnyPublisher() + } + +} diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+DomainBlock.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+DomainBlock.swift new file mode 100644 index 000000000..04ed813ab --- /dev/null +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+DomainBlock.swift @@ -0,0 +1,146 @@ +// +// File.swift +// +// +// Created by sxiaojian on 2021/4/29. +// + +import Foundation +import Combine + +extension Mastodon.API.DomainBlock { + static func domainBlockEndpointURL(domain: String) -> URL { + Mastodon.API.endpointURL(domain: domain).appendingPathComponent("domain_blocks") + } + + /// Fetch domain blocks + /// + /// - Since: 1.4.0 + /// # Reference + /// [Document](https://docs.joinmastodon.org/methods/accounts/domain_blocks/) + /// - Parameters: + /// - domain: Mastodon instance domain. e.g. "example.com" + /// - session: `URLSession` + /// - authorization: User token + /// - Returns: `AnyPublisher` contains `String` nested in the response + public static func getDomainblocks( + domain: String, + session: URLSession, + authorization: Mastodon.API.OAuth.Authorization, + query: Mastodon.API.DomainBlock.Query + ) -> AnyPublisher, Error> { + let url = domainBlockEndpointURL(domain: domain) + let request = Mastodon.API.get(url: url, query: query, authorization: authorization) + return session.dataTaskPublisher(for: request) + .tryMap { data, response in + let value = try Mastodon.API.decode(type: [String].self, from: data, response: response) + return Mastodon.Response.Content(value: value, response: response) + } + .eraseToAnyPublisher() + } + + /// Block a domain + /// + /// - Since: 1.4.0 + /// # Reference + /// [Document](https://docs.joinmastodon.org/methods/accounts/domain_blocks/) + /// - Parameters: + /// - domain: Mastodon instance domain. e.g. "example.com" + /// - session: `URLSession` + /// - authorization: User token + /// - Returns: `AnyPublisher` contains `String` nested in the response + public static func blockDomain( + domain: String, + blockDomain:String, + session: URLSession, + authorization: Mastodon.API.OAuth.Authorization + ) -> AnyPublisher, Error> { + let query = Mastodon.API.DomainBlock.BlockQuery(domain: blockDomain) + let request = Mastodon.API.post( + url: domainBlockEndpointURL(domain: domain), + query: query, + authorization: authorization + ) + return session.dataTaskPublisher(for: request) + .tryMap { data, response in + let value = try Mastodon.API.decode(type: Mastodon.Entity.Empty.self, from: data, response: response) + return Mastodon.Response.Content(value: value, response: response) + } + .eraseToAnyPublisher() + } + + /// Unblock a domain + /// + /// - Since: 1.4.0 + /// # Reference + /// [Document](https://docs.joinmastodon.org/methods/accounts/domain_blocks/) + /// - Parameters: + /// - domain: Mastodon instance domain. e.g. "example.com" + /// - session: `URLSession` + /// - authorization: User token + /// - Returns: `AnyPublisher` contains `String` nested in the response + public static func unblockDomain( + domain: String, + blockDomain:String, + session: URLSession, + authorization: Mastodon.API.OAuth.Authorization + ) -> AnyPublisher, Error> { + let query = Mastodon.API.DomainBlock.BlockDeleteQuery(domain: blockDomain) + let request = Mastodon.API.delete( + url: domainBlockEndpointURL(domain: domain), + query: query, + authorization: authorization + ) + return session.dataTaskPublisher(for: request) + .tryMap { data, response in + let value = try Mastodon.API.decode(type: Mastodon.Entity.Empty.self, from: data, response: response) + return Mastodon.Response.Content(value: value, response: response) + } + .eraseToAnyPublisher() + } +} + +extension Mastodon.API.DomainBlock { + public struct Query: GetQuery { + public let maxID: Mastodon.Entity.Status.ID? + public let sinceID: Mastodon.Entity.Status.ID? + public let limit: Int? + + public init( + maxID: Mastodon.Entity.Status.ID?, + sinceID: Mastodon.Entity.Status.ID?, + limit: Int? + ) { + self.maxID = maxID + self.sinceID = sinceID + self.limit = limit + } + + var queryItems: [URLQueryItem]? { + var items: [URLQueryItem] = [] + maxID.flatMap { items.append(URLQueryItem(name: "max_id", value: $0)) } + sinceID.flatMap { items.append(URLQueryItem(name: "since_id", value: $0)) } + limit.flatMap { items.append(URLQueryItem(name: "limit", value: String($0))) } + guard !items.isEmpty else { return nil } + return items + } + } + + public struct BlockDeleteQuery: Codable, DeleteQuery { + + public let domain: String + + public init(domain: String) { + self.domain = domain + } + } + + public struct BlockQuery: Codable, PostQuery { + + public let domain: String + + public init(domain: String) { + self.domain = domain + } + } +} diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Favorites.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Favorites.swift index 6942fa2f1..01b9d61a4 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Favorites.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Favorites.swift @@ -9,51 +9,32 @@ import Combine import Foundation extension Mastodon.API.Favorites { + static func favoritesStatusesEndpointURL(domain: String) -> URL { return Mastodon.API.endpointURL(domain: domain).appendingPathComponent("favourites") } - static func favoriteByUserListsEndpointURL(domain: String, statusID: String) -> URL { - let pathComponent = "statuses/" + statusID + "/favourited_by" - return Mastodon.API.endpointURL(domain: domain).appendingPathComponent(pathComponent) - } - - static func favoriteActionEndpointURL(domain: String, statusID: String, favoriteKind: FavoriteKind) -> URL { - var actionString: String - switch favoriteKind { - case .create: - actionString = "/favourite" - case .destroy: - actionString = "/unfavourite" - } - let pathComponent = "statuses/" + statusID + actionString - return Mastodon.API.endpointURL(domain: domain).appendingPathComponent(pathComponent) - } - - public static func favorites(domain: String, statusID: String, session: URLSession, authorization: Mastodon.API.OAuth.Authorization, favoriteKind: FavoriteKind) -> AnyPublisher, Error> { - let url: URL = favoriteActionEndpointURL(domain: domain, statusID: statusID, favoriteKind: favoriteKind) - var request = Mastodon.API.post(url: url, query: nil, authorization: authorization) - request.httpMethod = "POST" - return session.dataTaskPublisher(for: request) - .tryMap { data, response in - let value = try Mastodon.API.decode(type: Mastodon.Entity.Status.self, from: data, response: response) - return Mastodon.Response.Content(value: value, response: response) - } - .eraseToAnyPublisher() - } - - public static func getFavoriteByUserLists(domain: String, statusID: String, session: URLSession, authorization: Mastodon.API.OAuth.Authorization) -> AnyPublisher, Error> { - let url = favoriteByUserListsEndpointURL(domain: domain, statusID: statusID) - let request = Mastodon.API.get(url: url, query: nil, authorization: authorization) - return session.dataTaskPublisher(for: request) - .tryMap { data, response in - let value = try Mastodon.API.decode(type: [Mastodon.Entity.Account].self, from: data, response: response) - return Mastodon.Response.Content(value: value, response: response) - } - .eraseToAnyPublisher() - } - - public static func getFavoriteStatus(domain: String, session: URLSession, authorization: Mastodon.API.OAuth.Authorization, query: Mastodon.API.Favorites.ListQuery) -> AnyPublisher, Error> { + /// Favourited statuses + /// + /// Using this endpoint to view the favourited list for user + /// + /// - Since: 0.0.0 + /// - Version: 3.3.0 + /// # Last Update + /// 2021/3/3 + /// # Reference + /// [Document](https://docs.joinmastodon.org/methods/accounts/favourites/) + /// - Parameters: + /// - domain: Mastodon instance domain. e.g. "example.com" + /// - session: `URLSession` + /// - authorization: User token + /// - Returns: `AnyPublisher` contains `Server` nested in the response + public static func favoritedStatus( + domain: String, + session: URLSession, + authorization: Mastodon.API.OAuth.Authorization, + query: Mastodon.API.Favorites.FavoriteStatusesQuery + ) -> AnyPublisher, Error> { let url = favoritesStatusesEndpointURL(domain: domain) let request = Mastodon.API.get(url: url, query: query, authorization: authorization) return session.dataTaskPublisher(for: request) @@ -63,15 +44,8 @@ extension Mastodon.API.Favorites { } .eraseToAnyPublisher() } -} - -public extension Mastodon.API.Favorites { - enum FavoriteKind { - case create - case destroy - } - struct ListQuery: GetQuery,TimelineQueryType { + public struct FavoriteStatusesQuery: GetQuery, PagedQueryType { public var limit: Int? public var minID: String? @@ -103,4 +77,101 @@ public extension Mastodon.API.Favorites { return items } } + +} + +extension Mastodon.API.Favorites { + + static func favoriteActionEndpointURL(domain: String, statusID: String, favoriteKind: FavoriteKind) -> URL { + var actionString: String + switch favoriteKind { + case .create: + actionString = "/favourite" + case .destroy: + actionString = "/unfavourite" + } + let pathComponent = "statuses/" + statusID + actionString + return Mastodon.API.endpointURL(domain: domain).appendingPathComponent(pathComponent) + } + + /// Favourite / Undo Favourite + /// + /// Add a status to your favourites list / Remove a status from your favourites list + /// + /// - Since: 0.0.0 + /// - Version: 3.3.0 + /// # Last Update + /// 2021/3/3 + /// # Reference + /// [Document](https://docs.joinmastodon.org/methods/statuses/) + /// - Parameters: + /// - domain: Mastodon instance domain. e.g. "example.com" + /// - statusID: Mastodon status id + /// - session: `URLSession` + /// - authorization: User token + /// - Returns: `AnyPublisher` contains `Server` nested in the response + public static func favorites( + domain: String, + statusID: String, + session: URLSession, + authorization: Mastodon.API.OAuth.Authorization, + favoriteKind: FavoriteKind + ) -> AnyPublisher, Error> { + let url: URL = favoriteActionEndpointURL(domain: domain, statusID: statusID, favoriteKind: favoriteKind) + var request = Mastodon.API.post(url: url, query: nil, authorization: authorization) + request.httpMethod = "POST" + return session.dataTaskPublisher(for: request) + .tryMap { data, response in + let value = try Mastodon.API.decode(type: Mastodon.Entity.Status.self, from: data, response: response) + return Mastodon.Response.Content(value: value, response: response) + } + .eraseToAnyPublisher() + } + + public enum FavoriteKind { + case create + case destroy + } + +} + +extension Mastodon.API.Favorites { + + static func favoriteByUserListsEndpointURL(domain: String, statusID: String) -> URL { + let pathComponent = "statuses/" + statusID + "/favourited_by" + return Mastodon.API.endpointURL(domain: domain).appendingPathComponent(pathComponent) + } + + /// Favourited by + /// + /// View who favourited a given status. + /// + /// - Since: 0.0.0 + /// - Version: 3.3.0 + /// # Last Update + /// 2021/3/3 + /// # Reference + /// [Document](https://docs.joinmastodon.org/methods/statuses/) + /// - Parameters: + /// - domain: Mastodon instance domain. e.g. "example.com" + /// - statusID: Mastodon status id + /// - session: `URLSession` + /// - authorization: User token + /// - Returns: `AnyPublisher` contains `Server` nested in the response + public static func favoriteBy( + domain: String, + statusID: String, + session: URLSession, + authorization: Mastodon.API.OAuth.Authorization + ) -> AnyPublisher, Error> { + let url = favoriteByUserListsEndpointURL(domain: domain, statusID: statusID) + let request = Mastodon.API.get(url: url, query: nil, authorization: authorization) + return session.dataTaskPublisher(for: request) + .tryMap { data, response in + let value = try Mastodon.API.decode(type: [Mastodon.Entity.Account].self, from: data, response: response) + return Mastodon.Response.Content(value: value, response: response) + } + .eraseToAnyPublisher() + } + } diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Media.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Media.swift new file mode 100644 index 000000000..5ae344b3d --- /dev/null +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Media.swift @@ -0,0 +1,136 @@ +// +// Mastodon+API+Media.swift +// +// +// Created by MainasuK Cirno on 2021-3-18. +// + +import Foundation +import Combine + +extension Mastodon.API.Media { + + static func uploadMediaEndpointURL(domain: String) -> URL { + return Mastodon.API.endpointURL(domain: domain).appendingPathComponent("media") + } + + /// Upload media as attachment + /// + /// Creates an attachment to be used with a new status. + /// + /// - Since: 0.0.0 + /// - Version: 3.3.0 + /// # Last Update + /// 2021/3/18 + /// # Reference + /// [Document](https://docs.joinmastodon.org/methods/statuses/media/) + /// - Parameters: + /// - session: `URLSession` + /// - domain: Mastodon instance domain. e.g. "example.com" + /// - query: `UploadMediaQuery` + /// - authorization: User token + /// - Returns: `AnyPublisher` contains `Attachment` nested in the response + public static func uploadMedia( + session: URLSession, + domain: String, + query: UploadMeidaQuery, + authorization: Mastodon.API.OAuth.Authorization? + ) -> AnyPublisher, Error> { + var request = Mastodon.API.post( + url: uploadMediaEndpointURL(domain: domain), + query: query, + authorization: authorization + ) + request.timeoutInterval = 180 // should > 200 Kb/s for 40 MiB media attachment + return session.dataTaskPublisher(for: request) + .tryMap { data, response in + let value = try Mastodon.API.decode(type: Mastodon.Entity.Attachment.self, from: data, response: response) + return Mastodon.Response.Content(value: value, response: response) + } + .eraseToAnyPublisher() + } + + public struct UploadMeidaQuery: PostQuery, PutQuery { + public let file: Mastodon.Query.MediaAttachment? + public let thumbnail: Mastodon.Query.MediaAttachment? + public let description: String? + public let focus: String? + + public init( + file: Mastodon.Query.MediaAttachment?, + thumbnail: Mastodon.Query.MediaAttachment?, + description: String?, + focus: String? + ) { + self.file = file + self.thumbnail = thumbnail + self.description = description + self.focus = focus + } + + var contentType: String? { + return Self.multipartContentType() + } + + var body: Data? { + var data = Data() + + file.flatMap { data.append(Data.multipart(key: "file", value: $0)) } + thumbnail.flatMap { data.append(Data.multipart(key: "thumbnail", value: $0)) } + description.flatMap { data.append(Data.multipart(key: "description", value: $0)) } + focus.flatMap { data.append(Data.multipart(key: "focus", value: $0)) } + + data.append(Data.multipartEnd()) + return data + } + } + +} + +extension Mastodon.API.Media { + + static func updateMediaEndpointURL(domain: String, attachmentID: Mastodon.Entity.Attachment.ID) -> URL { + return Mastodon.API.endpointURL(domain: domain).appendingPathComponent("media").appendingPathComponent(attachmentID) + } + + /// Update attachment + /// + /// Update an Attachment, before it is attached to a status and posted.. + /// + /// - Since: 0.0.0 + /// - Version: 3.3.0 + /// # Last Update + /// 2021/3/18 + /// # Reference + /// [Document](https://docs.joinmastodon.org/methods/statuses/media/) + /// - Parameters: + /// - session: `URLSession` + /// - domain: Mastodon instance domain. e.g. "example.com" + /// - query: `UploadMediaQuery` + /// - authorization: User token + /// - Returns: `AnyPublisher` contains `Attachment` nested in the response + public static func updateMedia( + session: URLSession, + domain: String, + attachmentID: Mastodon.Entity.Attachment.ID, + query: UpdateMediaQuery, + authorization: Mastodon.API.OAuth.Authorization? + ) -> AnyPublisher, Error> { + var request = Mastodon.API.put( + url: updateMediaEndpointURL(domain: domain, attachmentID: attachmentID), + query: query, + authorization: authorization + ) + request.timeoutInterval = 180 // should > 200 Kb/s for 40 MiB media attachment + return session.dataTaskPublisher(for: request) + .tryMap { data, response in + let value = try Mastodon.API.decode(type: Mastodon.Entity.Attachment.self, from: data, response: response) + return Mastodon.Response.Content(value: value, response: response) + } + .eraseToAnyPublisher() + } + + public typealias UpdateMediaQuery = UploadMeidaQuery + +} + diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Notifications.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Notifications.swift new file mode 100644 index 000000000..c6b56c9e9 --- /dev/null +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Notifications.swift @@ -0,0 +1,128 @@ +// +// File.swift +// +// +// Created by BradGao on 2021/4/1. +// + +import Combine +import Foundation + +extension Mastodon.API.Notifications { + internal static func notificationsEndpointURL(domain: String) -> URL { + Mastodon.API.endpointURL(domain: domain).appendingPathComponent("notifications") + } + + internal static func getNotificationEndpointURL(domain: String, notificationID: String) -> URL { + notificationsEndpointURL(domain: domain).appendingPathComponent(notificationID) + } + + /// Get all notifications + /// + /// - Since: 0.0.0 + /// - Version: 3.1.0 + /// # Last Update + /// 2021/4/1 + /// # Reference + /// [Document](https://docs.joinmastodon.org/methods/notifications/) + /// - Parameters: + /// - session: `URLSession` + /// - domain: Mastodon instance domain. e.g. "example.com" + /// - query: `NotificationsQuery` with query parameters + /// - authorization: User token + /// - Returns: `AnyPublisher` contains `Token` nested in the response + public static func getNotifications( + session: URLSession, + domain: String, + query: Mastodon.API.Notifications.Query, + authorization: Mastodon.API.OAuth.Authorization + ) -> AnyPublisher, Error> { + let request = Mastodon.API.get( + url: notificationsEndpointURL(domain: domain), + query: query, + authorization: authorization + ) + return session.dataTaskPublisher(for: request) + .tryMap { data, response in + let value = try Mastodon.API.decode(type: [Mastodon.Entity.Notification].self, from: data, response: response) + return Mastodon.Response.Content(value: value, response: response) + } + .eraseToAnyPublisher() + } + + /// Get a single notification + /// + /// - Since: 0.0.0 + /// - Version: 3.1.0 + /// # Last Update + /// 2021/4/1 + /// # Reference + /// [Document](https://docs.joinmastodon.org/methods/notifications/) + /// - Parameters: + /// - session: `URLSession` + /// - domain: Mastodon instance domain. e.g. "example.com" + /// - notificationID: ID of the notification. + /// - authorization: User token + /// - Returns: `AnyPublisher` contains `Token` nested in the response + public static func getNotification( + session: URLSession, + domain: String, + notificationID: Mastodon.Entity.Notification.ID, + authorization: Mastodon.API.OAuth.Authorization + ) -> AnyPublisher, Error> { + let request = Mastodon.API.get( + url: getNotificationEndpointURL(domain: domain, notificationID: notificationID), + query: nil, + authorization: authorization + ) + return session.dataTaskPublisher(for: request) + .tryMap { data, response in + let value = try Mastodon.API.decode(type: Mastodon.Entity.Notification.self, from: data, response: response) + return Mastodon.Response.Content(value: value, response: response) + } + .eraseToAnyPublisher() + } +} + +extension Mastodon.API.Notifications { + public struct Query: PagedQueryType, GetQuery { + public let maxID: Mastodon.Entity.Status.ID? + public let sinceID: Mastodon.Entity.Status.ID? + public let minID: Mastodon.Entity.Status.ID? + public let limit: Int? + public let excludeTypes: [Mastodon.Entity.Notification.NotificationType]? + public let accountID: String? + + public init( + maxID: Mastodon.Entity.Status.ID? = nil, + sinceID: Mastodon.Entity.Status.ID? = nil, + minID: Mastodon.Entity.Status.ID? = nil, + limit: Int? = nil, + excludeTypes: [Mastodon.Entity.Notification.NotificationType]? = nil, + accountID: String? = nil + ) { + self.maxID = maxID + self.sinceID = sinceID + self.minID = minID + self.limit = limit + self.excludeTypes = excludeTypes + self.accountID = accountID + } + + var queryItems: [URLQueryItem]? { + var items: [URLQueryItem] = [] + maxID.flatMap { items.append(URLQueryItem(name: "max_id", value: $0)) } + sinceID.flatMap { items.append(URLQueryItem(name: "since_id", value: $0)) } + minID.flatMap { items.append(URLQueryItem(name: "min_id", value: $0)) } + limit.flatMap { items.append(URLQueryItem(name: "limit", value: String($0))) } + if let excludeTypes = excludeTypes { + excludeTypes.forEach { + items.append(URLQueryItem(name: "exclude_types[]", value: $0.rawValue)) + } + } + accountID.flatMap { items.append(URLQueryItem(name: "account_id", value: $0)) } + guard !items.isEmpty else { return nil } + return items + } + } +} diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Polls.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Polls.swift new file mode 100644 index 000000000..8ed031413 --- /dev/null +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Polls.swift @@ -0,0 +1,105 @@ +// +// Mastodon+API+Polls.swift +// +// +// Created by MainasuK Cirno on 2021-3-3. +// + +import Foundation +import Combine + +extension Mastodon.API.Polls { + + static func viewPollEndpointURL(domain: String, pollID: Mastodon.Entity.Poll.ID) -> URL { + let pathComponent = "polls/" + pollID + return Mastodon.API.endpointURL(domain: domain).appendingPathComponent(pathComponent) + } + + static func votePollEndpointURL(domain: String, pollID: Mastodon.Entity.Poll.ID) -> URL { + let pathComponent = "polls/" + pollID + "/votes" + return Mastodon.API.endpointURL(domain: domain).appendingPathComponent(pathComponent) + } + + /// View a poll + /// + /// Using this endpoint to view the poll of status + /// + /// - Since: 2.8.0 + /// - Version: 3.3.0 + /// # Last Update + /// 2021/3/3 + /// # Reference + /// [Document](https://docs.joinmastodon.org/methods/statuses/polls/) + /// - Parameters: + /// - session: `URLSession` + /// - domain: Mastodon instance domain. e.g. "example.com" + /// - pollID: id for poll + /// - authorization: User token. Could be nil if status is public + /// - Returns: `AnyPublisher` contains `Poll` nested in the response + public static func poll( + session: URLSession, + domain: String, + pollID: Mastodon.Entity.Poll.ID, + authorization: Mastodon.API.OAuth.Authorization? + ) -> AnyPublisher, Error> { + let request = Mastodon.API.get( + url: viewPollEndpointURL(domain: domain, pollID: pollID), + query: nil, + authorization: authorization + ) + return session.dataTaskPublisher(for: request) + .tryMap { data, response in + let value = try Mastodon.API.decode(type: Mastodon.Entity.Poll.self, from: data, response: response) + return Mastodon.Response.Content(value: value, response: response) + } + .eraseToAnyPublisher() + } + + /// Vote on a poll + /// + /// Using this endpoint to vote an option of poll + /// + /// - Since: 2.8.0 + /// - Version: 3.3.0 + /// # Last Update + /// 2021/3/4 + /// # Reference + /// [Document](https://docs.joinmastodon.org/methods/statuses/polls/) + /// - Parameters: + /// - session: `URLSession` + /// - domain: Mastodon instance domain. e.g. "example.com" + /// - pollID: id for poll + /// - query: `VoteQuery` + /// - authorization: User token + /// - Returns: `AnyPublisher` contains `Poll` nested in the response + public static func vote( + session: URLSession, + domain: String, + pollID: Mastodon.Entity.Poll.ID, + query: VoteQuery, + authorization: Mastodon.API.OAuth.Authorization + ) -> AnyPublisher, Error> { + let request = Mastodon.API.post( + url: votePollEndpointURL(domain: domain, pollID: pollID), + query: query, + authorization: authorization + ) + return session.dataTaskPublisher(for: request) + .tryMap { data, response in + let value = try Mastodon.API.decode(type: Mastodon.Entity.Poll.self, from: data, response: response) + return Mastodon.Response.Content(value: value, response: response) + } + .eraseToAnyPublisher() + } + +} + +extension Mastodon.API.Polls { + public struct VoteQuery: Codable, PostQuery { + public let choices: [Int] + + public init(choices: [Int]) { + self.choices = choices + } + } +} diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Push.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Push.swift new file mode 100644 index 000000000..b66b89aad --- /dev/null +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Push.swift @@ -0,0 +1,262 @@ +// +// File.swift +// +// +// Created by ihugo on 2021/4/9. +// + +import Foundation +import Combine + +extension Mastodon.API.Subscriptions { + + static func pushEndpointURL(domain: String) -> URL { + return Mastodon.API.endpointURL(domain: domain).appendingPathComponent("push/subscription") + } + + /// Get current subscription + /// + /// Using this endpoint to get current subscription + /// + /// - Since: 2.4.0 + /// - Version: 3.3.0 + /// # Last Update + /// 2021/4/25 + /// # Reference + /// [Document](https://docs.joinmastodon.org/methods/notifications/push/) + /// - Parameters: + /// - session: `URLSession` + /// - domain: Mastodon instance domain. e.g. "example.com" + /// - authorization: User token. Could be nil if status is public + /// - Returns: `AnyPublisher` contains `Subscription` nested in the response + public static func subscription( + session: URLSession, + domain: String, + authorization: Mastodon.API.OAuth.Authorization + ) -> AnyPublisher, Error> { + let request = Mastodon.API.get( + url: pushEndpointURL(domain: domain), + query: nil, + authorization: authorization + ) + return session.dataTaskPublisher(for: request) + .tryMap { data, response in + let value = try Mastodon.API.decode(type: Mastodon.Entity.Subscription.self, from: data, response: response) + return Mastodon.Response.Content(value: value, response: response) + } + .eraseToAnyPublisher() + } + + /// Subscribe to push notifications + /// + /// Add a Web Push API subscription to receive notifications. Each access token can have one push subscription. If you create a new subscription, the old subscription is deleted. + /// + /// - Since: 2.4.0 + /// - Version: 3.3.0 + /// # Last Update + /// 2021/4/25 + /// # Reference + /// [Document](https://docs.joinmastodon.org/methods/notifications/push/) + /// - Parameters: + /// - session: `URLSession` + /// - domain: Mastodon instance domain. e.g. "example.com" + /// - authorization: User token. Could be nil if status is public + /// - Returns: `AnyPublisher` contains `Subscription` nested in the response + public static func createSubscription( + session: URLSession, + domain: String, + authorization: Mastodon.API.OAuth.Authorization, + query: CreateSubscriptionQuery + ) -> AnyPublisher, Error> { + let request = Mastodon.API.post( + url: pushEndpointURL(domain: domain), + query: query, + authorization: authorization + ) + return session.dataTaskPublisher(for: request) + .tryMap { data, response in + let value = try Mastodon.API.decode(type: Mastodon.Entity.Subscription.self, from: data, response: response) + return Mastodon.Response.Content(value: value, response: response) + } + .eraseToAnyPublisher() + } + + /// Change types of notifications + /// + /// Updates the current push subscription. Only the data part can be updated. To change fundamentals, a new subscription must be created instead. + /// + /// - Since: 2.4.0 + /// - Version: 3.3.0 + /// # Last Update + /// 2021/4/25 + /// # Reference + /// [Document](https://docs.joinmastodon.org/methods/notifications/push/) + /// - Parameters: + /// - session: `URLSession` + /// - domain: Mastodon instance domain. e.g. "example.com" + /// - authorization: User token. Could be nil if status is public + /// - Returns: `AnyPublisher` contains `Subscription` nested in the response + public static func updateSubscription( + session: URLSession, + domain: String, + authorization: Mastodon.API.OAuth.Authorization, + query: UpdateSubscriptionQuery + ) -> AnyPublisher, Error> { + let request = Mastodon.API.put( + url: pushEndpointURL(domain: domain), + query: query, + authorization: authorization + ) + return session.dataTaskPublisher(for: request) + .tryMap { data, response in + let value = try Mastodon.API.decode(type: Mastodon.Entity.Subscription.self, from: data, response: response) + return Mastodon.Response.Content(value: value, response: response) + } + .eraseToAnyPublisher() + } + + /// Remove current subscription + /// + /// Removes the current Web Push API subscription. + /// + /// - Since: 2.4.0 + /// - Version: 3.3.0 + /// # Last Update + /// 2021/4/26 + /// # Reference + /// [Document](https://docs.joinmastodon.org/methods/notifications/push/) + /// - Parameters: + /// - session: `URLSession` + /// - domain: Mastodon instance domain. e.g. "example.com" + /// - authorization: User token. Could be nil if status is public + /// - Returns: `AnyPublisher` contains `Subscription` nested in the response + public static func removeSubscription( + session: URLSession, + domain: String, + authorization: Mastodon.API.OAuth.Authorization + ) -> AnyPublisher, Error> { + let request = Mastodon.API.delete( + url: pushEndpointURL(domain: domain), + query: nil, + authorization: authorization + ) + return session.dataTaskPublisher(for: request) + .tryMap { data, response in + let value = try Mastodon.API.decode(type: Mastodon.Entity.EmptySubscription.self, from: data, response: response) + return Mastodon.Response.Content(value: value, response: response) + } + .eraseToAnyPublisher() + } +} + +extension Mastodon.API.Subscriptions { + + public typealias Policy = QueryData.Policy + + public struct QuerySubscription: Codable { + let endpoint: String + let keys: Keys + + public init( + endpoint: String, + keys: Keys + ) { + self.endpoint = endpoint + self.keys = keys + } + + public struct Keys: Codable { + let p256dh: String + let auth: String + + public init(p256dh: Data, auth: Data) { + self.p256dh = p256dh.base64UrlEncodedString() + self.auth = auth.base64UrlEncodedString() + } + } + } + + public struct QueryData: Codable { + let policy: Policy? + let alerts: Alerts + + public init( + policy: Policy?, + alerts: Mastodon.API.Subscriptions.QueryData.Alerts + ) { + self.policy = policy + self.alerts = alerts + } + + public struct Alerts: Codable { + let favourite: Bool? + let follow: Bool? + let reblog: Bool? + let mention: Bool? + let poll: Bool? + + public init(favourite: Bool?, follow: Bool?, reblog: Bool?, mention: Bool?, poll: Bool?) { + self.favourite = favourite + self.follow = follow + self.reblog = reblog + self.mention = mention + self.poll = poll + } + } + + public enum Policy: RawRepresentable, Codable { + case all + case followed + case follower + case none + + case _other(String) + + public init?(rawValue: String) { + switch rawValue { + case "all": self = .all + case "followed": self = .followed + case "follower": self = .follower + case "none": self = .none + + default: self = ._other(rawValue) + } + } + + public var rawValue: String { + switch self { + case .all: return "all" + case .followed: return "followed" + case .follower: return "follower" + case .none: return "none" + case ._other(let value): return value + } + } + } + } + + + public struct CreateSubscriptionQuery: Codable, PostQuery { + let subscription: QuerySubscription + let data: QueryData + + public init( + subscription: Mastodon.API.Subscriptions.QuerySubscription, + data: Mastodon.API.Subscriptions.QueryData + ) { + self.subscription = subscription + self.data = data + } + } + + public struct UpdateSubscriptionQuery: Codable, PutQuery { + + let data: QueryData + + public init(data: Mastodon.API.Subscriptions.QueryData) { + self.data = data + } + + var queryItems: [URLQueryItem]? { nil } + } +} diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Reblog.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Reblog.swift new file mode 100644 index 000000000..cb7a96188 --- /dev/null +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Reblog.swift @@ -0,0 +1,176 @@ +// +// Mastodon+API+Status+Reblog.swift +// +// +// Created by MainasuK Cirno on 2021-3-9. +// + +import Foundation +import Combine + +extension Mastodon.API.Reblog { + + static func rebloggedByEndpointURL(domain: String, statusID: Mastodon.Entity.Status.ID) -> URL { + let pathComponent = "statuses/" + statusID + "/reblogged_by" + return Mastodon.API.endpointURL(domain: domain).appendingPathComponent(pathComponent) + } + + /// Boosted by + /// + /// View who boosted a given status. + /// + /// - Since: 0.0.0 + /// - Version: 3.3.0 + /// # Last Update + /// 2021/3/15 + /// # Reference + /// [Document](https://docs.joinmastodon.org/methods/statuses/) + /// - Parameters: + /// - session: `URLSession` + /// - domain: Mastodon instance domain. e.g. "example.com" + /// - statusID: id for status + /// - authorization: User token. Could be nil if status is public + /// - Returns: `AnyPublisher` contains `Status` nested in the response + public static func rebloggedBy( + session: URLSession, + domain: String, + statusID: Mastodon.Entity.Status.ID, + authorization: Mastodon.API.OAuth.Authorization? + ) -> AnyPublisher, Error> { + let request = Mastodon.API.get( + url: rebloggedByEndpointURL(domain: domain, statusID: statusID), + query: nil, + authorization: authorization + ) + return session.dataTaskPublisher(for: request) + .tryMap { data, response in + let value = try Mastodon.API.decode(type: Mastodon.Entity.Status.self, from: data, response: response) + return Mastodon.Response.Content(value: value, response: response) + } + .eraseToAnyPublisher() + } + +} + +extension Mastodon.API.Reblog { + + static func reblogEndpointURL(domain: String, statusID: Mastodon.Entity.Status.ID) -> URL { + let pathComponent = "statuses/" + statusID + "/reblog" + return Mastodon.API.endpointURL(domain: domain).appendingPathComponent(pathComponent) + } + + /// Boost + /// + /// Reshare a status. + /// + /// - Since: 0.0.0 + /// - Version: 3.3.0 + /// # Last Update + /// 2021/3/15 + /// # Reference + /// [Document](https://docs.joinmastodon.org/methods/statuses/) + /// - Parameters: + /// - session: `URLSession` + /// - domain: Mastodon instance domain. e.g. "example.com" + /// - statusID: id for status + /// - authorization: User token. + /// - Returns: `AnyPublisher` contains `Status` nested in the response + public static func reblog( + session: URLSession, + domain: String, + statusID: Mastodon.Entity.Status.ID, + query: ReblogQuery, + authorization: Mastodon.API.OAuth.Authorization + ) -> AnyPublisher, Error> { + let request = Mastodon.API.post( + url: reblogEndpointURL(domain: domain, statusID: statusID), + query: nil, + authorization: authorization + ) + return session.dataTaskPublisher(for: request) + .tryMap { data, response in + let value = try Mastodon.API.decode(type: Mastodon.Entity.Status.self, from: data, response: response) + return Mastodon.Response.Content(value: value, response: response) + } + .eraseToAnyPublisher() + } + + public typealias Visibility = Mastodon.Entity.Source.Privacy + + public struct ReblogQuery: Codable, PostQuery { + public let visibility: Visibility? + + public init(visibility: Visibility?) { + self.visibility = visibility + } + } + +} + +extension Mastodon.API.Reblog { + + static func unreblogEndpointURL(domain: String, statusID: Mastodon.Entity.Status.ID) -> URL { + let pathComponent = "statuses/" + statusID + "/unreblog" + return Mastodon.API.endpointURL(domain: domain).appendingPathComponent(pathComponent) + } + + /// Undo reblog + /// + /// Undo a reshare of a status. + /// + /// - Since: 0.0.0 + /// - Version: 3.3.0 + /// # Last Update + /// 2021/3/9 + /// # Reference + /// [Document](https://docs.joinmastodon.org/methods/statuses/) + /// - Parameters: + /// - session: `URLSession` + /// - domain: Mastodon instance domain. e.g. "example.com" + /// - statusID: id for status + /// - authorization: User token. + /// - Returns: `AnyPublisher` contains `Status` nested in the response + public static func undoReblog( + session: URLSession, + domain: String, + statusID: Mastodon.Entity.Status.ID, + authorization: Mastodon.API.OAuth.Authorization + ) -> AnyPublisher, Error> { + let request = Mastodon.API.post( + url: unreblogEndpointURL(domain: domain, statusID: statusID), + query: nil, + authorization: authorization + ) + return session.dataTaskPublisher(for: request) + .tryMap { data, response in + let value = try Mastodon.API.decode(type: Mastodon.Entity.Status.self, from: data, response: response) + return Mastodon.Response.Content(value: value, response: response) + } + .eraseToAnyPublisher() + } + +} + +extension Mastodon.API.Reblog { + + public enum ReblogKind { + case reblog(query: ReblogQuery) + case undoReblog + } + + public static func reblog( + session: URLSession, + domain: String, + statusID: Mastodon.Entity.Status.ID, + reblogKind: ReblogKind, + authorization: Mastodon.API.OAuth.Authorization + ) -> AnyPublisher, Error> { + switch reblogKind { + case .reblog(let query): + return reblog(session: session, domain: domain, statusID: statusID, query: query, authorization: authorization) + case .undoReblog: + return undoReblog(session: session, domain: domain, statusID: statusID, authorization: authorization) + } + } + +} diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Report.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Report.swift new file mode 100644 index 000000000..6ba8c3cf5 --- /dev/null +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Report.swift @@ -0,0 +1,91 @@ +// +// File.swift +// +// +// Created by ihugo on 2021/4/19. +// + +import Combine +import Foundation +import enum NIOHTTP1.HTTPResponseStatus + +extension Mastodon.API.Reports { + static func reportsEndpointURL(domain: String) -> URL { + Mastodon.API.endpointURL(domain: domain).appendingPathComponent("reports") + } + + /// File a report + /// + /// Version history: + /// 1.1 - added + /// 2.3.0 - add forward parameter + /// # Reference + /// [Document](https://docs.joinmastodon.org/methods/accounts/reports/) + /// - Parameters: + /// - session: `URLSession` + /// - domain: Mastodon instance domain. e.g. "example.com" + /// - query: fileReportQuery query + /// - authorization: User token + /// - Returns: `AnyPublisher` contains status indicate if report sucessfully. + public static func fileReport( + session: URLSession, + domain: String, + query: Mastodon.API.Reports.FileReportQuery, + authorization: Mastodon.API.OAuth.Authorization + ) -> AnyPublisher, Error> { + let request = Mastodon.API.post( + url: reportsEndpointURL(domain: domain), + query: query, + authorization: authorization + ) + return session.dataTaskPublisher(for: request) + .tryMap { data, response in + guard let response = response as? HTTPURLResponse else { + assertionFailure() + throw NSError() + } + + if response.statusCode == 200 { + return Mastodon.Response.Content( + value: true, + response: response + ) + } else { + let httpResponseStatus = HTTPResponseStatus(statusCode: response.statusCode) + throw Mastodon.API.Error( + httpResponseStatus: httpResponseStatus, + mastodonError: nil + ) + } + } + .eraseToAnyPublisher() + } +} + + +public extension Mastodon.API.Reports { + class FileReportQuery: Codable, PostQuery { + public let accountID: Mastodon.Entity.Account.ID + public var statusIDs: [Mastodon.Entity.Status.ID]? + public var comment: String? + public let forward: Bool? + + enum CodingKeys: String, CodingKey { + case accountID = "account_id" + case statusIDs = "status_ids" + case comment + case forward + } + + public init( + accountID: Mastodon.Entity.Account.ID, + statusIDs: [Mastodon.Entity.Status.ID]?, + comment: String?, + forward: Bool?) { + self.accountID = accountID + self.statusIDs = statusIDs + self.comment = comment + self.forward = forward + } + } +} diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Statuses.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Statuses.swift new file mode 100644 index 000000000..e6c8b19d3 --- /dev/null +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Statuses.swift @@ -0,0 +1,242 @@ +// +// Mastodon+API+Statuses.swift +// +// +// Created by MainasuK Cirno on 2021-3-10. +// + +import Foundation +import Combine + +extension Mastodon.API.Statuses { + + static func statusEndpointURL(domain: String, statusID: Mastodon.Entity.Status.ID) -> URL { + let pathComponent = "statuses/" + statusID + return Mastodon.API.endpointURL(domain: domain).appendingPathComponent(pathComponent) + } + + /// View specific status + /// + /// View information about a status + /// + /// - Since: 0.0.0 + /// - Version: 3.3.0 + /// # Last Update + /// 2021/3/10 + /// # Reference + /// [Document](https://docs.joinmastodon.org/methods/statuses/) + /// - Parameters: + /// - session: `URLSession` + /// - domain: Mastodon instance domain. e.g. "example.com" + /// - statusID: id for status + /// - authorization: User token. Could be nil if status is public + /// - Returns: `AnyPublisher` contains `Status` nested in the response + public static func status( + session: URLSession, + domain: String, + statusID: Mastodon.Entity.Poll.ID, + authorization: Mastodon.API.OAuth.Authorization? + ) -> AnyPublisher, Error> { + let request = Mastodon.API.get( + url: statusEndpointURL(domain: domain, statusID: statusID), + query: nil, + authorization: authorization + ) + return session.dataTaskPublisher(for: request) + .tryMap { data, response in + let value = try Mastodon.API.decode(type: Mastodon.Entity.Status.self, from: data, response: response) + return Mastodon.Response.Content(value: value, response: response) + } + .eraseToAnyPublisher() + } + +} + +extension Mastodon.API.Statuses { + + static func publishNewStatusEndpointURL(domain: String) -> URL { + return Mastodon.API.endpointURL(domain: domain).appendingPathComponent("statuses") + } + + /// Publish new status + /// + /// Post a new status. + /// + /// - Since: 0.0.0 + /// - Version: 3.3.0 + /// # Last Update + /// 2021/3/18 + /// # Reference + /// [Document](https://docs.joinmastodon.org/methods/statuses/) + /// - Parameters: + /// - session: `URLSession` + /// - domain: Mastodon instance domain. e.g. "example.com" + /// - query: `PublishStatusQuery` + /// - authorization: User token + /// - Returns: `AnyPublisher` contains `Status` nested in the response + public static func publishStatus( + session: URLSession, + domain: String, + query: PublishStatusQuery, + authorization: Mastodon.API.OAuth.Authorization? + ) -> AnyPublisher, Error> { + let request = Mastodon.API.post( + url: publishNewStatusEndpointURL(domain: domain), + query: query, + authorization: authorization + ) + return session.dataTaskPublisher(for: request) + .tryMap { data, response in + let value = try Mastodon.API.decode(type: Mastodon.Entity.Status.self, from: data, response: response) + return Mastodon.Response.Content(value: value, response: response) + } + .eraseToAnyPublisher() + } + + public struct PublishStatusQuery: Codable, PostQuery { + public let status: String? + public let mediaIDs: [String]? + public let pollOptions: [String]? + public let pollExpiresIn: Int? + public let inReplyToID: Mastodon.Entity.Status.ID? + public let sensitive: Bool? + public let spoilerText: String? + public let visibility: Mastodon.Entity.Status.Visibility? + + public init( + status: String?, + mediaIDs: [String]?, + pollOptions: [String]?, + pollExpiresIn: Int?, + inReplyToID: Mastodon.Entity.Status.ID?, + sensitive: Bool?, + spoilerText: String?, + visibility: Mastodon.Entity.Status.Visibility? + ) { + self.status = status + self.mediaIDs = mediaIDs + self.pollOptions = pollOptions + self.pollExpiresIn = pollExpiresIn + self.inReplyToID = inReplyToID + self.sensitive = sensitive + self.spoilerText = spoilerText + self.visibility = visibility + } + + var contentType: String? { + return Self.multipartContentType() + } + + var body: Data? { + var data = Data() + + status.flatMap { data.append(Data.multipart(key: "status", value: $0)) } + for mediaID in mediaIDs ?? [] { + data.append(Data.multipart(key: "media_ids[]", value: mediaID)) + } + for pollOption in pollOptions ?? [] { + data.append(Data.multipart(key: "poll[options][]", value: pollOption)) + } + pollExpiresIn.flatMap { data.append(Data.multipart(key: "poll[expires_in]", value: $0)) } + inReplyToID.flatMap { data.append(Data.multipart(key: "in_reply_to_id", value: $0)) } + sensitive.flatMap { data.append(Data.multipart(key: "sensitive", value: $0)) } + spoilerText.flatMap { data.append(Data.multipart(key: "spoiler_text", value: $0)) } + visibility.flatMap { data.append(Data.multipart(key: "visibility", value: $0.rawValue)) } + + data.append(Data.multipartEnd()) + return data + } + } + +} + +extension Mastodon.API.Statuses { + + /// Delete status + /// + /// Delete one of your own statuses. + /// + /// - Since: 0.0.0 + /// - Version: 3.3.0 + /// # Last Update + /// 2021/5/7 + /// # Reference + /// [Document](https://docs.joinmastodon.org/methods/statuses/) + /// - Parameters: + /// - session: `URLSession` + /// - domain: Mastodon instance domain. e.g. "example.com" + /// - query: `DeleteStatusQuery` + /// - authorization: User token + /// - Returns: `AnyPublisher` contains `Status` nested in the response + public static func deleteStatus( + session: URLSession, + domain: String, + query: DeleteStatusQuery, + authorization: Mastodon.API.OAuth.Authorization? + ) -> AnyPublisher, Error> { + let request = Mastodon.API.delete( + url: statusEndpointURL(domain: domain, statusID: query.id), + query: query, + authorization: authorization + ) + return session.dataTaskPublisher(for: request) + .tryMap { data, response in + let value = try Mastodon.API.decode(type: Mastodon.Entity.Status.self, from: data, response: response) + return Mastodon.Response.Content(value: value, response: response) + } + .eraseToAnyPublisher() + } + + public struct DeleteStatusQuery: Codable, DeleteQuery { + public let id: Mastodon.Entity.Status.ID + + public init( + id: Mastodon.Entity.Status.ID + ) { + self.id = id + } + } +} + +extension Mastodon.API.Statuses { + + static func statusContextEndpointURL(domain: String, statusID: Mastodon.Entity.Status.ID) -> URL { + return Mastodon.API.endpointURL(domain: domain).appendingPathComponent("statuses/\(statusID)/context") + } + + /// Parent and child statuses + /// + /// View statuses above and below this status in the thread. + /// + /// - Since: 0.0.0 + /// - Version: 3.3.0 + /// # Last Update + /// 2021/4/12 + /// # Reference + /// [Document](https://docs.joinmastodon.org/methods/statuses/) + /// - Parameters: + /// - session: `URLSession` + /// - domain: Mastodon instance domain. e.g. "example.com" + /// - statusID: id of status + /// - authorization: User token. Optional for public statuses + /// - Returns: `AnyPublisher` contains `Context` nested in the response + public static func statusContext( + session: URLSession, + domain: String, + statusID: Mastodon.Entity.Status.ID, + authorization: Mastodon.API.OAuth.Authorization? + ) -> AnyPublisher, Error> { + let request = Mastodon.API.get( + url: statusContextEndpointURL(domain: domain, statusID: statusID), + query: nil, + authorization: authorization + ) + return session.dataTaskPublisher(for: request) + .tryMap { data, response in + let value = try Mastodon.API.decode(type: Mastodon.Entity.Context.self, from: data, response: response) + return Mastodon.Response.Content(value: value, response: response) + } + .eraseToAnyPublisher() + } + +} diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Suggestions.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Suggestions.swift new file mode 100644 index 000000000..558089645 --- /dev/null +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Suggestions.swift @@ -0,0 +1,65 @@ +// +// Mastodon+API+Suggestions.swift +// +// +// Created by sxiaojian on 2021/3/31. +// + +import Combine +import Foundation + +extension Mastodon.API.Suggestions { + static func suggestionsURL(domain: String) -> URL { + Mastodon.API.endpointURL(domain: domain).appendingPathComponent("suggestions") + } + + /// Follow suggestions + /// + /// Server-generated suggestions on who to follow, based on previous positive interactions. + /// + /// Version history: + /// 2.4.3 - added + /// # Reference + /// [Document](https://docs.joinmastodon.org/methods/accounts/suggestions/) + /// - Parameters: + /// - session: `URLSession` + /// - domain: Mastodon instance domain. e.g. "example.com" + /// - query: query + /// - authorization: User token. + /// - Returns: `AnyPublisher` contains `Accounts` nested in the response + public static func get( + session: URLSession, + domain: String, + query: Mastodon.API.Suggestions.Query?, + authorization: Mastodon.API.OAuth.Authorization + ) -> AnyPublisher, Error> { + let request = Mastodon.API.get( + url: suggestionsURL(domain: domain), + query: query, + authorization: authorization + ) + return session.dataTaskPublisher(for: request) + .tryMap { data, response in + let value = try Mastodon.API.decode(type: [Mastodon.Entity.Account].self, from: data, response: response) + return Mastodon.Response.Content(value: value, response: response) + } + .eraseToAnyPublisher() + } +} + +extension Mastodon.API.Suggestions { + public struct Query: Codable, GetQuery { + public init(limit: Int?) { + self.limit = limit + } + + public let limit: Int? // Maximum number of results to return. Defaults to 40. + + var queryItems: [URLQueryItem]? { + var items: [URLQueryItem] = [] + limit.flatMap { items.append(URLQueryItem(name: "limit", value: String($0))) } + guard !items.isEmpty else { return nil } + return items + } + } +} diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Timeline.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Timeline.swift index d4ec364bf..c1857ae82 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Timeline.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Timeline.swift @@ -16,6 +16,10 @@ extension Mastodon.API.Timeline { static func homeTimelineEndpointURL(domain: String) -> URL { return Mastodon.API.endpointURL(domain: domain).appendingPathComponent("timelines/home") } + static func hashtagTimelineEndpointURL(domain: String, hashtag: String) -> URL { + return Mastodon.API.endpointURL(domain: domain) + .appendingPathComponent("timelines/tag/\(hashtag)") + } /// View public timeline statuses /// @@ -53,13 +57,14 @@ extension Mastodon.API.Timeline { /// - Since: 0.0.0 /// - Version: 3.3.0 /// # Last Update - /// 2021/2/19 + /// 2021/3/3 /// # Reference /// [Document](https://https://docs.joinmastodon.org/methods/timelines/) /// - Parameters: /// - session: `URLSession` /// - domain: Mastodon instance domain. e.g. "example.com" /// - query: `PublicTimelineQuery` with query parameters + /// - authorization: User token /// - Returns: `AnyPublisher` contains `Token` nested in the response public static func home( session: URLSession, @@ -80,16 +85,50 @@ extension Mastodon.API.Timeline { .eraseToAnyPublisher() } + /// View public statuses containing the given hashtag. + /// + /// - Since: 0.0.0 + /// - Version: 3.3.0 + /// # Last Update + /// 2021/3/29 + /// # Reference + /// [Document](https://https://docs.joinmastodon.org/methods/timelines/) + /// - Parameters: + /// - session: `URLSession` + /// - domain: Mastodon instance domain. e.g. "example.com" + /// - query: `HashtagTimelineQuery` with query parameters + /// - hashtag: Content of a #hashtag, not including # symbol. + /// - authorization: User token, auth is required if public preview is disabled + /// - Returns: `AnyPublisher` contains `Token` nested in the response + public static func hashtag( + session: URLSession, + domain: String, + query: HashtagTimelineQuery, + hashtag: String, + authorization: Mastodon.API.OAuth.Authorization? + ) -> AnyPublisher, Error> { + let request = Mastodon.API.get( + url: hashtagTimelineEndpointURL(domain: domain, hashtag: hashtag), + query: query, + authorization: authorization + ) + return session.dataTaskPublisher(for: request) + .tryMap { data, response in + let value = try Mastodon.API.decode(type: [Mastodon.Entity.Status].self, from: data, response: response) + return Mastodon.Response.Content(value: value, response: response) + } + .eraseToAnyPublisher() + } } -public protocol TimelineQueryType { +public protocol PagedQueryType { var maxID: Mastodon.Entity.Status.ID? { get } var sinceID: Mastodon.Entity.Status.ID? { get } } extension Mastodon.API.Timeline { - public typealias TimelineQuery = TimelineQueryType + public typealias TimelineQuery = PagedQueryType public struct PublicTimelineQuery: Codable, TimelineQuery, GetQuery { @@ -166,4 +205,41 @@ extension Mastodon.API.Timeline { } } + public struct HashtagTimelineQuery: Codable, TimelineQuery, GetQuery { + public let maxID: Mastodon.Entity.Status.ID? + public let sinceID: Mastodon.Entity.Status.ID? + public let minID: Mastodon.Entity.Status.ID? + public let limit: Int? + public let local: Bool? + public let onlyMedia: Bool? + + public init( + maxID: Mastodon.Entity.Status.ID? = nil, + sinceID: Mastodon.Entity.Status.ID? = nil, + minID: Mastodon.Entity.Status.ID? = nil, + limit: Int? = nil, + local: Bool? = nil, + onlyMedia: Bool? = nil + ) { + self.maxID = maxID + self.sinceID = sinceID + self.minID = minID + self.limit = limit + self.local = local + self.onlyMedia = onlyMedia + } + + var queryItems: [URLQueryItem]? { + var items: [URLQueryItem] = [] + maxID.flatMap { items.append(URLQueryItem(name: "max_id", value: $0)) } + sinceID.flatMap { items.append(URLQueryItem(name: "since_id", value: $0)) } + minID.flatMap { items.append(URLQueryItem(name: "min_id", value: $0)) } + limit.flatMap { items.append(URLQueryItem(name: "limit", value: String($0))) } + local.flatMap { items.append(URLQueryItem(name: "local", value: $0.queryItemValue)) } + onlyMedia.flatMap { items.append(URLQueryItem(name: "only_media", value: $0.queryItemValue)) } + guard !items.isEmpty else { return nil } + return items + } + } + } diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Trends.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Trends.swift new file mode 100644 index 000000000..385e3d756 --- /dev/null +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Trends.swift @@ -0,0 +1,64 @@ +// +// Mastodon+API+Trends.swift +// +// +// Created by sxiaojian on 2021/3/31. +// + +import Combine +import Foundation + +extension Mastodon.API.Trends { + static func trendsURL(domain: String) -> URL { + Mastodon.API.endpointURL(domain: domain).appendingPathComponent("trends") + } + + /// Trending tags + /// + /// Tags that are being used more frequently within the past week. + /// + /// Version history: + /// 3.0.0 - added + /// # Reference + /// [Document](https://docs.joinmastodon.org/methods/instance/trends/) + /// - Parameters: + /// - session: `URLSession` + /// - domain: Mastodon instance domain. e.g. "example.com" + /// - query: query + /// - Returns: `AnyPublisher` contains `Hashtags` nested in the response + + public static func get( + session: URLSession, + domain: String, + query: Mastodon.API.Trends.Query? + ) -> AnyPublisher, Error> { + let request = Mastodon.API.get( + url: trendsURL(domain: domain), + query: query, + authorization: nil + ) + return session.dataTaskPublisher(for: request) + .tryMap { data, response in + let value = try Mastodon.API.decode(type: [Mastodon.Entity.Tag].self, from: data, response: response) + return Mastodon.Response.Content(value: value, response: response) + } + .eraseToAnyPublisher() + } +} + +extension Mastodon.API.Trends { + public struct Query: Codable, GetQuery { + public init(limit: Int?) { + self.limit = limit + } + + public let limit: Int? // Maximum number of results to return. Defaults to 10. + + var queryItems: [URLQueryItem]? { + var items: [URLQueryItem] = [] + limit.flatMap { items.append(URLQueryItem(name: "limit", value: String($0))) } + guard !items.isEmpty else { return nil } + return items + } + } +} diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+V2+Search.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+V2+Search.swift new file mode 100644 index 000000000..c0a687f17 --- /dev/null +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+V2+Search.swift @@ -0,0 +1,128 @@ +// +// Mastodon+API+Search.swift +// +// +// Created by sxiaojian on 2021/3/31. +// + +import Combine +import Foundation + +extension Mastodon.API.V2.Search { + static func searchURL(domain: String) -> URL { + Mastodon.API.endpointV2URL(domain: domain).appendingPathComponent("search") + } + + /// Search results + /// + /// Search for content in accounts, statuses and hashtags. + /// + /// Version history: + /// 2.4.1 - added, limit hardcoded to 5 + /// 2.8.0 - add type, limit, offset, min_id, max_id, account_id + /// 3.0.0 - add exclude_unreviewed param + /// # Reference + /// [Document](https://docs.joinmastodon.org/methods/search/) + /// - Parameters: + /// - session: `URLSession` + /// - domain: Mastodon instance domain. e.g. "example.com" + /// - query: search query + /// - authorization: User token + /// - Returns: `AnyPublisher` contains `Accounts,Hashtags,Status` nested in the response + public static func search( + session: URLSession, + domain: String, + query: Mastodon.API.V2.Search.Query, + authorization: Mastodon.API.OAuth.Authorization + ) -> AnyPublisher, Error> { + let request = Mastodon.API.get( + url: searchURL(domain: domain), + query: query, + authorization: authorization + ) + return session.dataTaskPublisher(for: request) + .tryMap { data, response in + let value = try Mastodon.API.decode(type: Mastodon.Entity.SearchResult.self, from: data, response: response) + return Mastodon.Response.Content(value: value, response: response) + } + .eraseToAnyPublisher() + } +} + +extension Mastodon.API.V2.Search { + + public struct Query: Codable, GetQuery { + public init(q: String, + type: SearchType? = nil, + accountID: Mastodon.Entity.Account.ID? = nil, + maxID: Mastodon.Entity.Status.ID? = nil, + minID: Mastodon.Entity.Status.ID? = nil, + excludeUnreviewed: Bool? = nil, + resolve: Bool? = nil, + limit: Int? = nil, + offset: Int? = nil, + following: Bool? = nil) { + + self.accountID = accountID + self.maxID = maxID + self.minID = minID + self.type = type + self.excludeUnreviewed = excludeUnreviewed + self.q = q + self.resolve = resolve + self.limit = limit + self.offset = offset + self.following = following + } + + public let accountID: Mastodon.Entity.Account.ID? + public let maxID: Mastodon.Entity.Status.ID? + public let minID: Mastodon.Entity.Status.ID? + public let type: SearchType? + public let excludeUnreviewed: Bool? // Filter out unreviewed tags? Defaults to false. Use true when trying to find trending tags. + public let q: String + public let resolve: Bool? // Attempt WebFinger lookup. Defaults to false. + public let limit: Int? // Maximum number of results to load, per type. Defaults to 20. Max 40. + public let offset: Int? // Offset in search results. Used for pagination. Defaults to 0. + public let following: Bool? // Only include accounts that the user is following. Defaults to false. + + var queryItems: [URLQueryItem]? { + var items: [URLQueryItem] = [] + accountID.flatMap { items.append(URLQueryItem(name: "account_id", value: $0)) } + maxID.flatMap { items.append(URLQueryItem(name: "max_id", value: $0)) } + minID.flatMap { items.append(URLQueryItem(name: "min_id", value: $0)) } + type.flatMap { items.append(URLQueryItem(name: "type", value: $0.rawValue)) } + excludeUnreviewed.flatMap { items.append(URLQueryItem(name: "exclude_unreviewed", value: $0.queryItemValue)) } + items.append(URLQueryItem(name: "q", value: q)) + resolve.flatMap { items.append(URLQueryItem(name: "resolve", value: $0.queryItemValue)) } + + limit.flatMap { items.append(URLQueryItem(name: "limit", value: String($0))) } + offset.flatMap { items.append(URLQueryItem(name: "offset", value: String($0))) } + following.flatMap { items.append(URLQueryItem(name: "following", value: $0.queryItemValue)) } + guard !items.isEmpty else { return nil } + return items + } + } +} + +public extension Mastodon.API.V2.Search { + enum SearchType: String, Codable { + case accounts + case hashtags + case statuses + case `default` + + public var rawValue: String { + switch self { + case .accounts: + return "accounts" + case .hashtags: + return "hashtags" + case .statuses: + return "statuses" + case .default: + return "" + } + } + } +} diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+V2+Suggestions.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+V2+Suggestions.swift new file mode 100644 index 000000000..9e6876b41 --- /dev/null +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+V2+Suggestions.swift @@ -0,0 +1,41 @@ +// +// Mastodon+API+V2+Suggestions.swift +// +// +// Created by sxiaojian on 2021/4/20. +// + +import Combine +import Foundation + +extension Mastodon.API.V2.Suggestions { + static func suggestionsURL(domain: String) -> URL { + Mastodon.API.endpointV2URL(domain: domain).appendingPathComponent("suggestions") + } + + /// Follow suggestions, No document for now + /// - Parameters: + /// - session: `URLSession` + /// - domain: Mastodon instance domain. e.g. "example.com" + /// - query: query + /// - authorization: User token. + /// - Returns: `AnyPublisher` contains `AccountsSuggestion` nested in the response + public static func get( + session: URLSession, + domain: String, + query: Mastodon.API.Suggestions.Query?, + authorization: Mastodon.API.OAuth.Authorization + ) -> AnyPublisher, Error> { + let request = Mastodon.API.get( + url: suggestionsURL(domain: domain), + query: query, + authorization: authorization + ) + return session.dataTaskPublisher(for: request) + .tryMap { data, response in + let value = try Mastodon.API.decode(type: [Mastodon.Entity.V2.SuggestionAccount].self, from: data, response: response) + return Mastodon.Response.Content(value: value, response: response) + } + .eraseToAnyPublisher() + } +} diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift index 92897090c..e202568c5 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift @@ -5,6 +5,7 @@ // Created by xiaojian sun on 2021/1/25. // +import os.log import Foundation import enum NIOHTTP1.HTTPResponseStatus @@ -49,6 +50,9 @@ extension Mastodon.API { if let date = fullDatePreciseISO8601Formatter.date(from: string) { return date } + if let timestamp = TimeInterval(string) { + return Date(timeIntervalSince1970: timestamp) + } } catch { // do nothing } @@ -85,16 +89,40 @@ extension Mastodon.API { return URL(string: "https://" + domain + "/auth/confirmation/new")! } + public static func serverRulesURL(domain: String) -> URL { + return URL(string: "https://" + domain + "/about/more")! + } + + public static func privacyURL(domain: String) -> URL { + return URL(string: "https://" + domain + "/terms")! + } } extension Mastodon.API { + public enum V2 { } public enum Account { } public enum App { } + public enum CustomEmojis { } + public enum Favorites { } public enum Instance { } + public enum Media { } public enum OAuth { } public enum Onboarding { } + public enum Polls { } + public enum Reblog { } + public enum Statuses { } public enum Timeline { } - public enum Favorites { } + public enum Trends { } + public enum Suggestions { } + public enum Notifications { } + public enum Subscriptions { } + public enum Reports { } + public enum DomainBlock { } +} + +extension Mastodon.API.V2 { + public enum Search { } + public enum Suggestions { } } extension Mastodon.API { @@ -122,6 +150,22 @@ extension Mastodon.API { ) -> URLRequest { return buildRequest(url: url, method: .PATCH, query: query, authorization: authorization) } + + static func put( + url: URL, + query: PutQuery?, + authorization: OAuth.Authorization? + ) -> URLRequest { + return buildRequest(url: url, method: .PUT, query: query, authorization: authorization) + } + + static func delete( + url: URL, + query: DeleteQuery?, + authorization: OAuth.Authorization? + ) -> URLRequest { + return buildRequest(url: url, method: .DELETE, query: query, authorization: authorization) + } private static func buildRequest( url: URL, @@ -138,8 +182,13 @@ extension Mastodon.API { timeoutInterval: Mastodon.API.timeoutInterval ) request.httpMethod = method.rawValue - request.setValue("application/json; charset=utf-8", forHTTPHeaderField: "Content-Type") - request.httpBody = query?.body + if let contentType = query?.contentType { + request.setValue(contentType, forHTTPHeaderField: "Content-Type") + } + if let body = query?.body { + request.httpBody = body + request.setValue("\(body.count)", forHTTPHeaderField: "Content-Length") + } if let authorization = authorization { request.setValue( "Bearer \(authorization.accessToken)", @@ -155,6 +204,7 @@ extension Mastodon.API { return try Mastodon.API.decoder.decode(type, from: data) } catch let decodeError { #if DEBUG + os_log(.info, "%{public}s[%{public}ld], %{public}s: decode fail. content %s", ((#file as NSString).lastPathComponent), #line, #function, String(data: data, encoding: .utf8) ?? "") debugPrint(decodeError) #endif diff --git a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Account.swift b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Account.swift index 82fc9502b..13f3c0a71 100644 --- a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Account.swift +++ b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Account.swift @@ -82,5 +82,6 @@ extension Mastodon.Entity { case muteExpiresAt = "mute_expires_at" } } + } diff --git a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Attachment.swift b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Attachment.swift index 2a09ccfc8..d50d87695 100644 --- a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Attachment.swift +++ b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Attachment.swift @@ -23,7 +23,7 @@ extension Mastodon.Entity { public let id: ID public let type: Type public let url: String - public let previewURL: String + public let previewURL: String? // could be nil when attachement is audio public let remoteURL: String? public let textURL: String? diff --git a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Empty.swift b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Empty.swift new file mode 100644 index 000000000..494151178 --- /dev/null +++ b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Empty.swift @@ -0,0 +1,14 @@ +// +// File.swift +// +// +// Created by sxiaojian on 2021/4/30. +// + +import Foundation + +extension Mastodon.Entity { + public struct Empty: Codable { + + } +} diff --git a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Error+Detail.swift b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Error+Detail.swift new file mode 100644 index 000000000..ef7f1b640 --- /dev/null +++ b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Error+Detail.swift @@ -0,0 +1,110 @@ +// +// Mastodon+Entity+ErrorDetail.swift +// +// +// Created by sxiaojian on 2021/3/1. +// + +import Foundation + +extension Mastodon.Entity.Error { + public struct Detail: Codable { + public let username: [Reason]? + public let email: [Reason]? + public let password: [Reason]? + public let agreement: [Reason]? + public let locale: [Reason]? + public let reason: [Reason]? + + enum CodingKeys: String, CodingKey { + case username + case email + case password + case agreement + case locale + case reason + } + } +} + + + +extension Mastodon.Entity.Error.Detail { + public struct Reason: Codable { + public let error: Error + public let description: String + + enum CodingKeys: String, CodingKey { + case error + case description + } + } +} + +extension Mastodon.Entity.Error.Detail.Reason { + /// - Since: 3.3.1 + /// - Version: 3.3.1 + /// # Last Update + /// 2021/3/4 + /// # Reference + /// [Document](https://github.com/tootsuite/mastodon/pull/15803) + public enum Error: RawRepresentable, Codable { + /// When e-mail provider is not allowed + case ERR_BLOCKED + /// When e-mail address does not resolve to any IP via DNS (MX, A, AAAA) + case ERR_UNREACHABLE + /// When username or e-mail are already taken + case ERR_TAKEN + /// When a username is reserved, e.g. "webmaster" or "admin" + case ERR_RESERVED + /// When agreement has not been accepted + case ERR_ACCEPTED + /// When a required attribute is blank + case ERR_BLANK + /// When an attribute is malformed, e.g. wrong characters or invalid e-mail address + case ERR_INVALID + /// When an attribute is over the character limit + case ERR_TOO_LONG + /// When an attribute is under the character requirement + case ERR_TOO_SHORT + /// When an attribute is not one of the allowed values, e.g. unsupported locale + case ERR_INCLUSION + /// Not handled error + case _other(String) + + public init?(rawValue: String) { + switch rawValue { + case "ERR_BLOCKED": self = .ERR_BLOCKED + case "ERR_UNREACHABLE": self = .ERR_UNREACHABLE + case "ERR_TAKEN": self = .ERR_TAKEN + case "ERR_RESERVED": self = .ERR_RESERVED + case "ERR_ACCEPTED": self = .ERR_ACCEPTED + case "ERR_BLANK": self = .ERR_BLANK + case "ERR_INVALID": self = .ERR_INVALID + case "ERR_TOO_LONG": self = .ERR_TOO_LONG + case "ERR_TOO_SHORT": self = .ERR_TOO_SHORT + case "ERR_INCLUSION": self = .ERR_INCLUSION + + default: + self = ._other(rawValue) + } + } + + public var rawValue: String { + switch self { + case .ERR_BLOCKED: return "ERR_BLOCKED" + case .ERR_UNREACHABLE: return "ERR_UNREACHABLE" + case .ERR_TAKEN: return "ERR_TAKEN" + case .ERR_RESERVED: return "ERR_RESERVED" + case .ERR_ACCEPTED: return "ERR_ACCEPTED" + case .ERR_BLANK: return "ERR_BLANK" + case .ERR_INVALID: return "ERR_INVALID" + case .ERR_TOO_LONG: return "ERR_TOO_LONG" + case .ERR_TOO_SHORT: return "ERR_TOO_SHORT" + case .ERR_INCLUSION: return "ERR_INCLUSION" + + case ._other(let value): return value + } + } + } +} diff --git a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Error.swift b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Error.swift index 9d5ae2a50..daf47bbd7 100644 --- a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Error.swift +++ b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Error.swift @@ -13,16 +13,18 @@ extension Mastodon.Entity { /// - Since: 0.6.0 /// - Version: 3.3.0 /// # Last Update - /// 2021/1/28 + /// 2021/3/4 /// # Reference /// [Document](https://docs.joinmastodon.org/entities/error/) public struct Error: Codable { public let error: String public let errorDescription: String? - + public let details: Detail? + enum CodingKeys: String, CodingKey { case error case errorDescription = "error_description" + case details } } } diff --git a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+History.swift b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+History.swift index 9e2f9efb2..9bf1a3a28 100644 --- a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+History.swift +++ b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+History.swift @@ -19,7 +19,7 @@ extension Mastodon.Entity { public struct History: Codable { /// UNIX timestamp on midnight of the given day public let day: Date - public let uses: Int - public let accounts: Int + public let uses: String + public let accounts: String } } diff --git a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Instance.swift b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Instance.swift index 18e41b3e0..226af40f8 100644 --- a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Instance.swift +++ b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Instance.swift @@ -47,7 +47,7 @@ extension Mastodon.Entity { case approvalRequired = "approval_required" case invitesEnabled = "invites_enabled" case urls - case statistics + case statistics = "stats" case thumbnail case contactAccount = "contact_account" diff --git a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Notification.swift b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Notification.swift index 413c89bd3..0cdcc2e7c 100644 --- a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Notification.swift +++ b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Notification.swift @@ -37,6 +37,7 @@ extension Mastodon.Entity { } extension Mastodon.Entity.Notification { + public typealias NotificationType = Type public enum `Type`: RawRepresentable, Codable { case follow case followRequest diff --git a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+SearchResult.swift b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+SearchResult.swift new file mode 100644 index 000000000..44446d0d9 --- /dev/null +++ b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+SearchResult.swift @@ -0,0 +1,22 @@ +// +// File.swift +// +// +// Created by sxiaojian on 2021/3/31. +// + +import Foundation +extension Mastodon.Entity { + public struct SearchResult: Codable { + public init(accounts: [Mastodon.Entity.Account], statuses: [Mastodon.Entity.Status], hashtags: [Mastodon.Entity.Tag]) { + self.accounts = accounts + self.statuses = statuses + self.hashtags = hashtags + } + + public let accounts: [Mastodon.Entity.Account] + public let statuses: [Mastodon.Entity.Status] + public let hashtags: [Mastodon.Entity.Tag] + } + +} diff --git a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Status.swift b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Status.swift index 31a8806a7..7f8a4fd4e 100644 --- a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Status.swift +++ b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Status.swift @@ -1,5 +1,5 @@ // -// Mastodon+Entity+Toot.swift +// Mastodon+Entity+Status.swift // // // Created by MainasuK Cirno on 2021/1/27. @@ -26,7 +26,7 @@ extension Mastodon.Entity { public let uri: String public let createdAt: Date public let account: Account - public let content: String + public let content: String? // will be optional when delete status public let visibility: Visibility? public let sensitive: Bool? diff --git a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Subscription.swift b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Subscription.swift new file mode 100644 index 000000000..e968c32d6 --- /dev/null +++ b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Subscription.swift @@ -0,0 +1,70 @@ +// +// Mastodon+Entity+Subscription.swift +// +// +// Created by ihugo on 2021/4/9. +// + +import Foundation + + +extension Mastodon.Entity { + /// Subscription + /// + /// - Since: 2.4.0 + /// - Version: 3.3.0 + /// # Last Update + /// 2021/4/26 + /// # Reference + /// [Document](https://docs.joinmastodon.org/entities/pushsubscription/) + public struct Subscription: Codable { + // Base + public let id: String + public let endpoint: String + public let alerts: Alerts + public let serverKey: String + + enum CodingKeys: String, CodingKey { + case id + case endpoint + case serverKey = "server_key" + case alerts + } + + public struct Alerts: Codable { + public let follow: Bool? + public let followRequest: Bool? + public let favourite: Bool? + public let reblog: Bool? + public let mention: Bool? + public let poll: Bool? + + enum CodingKeys: String, CodingKey { + case follow + case followRequest = "follow_request" + case favourite + case reblog + case mention + case poll + } + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + var id = try? container.decode(String.self, forKey: .id) + if nil == id, let numId = try? container.decode(Int.self, forKey: .id) { + id = String(numId) + } + self.id = id ?? "" + + endpoint = try container.decode(String.self, forKey: .endpoint) + alerts = try container.decode(Alerts.self, forKey: .alerts) + serverKey = try container.decode(String.self, forKey: .serverKey) + } + } + + public struct EmptySubscription: Codable { + + } +} diff --git a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Suggestion.swift b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Suggestion.swift new file mode 100644 index 000000000..98d67d5fa --- /dev/null +++ b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Suggestion.swift @@ -0,0 +1,23 @@ +// +// Mastodon+Entity+Suggestion.swift +// +// +// Created by sxiaojian on 2021/4/20. +// + +import Foundation + +extension Mastodon.Entity.V2 { + + public struct SuggestionAccount: Codable { + + public let source: String + public let account: Mastodon.Entity.Account + + + enum CodingKeys: String, CodingKey { + case source + case account + } + } +} diff --git a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Tag.swift b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Tag.swift index 61c47ed68..e7f095eb3 100644 --- a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Tag.swift +++ b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Tag.swift @@ -22,5 +22,10 @@ extension Mastodon.Entity { public let url: String public let history: [History]? + enum CodingKeys: String, CodingKey { + case name + case url + case history + } } } diff --git a/MastodonSDK/Sources/MastodonSDK/Extension/Data.swift b/MastodonSDK/Sources/MastodonSDK/Extension/Data.swift new file mode 100644 index 000000000..48e442b9d --- /dev/null +++ b/MastodonSDK/Sources/MastodonSDK/Extension/Data.swift @@ -0,0 +1,46 @@ +// +// Data.swift +// +// +// Created by MainasuK Cirno on 2021-3-8. +// + +import Foundation + +extension Data { + + static func multipart( + boundary: String = Multipart.boundary, + key: String, + value: MultipartFormValue + ) -> Data { + var data = Data() + data.append("\r\n--\(boundary)\r\n".data(using: .utf8)!) + data.append("Content-Disposition: form-data; name=\"\(key)\"".data(using: .utf8)!) + if let filename = value.multipartFilename { + data.append("; filename=\"\(filename)\"\r\n".data(using: .utf8)!) + } else { + data.append("\r\n".data(using: .utf8)!) + } + if let contentType = value.multipartContentType { + data.append("Content-Type: \(contentType)\r\n".data(using: .utf8)!) + } + data.append("\r\n".data(using: .utf8)!) + data.append(value.multipartValue) + return data + } + + static func multipartEnd(boundary: String = Multipart.boundary) -> Data { + return "\r\n--\(boundary)--\r\n".data(using: .utf8)! + } + +} + +extension Data { + func base64UrlEncodedString() -> String { + return base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + } +} diff --git a/MastodonSDK/Sources/MastodonSDK/Mastodon.swift b/MastodonSDK/Sources/MastodonSDK/Mastodon.swift index 0c5e90609..b64d3726e 100644 --- a/MastodonSDK/Sources/MastodonSDK/Mastodon.swift +++ b/MastodonSDK/Sources/MastodonSDK/Mastodon.swift @@ -12,4 +12,5 @@ public enum Mastodon { public enum Response { } public enum API { } public enum Entity { } + public enum Query { } } diff --git a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+MediaAttachment.swift b/MastodonSDK/Sources/MastodonSDK/Query/MediaAttachment.swift similarity index 66% rename from MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+MediaAttachment.swift rename to MastodonSDK/Sources/MastodonSDK/Query/MediaAttachment.swift index 39ffe23d7..f3bd88832 100644 --- a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+MediaAttachment.swift +++ b/MastodonSDK/Sources/MastodonSDK/Query/MediaAttachment.swift @@ -1,5 +1,5 @@ // -// Mastodon+Entity+MediaAttachment.swift +// MediaAttachment.swift // // // Created by jk234ert on 2/9/21. @@ -7,7 +7,7 @@ import Foundation -extension Mastodon.Entity { +extension Mastodon.Query { public enum MediaAttachment { /// JPEG (Joint Photographic Experts Group) image case jpeg(Data?) @@ -20,7 +20,7 @@ extension Mastodon.Entity { } } -extension Mastodon.Entity.MediaAttachment { +extension Mastodon.Query.MediaAttachment { var data: Data? { switch self { case .jpeg(let data): return data @@ -31,11 +31,12 @@ extension Mastodon.Entity.MediaAttachment { } var fileName: String { + let name = UUID().uuidString switch self { - case .jpeg: return "file.jpg" - case .gif: return "file.gif" - case .png: return "file.png" - case .other(_, let fileExtension, _): return "file.\(fileExtension)" + case .jpeg: return "\(name).jpg" + case .gif: return "\(name).gif" + case .png: return "\(name).png" + case .other(_, let fileExtension, _): return "\(name).\(fileExtension)" } } @@ -53,3 +54,8 @@ extension Mastodon.Entity.MediaAttachment { } } +extension Mastodon.Query.MediaAttachment: MultipartFormValue { + var multipartValue: Data { return data ?? Data() } + var multipartContentType: String? { return mimeType } + var multipartFilename: String? { return fileName } +} diff --git a/MastodonSDK/Sources/MastodonSDK/Query/MultipartFormValue.swift b/MastodonSDK/Sources/MastodonSDK/Query/MultipartFormValue.swift new file mode 100644 index 000000000..f86a71c8e --- /dev/null +++ b/MastodonSDK/Sources/MastodonSDK/Query/MultipartFormValue.swift @@ -0,0 +1,46 @@ +// +// MultipartFormValue.swift +// +// +// Created by MainasuK Cirno on 2021-3-8. +// + +import Foundation + +enum Multipart { + static let boundary = "__boundary__" +} + +protocol MultipartFormValue { + var multipartValue: Data { get } + var multipartContentType: String? { get } + var multipartFilename: String? { get } +} + +extension Bool: MultipartFormValue { + var multipartValue: Data { + switch self { + case true: return "true".data(using: .utf8)! + case false: return "false".data(using: .utf8)! + } + } + var multipartContentType: String? { return nil } + var multipartFilename: String? { return nil } +} + +extension String: MultipartFormValue { + var multipartValue: Data { + return self.data(using: .utf8)! + } + var multipartContentType: String? { return nil } + var multipartFilename: String? { return nil } +} + + +extension Int: MultipartFormValue { + var multipartValue: Data { + return String(self).data(using: .utf8)! + } + var multipartContentType: String? { return nil } + var multipartFilename: String? { return nil } +} diff --git a/MastodonSDK/Sources/MastodonSDK/Protocol/Query.swift b/MastodonSDK/Sources/MastodonSDK/Query/Query.swift similarity index 55% rename from MastodonSDK/Sources/MastodonSDK/Protocol/Query.swift rename to MastodonSDK/Sources/MastodonSDK/Query/Query.swift index 7a6608d66..7e27eb50a 100644 --- a/MastodonSDK/Sources/MastodonSDK/Protocol/Query.swift +++ b/MastodonSDK/Sources/MastodonSDK/Query/Query.swift @@ -14,34 +14,54 @@ enum RequestMethod: String { protocol RequestQuery { // All kinds of queries could have queryItems and body var queryItems: [URLQueryItem]? { get } + var contentType: String? { get } var body: Data? { get } } +extension RequestQuery { + static func multipartContentType(boundary: String = Multipart.boundary) -> String { + return "multipart/form-data; charset=utf-8; boundary=\"\(boundary)\"" + } +} + // An `Encodable` query provides its body by encoding itself // A `Get` query only contains queryItems, it should not be `Encodable` extension RequestQuery where Self: Encodable { + var contentType: String? { + return "application/json; charset=utf-8" + } var body: Data? { return try? Mastodon.API.encoder.encode(self) } } +// GET protocol GetQuery: RequestQuery { } extension GetQuery { // By default a `GetQuery` does not has data body var body: Data? { nil } + var contentType: String? { nil } } -protocol PostQuery: RequestQuery & Encodable { } +// POST +protocol PostQuery: RequestQuery { } extension PostQuery { - // By default a `GetQuery` does not has query items + // By default a `PostQuery` does not has query items var queryItems: [URLQueryItem]? { nil } } -protocol PatchQuery: RequestQuery & Encodable { } +// PATCH +protocol PatchQuery: RequestQuery { } -extension PatchQuery { - // By default a `GetQuery` does not has query items +// PUT +protocol PutQuery: RequestQuery { } + +// DELETE +protocol DeleteQuery: RequestQuery { } + +extension DeleteQuery { + // By default a `DeleteQuery` does not has query items var queryItems: [URLQueryItem]? { nil } } diff --git a/MastodonSDK/Sources/MastodonSDK/Response/Mastodon+Response+Content.swift b/MastodonSDK/Sources/MastodonSDK/Response/Mastodon+Response+Content.swift index f99614311..9c39615f9 100644 --- a/MastodonSDK/Sources/MastodonSDK/Response/Mastodon+Response+Content.swift +++ b/MastodonSDK/Sources/MastodonSDK/Response/Mastodon+Response+Content.swift @@ -18,6 +18,7 @@ extension Mastodon.Response { // application fields public let rateLimit: RateLimit? + public let link: Link? public let responseTime: Int? public var networkDate: Date { @@ -33,16 +34,35 @@ extension Mastodon.Response { }() self.rateLimit = RateLimit(response: response) + self.link = { + guard let string = (response as? HTTPURLResponse)?.value(forHTTPHeaderField: "link") else { return nil } + return Link(link: string) + }() + self.responseTime = { guard let string = (response as? HTTPURLResponse)?.value(forHTTPHeaderField: "x-response-time") else { return nil } return Int(string) }() } + init(value: T, old: Mastodon.Response.Content) { + self.value = value + self.date = old.date + self.rateLimit = old.rateLimit + self.link = old.link + self.responseTime = old.responseTime + } + } } extension Mastodon.Response.Content { + public func map(_ transform: (T) -> R) -> Mastodon.Response.Content { + return Mastodon.Response.Content(value: transform(value), old: self) + } +} + +extension Mastodon.Response { public struct RateLimit { public let limit: Int @@ -77,3 +97,30 @@ extension Mastodon.Response.Content { } } + +extension Mastodon.Response { + public struct Link { + public let maxID: Mastodon.Entity.Status.ID? + public let minID: Mastodon.Entity.Status.ID? + + init(link: String) { + self.maxID = { + guard let regex = try? NSRegularExpression(pattern: "max_id=([[:digit:]]+)", options: []) else { return nil } + let results = regex.matches(in: link, options: [], range: NSRange(link.startIndex.. AnyPublisher, Error> in // TODO: replace with test account acct - XCTAssertEqual(result.value.acct, "") + XCTAssert(!result.value.acct.isEmpty) theExpectation1.fulfill() - - var query = Mastodon.API.Account.UpdateCredentialQuery() - query.note = dateString + + let query = Mastodon.API.Account.UpdateCredentialQuery( + bot: !(result.value.bot ?? false), + note: dateString, + header: Mastodon.Query.MediaAttachment.jpeg(UIImage(systemName: "house")!.jpegData(compressionQuality: 0.8)) + ) return Mastodon.API.Account.updateCredentials(session: self.session, domain: self.domain, query: query, authorization: authorization) }) .sink { completion in @@ -73,8 +78,7 @@ extension MastodonSDKTests { func testRetrieveAccountInfo() throws { let theExpectation = expectation(description: "Verify Account Credentials") - let query = Mastodon.API.Account.AccountInfoQuery(id: "1") - Mastodon.API.Account.accountInfo(session: session, domain: domain, query: query, authorization: nil) + Mastodon.API.Account.accountInfo(session: session, domain: "mastodon.online", userID: "1", authorization: nil) .receive(on: DispatchQueue.main) .sink { completion in switch completion { @@ -91,4 +95,5 @@ extension MastodonSDKTests { wait(for: [theExpectation], timeout: 5.0) } + } diff --git a/NotificationService/Info.plist b/NotificationService/Info.plist new file mode 100644 index 000000000..7db9ec9cb --- /dev/null +++ b/NotificationService/Info.plist @@ -0,0 +1,31 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + NotificationService + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + NSExtension + + NSExtensionPointIdentifier + com.apple.usernotifications.service + NSExtensionPrincipalClass + $(PRODUCT_MODULE_NAME).NotificationService + + + diff --git a/NotificationService/MastodonNotification.swift b/NotificationService/MastodonNotification.swift new file mode 100644 index 000000000..f3941b12d --- /dev/null +++ b/NotificationService/MastodonNotification.swift @@ -0,0 +1,35 @@ +// +// MastodonNotification.swift +// NotificationService +// +// Created by MainasuK Cirno on 2021-4-26. +// + +import Foundation + +struct MastodonPushNotification: Codable { + + private let _accessToken: String + var accessToken: String { + return String.normalize(base64String: _accessToken) + } + + let notificationID: Int + let notificationType: String + + let preferredLocale: String? + let icon: String? + let title: String + let body: String + + enum CodingKeys: String, CodingKey { + case _accessToken = "access_token" + case notificationID = "notification_id" + case notificationType = "notification_type" + case preferredLocale = "preferred_locale" + case icon + case title + case body + } + +} diff --git a/NotificationService/NotificationService+Decrypt.swift b/NotificationService/NotificationService+Decrypt.swift new file mode 100644 index 000000000..858e7c2c5 --- /dev/null +++ b/NotificationService/NotificationService+Decrypt.swift @@ -0,0 +1,73 @@ +// +// NotificationService+Decrypt.swift +// NotificationService +// +// Created by MainasuK Cirno on 2021-4-25. +// + +import os.log +import Foundation +import CryptoKit + +extension NotificationService { + + static func decrypt(payload: Data, salt: Data, auth: Data, privateKey: P256.KeyAgreement.PrivateKey, publicKey: P256.KeyAgreement.PublicKey) -> Data? { + guard let sharedSecret = try? privateKey.sharedSecretFromKeyAgreement(with: publicKey) else { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: failed to craete shared secret", ((#file as NSString).lastPathComponent), #line, #function) + return nil + } + + let keyMaterial = sharedSecret.hkdfDerivedSymmetricKey(using: SHA256.self, salt: auth, sharedInfo: Data("Content-Encoding: auth\0".utf8), outputByteCount: 32) + + let keyInfo = info(type: "aesgcm", clientPublicKey: privateKey.publicKey.x963Representation, serverPublicKey: publicKey.x963Representation) + let key = HKDF.deriveKey(inputKeyMaterial: keyMaterial, salt: salt, info: keyInfo, outputByteCount: 16) + + let nonceInfo = info(type: "nonce", clientPublicKey: privateKey.publicKey.x963Representation, serverPublicKey: publicKey.x963Representation) + let nonce = HKDF.deriveKey(inputKeyMaterial: keyMaterial, salt: salt, info: nonceInfo, outputByteCount: 12) + + let nonceData = nonce.withUnsafeBytes(Array.init) + + guard let sealedBox = try? AES.GCM.SealedBox(combined: nonceData + payload) else { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: failed to create sealedBox", ((#file as NSString).lastPathComponent), #line, #function) + return nil + } + + var _plaintext: Data? + do { + _plaintext = try AES.GCM.open(sealedBox, using: key) + } catch { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: sealedBox open fail %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) + } + guard let plaintext = _plaintext else { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: failed to open sealedBox", ((#file as NSString).lastPathComponent), #line, #function) + return nil + } + + let paddingLength = Int(plaintext[0]) * 256 + Int(plaintext[1]) + guard plaintext.count >= 2 + paddingLength else { + print("1") + fatalError() + } + let unpadded = plaintext.suffix(from: paddingLength + 2) + + return Data(unpadded) + } + + static private func info(type: String, clientPublicKey: Data, serverPublicKey: Data) -> Data { + var info = Data() + + info.append("Content-Encoding: ".data(using: .utf8)!) + info.append(type.data(using: .utf8)!) + info.append(0) + info.append("P-256".data(using: .utf8)!) + info.append(0) + info.append(0) + info.append(65) + info.append(clientPublicKey) + info.append(0) + info.append(65) + info.append(serverPublicKey) + + return info + } +} diff --git a/NotificationService/NotificationService.entitlements b/NotificationService/NotificationService.entitlements new file mode 100644 index 000000000..d334a5e6d --- /dev/null +++ b/NotificationService/NotificationService.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.application-groups + + group.org.joinmastodon.mastodon-temp + + + diff --git a/NotificationService/NotificationService.swift b/NotificationService/NotificationService.swift new file mode 100644 index 000000000..08ba12be4 --- /dev/null +++ b/NotificationService/NotificationService.swift @@ -0,0 +1,108 @@ +// +// NotificationService.swift +// NotificationService +// +// Created by MainasuK Cirno on 2021-4-23. +// + +import UserNotifications +import CommonOSLog +import CryptoKit +import AlamofireImage +import Base85 +import AppShared + +class NotificationService: UNNotificationServiceExtension { + + var contentHandler: ((UNNotificationContent) -> Void)? + var bestAttemptContent: UNMutableNotificationContent? + + override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) { + self.contentHandler = contentHandler + bestAttemptContent = (request.content.mutableCopy() as? UNMutableNotificationContent) + + if let bestAttemptContent = bestAttemptContent { + // Modify the notification content here... + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + + let privateKey = AppSecret.default.notificationPrivateKey + let auth = AppSecret.default.notificationAuth + + guard let encodedPayload = bestAttemptContent.userInfo["p"] as? String, + let payload = Data(base85Encoded: encodedPayload, options: [], encoding: .z85) else { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: invalid payload", ((#file as NSString).lastPathComponent), #line, #function) + contentHandler(bestAttemptContent) + return + } + + guard let encodedPublicKey = bestAttemptContent.userInfo["k"] as? String, + let publicKey = NotificationService.publicKey(encodedPublicKey: encodedPublicKey) else { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: invalid public key", ((#file as NSString).lastPathComponent), #line, #function) + contentHandler(bestAttemptContent) + return + } + + guard let encodedSalt = bestAttemptContent.userInfo["s"] as? String, + let salt = Data(base85Encoded: encodedSalt, options: [], encoding: .z85) else { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: invalid salt", ((#file as NSString).lastPathComponent), #line, #function) + contentHandler(bestAttemptContent) + return + } + + guard let plaintextData = NotificationService.decrypt(payload: payload, salt: salt, auth: auth, privateKey: privateKey, publicKey: publicKey), + let notification = try? JSONDecoder().decode(MastodonPushNotification.self, from: plaintextData) else { + contentHandler(bestAttemptContent) + return + } + + bestAttemptContent.title = notification.title + bestAttemptContent.subtitle = "" + bestAttemptContent.body = notification.body + bestAttemptContent.sound = .default + bestAttemptContent.userInfo["plaintext"] = plaintextData + + UserDefaults.shared.notificationBadgeCount += 1 + bestAttemptContent.badge = NSNumber(integerLiteral: UserDefaults.shared.notificationBadgeCount) + + if let urlString = notification.icon, let url = URL(string: urlString) { + let temporaryDirectoryURL = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("notification-attachments") + try? FileManager.default.createDirectory(at: temporaryDirectoryURL, withIntermediateDirectories: true, attributes: nil) + let filename = url.lastPathComponent + let fileURL = temporaryDirectoryURL.appendingPathComponent(filename) + + ImageDownloader.default.download(URLRequest(url: url), completion: { [weak self] response in + guard let _ = self else { return } + switch response.result { + 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) + case .success(let image): + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: download image %s success", ((#file as NSString).lastPathComponent), #line, #function, url.debugDescription) + try? image.pngData()?.write(to: fileURL) + if let attachment = try? UNNotificationAttachment(identifier: filename, url: fileURL, options: nil) { + bestAttemptContent.attachments = [attachment] + } + } + contentHandler(bestAttemptContent) + }) + } else { + contentHandler(bestAttemptContent) + } + } + } + + override func serviceExtensionTimeWillExpire() { + // Called just before the extension will be terminated by the system. + // Use this as an opportunity to deliver your "best attempt" at modified content, otherwise the original push payload will be used. + if let contentHandler = contentHandler, let bestAttemptContent = bestAttemptContent { + contentHandler(bestAttemptContent) + } + } + +} + +extension NotificationService { + static func publicKey(encodedPublicKey: String) -> P256.KeyAgreement.PublicKey? { + guard let publicKeyData = Data(base85Encoded: encodedPublicKey, options: [], encoding: .z85) else { return nil } + return try? P256.KeyAgreement.PublicKey(x963Representation: publicKeyData) + } +} diff --git a/Podfile b/Podfile index d4bec65d4..ea7075ea8 100644 --- a/Podfile +++ b/Podfile @@ -23,4 +23,20 @@ target 'Mastodon' do # Pods for testing end + target 'NotificationService' do + + end + + target 'AppShared' do + + end + end + +plugin 'cocoapods-keys', { + :project => "Mastodon", + :keys => [ + "notification_endpoint", + "notification_endpoint_debug" + ] +} \ No newline at end of file diff --git a/Podfile.lock b/Podfile.lock index 4f553c4e3..e341a2420 100644 --- a/Podfile.lock +++ b/Podfile.lock @@ -1,12 +1,14 @@ PODS: - DateToolsSwift (5.0.0) - Kanna (5.2.4) + - Keys (1.0.1) - SwiftGen (6.4.0) - "UITextField+Shake (1.2.1)" DEPENDENCIES: - DateToolsSwift (~> 5.0.0) - Kanna (~> 5.2.2) + - Keys (from `Pods/CocoaPodsKeys`) - SwiftGen (~> 6.4.0) - "UITextField+Shake (~> 1.2)" @@ -17,12 +19,17 @@ SPEC REPOS: - SwiftGen - "UITextField+Shake" +EXTERNAL SOURCES: + Keys: + :path: Pods/CocoaPodsKeys + SPEC CHECKSUMS: DateToolsSwift: 4207ada6ad615d8dc076323d27037c94916dbfa6 Kanna: b9d00d7c11428308c7f95e1f1f84b8205f567a8f + Keys: a576f4c9c1c641ca913a959a9c62ed3f215a8de9 SwiftGen: 67860cc7c3cfc2ed25b9b74cfd55495fc89f9108 "UITextField+Shake": 298ac5a0f239d731bdab999b19b628c956ca0ac3 -PODFILE CHECKSUM: 30e8e3a555251a512e7b5e91183747152f126e7a +PODFILE CHECKSUM: a8dbae22e6e0bfb84f7db59aef1aa1716793d287 COCOAPODS: 1.10.1 diff --git a/README.md b/README.md index 0847c82e5..71194684d 100644 --- a/README.md +++ b/README.md @@ -48,10 +48,15 @@ arch -x86_64 pod install - [AlamofireNetworkActivityIndicator](https://github.com/Alamofire/AlamofireNetworkActivityIndicator) - [Alamofire](https://github.com/Alamofire/Alamofire) - [CommonOSLog](https://github.com/mainasuk/CommonOSLog) +- [CryptoSwift](https://github.com/krzyzanowskim/CryptoSwift) - [DateToolSwift](https://github.com/MatthewYork/DateTools) - [Kanna](https://github.com/tid-kijyun/Kanna) +- [KeychainAccess](https://github.com/kishikawakatsumi/KeychainAccess.git) - [Kingfisher](https://github.com/onevcat/Kingfisher) - [SwiftGen](https://github.com/SwiftGen/SwiftGen) - [SwiftyJSON](https://github.com/SwiftyJSON/SwiftyJSON) +- [TwitterTextEditor](https://github.com/twitter/TwitterTextEditor) +- [TwitterProfile](https://github.com/OfTheWolf/TwitterProfile) +- [UITextView-Placeholder](https://github.com/devxoul/UITextView-Placeholder) ## License