diff --git a/.gitignore b/.gitignore index a766fc62..24e748a9 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/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents b/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents index 3b558f9f..0d017028 100644 --- a/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents +++ b/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents @@ -65,6 +65,22 @@ + + + + + + + + + + + + + + + + @@ -155,6 +171,16 @@ + + + + + + + + + + @@ -194,6 +220,26 @@ + + + + + + + + + + + + + + + + + + + + @@ -210,13 +256,17 @@ + + + + - \ No newline at end of file + diff --git a/CoreDataStack/Entity/Notification.swift b/CoreDataStack/Entity/Notification.swift new file mode 100644 index 00000000..31c361aa --- /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/Setting.swift b/CoreDataStack/Entity/Setting.swift new file mode 100644 index 00000000..671f9bab --- /dev/null +++ b/CoreDataStack/Entity/Setting.swift @@ -0,0 +1,90 @@ +// +// Setting.swift +// CoreDataStack +// +// Created by ihugo on 2021/4/9. +// + +import CoreData +import Foundation + +public final class Setting: NSManagedObject { + @NSManaged public var appearance: String? + @NSManaged public var triggerBy: 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 + + // relationships + @NSManaged public var subscription: Set? +} + +public extension Setting { + override func awakeFromInsert() { + super.awakeFromInsert() + setPrimitiveValue(Date(), forKey: #keyPath(Setting.createdAt)) + } + + func didUpdate(at networkDate: Date) { + self.updatedAt = networkDate + } + + @discardableResult + static func insert( + into context: NSManagedObjectContext, + property: Property + ) -> Setting { + let setting: Setting = context.insertObject() + setting.appearance = property.appearance + setting.triggerBy = property.triggerBy + setting.domain = property.domain + setting.userID = property.userID + return setting + } + + func update(appearance: String?) { + guard appearance != self.appearance else { return } + self.appearance = appearance + didUpdate(at: Date()) + } + + func update(triggerBy: String?) { + guard triggerBy != self.triggerBy else { return } + self.triggerBy = triggerBy + didUpdate(at: Date()) + } +} + +public extension Setting { + struct Property { + public let appearance: String + public let triggerBy: String + public let domain: String + public let userID: String + + public init(appearance: String, triggerBy: String, domain: String, userID: String) { + self.appearance = appearance + self.triggerBy = triggerBy + self.domain = domain + self.userID = userID + } + } +} + +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/Subscription.swift b/CoreDataStack/Entity/Subscription.swift new file mode 100644 index 00000000..8ced945d --- /dev/null +++ b/CoreDataStack/Entity/Subscription.swift @@ -0,0 +1,101 @@ +// +// 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 serverKey: String + + /// four types: + /// - anyone + /// - a follower + /// - anyone I follow + /// - no one + @NSManaged public var type: String + + @NSManaged public private(set) var createdAt: Date + @NSManaged public private(set) var updatedAt: Date + + // MARK: - relationships + @NSManaged public var alert: SubscriptionAlerts? + // MARK: holder + @NSManaged public var setting: Setting? +} + +public extension Subscription { + override func awakeFromInsert() { + super.awakeFromInsert() + setPrimitiveValue(Date(), forKey: #keyPath(Subscription.createdAt)) + } + + func didUpdate(at networkDate: Date) { + self.updatedAt = networkDate + } + + @discardableResult + static func insert( + into context: NSManagedObjectContext, + property: Property + ) -> Subscription { + let setting: Subscription = context.insertObject() + setting.id = property.id + setting.endpoint = property.endpoint + setting.serverKey = property.serverKey + setting.type = property.type + + return setting + } +} + +public extension Subscription { + struct Property { + public let endpoint: String + public let id: String + public let serverKey: String + public let type: String + + public init(endpoint: String, id: String, serverKey: String, type: String) { + self.endpoint = endpoint + self.id = id + self.serverKey = serverKey + self.type = type + } + } + + func updateIfNeed(property: Property) { + if self.endpoint != property.endpoint { + self.endpoint = property.endpoint + } + if self.id != property.id { + self.id = property.id + } + if self.serverKey != property.serverKey { + self.serverKey = property.serverKey + } + if self.type != property.type { + self.type = property.type + } + } +} + +extension Subscription: Managed { + public static var defaultSortDescriptors: [NSSortDescriptor] { + return [NSSortDescriptor(keyPath: \Subscription.createdAt, ascending: false)] + } +} + +extension Subscription { + + public static func predicate(type: String) -> NSPredicate { + return NSPredicate(format: "%K == %@", #keyPath(Subscription.type), type) + } + +} diff --git a/CoreDataStack/Entity/SubscriptionAlerts.swift b/CoreDataStack/Entity/SubscriptionAlerts.swift new file mode 100644 index 00000000..f5abf495 --- /dev/null +++ b/CoreDataStack/Entity/SubscriptionAlerts.swift @@ -0,0 +1,130 @@ +// +// PushSubscriptionAlerts+CoreDataClass.swift +// CoreDataStack +// +// Created by ihugo on 2021/4/9. +// +// + +import Foundation +import CoreData + +public final class SubscriptionAlerts: NSManagedObject { + @NSManaged public var follow: NSNumber? + @NSManaged public var favourite: NSNumber? + @NSManaged public var reblog: NSNumber? + @NSManaged public var mention: NSNumber? + @NSManaged public var poll: NSNumber? + + @NSManaged public private(set) var createdAt: Date + @NSManaged public private(set) var updatedAt: Date + + // MARK: - relationships + @NSManaged public var subscription: Subscription? +} + +public extension SubscriptionAlerts { + override func awakeFromInsert() { + super.awakeFromInsert() + setPrimitiveValue(Date(), forKey: #keyPath(SubscriptionAlerts.createdAt)) + } + + func didUpdate(at networkDate: Date) { + self.updatedAt = networkDate + } + + @discardableResult + static func insert( + into context: NSManagedObjectContext, + property: Property + ) -> SubscriptionAlerts { + let alerts: SubscriptionAlerts = context.insertObject() + alerts.favourite = property.favourite + alerts.follow = property.follow + alerts.mention = property.mention + alerts.poll = property.poll + alerts.reblog = property.reblog + return alerts + } + + func update(favourite: NSNumber?) { + guard self.favourite != favourite else { return } + self.favourite = favourite + + didUpdate(at: Date()) + } + + func update(follow: NSNumber?) { + guard self.follow != follow else { return } + self.follow = follow + + didUpdate(at: Date()) + } + + func update(mention: NSNumber?) { + guard self.mention != mention else { return } + self.mention = mention + + didUpdate(at: Date()) + } + + func update(poll: NSNumber?) { + guard self.poll != poll else { return } + self.poll = poll + + didUpdate(at: Date()) + } + + func update(reblog: NSNumber?) { + guard self.reblog != reblog else { return } + self.reblog = reblog + + didUpdate(at: Date()) + } +} + +public extension SubscriptionAlerts { + struct Property { + public let favourite: NSNumber? + public let follow: NSNumber? + public let mention: NSNumber? + public let poll: NSNumber? + public let reblog: NSNumber? + + public init(favourite: NSNumber?, follow: NSNumber?, mention: NSNumber?, poll: NSNumber?, reblog: NSNumber?) { + self.favourite = favourite + self.follow = follow + self.mention = mention + self.poll = poll + self.reblog = reblog + } + } + + func updateIfNeed(property: Property) { + if self.follow != property.follow { + self.follow = property.follow + } + + if self.favourite != property.favourite { + self.favourite = property.favourite + } + + if self.reblog != property.reblog { + self.reblog = property.reblog + } + + if self.mention != property.mention { + self.mention = property.mention + } + + if self.poll != property.poll { + self.poll = property.poll + } + } +} + +extension SubscriptionAlerts: Managed { + public static var defaultSortDescriptors: [NSSortDescriptor] { + return [NSSortDescriptor(keyPath: \SubscriptionAlerts.createdAt, ascending: false)] + } +} diff --git a/Localization/app.json b/Localization/app.json index e3ae30e9..bd1b29fe 100644 --- a/Localization/app.json +++ b/Localization/app.json @@ -22,6 +22,11 @@ "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" } }, "controls": { @@ -325,6 +330,18 @@ }, "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" }, "thread": { "back_title": "Post", @@ -337,6 +354,41 @@ "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" + } + } } } -} \ No newline at end of file +} diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index ff243e8b..0f8236e6 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -30,18 +30,22 @@ 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 */; }; - 2D19864F261C372A00F0B013 /* SearchBottomLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D19864E261C372A00F0B013 /* SearchBottomLoader.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 */; }; @@ -49,6 +53,8 @@ 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 */; }; @@ -76,6 +82,9 @@ 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 */; }; + 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 */; }; @@ -91,6 +100,7 @@ 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 */; }; @@ -110,6 +120,7 @@ 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 */; }; + 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 */; }; @@ -122,10 +133,21 @@ 2DF75BC725D1475D00694EC8 /* ManagedObjectContextObjectsDidChange.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DF75BC625D1475D00694EC8 /* ManagedObjectContextObjectsDidChange.swift */; }; 2DFAD5272616F9D300F9EE7C /* SearchViewController+Searching.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DFAD5262616F9D300F9EE7C /* SearchViewController+Searching.swift */; }; 2DFAD5372617010500F9EE7C /* SearchingTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DFAD5362617010500F9EE7C /* SearchingTableViewCell.swift */; }; - 2DFF41892614A4DC00F776A4 /* UIView+Constraint.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DFF41882614A4DC00F776A4 /* UIView+Constraint.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 */; }; + 5B90C463262599800002E742 /* SettingsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B90C45D262599800002E742 /* SettingsViewController.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 */; }; 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 */; }; @@ -423,18 +445,22 @@ 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 = ""; }; - 2D19864E261C372A00F0B013 /* SearchBottomLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchBottomLoader.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 = ""; }; @@ -442,6 +468,8 @@ 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 = ""; }; @@ -467,6 +495,9 @@ 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 = ""; }; + 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 = ""; }; @@ -481,6 +512,7 @@ 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 = ""; }; @@ -500,6 +532,7 @@ 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 = ""; }; + 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 = ""; }; @@ -512,13 +545,24 @@ 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 = ""; }; - 2DFF41882614A4DC00F776A4 /* UIView+Constraint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIView+Constraint.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 = ""; }; + 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 = ""; }; + 5B90C45D262599800002E742 /* SettingsViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SettingsViewController.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 = ""; }; 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 = ""; }; @@ -907,6 +951,15 @@ path = CollectionViewCell; sourceTree = ""; }; + 2D35237F26256F470031AF25 /* TableViewCell */ = { + isa = PBXGroup; + children = ( + 2D35238026256F690031AF25 /* NotificationTableViewCell.swift */, + 2D24E11C2626D8B100A59D4F /* NotificationStatusTableViewCell.swift */, + ); + path = TableViewCell; + sourceTree = ""; + }; 2D364F7025E66D5B00204FDC /* ResendEmail */ = { isa = PBXGroup; children = ( @@ -1063,6 +1116,7 @@ DB1E346725F518E20079D7DF /* CategoryPickerSection.swift */, 2DE0FAC02615F04D00CDF649 /* RecommendHashTagSection.swift */, 2DE0FACD2615F7AD00CDF649 /* RecommendAccountSection.swift */, + 2D35237926256D920031AF25 /* NotificationSection.swift */, 2D198648261C0B8500F0B013 /* SearchResultSection.swift */, DB66729525F9F91600D60309 /* ComposeStatusSection.swift */, DB447680260B3ED600B66B82 /* CustomEmojiPickerSection.swift */, @@ -1116,6 +1170,7 @@ children = ( 2D7631B225C159F700929FB9 /* Item.swift */, 2D198642261BF09500F0B013 /* SearchResultItem.swift */, + 2D7867182625B77500211898 /* NotificationItem.swift */, DB4481CB25EE2AFE00BEFB67 /* PollItem.swift */, DB1E347725F519300079D7DF /* PickServerItem.swift */, DB1FD45925F27898004CFCFC /* CategoryPickerItem.swift */, @@ -1154,7 +1209,6 @@ isa = PBXGroup; children = ( 2DFAD5362617010500F9EE7C /* SearchingTableViewCell.swift */, - 2D19864E261C372A00F0B013 /* SearchBottomLoader.swift */, ); path = TableViewCell; sourceTree = ""; @@ -1169,6 +1223,35 @@ name = Frameworks; sourceTree = ""; }; + 5B90C455262599800002E742 /* Settings */ = { + isa = PBXGroup; + children = ( + 5B90C457262599800002E742 /* View */, + 5B90C456262599800002E742 /* SettingsViewModel.swift */, + 5B90C45D262599800002E742 /* SettingsViewController.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 = ( @@ -1346,6 +1429,7 @@ DB938F1426241FDF00E5B6C1 /* APIService+Thread.swift */, DB49A62A25FF36C700B98345 /* APIService+CustomEmoji.swift */, DB9A488326034BD7008B817C /* APIService+Status.swift */, + 2D61254C262547C200299647 /* APIService+Notification.swift */, DB9A488F26035963008B817C /* APIService+Media.swift */, 2D34D9D026148D9E0081BFC0 /* APIService+Recommend.swift */, 2D34D9DA261494120081BFC0 /* APIService+Search.swift */, @@ -1354,6 +1438,7 @@ DBAE3F932616E28B004B8251 /* APIService+Follow.swift */, DBAE3F8D2616E0B1004B8251 /* APIService+Block.swift */, DBAE3F9D2616E308004B8251 /* APIService+Mute.swift */, + 5B90C48426259BF10002E742 /* APIService+Subscriptions.swift */, ); path = APIService; sourceTree = ""; @@ -1365,6 +1450,7 @@ DB45FADC25CA6F6B005A8AC7 /* APIService+CoreData+MastodonUser.swift */, DB45FAF825CA80A2005A8AC7 /* APIService+CoreData+MastodonAuthentication.swift */, 2D79E700261EA5550011E398 /* APIService+CoreData+Tag.swift */, + 5B90C48A26259C120002E742 /* APIService+CoreData+Subscriptions.swift */, ); path = CoreData; sourceTree = ""; @@ -1425,6 +1511,7 @@ 5DDDF1982617447F00311060 /* Mastodon+Entity+Tag.swift */, 5DDDF1A82617489F00311060 /* Mastodon+Entity+History.swift */, 2D650FAA25ECDC9300851B58 /* Mastodon+Entity+Error+Detail.swift */, + 2DB72C8B262D764300CE6173 /* Mastodon+Entity+Notification+Type.swift */, ); path = MastodonSDK; sourceTree = ""; @@ -1521,6 +1608,7 @@ isa = PBXGroup; children = ( DB89BA2625C110B4008580ED /* Status.swift */, + 2D6125462625436B00299647 /* Notification.swift */, 2D0B7A1C261D839600B44727 /* SearchHistory.swift */, DB8AF52425C131D1002E6C99 /* MastodonUser.swift */, DB8AF56725C13E2A002E6C99 /* HomeTimelineIndex.swift */, @@ -1534,6 +1622,9 @@ DB4481AC25EE155900BEFB67 /* Poll.swift */, DB4481B225EE16D000BEFB67 /* PollOption.swift */, DBCC3B9A2615849F0045B23D /* PrivateNote.swift */, + 5B90C46D26259B2C0002E742 /* Setting.swift */, + 5B90C46C26259B2C0002E742 /* Subscription.swift */, + 5B90C47E26259BA90002E742 /* SubscriptionAlerts.swift */, ); path = Entity; sourceTree = ""; @@ -1585,6 +1676,7 @@ 2D76316325C14BAC00929FB9 /* PublicTimeline */, 0F2021F5261325ED000C64BF /* HashtagTimeline */, DB9D6BEE25E4F5370051B173 /* Search */, + 5B90C455262599800002E742 /* Settings */, DB9D6BFD25E4F57B0051B173 /* Notification */, DB9D6C0825E4F5A60051B173 /* Profile */, DB789A1025F9F29B0071ACA0 /* Compose */, @@ -1617,8 +1709,9 @@ 5DF1056325F887CB00D6C0D4 /* AVPlayer.swift */, DB47229625F9EFAD00DA7F53 /* NSManagedObjectContext.swift */, 2D32EAB925CB9B0500C9ED86 /* UIView.swift */, - 2DFF41882614A4DC00F776A4 /* UIView+Constraint.swift */, + 5DA732CB2629CEF500A92342 /* UIView+Remove.swift */, DB8AF55C25C138B7002E6C99 /* UIViewController.swift */, + 2D24E1222626ED9D00A59D4F /* UIView+Gesture.swift */, 2D3F9E0325DFA133004262D9 /* UITapGestureRecognizer.swift */, 2D84350425FF858100EECE90 /* UIScrollView.swift */, DB9E0D6E25EE008500CFDD76 /* UIInterpolatingMotionEffect.swift */, @@ -1691,6 +1784,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 = ""; @@ -2262,6 +2360,7 @@ DB938F0F2624119800E5B6C1 /* ThreadViewModel+LoadThreadState.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 */, @@ -2280,9 +2379,10 @@ 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 */, - 2D19864F261C372A00F0B013 /* SearchBottomLoader.swift in Sources */, DB2FF510260B113300ADA9FE /* ComposeStatusPollExpiresOptionCollectionViewCell.swift in Sources */, 0F202213261351F5000C64BF /* APIService+HashtagTimeline.swift in Sources */, DB0AC6FC25CD02E600D75117 /* APIService+Instance.swift in Sources */, @@ -2294,10 +2394,12 @@ DB49A62B25FF36C700B98345 /* APIService+CustomEmoji.swift in Sources */, DBCBED1D26132E1A00B49291 /* StatusFetchedResultsController.swift in Sources */, 2D79E701261EA5550011E398 /* APIService+CoreData+Tag.swift in Sources */, + 5B90C461262599800002E742 /* SettingsLinkTableViewCell.swift in Sources */, DBE3CDEC261C6B2900430CC6 /* FavoriteViewController.swift in Sources */, DB938EE62623F50700E5B6C1 /* ThreadViewController.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 */, @@ -2317,6 +2419,7 @@ DBD9149025DF6D8D00903DFD /* APIService+Onboarding.swift in Sources */, DBAE3FAF26172FC0004B8251 /* RemoteProfileViewModel.swift in Sources */, DBE3CE0D261D767100430CC6 /* FavoriteViewController+StatusProvider.swift in Sources */, + 2D084B9326259545003AA3AF /* NotificationViewModel+LoadLatestState.swift in Sources */, DB98337F25C9452D00AD9700 /* APIService+APIError.swift in Sources */, DB9E0D6F25EE008500CFDD76 /* UIInterpolatingMotionEffect.swift in Sources */, DBB9759C262462E1004620BD /* ThreadMetaView.swift in Sources */, @@ -2324,6 +2427,7 @@ 5DDDF1A92617489F00311060 /* Mastodon+Entity+History.swift in Sources */, DB59F11825EFA35B001F1DAB /* StripProgressView.swift in Sources */, DB59F10425EF5EBC001F1DAB /* TableViewCellHeightCacheableContainer.swift in Sources */, + 5B90C463262599800002E742 /* SettingsViewController.swift in Sources */, DB73B490261F030A002E9E9F /* SafariActivity.swift in Sources */, DB9A48962603685D008B817C /* MastodonAttachmentService+UploadState.swift in Sources */, 2D198643261BF09500F0B013 /* SearchResultItem.swift in Sources */, @@ -2331,6 +2435,7 @@ 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 */, @@ -2343,6 +2448,7 @@ DB71FD3C25F8A1C500512AE1 /* APIService+Persist+PersistCache.swift in Sources */, 2DA6055125F74407006356F9 /* AudioContainerViewModel.swift in Sources */, 0FB3D2FE25E4CB6400AAD544 /* PickServerTitleCell.swift in Sources */, + 5DA732CC2629CEF500A92342 /* UIView+Remove.swift in Sources */, 2D38F1DF25CD46A400561493 /* HomeTimelineViewController+StatusProvider.swift in Sources */, 2D206B9225F60EA700143C56 /* UIControl.swift in Sources */, 2D46975E25C2A54100CF4AA9 /* NSLayoutConstraint.swift in Sources */, @@ -2351,6 +2457,7 @@ 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 */, DB9A486C26032AC1008B817C /* AttachmentContainerView+EmptyStateView.swift in Sources */, @@ -2360,6 +2467,7 @@ 0F20222D261457EE000C64BF /* HashtagTimelineViewModel+LoadOldestState.swift in Sources */, DB4563BD25E11A24004DA0B9 /* KeyboardResponderService.swift in Sources */, DB5086BE25CC0D9900C2C187 /* SplashPreference.swift in Sources */, + 5B90C462262599800002E742 /* SettingsSectionHeader.swift in Sources */, DB44768B260B3F2100B66B82 /* CustomEmojiPickerItem.swift in Sources */, 2DF75BA125D0E29D00694EC8 /* StatusProvider+StatusTableViewCellDelegate.swift in Sources */, 5DF1056425F887CB00D6C0D4 /* AVPlayer.swift in Sources */, @@ -2376,14 +2484,17 @@ DB789A1C25F9F76A0071ACA0 /* ComposeStatusContentCollectionViewCell.swift in Sources */, DB1FD43625F26899004CFCFC /* MastodonPickServerViewModel+LoadIndexedServerState.swift in Sources */, 2D939AE825EE1CF80076FA61 /* MastodonRegisterViewController+Avatar.swift in Sources */, + 2D084B8D26258EA3003AA3AF /* NotificationViewModel+diffable.swift in Sources */, DB1D186C25EF5BA7003F1F23 /* PollTableView.swift in Sources */, 2D5981A125E4A593000FB903 /* MastodonConfirmEmailViewModel.swift in Sources */, DB87D4452609BE0500D12C0D /* ComposeStatusPollOptionCollectionViewCell.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 */, DBAE3F822615DDA3004B8251 /* ProfileViewController+UserProvider.swift in Sources */, DB938EED2623F79B00E5B6C1 /* ThreadViewModel.swift in Sources */, @@ -2415,8 +2526,11 @@ DB35FC252612FD7A006193C9 /* ProfileFieldView.swift in Sources */, DB938F0326240EA300E5B6C1 /* CachedThreadViewModel.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 */, DBAE3FA92617106E004B8251 /* MastodonMetricFormatter.swift in Sources */, + 2D35237A26256D920031AF25 /* NotificationSection.swift in Sources */, DB084B5725CBC56C00F898ED /* Status.swift in Sources */, DB447691260B406600B66B82 /* CustomEmojiPickerItemCollectionViewCell.swift in Sources */, DB9282B225F3222800823B15 /* PickServerEmptyStateView.swift in Sources */, @@ -2429,14 +2543,16 @@ 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 */, - 2DFF41892614A4DC00F776A4 /* UIView+Constraint.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+removeDuplicates.swift in Sources */, + 5B90C460262599800002E742 /* SettingsAppearanceTableViewCell.swift in Sources */, DB8AF54425C13647002E6C99 /* SceneCoordinator.swift in Sources */, 5DF1058525F88AE500D6C0D4 /* NeedsDependency+AVPlayerViewControllerDelegate.swift in Sources */, DB447697260B439000B66B82 /* CustomEmojiPickerHeaderCollectionReusableView.swift in Sources */, @@ -2524,6 +2640,7 @@ 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 */, @@ -2536,6 +2653,7 @@ 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 */, @@ -2544,6 +2662,8 @@ DB89BA1D25C1107F008580ED /* URL.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; }; diff --git a/Mastodon/Coordinator/SceneCoordinator.swift b/Mastodon/Coordinator/SceneCoordinator.swift index 8874a69e..c2608fe8 100644 --- a/Mastodon/Coordinator/SceneCoordinator.swift +++ b/Mastodon/Coordinator/SceneCoordinator.swift @@ -68,6 +68,7 @@ extension SceneCoordinator { #if DEBUG case publicTimeline + case settings #endif var isOnboarding: Bool { @@ -269,6 +270,10 @@ private extension SceneCoordinator { let _viewController = PublicTimelineViewController() _viewController.viewModel = PublicTimelineViewModel(context: appContext) viewController = _viewController + case .settings: + let _viewController = SettingsViewController() + _viewController.viewModel = SettingsViewModel(context: appContext, coordinator: self) + viewController = _viewController #endif } diff --git a/Mastodon/Diffiable/Item/NotificationItem.swift b/Mastodon/Diffiable/Item/NotificationItem.swift new file mode 100644 index 00000000..ba0d0c14 --- /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) + + 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/Section/NotificationSection.swift b/Mastodon/Diffiable/Section/NotificationSection.swift new file mode 100644 index 00000000..5ccab431 --- /dev/null +++ b/Mastodon/Diffiable/Section/NotificationSection.swift @@ -0,0 +1,118 @@ +// +// 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, + requestUserID: String + ) -> 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 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 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: Item.StatusAttribute(isStatusTextSensitive: false, isStatusSensitive: false)) + 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.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 + } + case .bottomLoader: + let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self)) as! TimelineBottomLoaderTableViewCell + cell.startAnimating() + return cell + } + } + } +} + diff --git a/Mastodon/Diffiable/Section/SearchResultSection.swift b/Mastodon/Diffiable/Section/SearchResultSection.swift index 50c56160..1b9230ee 100644 --- a/Mastodon/Diffiable/Section/SearchResultSection.swift +++ b/Mastodon/Diffiable/Section/SearchResultSection.swift @@ -44,7 +44,7 @@ extension SearchResultSection { cell.config(with: user) return cell case .bottomLoader: - let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: SearchBottomLoader.self)) as! SearchBottomLoader + let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self)) as! TimelineBottomLoaderTableViewCell cell.startAnimating() return cell } diff --git a/Mastodon/Diffiable/Section/StatusSection.swift b/Mastodon/Diffiable/Section/StatusSection.swift index a432bf06..1dd155d5 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 @@ -127,7 +133,7 @@ extension StatusSection { extension StatusSection { static func configure( - cell: StatusTableViewCell, + cell: StatusCell, dependency: NeedsDependency, readableLayoutFrame: CGRect?, timestampUpdatePublisher: AnyPublisher, @@ -283,14 +289,27 @@ extension StatusSection { if let videoAttachment = mediaAttachments.filter({ $0.type == .gifv || $0.type == .video }).first, let videoPlayerViewModel = dependency.context.videoPlaybackService.dequeueVideoPlayerViewModel(for: videoAttachment) { - let parent = cell.delegate?.parent() + 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() + default: + parent = nil + assertionFailure("unknown cell") + } let playerContainerView = cell.statusView.playerContainerView let playerViewController = playerContainerView.setupPlayer( aspectRatio: videoPlayerViewModel.videoSize, maxSize: playerViewMaxSize, parent: parent ) - playerViewController.delegate = cell.delegate?.playerViewControllerDelegate + playerViewController.delegate = playerViewControllerDelegate playerViewController.player = videoPlayerViewModel.player playerViewController.showsPlaybackControls = videoPlayerViewModel.videoKind != .gif playerContainerView.setMediaKind(kind: videoPlayerViewModel.videoKind) @@ -376,7 +395,9 @@ extension StatusSection { StatusSection.configureActionToolBar(cell: cell, status: status, requestUserID: requestUserID) // separator line - cell.separatorLine.isHidden = statusItemAttribute.isSeparatorLineHidden + if let statusTableViewCell = cell as? StatusTableViewCell { + statusTableViewCell.separatorLine.isHidden = statusItemAttribute.isSeparatorLineHidden + } // set date let createdAt = (status.reblog ?? status).createdAt @@ -520,7 +541,7 @@ extension StatusSection { static func configureHeader( - cell: StatusTableViewCell, + cell: StatusCell, status: Status ) { if status.reblog != nil { @@ -548,7 +569,7 @@ extension StatusSection { } static func configureActionToolBar( - cell: StatusTableViewCell, + cell: StatusCell, status: Status, requestUserID: String ) { @@ -579,7 +600,7 @@ extension StatusSection { } static func configurePoll( - cell: StatusTableViewCell, + cell: StatusCell, poll: Poll?, requestUserID: String, updateProgressAnimated: Bool, 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 00000000..77a7b412 --- /dev/null +++ b/Mastodon/Extension/MastodonSDK/Mastodon+Entity+Notification+Type.swift @@ -0,0 +1,75 @@ +// +// 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 + 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 + 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" + default: + actionImageName = "" + } + return actionImageName + } + } +} diff --git a/Mastodon/Extension/UIButton.swift b/Mastodon/Extension/UIButton.swift index 916ad222..31043157 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/UIView+Constraint.swift b/Mastodon/Extension/UIView+Constraint.swift deleted file mode 100644 index baa923ad..00000000 --- a/Mastodon/Extension/UIView+Constraint.swift +++ /dev/null @@ -1,261 +0,0 @@ -// -// UIView+Constraint.swift -// Mastodon -// -// Created by sxiaojian on 2021/3/31. -// - -import UIKit - -enum Dimension { - case width - case height - - var layoutAttribute: NSLayoutConstraint.Attribute { - switch self { - case .width: - return .width - case .height: - return .height - } - } - -} - -extension UIView { - - func constrain(toSuperviewEdges: UIEdgeInsets?) { - guard let view = superview else { assert(false, "Superview cannot be nil when adding contraints"); return} - translatesAutoresizingMaskIntoConstraints = false - NSLayoutConstraint.activate([ - NSLayoutConstraint(item: self, - attribute: .leading, - relatedBy: .equal, - toItem: view, - attribute: .leading, - multiplier: 1.0, - constant: toSuperviewEdges?.left ?? 0.0), - NSLayoutConstraint(item: self, - attribute: .top, - relatedBy: .equal, - toItem: view, - attribute: .top, - multiplier: 1.0, - constant: toSuperviewEdges?.top ?? 0.0), - NSLayoutConstraint(item: view, - attribute: .trailing, - relatedBy: .equal, - toItem: self, - attribute: .trailing, - multiplier: 1.0, - constant: toSuperviewEdges?.right ?? 0.0), - NSLayoutConstraint(item: view, - attribute: .bottom, - relatedBy: .equal, - toItem: self, - attribute: .bottom, - multiplier: 1.0, - constant: toSuperviewEdges?.bottom ?? 0.0) - ]) - } - - func constrain(_ constraints: [NSLayoutConstraint?]) { - guard superview != nil else { assert(false, "Superview cannot be nil when adding contraints"); return } - translatesAutoresizingMaskIntoConstraints = false - NSLayoutConstraint.activate(constraints.compactMap { $0 }) - } - - func constraint(_ attribute: NSLayoutConstraint.Attribute, toView: UIView, constant: CGFloat?) -> NSLayoutConstraint? { - guard superview != nil else { assert(false, "Superview cannot be nil when adding contraints"); return nil} - translatesAutoresizingMaskIntoConstraints = false - return NSLayoutConstraint(item: self, attribute: attribute, relatedBy: .equal, toItem: toView, attribute: attribute, multiplier: 1.0, constant: constant ?? 0.0) - } - - func constraint(_ attribute: NSLayoutConstraint.Attribute, toView: UIView) -> NSLayoutConstraint? { - guard superview != nil else { assert(false, "Superview cannot be nil when adding contraints"); return nil} - translatesAutoresizingMaskIntoConstraints = false - return NSLayoutConstraint(item: self, attribute: attribute, relatedBy: .equal, toItem: toView, attribute: attribute, multiplier: 1.0, constant: 0.0) - } - - func constraint(_ dimension: Dimension, constant: CGFloat) -> NSLayoutConstraint? { - guard superview != nil else { assert(false, "Superview cannot be nil when adding contraints"); return nil } - translatesAutoresizingMaskIntoConstraints = false - return NSLayoutConstraint(item: self, - attribute: dimension.layoutAttribute, - relatedBy: .equal, - toItem: nil, - attribute: .notAnAttribute, - multiplier: 1.0, - constant: constant) - } - - func constrainTopCorners(sidePadding: CGFloat, topPadding: CGFloat, topLayoutGuide: UILayoutSupport) { - guard let view = superview else { assert(false, "Superview cannot be nil when adding contraints"); return } - translatesAutoresizingMaskIntoConstraints = false - constrain([ - constraint(.leading, toView: view, constant: sidePadding), - NSLayoutConstraint(item: self, attribute: .top, relatedBy: .equal, toItem: topLayoutGuide, attribute: .bottom, multiplier: 1.0, constant: topPadding), - constraint(.trailing, toView: view, constant: -sidePadding) - ]) - } - - func constrainTopCorners(sidePadding: CGFloat, topPadding: CGFloat) { - guard let view = superview else { assert(false, "Superview cannot be nil when adding contraints"); return } - translatesAutoresizingMaskIntoConstraints = false - constrain([ - constraint(.leading, toView: view, constant: sidePadding), - constraint(.top, toView: view, constant: topPadding), - constraint(.trailing, toView: view, constant: -sidePadding) - ]) - } - - func constrainTopCorners(height: CGFloat) { - guard let view = superview else { assert(false, "Superview cannot be nil when adding contraints"); return } - translatesAutoresizingMaskIntoConstraints = false - constrain([ - constraint(.leading, toView: view), - constraint(.top, toView: view), - constraint(.trailing, toView: view), - constraint(.height, constant: height) - ]) - } - - func constrainBottomCorners(sidePadding: CGFloat, bottomPadding: CGFloat) { - guard let view = superview else { assert(false, "Superview cannot be nil when adding contraints"); return } - translatesAutoresizingMaskIntoConstraints = false - constrain([ - constraint(.leading, toView: view, constant: sidePadding), - constraint(.bottom, toView: view, constant: -bottomPadding), - constraint(.trailing, toView: view, constant: -sidePadding) - ]) - } - - func constrainBottomCorners(height: CGFloat) { - guard let view = superview else { assert(false, "Superview cannot be nil when adding contraints"); return } - translatesAutoresizingMaskIntoConstraints = false - constrain([ - constraint(.leading, toView: view), - constraint(.bottom, toView: view), - constraint(.trailing, toView: view), - constraint(.height, constant: height) - ]) - } - - func constrainLeadingCorners() { - guard let view = superview else { assert(false, "Superview cannot be nil when adding contraints"); return } - translatesAutoresizingMaskIntoConstraints = false - constrain([ - constraint(.top, toView: view), - constraint(.leading, toView: view), - constraint(.bottom, toView: view) - ]) - } - - func constrainTrailingCorners() { - guard let view = superview else { assert(false, "Superview cannot be nil when adding contraints"); return } - translatesAutoresizingMaskIntoConstraints = false - constrain([ - constraint(.top, toView: view), - constraint(.trailing, toView: view), - constraint(.bottom, toView: view) - ]) - } - - func constrainToCenter() { - guard let view = superview else { assert(false, "Superview cannot be nil when adding contraints"); return } - translatesAutoresizingMaskIntoConstraints = false - constrain([ - constraint(.centerX, toView: view), - constraint(.centerY, toView: view) - ]) - } - - func pin(toSize: CGSize) { - guard superview != nil else { assert(false, "Superview cannot be nil when adding contraints"); return } - translatesAutoresizingMaskIntoConstraints = false - constrain([ - widthAnchor.constraint(equalToConstant: toSize.width), - heightAnchor.constraint(equalToConstant: toSize.height)]) - } - - func pin(top: CGFloat?,left: CGFloat?,bottom: CGFloat?, right: CGFloat?) { - guard let view = superview else { assert(false, "Superview cannot be nil when adding contraints"); return } - translatesAutoresizingMaskIntoConstraints = false - var constraints = [NSLayoutConstraint]() - if let topConstant = top { - constraints.append(topAnchor.constraint(equalTo: view.topAnchor, constant: topConstant)) - } - if let leftConstant = left { - constraints.append(leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: leftConstant)) - } - if let bottomConstant = bottom { - constraints.append(view.bottomAnchor.constraint(equalTo: bottomAnchor, constant: bottomConstant)) - } - if let rightConstant = right { - constraints.append(view.trailingAnchor.constraint(equalTo: trailingAnchor, constant: rightConstant)) - } - constrain(constraints) - - } - func pinTopLeft(padding: CGFloat) { - guard let view = superview else { assert(false, "Superview cannot be nil when adding contraints"); return } - translatesAutoresizingMaskIntoConstraints = false - constrain([ - leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: padding), - topAnchor.constraint(equalTo: view.topAnchor, constant: padding)]) - } - - func pinTopLeft(top: CGFloat, left: CGFloat) { - guard let view = superview else { assert(false, "Superview cannot be nil when adding contraints"); return } - translatesAutoresizingMaskIntoConstraints = false - constrain([ - leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: left), - topAnchor.constraint(equalTo: view.topAnchor, constant: top)]) - } - - func pinTopRight(padding: CGFloat) { - guard let view = superview else { assert(false, "Superview cannot be nil when adding contraints"); return } - translatesAutoresizingMaskIntoConstraints = false - constrain([ - view.trailingAnchor.constraint(equalTo: trailingAnchor, constant: padding), - topAnchor.constraint(equalTo: view.topAnchor, constant: padding)]) - } - - func pinTopRight(top: CGFloat, right: CGFloat) { - guard let view = superview else { assert(false, "Superview cannot be nil when adding contraints"); return } - translatesAutoresizingMaskIntoConstraints = false - constrain([ - view.trailingAnchor.constraint(equalTo: trailingAnchor, constant: right), - topAnchor.constraint(equalTo: view.topAnchor, constant: top)]) - } - - func pinTopLeft(toView: UIView, topPadding: CGFloat) { - guard superview != nil else { assert(false, "Superview cannot be nil when adding contraints"); return } - translatesAutoresizingMaskIntoConstraints = false - constrain([ - leadingAnchor.constraint(equalTo: toView.leadingAnchor), - topAnchor.constraint(equalTo: toView.bottomAnchor, constant: topPadding)]) - } - - /// Cross-fades between two views by animating their alpha then setting one or the other hidden. - /// - parameters: - /// - lhs: left view - /// - rhs: right view - /// - toRight: fade to the right view if true, fade to the left view if false - /// - duration: animation duration - /// - static func crossfade(_ lhs: UIView, _ rhs: UIView, toRight: Bool, duration: TimeInterval) { - lhs.alpha = toRight ? 1.0 : 0.0 - rhs.alpha = toRight ? 0.0 : 1.0 - lhs.isHidden = false - rhs.isHidden = false - - UIView.animate(withDuration: duration, animations: { - lhs.alpha = toRight ? 0.0 : 1.0 - rhs.alpha = toRight ? 1.0 : 0.0 - }, completion: { _ in - lhs.isHidden = toRight - rhs.isHidden = !toRight - }) - } -} diff --git a/Mastodon/Extension/UIView+Gesture.swift b/Mastodon/Extension/UIView+Gesture.swift new file mode 100644 index 00000000..a76843d8 --- /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 00000000..473b3c34 --- /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/Generated/Assets.swift b/Mastodon/Generated/Assets.swift index cd655e07..ce9e33e2 100644 --- a/Mastodon/Generated/Assets.swift +++ b/Mastodon/Generated/Assets.swift @@ -52,6 +52,7 @@ internal enum Asset { 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 { @@ -69,6 +70,11 @@ internal enum Asset { internal static let primary = ColorAsset(name: "Colors/Label/primary") internal static let secondary = ColorAsset(name: "Colors/Label/secondary") } + 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") } @@ -80,6 +86,7 @@ internal enum Asset { internal static let invalid = ColorAsset(name: "Colors/TextField/invalid") internal static let valid = ColorAsset(name: "Colors/TextField/valid") } + 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") @@ -120,6 +127,11 @@ internal enum Asset { 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 5d486b5f..2fd52927 100644 --- a/Mastodon/Generated/Strings.swift +++ b/Mastodon/Generated/Strings.swift @@ -35,6 +35,14 @@ internal enum L10n { /// 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") @@ -353,6 +361,26 @@ internal enum L10n { 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") + /// 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 { @@ -595,6 +623,62 @@ 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 Thread { /// Post internal static let backTitle = L10n.tr("Localizable", "Scene.Thread.BackTitle") 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 00000000..afc18df1 --- /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/Label/highlight.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/Label/highlight.colorset/Contents.json index 2e1ce5f3..d853a71a 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/Notification/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/Notification/Contents.json new file mode 100644 index 00000000..6e965652 --- /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 00000000..36de2027 --- /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 00000000..9dff2f59 --- /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 00000000..ec427cca --- /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/battleshipGrey.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/battleshipGrey.colorset/Contents.json new file mode 100644 index 00000000..37df8107 --- /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/Settings/Contents.json b/Mastodon/Resources/Assets.xcassets/Settings/Contents.json new file mode 100644 index 00000000..6e965652 --- /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 00000000..75da4a57 --- /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 00000000..868d8d8b 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 00000000..6ca47e40 --- /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 00000000..a214d285 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 00000000..86e635c3 --- /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 00000000..2b8b869b 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/en.lproj/Localizable.strings b/Mastodon/Resources/en.lproj/Localizable.strings index 46275884..736ef10d 100644 --- a/Mastodon/Resources/en.lproj/Localizable.strings +++ b/Mastodon/Resources/en.lproj/Localizable.strings @@ -6,6 +6,9 @@ Please check your internet connection."; "Common.Alerts.PublishPostFailure.Title" = "Publish 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"; @@ -118,6 +121,13 @@ tap the link to confirm your account."; "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.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.Followers" = "followers"; "Scene.Profile.Dashboard.Following" = "following"; "Scene.Profile.Dashboard.Posts" = "posts"; @@ -190,6 +200,27 @@ any server."; "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.Thread.BackTitle" = "Post"; "Scene.Thread.Favorite.Multiple" = "%@ favorites"; "Scene.Thread.Favorite.Single" = "%@ favorite"; @@ -197,4 +228,4 @@ any server."; "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 +back in your hands."; diff --git a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+LoadOldestState.swift b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+LoadOldestState.swift index e5c78f3d..13737364 100644 --- a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+LoadOldestState.swift +++ b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+LoadOldestState.swift @@ -123,9 +123,11 @@ extension HashtagTimelineViewModel.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/HomeTimelineViewController+DebugAction.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift index 401e4fc1..8bbf9436 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift @@ -37,6 +37,10 @@ extension HomeTimelineViewController { 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) @@ -323,5 +327,8 @@ extension HomeTimelineViewController { coordinator.present(scene: .alertController(alertController: alertController), from: self, transition: .alertController(animated: true, completion: nil)) } + @objc private func showSettings(_ sender: UIAction) { + coordinator.present(scene: .settings, from: self, transition: .modal(animated: true, completion: nil)) + } } #endif diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+LoadOldestState.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+LoadOldestState.swift index aaabd7a8..a74d03a5 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+LoadOldestState.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+LoadOldestState.swift @@ -103,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/Notification/NotificationViewController.swift b/Mastodon/Scene/Notification/NotificationViewController.swift index f8b3ba81..ad9a7472 100644 --- a/Mastodon/Scene/Notification/NotificationViewController.swift +++ b/Mastodon/Scene/Notification/NotificationViewController.swift @@ -2,23 +2,227 @@ // 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.rowHeight = UITableView.automaticDimension + tableView.separatorStyle = .singleLine + 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.tableFooterView = UIView() + tableView.estimatedRowHeight = UITableView.automaticDimension + 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.safeAreaLayoutGuide.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) + + // 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) { + tableView.deselectRow(at: indexPath, animated: true) + guard let diffableDataSource = viewModel.diffableDataSource else { return } + guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return } + switch item { + case .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 + } +} + +extension NotificationViewController: NotificationTableViewCellDelegate { + 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 + } +} + +// 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 00000000..0e6b0d62 --- /dev/null +++ b/Mastodon/Scene/Notification/NotificationViewModel+LoadLatestState.swift @@ -0,0 +1,95 @@ +// +// 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: [.followRequest], + accountID: nil + ) + viewModel.context.apiService.allNotifications( + domain: activeMastodonAuthenticationBox.domain, + query: query, + mastodonAuthenticationBox: activeMastodonAuthenticationBox + ) + .sink { completion in + switch completion { + case .failure(let error): + viewModel.isFetchingLatestNotification.value = false + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: fetch notification failed. %s", (#file as NSString).lastPathComponent, #line, #function, error.localizedDescription) + case .finished: + // handle isFetchingLatestTimeline in fetch controller delegate + break + } + + stateMachine.enter(Idle.self) + } receiveValue: { response in + if response.value.isEmpty { + viewModel.isFetchingLatestNotification.value = false + } + } + .store(in: &viewModel.disposeBag) + } + } + + class Fail: NotificationViewModel.LoadLatestState { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + stateClass == Loading.self || stateClass == Idle.self + } + } + + class Idle: NotificationViewModel.LoadLatestState { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + stateClass == Loading.self + } + } +} diff --git a/Mastodon/Scene/Notification/NotificationViewModel+LoadOldestState.swift b/Mastodon/Scene/Notification/NotificationViewModel+LoadOldestState.swift new file mode 100644 index 00000000..8075ce37 --- /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 00000000..5bd2d92d --- /dev/null +++ b/Mastodon/Scene/Notification/NotificationViewModel+diffable.swift @@ -0,0 +1,127 @@ +// +// 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() + guard let userid = activeMastodonAuthenticationBox.value?.userID else { + return + } + diffableDataSource = NotificationSection.tableViewDiffableDataSource( + for: tableView, + timestampUpdatePublisher: timestampUpdatePublisher, + managedObjectContext: context.managedObjectContext, + delegate: delegate, + dependency: dependency, + requestUserID: userid + ) + } +} + +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 newSnapshot = NSDiffableDataSourceSnapshot() + newSnapshot.appendSections([.main]) + newSnapshot.appendItems(notifications.map { NotificationItem.notification(objectID: $0.objectID) }, 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 00000000..e026af73 --- /dev/null +++ b/Mastodon/Scene/Notification/NotificationViewModel.swift @@ -0,0 +1,130 @@ +// +// NotificationViewModel.swift +// Mastodon +// +// Created by sxiaojian on 2021/4/12. +// + +import Combine +import CoreData +import CoreDataStack +import Foundation +import GameplayKit +import MastodonSDK +import UIKit + +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) + } +} + +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 00000000..871adcae --- /dev/null +++ b/Mastodon/Scene/Notification/TableViewCell/NotificationStatusTableViewCell.swift @@ -0,0 +1,209 @@ +// +// NotificationStatusTableViewCell.swift +// Mastodon +// +// Created by sxiaojian on 2021/4/14. +// + +import Combine +import Foundation +import UIKit + +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 = UIFont.preferredFont(forTextStyle: .body) + label.lineBreakMode = .byTruncatingTail + return label + }() + + let nameLabel: UILabel = { + let label = UILabel() + label.textColor = Asset.Colors.brandBlue.color + label.font = .systemFont(ofSize: 15, weight: .semibold) + 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() + + override func prepareForReuse() { + super.prepareForReuse() + avatatImageView.af.cancelImageRequest() + statusView.isStatusTextSensitive = false + statusView.cleanUpContentWarning() + 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() + DispatchQueue.main.async { + self.statusView.drawContentWarningImageView() + } + } +} + +extension NotificationStatusTableViewCell { + func configure() { + 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), + ]) + + + statusStackView.addArrangedSubview(statusBorder) + + containerStackView.addArrangedSubview(statusStackView) + + statusView.contentWarningBlurContentImageView.backgroundColor = Asset.Colors.Background.secondaryGroupedSystemBackground.color + statusView.isUserInteractionEnabled = false + // 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) + statusBorder.layer.borderColor = Asset.Colors.Border.notification.color.cgColor + actionImageBackground.layer.borderColor = Asset.Colors.Background.systemBackground.color.cgColor + } +} diff --git a/Mastodon/Scene/Notification/TableViewCell/NotificationTableViewCell.swift b/Mastodon/Scene/Notification/TableViewCell/NotificationTableViewCell.swift new file mode 100644 index 00000000..60b43ac3 --- /dev/null +++ b/Mastodon/Scene/Notification/TableViewCell/NotificationTableViewCell.swift @@ -0,0 +1,155 @@ +// +// 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) +} + +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 = UIFont.preferredFont(forTextStyle: .body) + label.lineBreakMode = .byTruncatingTail + return label + }() + + let nameLabel: UILabel = { + let label = UILabel() + label.textColor = Asset.Colors.brandBlue.color + label.font = .systemFont(ofSize: 15, weight: .semibold) + label.lineBreakMode = .byTruncatingTail + return label + }() + + 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 = .horizontal + containerStackView.alignment = .center + 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) + ]) + + nameLabel.translatesAutoresizingMaskIntoConstraints = false + containerStackView.addArrangedSubview(nameLabel) + actionLabel.translatesAutoresizingMaskIntoConstraints = false + containerStackView.addArrangedSubview(actionLabel) + nameLabel.setContentCompressionResistancePriority(.required - 1, for: .vertical) + nameLabel.setContentHuggingPriority(.defaultHigh, for: .horizontal) + actionLabel.setContentHuggingPriority(.defaultLow, for: .horizontal) + + } + + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + actionImageBackground.layer.borderColor = Asset.Colors.Background.systemBackground.color.cgColor + } +} diff --git a/Mastodon/Scene/Search/CollectionViewCell/SearchRecommendAccountsCollectionViewCell.swift b/Mastodon/Scene/Search/CollectionViewCell/SearchRecommendAccountsCollectionViewCell.swift index f4512467..9d6bbedc 100644 --- a/Mastodon/Scene/Search/CollectionViewCell/SearchRecommendAccountsCollectionViewCell.swift +++ b/Mastodon/Scene/Search/CollectionViewCell/SearchRecommendAccountsCollectionViewCell.swift @@ -62,6 +62,7 @@ class SearchRecommendAccountsCollectionViewCell: UICollectionViewCell { 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) @@ -97,50 +98,70 @@ extension SearchRecommendAccountsCollectionViewCell { 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) } - + override open func layoutSubviews() { + super.layoutSubviews() + followButton.layer.cornerRadius = followButton.frame.height/2 + } 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) - headerImageView.pin(top: 16, left: 0, bottom: 0, right: 0) + 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) - avatarImageView.pin(toSize: CGSize(width: 88, height: 88)) - avatarImageView.constrain([ - avatarImageView.constraint(.top, toView: contentView), - avatarImageView.constraint(.centerX, toView: contentView) + 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) - contentView.addSubview(displayNameLabel) - displayNameLabel.constrain([ - displayNameLabel.constraint(.top, toView: contentView, constant: 108), - displayNameLabel.constraint(.leading, toView: contentView), - displayNameLabel.constraint(.trailing, toView: contentView), - displayNameLabel.constraint(.centerX, toView: contentView) - ]) + acctLabel.translatesAutoresizingMaskIntoConstraints = false + containerStackView.addArrangedSubview(acctLabel) + containerStackView.setCustomSpacing(7, after: acctLabel) - contentView.addSubview(acctLabel) - acctLabel.constrain([ - acctLabel.constraint(.top, toView: contentView, constant: 132), - acctLabel.constraint(.leading, toView: contentView), - acctLabel.constraint(.trailing, toView: contentView), - acctLabel.constraint(.centerX, toView: contentView) - ]) - - contentView.addSubview(followButton) - followButton.pin(toSize: CGSize(width: 76, height: 24)) - followButton.constrain([ - followButton.constraint(.top, toView: contentView, constant: 159), - followButton.constraint(.centerX, toView: contentView) + 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 + acctLabel.text = "@" + mastodonUser.acct avatarImageView.af.setImage( withURL: URL(string: mastodonUser.avatar)!, placeholderImage: UIImage.placeholder(color: .systemFill), @@ -153,7 +174,13 @@ extension SearchRecommendAccountsCollectionViewCell { ) { [weak self] _ in guard let self = self else { return } self.headerImageView.addSubview(self.visualEffectView) - self.visualEffectView.pin(top: 0, left: 0, bottom: 0, right: 0) + 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) diff --git a/Mastodon/Scene/Search/CollectionViewCell/SearchRecommendTagsCollectionViewCell.swift b/Mastodon/Scene/Search/CollectionViewCell/SearchRecommendTagsCollectionViewCell.swift index d00cb050..abcd9d08 100644 --- a/Mastodon/Scene/Search/CollectionViewCell/SearchRecommendTagsCollectionViewCell.swift +++ b/Mastodon/Scene/Search/CollectionViewCell/SearchRecommendTagsCollectionViewCell.swift @@ -12,7 +12,6 @@ import UIKit class SearchRecommendTagsCollectionViewCell: UICollectionViewCell { let backgroundImageView: UIImageView = { let imageView = UIImageView() - imageView.translatesAutoresizingMaskIntoConstraints = false return imageView }() @@ -20,7 +19,6 @@ class SearchRecommendTagsCollectionViewCell: UICollectionViewCell { let label = UILabel() label.textColor = .white label.font = .systemFont(ofSize: 20, weight: .semibold) - label.translatesAutoresizingMaskIntoConstraints = false label.lineBreakMode = .byTruncatingTail return label }() @@ -29,7 +27,6 @@ class SearchRecommendTagsCollectionViewCell: UICollectionViewCell { let label = UILabel() label.textColor = .white label.font = .preferredFont(forTextStyle: .body) - label.translatesAutoresizingMaskIntoConstraints = false return label }() @@ -38,7 +35,6 @@ class SearchRecommendTagsCollectionViewCell: UICollectionViewCell { let image = UIImage(systemName: "flame.fill", withConfiguration: UIImage.SymbolConfiguration(pointSize: 20, weight: .semibold))!.withRenderingMode(.alwaysTemplate) imageView.image = image imageView.tintColor = .white - imageView.translatesAutoresizingMaskIntoConstraints = false return imageView }() @@ -74,17 +70,49 @@ extension SearchRecommendTagsCollectionViewCell { 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) - backgroundImageView.constrain(toSuperviewEdges: nil) + 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) + ]) - contentView.addSubview(hashtagTitleLabel) - hashtagTitleLabel.pin(top: 16, left: 16, bottom: nil, right: 42) - contentView.addSubview(peopleLabel) - peopleLabel.pinTopLeft(top: 46, left: 16) + let containerStackView = UIStackView() + containerStackView.axis = .vertical + containerStackView.distribution = .fill + containerStackView.spacing = 6 + 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) + ]) - contentView.addSubview(flameIconView) - flameIconView.pinTopRight(padding: 16) + + 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) } func config(with tag: Mastodon.Entity.Tag) { diff --git a/Mastodon/Scene/Search/SearchViewController+Recommend.swift b/Mastodon/Scene/Search/SearchViewController+Recommend.swift index e941fa84..f394f09f 100644 --- a/Mastodon/Scene/Search/SearchViewController+Recommend.swift +++ b/Mastodon/Scene/Search/SearchViewController+Recommend.swift @@ -23,8 +23,9 @@ extension SearchViewController { hashtagCollectionView.register(SearchRecommendTagsCollectionViewCell.self, forCellWithReuseIdentifier: String(describing: SearchRecommendTagsCollectionViewCell.self)) hashtagCollectionView.delegate = self + hashtagCollectionView.translatesAutoresizingMaskIntoConstraints = false stackView.addArrangedSubview(hashtagCollectionView) - hashtagCollectionView.constrain([ + NSLayoutConstraint.activate([ hashtagCollectionView.frameLayoutGuide.heightAnchor.constraint(equalToConstant: 130) ]) } @@ -39,8 +40,9 @@ extension SearchViewController { accountsCollectionView.register(SearchRecommendAccountsCollectionViewCell.self, forCellWithReuseIdentifier: String(describing: SearchRecommendAccountsCollectionViewCell.self)) accountsCollectionView.delegate = self + accountsCollectionView.translatesAutoresizingMaskIntoConstraints = false stackView.addArrangedSubview(accountsCollectionView) - accountsCollectionView.constrain([ + NSLayoutConstraint.activate([ accountsCollectionView.frameLayoutGuide.heightAnchor.constraint(equalToConstant: 202) ]) } diff --git a/Mastodon/Scene/Search/SearchViewController+Searching.swift b/Mastodon/Scene/Search/SearchViewController+Searching.swift index 3eb9793a..0602ac20 100644 --- a/Mastodon/Scene/Search/SearchViewController+Searching.swift +++ b/Mastodon/Scene/Search/SearchViewController+Searching.swift @@ -15,16 +15,18 @@ import UIKit extension SearchViewController { func setupSearchingTableView() { - searchingTableView.delegate = self searchingTableView.register(SearchingTableViewCell.self, forCellReuseIdentifier: String(describing: SearchingTableViewCell.self)) - searchingTableView.register(SearchBottomLoader.self, forCellReuseIdentifier: String(describing: SearchBottomLoader.self)) + searchingTableView.register(TimelineBottomLoaderTableViewCell.self, forCellReuseIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self)) + searchingTableView.estimatedRowHeight = 66 + searchingTableView.rowHeight = 66 view.addSubview(searchingTableView) - searchingTableView.constrain([ - searchingTableView.frameLayoutGuide.topAnchor.constraint(equalTo: searchBar.bottomAnchor), - searchingTableView.frameLayoutGuide.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor), - searchingTableView.frameLayoutGuide.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor), - searchingTableView.frameLayoutGuide.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor), - searchingTableView.contentLayoutGuide.widthAnchor.constraint(equalTo: view.widthAnchor) + 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 @@ -50,18 +52,23 @@ extension SearchViewController { } func setupSearchHeader() { - searchHeader.addSubview(recentSearchesLabel) - recentSearchesLabel.constrain([ - recentSearchesLabel.constraint(.leading, toView: searchHeader, constant: 16), - recentSearchesLabel.constraint(.centerY, toView: searchHeader) + 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) ]) - - searchHeader.addSubview(clearSearchHistoryButton) - recentSearchesLabel.constrain([ - searchHeader.trailingAnchor.constraint(equalTo: clearSearchHistoryButton.trailingAnchor, constant: 16), - clearSearchHistoryButton.constraint(.centerY, toView: searchHeader) - ]) - + recentSearchesLabel.translatesAutoresizingMaskIntoConstraints = false + containerStackView.addArrangedSubview(recentSearchesLabel) + clearSearchHistoryButton.translatesAutoresizingMaskIntoConstraints = false + containerStackView.addArrangedSubview(clearSearchHistoryButton) clearSearchHistoryButton.addTarget(self, action: #selector(SearchViewController.clearAction(_:)), for: .touchUpInside) } } @@ -75,15 +82,9 @@ extension SearchViewController { // MARK: - UITableViewDelegate extension SearchViewController: UITableViewDelegate { - func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat { - 66 - } - - func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { - 66 - } 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 704e425a..770fb1da 100644 --- a/Mastodon/Scene/Search/SearchViewController.swift +++ b/Mastodon/Scene/Search/SearchViewController.swift @@ -82,7 +82,7 @@ final class SearchViewController: UIViewController, NeedsDependency { // searching let searchingTableView: UITableView = { let tableView = UITableView() - tableView.backgroundColor = Asset.Colors.Background.systemGroupedBackground.color + 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) @@ -135,14 +135,16 @@ extension SearchViewController { func setupSearchBar() { searchBar.delegate = self view.addSubview(searchBar) - searchBar.constrain([ + 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), ]) - view.addSubview(statusBar) - statusBar.constrain([ + 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), @@ -151,8 +153,9 @@ extension SearchViewController { } func setupScrollView() { + scrollView.translatesAutoresizingMaskIntoConstraints = false view.addSubview(scrollView) - scrollView.constrain([ + NSLayoutConstraint.activate([ scrollView.frameLayoutGuide.topAnchor.constraint(equalTo: searchBar.bottomAnchor), scrollView.frameLayoutGuide.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor), scrollView.frameLayoutGuide.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor), @@ -160,8 +163,9 @@ extension SearchViewController { scrollView.contentLayoutGuide.widthAnchor.constraint(equalTo: view.widthAnchor), ]) + stackView.translatesAutoresizingMaskIntoConstraints = false scrollView.addSubview(stackView) - stackView.constrain([ + NSLayoutConstraint.activate([ stackView.topAnchor.constraint(equalTo: scrollView.contentLayoutGuide.topAnchor), stackView.leadingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.leadingAnchor), stackView.trailingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.trailingAnchor), @@ -227,7 +231,7 @@ extension SearchViewController: UISearchBarDelegate { } extension SearchViewController: LoadMoreConfigurableTableViewContainer { - typealias BottomLoaderTableViewCell = SearchBottomLoader + typealias BottomLoaderTableViewCell = TimelineBottomLoaderTableViewCell typealias LoadingState = SearchViewModel.LoadOldestState.Loading var loadMoreConfigurableTableView: UITableView { searchingTableView } var loadMoreConfigurableStateMachine: GKStateMachine { viewModel.loadoldestStateMachine } diff --git a/Mastodon/Scene/Search/SearchViewModel+LoadOldestState.swift b/Mastodon/Scene/Search/SearchViewModel+LoadOldestState.swift index c76ab202..b486df77 100644 --- a/Mastodon/Scene/Search/SearchViewModel+LoadOldestState.swift +++ b/Mastodon/Scene/Search/SearchViewModel+LoadOldestState.swift @@ -136,9 +136,11 @@ extension SearchViewModel.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/Search/SearchViewModel.swift b/Mastodon/Scene/Search/SearchViewModel.swift index 1d87629b..27c322c8 100644 --- a/Mastodon/Scene/Search/SearchViewModel.swift +++ b/Mastodon/Scene/Search/SearchViewModel.swift @@ -237,10 +237,10 @@ final class SearchViewModel: NSObject { .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) + 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: recommendHashTags request success", (#file as NSString).lastPathComponent, #line, #function) + 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 diff --git a/Mastodon/Scene/Search/TableViewCell/SearchBottomLoader.swift b/Mastodon/Scene/Search/TableViewCell/SearchBottomLoader.swift deleted file mode 100644 index 7ab18bb0..00000000 --- a/Mastodon/Scene/Search/TableViewCell/SearchBottomLoader.swift +++ /dev/null @@ -1,47 +0,0 @@ -// -// SearchBottomLoader.swift -// Mastodon -// -// Created by sxiaojian on 2021/4/6. -// - -import Foundation -import UIKit - -final class SearchBottomLoader: UITableViewCell { - let activityIndicatorView: UIActivityIndicatorView = { - let activityIndicatorView = UIActivityIndicatorView(style: .medium) - activityIndicatorView.tintColor = Asset.Colors.Label.primary.color - activityIndicatorView.hidesWhenStopped = true - return activityIndicatorView - }() - - override func prepareForReuse() { - super.prepareForReuse() - } - - override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { - super.init(style: style, reuseIdentifier: reuseIdentifier) - _init() - } - - required init?(coder: NSCoder) { - super.init(coder: coder) - _init() - } - - func startAnimating() { - activityIndicatorView.startAnimating() - } - - func stopAnimating() { - activityIndicatorView.stopAnimating() - } - - func _init() { - selectionStyle = .none - backgroundColor = Asset.Colors.Background.systemGroupedBackground.color - contentView.addSubview(activityIndicatorView) - activityIndicatorView.constrainToCenter() - } -} diff --git a/Mastodon/Scene/Search/TableViewCell/SearchingTableViewCell.swift b/Mastodon/Scene/Search/TableViewCell/SearchingTableViewCell.swift index 5a258d8a..a3a7b58a 100644 --- a/Mastodon/Scene/Search/TableViewCell/SearchingTableViewCell.swift +++ b/Mastodon/Scene/Search/TableViewCell/SearchingTableViewCell.swift @@ -55,19 +55,40 @@ final class SearchingTableViewCell: UITableViewCell { extension SearchingTableViewCell { private func configure() { backgroundColor = .clear - selectionStyle = .none - contentView.addSubview(_imageView) - _imageView.pin(toSize: CGSize(width: 42, height: 42)) - _imageView.constrain([ - _imageView.constraint(.leading, toView: contentView, constant: 21), - _imageView.constraint(.centerY, toView: contentView) + + 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) ]) - contentView.addSubview(_titleLabel) - _titleLabel.pin(top: 12, left: 75, bottom: nil, right: 0) + _imageView.translatesAutoresizingMaskIntoConstraints = false + containerStackView.addArrangedSubview(_imageView) + NSLayoutConstraint.activate([ + _imageView.widthAnchor.constraint(equalToConstant: 42), + _imageView.heightAnchor.constraint(equalToConstant: 42), + ]) - contentView.addSubview(_subTitleLabel) - _subTitleLabel.pin(top: 34, left: 75, bottom: nil, right: 0) + 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) { diff --git a/Mastodon/Scene/Search/View/SearchRecommendCollectionHeader.swift b/Mastodon/Scene/Search/View/SearchRecommendCollectionHeader.swift index bc5bd766..3db8c280 100644 --- a/Mastodon/Scene/Search/View/SearchRecommendCollectionHeader.swift +++ b/Mastodon/Scene/Search/View/SearchRecommendCollectionHeader.swift @@ -47,14 +47,35 @@ extension SearchRecommendCollectionHeader { private func configure() { backgroundColor = .clear translatesAutoresizingMaskIntoConstraints = false - addSubview(titleLabel) - titleLabel.pinTopLeft(top: 31, left: 16) - addSubview(descriptionLabel) - descriptionLabel.constrain(toSuperviewEdges: UIEdgeInsets(top: 60, left: 16, bottom: 16, right: 16)) + 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) + ]) - addSubview(seeAllButton) - seeAllButton.pinTopRight(top: 26, right: 16) + 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) + } } diff --git a/Mastodon/Scene/Settings/SettingsViewController.swift b/Mastodon/Scene/Settings/SettingsViewController.swift new file mode 100644 index 00000000..4615f92a --- /dev/null +++ b/Mastodon/Scene/Settings/SettingsViewController.swift @@ -0,0 +1,488 @@ +// +// SettingsViewController.swift +// Mastodon +// +// Created by ihugo on 2021/4/7. +// + +import os.log +import UIKit +import Combine +import ActiveLabel +import CoreData +import CoreDataStack +import AlamofireImage +import Kingfisher + +// iTODO: when to ask permission to Use Notifications + +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 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(by: anyone) + }, + UIAction(title: follower, image: UIImage(systemName: "person.crop.circle.badge.plus"), attributes: []) { [weak self] action in + self?.updateTrigger(by: follower) + }, + UIAction(title: follow, image: UIImage(systemName: "person.crop.circle.badge.checkmark"), attributes: []) { [weak self] action in + self?.updateTrigger(by: follow) + }, + UIAction(title: noOne, image: UIImage(systemName: "nosign"), attributes: []) { [weak self] action in + self?.updateTrigger(by: noOne) + }, + ] + ) + return menu + } + + lazy var notifySectionHeader: UIView = { + let view = UIStackView() + view.translatesAutoresizingMaskIntoConstraints = false + view.isLayoutMarginsRelativeArrangement = true + view.layoutMargins = UIEdgeInsets(top: 15, left: 4, bottom: 5, right: 4) + view.axis = .horizontal + view.alignment = .fill + view.distribution = .equalSpacing + view.spacing = 4 + + let notifyLabel = UILabel() + notifyLabel.translatesAutoresizingMaskIntoConstraints = false + notifyLabel.font = UIFontMetrics(forTextStyle: .title3).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) + return view + }() + + 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) + if let setting = self.viewModel.setting.value, let trigger = setting.triggerBy { + whoButton.setTitle(trigger, for: .normal) + } + 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 + }() + + 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: "SettingsAppearanceTableViewCell") + tableView.register(SettingsToggleTableViewCell.self, forCellReuseIdentifier: "SettingsToggleTableViewCell") + tableView.register(SettingsLinkTableViewCell.self, forCellReuseIdentifier: "SettingsLinkTableViewCell") + return tableView + }() + + lazy var footerView: 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.textAlignment = .center + label.configure(content: "Mastodon is open source software. You can contribute or report issues on GitHub at tootsuite/mastodon (v3.3.0).") + 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 + } + } + + // MAKR: - Private methods + private func bindViewModel() { + let input = SettingsViewModel.Input() + _ = viewModel.transform(input: input) + } + + private func setupView() { + view.backgroundColor = Asset.Colors.Background.systemGroupedBackground.color + setupNavigation() + setupTableView() + + 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), + ]) + } + + 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.dataSource = UITableViewDiffableDataSource(tableView: tableView, cellProvider: { [weak self] (tableView, indexPath, item) -> UITableViewCell? in + guard let self = self else { return nil } + + switch item { + case .apperance(let item): + guard let cell = tableView.dequeueReusableCell(withIdentifier: "SettingsAppearanceTableViewCell") as? SettingsAppearanceTableViewCell else { + assertionFailure() + return nil + } + cell.update(with: item, delegate: self) + return cell + case .notification(let item): + guard let cell = tableView.dequeueReusableCell(withIdentifier: "SettingsToggleTableViewCell") as? SettingsToggleTableViewCell else { + assertionFailure() + return nil + } + cell.update(with: item, delegate: self) + return cell + case .boringZone(let item), .spicyZone(let item): + guard let cell = tableView.dequeueReusableCell(withIdentifier: "SettingsLinkTableViewCell") as? SettingsLinkTableViewCell else { + assertionFailure() + return nil + } + cell.update(with: item) + return cell + } + }) + + tableView.tableFooterView = footerView + } + + 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 + @objc 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 sectionData = sections[section] + + if section == 1 { + let header = SettingsSectionHeader( + frame: CGRect(x: 0, y: 0, width: 375, height: 66), + customView: notifySectionHeader) + header.update(title: sectionData.title) + + if let setting = self.viewModel.setting.value, let trigger = setting.triggerBy { + whoButton.setTitle(trigger, for: .normal) + } else { + let anyone = L10n.Scene.Settings.Section.Notifications.Trigger.anyone + whoButton.setTitle(anyone, for: .normal) + } + return header + } else { + let header = SettingsSectionHeader(frame: CGRect(x: 0, y: 0, width: 375, height: 66)) + header.update(title: sectionData.title) + 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 0 + } + + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + let snapshot = self.viewModel.dataSource.snapshot() + let sectionIds = snapshot.sectionIdentifiers + guard indexPath.section < sectionIds.count else { return } + let sectionIdentifier = sectionIds[indexPath.section] + let items = snapshot.itemIdentifiers(inSection: sectionIdentifier) + guard indexPath.row < items.count else { return } + let item = items[indexPath.item] + + 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(by who: String) { + guard self.viewModel.triggerBy != who else { return } + guard let setting = self.viewModel.setting.value else { return } + + setting.update(triggerBy: who) + // trigger to call `subscription` API with POST method + // confirm the local data is correct even if request failed + // The asynchronous execution is to solve the problem of dropped frames for animations. + DispatchQueue.main.async { [weak self] in + self?.viewModel.setting.value = setting + } + } + + func updateAlert(title: String?, isOn: Bool) { + guard let title = title else { return } + guard let settings = self.viewModel.setting.value else { return } + guard let triggerBy = settings.triggerBy else { return } + + if let alerts = settings.subscription?.first(where: { (s) -> Bool in + return s.type == settings.triggerBy + })?.alert { + var alertValues = [Bool?]() + alertValues.append(alerts.favourite?.boolValue) + alertValues.append(alerts.follow?.boolValue) + alertValues.append(alerts.reblog?.boolValue) + alertValues.append(alerts.mention?.boolValue) + + // need to update `alerts` to make update API with correct parameter + switch title { + case L10n.Scene.Settings.Section.Notifications.favorites: + alertValues[0] = isOn + alerts.favourite = NSNumber(booleanLiteral: isOn) + case L10n.Scene.Settings.Section.Notifications.follows: + alertValues[1] = isOn + alerts.follow = NSNumber(booleanLiteral: isOn) + case L10n.Scene.Settings.Section.Notifications.boosts: + alertValues[2] = isOn + alerts.reblog = NSNumber(booleanLiteral: isOn) + case L10n.Scene.Settings.Section.Notifications.mentions: + alertValues[3] = isOn + alerts.mention = NSNumber(booleanLiteral: isOn) + default: break + } + self.viewModel.updateSubscriptionSubject.send((triggerBy: triggerBy, values: alertValues)) + } else if let alertValues = self.viewModel.notificationDefaultValue[triggerBy] { + self.viewModel.updateSubscriptionSubject.send((triggerBy: triggerBy, values: alertValues)) + } + } +} + +extension SettingsViewController: SettingsAppearanceTableViewCellDelegate { + func settingsAppearanceCell(_ view: SettingsAppearanceTableViewCell, didSelect: SettingsItem.AppearanceMode) { + guard let setting = self.viewModel.setting.value else { return } + + context.managedObjectContext.performChanges { + setting.update(appearance: didSelect.rawValue) + } + .sink { (_) in + // change light / dark mode + var overrideUserInterfaceStyle: UIUserInterfaceStyle! + switch didSelect { + case .automatic: + overrideUserInterfaceStyle = .unspecified + case .light: + overrideUserInterfaceStyle = .light + case .dark: + overrideUserInterfaceStyle = .dark + } + view.window?.overrideUserInterfaceStyle = overrideUserInterfaceStyle + }.store(in: &disposeBag) + } +} + +extension SettingsViewController: SettingsToggleCellDelegate { + func settingsToggleCell(_ cell: SettingsToggleTableViewCell, didChangeStatus: Bool) { + updateAlert(title: cell.data?.title, isOn: didChangeStatus) + } +} + +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) + ) + } +} + +extension SettingsViewController { + static func updateOverrideUserInterfaceStyle(window: UIWindow?) { + guard let box = AppContext.shared.authenticationService.activeMastodonAuthenticationBox.value else { + return + } + + guard let setting: Setting? = { + let domain = box.domain + let request = Setting.sortedFetchRequest + request.predicate = Setting.predicate(domain: domain, userID: box.userID) + request.fetchLimit = 1 + request.returnsObjectsAsFaults = false + do { + return try AppContext.shared.managedObjectContext.fetch(request).first + } catch { + assertionFailure(error.localizedDescription) + return nil + } + }() else { return } + + guard let didSelect = SettingsItem.AppearanceMode(rawValue: setting?.appearance ?? "") else { + return + } + + var overrideUserInterfaceStyle: UIUserInterfaceStyle! + switch didSelect { + case .automatic: + overrideUserInterfaceStyle = .unspecified + case .light: + overrideUserInterfaceStyle = .light + case .dark: + overrideUserInterfaceStyle = .dark + } + window?.overrideUserInterfaceStyle = overrideUserInterfaceStyle + } +} + +#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 00000000..470617ae --- /dev/null +++ b/Mastodon/Scene/Settings/SettingsViewModel.swift @@ -0,0 +1,393 @@ +// +// 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: NSObject, NeedsDependency { + // confirm set only once + weak var context: AppContext! { willSet { precondition(context == nil) } } + weak var coordinator: SceneCoordinator! { willSet { precondition(coordinator == nil) } } + + var dataSource: UITableViewDiffableDataSource! + var disposeBag = Set() + var updateDisposeBag = Set() + var createDisposeBag = Set() + + let viewDidLoad = PassthroughSubject() + lazy var fetchResultsController: NSFetchedResultsController = { + let fetchRequest = Setting.sortedFetchRequest + if let box = + self.context.authenticationService.activeMastodonAuthenticationBox.value { + let domain = box.domain + fetchRequest.predicate = Setting.predicate(domain: domain, userID: box.userID) + } + + fetchRequest.fetchLimit = 1 + fetchRequest.returnsObjectsAsFaults = false + let controller = NSFetchedResultsController( + fetchRequest: fetchRequest, + managedObjectContext: context.managedObjectContext, + sectionNameKeyPath: nil, + cacheName: nil + ) + controller.delegate = self + return controller + }() + let setting = CurrentValueSubject(nil) + + /// 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 notificationDefaultValue: [String: [Bool?]] = { + let followerSwitchItems: [Bool?] = [true, nil, true, true] + let anyoneSwitchItems: [Bool?] = [true, true, true, true] + let noOneSwitchItems: [Bool?] = [nil, nil, nil, nil] + let followSwitchItems: [Bool?] = [true, true, true, true] + + 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 + return [anyone: anyoneSwitchItems, + follower: followerSwitchItems, + follow: followSwitchItems, + noOne: noOneSwitchItems] + }() + + lazy var privacyURL: URL? = { + guard let box = AppContext.shared.authenticationService.activeMastodonAuthenticationBox.value else { + return nil + } + + return Mastodon.API.privacyURL(domain: box.domain) + }() + + /// to store who trigger the notification. + var triggerBy: String? + + struct Input { + } + + struct Output { + } + + init(context: AppContext, coordinator: SceneCoordinator) { + self.context = context + self.coordinator = coordinator + + super.init() + } + + func transform(input: Input?) -> Output? { + typealias SubscriptionResponse = Mastodon.Response.Content + createSubscriptionSubject + .debounce(for: .milliseconds(300), scheduler: DispatchQueue.main) + .sink { _ in + } receiveValue: { [weak self] (arg) in + let (triggerBy, values) = arg + guard let self = self else { + return + } + guard let activeMastodonAuthenticationBox = + self.context.authenticationService.activeMastodonAuthenticationBox.value else { + return + } + guard values.count >= 4 else { + return + } + + self.createDisposeBag.removeAll() + typealias Query = Mastodon.API.Subscriptions.CreateSubscriptionQuery + let domain = activeMastodonAuthenticationBox.domain + let query = Query( + // FIXME: to replace the correct endpoint, p256dh, auth + endpoint: "http://www.google.com", + p256dh: "BLQELIDm-6b9Bl07YrEuXJ4BL_YBVQ0dvt9NQGGJxIQidJWHPNa9YrouvcQ9d7_MqzvGS9Alz60SZNCG3qfpk=", + auth: "4vQK-SvRAN5eo-8ASlrwA==", + favourite: values[0], + follow: values[1], + reblog: values[2], + mention: values[3], + poll: nil + ) + self.context.apiService.changeSubscription( + domain: domain, + mastodonAuthenticationBox: activeMastodonAuthenticationBox, + query: query, + triggerBy: triggerBy, + userID: activeMastodonAuthenticationBox.userID + ) + .sink { (_) in + } receiveValue: { (_) in + } + .store(in: &self.createDisposeBag) + } + .store(in: &disposeBag) + + updateSubscriptionSubject + .debounce(for: .milliseconds(300), scheduler: DispatchQueue.main) + .sink { _ in + } receiveValue: { [weak self] (arg) in + let (triggerBy, values) = arg + guard let self = self else { + return + } + guard let activeMastodonAuthenticationBox = + self.context.authenticationService.activeMastodonAuthenticationBox.value else { + return + } + guard values.count >= 4 else { + return + } + + self.updateDisposeBag.removeAll() + typealias Query = Mastodon.API.Subscriptions.UpdateSubscriptionQuery + let domain = activeMastodonAuthenticationBox.domain + let query = Query( + favourite: values[0], + follow: values[1], + reblog: values[2], + mention: values[3], + poll: nil) + self.context.apiService.updateSubscription( + domain: domain, + mastodonAuthenticationBox: activeMastodonAuthenticationBox, + query: query, + triggerBy: triggerBy, + userID: activeMastodonAuthenticationBox.userID + ) + .sink { (_) in + } receiveValue: { (_) in + } + .store(in: &self.updateDisposeBag) + } + .store(in: &disposeBag) + + // build data for table view + buildDataSource() + + // request subsription data for updating or initialization + requestSubscription() + return nil + } + + // MARK: - Private methods + fileprivate func processDataSource(_ settings: Setting?) { + var snapshot = NSDiffableDataSourceSnapshot() + + // appearance + let appearnceMode = SettingsItem.AppearanceMode(rawValue: settings?.appearance ?? "") ?? .automatic + let appearanceItem = SettingsItem.apperance(item: appearnceMode) + let appearance = SettingsSection.apperance(title: L10n.Scene.Settings.Section.Appearance.title, selectedMode:appearanceItem) + snapshot.appendSections([appearance]) + snapshot.appendItems([appearanceItem]) + + // notifications + var switches: [Bool?]? + if let alerts = settings?.subscription?.first(where: { (s) -> Bool in + return s.type == settings?.triggerBy + })?.alert { + var items = [Bool?]() + items.append(alerts.favourite?.boolValue) + items.append(alerts.follow?.boolValue) + items.append(alerts.reblog?.boolValue) + items.append(alerts.mention?.boolValue) + switches = items + } else if let triggerBy = settings?.triggerBy, + let values = self.notificationDefaultValue[triggerBy] { + switches = values + } else { + // fallback a default value + let anyone = L10n.Scene.Settings.Section.Notifications.Trigger.anyone + switches = self.notificationDefaultValue[anyone] + } + + let notifications = [L10n.Scene.Settings.Section.Notifications.favorites, + L10n.Scene.Settings.Section.Notifications.follows, + L10n.Scene.Settings.Section.Notifications.boosts, + L10n.Scene.Settings.Section.Notifications.mentions,] + var notificationItems = [SettingsItem]() + for (i, noti) in notifications.enumerated() { + var value: Bool? = nil + if let switches = switches, i < switches.count { + value = switches[i] + } + + let item = SettingsItem.notification(item: SettingsItem.NotificationSwitch(title: noti, isOn: value == true, enable: value != nil)) + notificationItems.append(item) + } + let notificationSection = SettingsSection.notifications(title: L10n.Scene.Settings.Section.Notifications.title, items: notificationItems) + snapshot.appendSections([notificationSection]) + snapshot.appendItems(notificationItems) + + // boring zone + let boringLinks = [L10n.Scene.Settings.Section.Boringzone.terms, + L10n.Scene.Settings.Section.Boringzone.privacy] + var boringLinkItems = [SettingsItem]() + for l in boringLinks { + let item = SettingsItem.boringZone(item: SettingsItem.Link(title: l, color: .systemBlue)) + boringLinkItems.append(item) + } + let boringSection = SettingsSection.boringZone(title: L10n.Scene.Settings.Section.Boringzone.title, items: boringLinkItems) + snapshot.appendSections([boringSection]) + snapshot.appendItems(boringLinkItems) + + // spicy zone + let spicyLinks = [L10n.Scene.Settings.Section.Spicyzone.clear, + L10n.Scene.Settings.Section.Spicyzone.signout] + var spicyLinkItems = [SettingsItem]() + for l in spicyLinks { + let item = SettingsItem.spicyZone(item: SettingsItem.Link(title: l, color: .systemRed)) + spicyLinkItems.append(item) + } + let spicySection = SettingsSection.spicyZone(title: L10n.Scene.Settings.Section.Spicyzone.title, items: spicyLinkItems) + snapshot.appendSections([spicySection]) + snapshot.appendItems(spicyLinkItems) + + self.dataSource.apply(snapshot, animatingDifferences: false) + } + + private func buildDataSource() { + setting.sink { [weak self] (settings) in + guard let self = self else { return } + self.processDataSource(settings) + } + .store(in: &disposeBag) + } + + private func requestSubscription() { + setting.sink { [weak self] (settings) in + guard let self = self else { return } + guard settings != nil else { return } + guard self.triggerBy != settings?.triggerBy else { return } + self.triggerBy = settings?.triggerBy + + var switches: [Bool?]? + var who: String? + if let alerts = settings?.subscription?.first(where: { (s) -> Bool in + return s.type == settings?.triggerBy + })?.alert { + var items = [Bool?]() + items.append(alerts.favourite?.boolValue) + items.append(alerts.follow?.boolValue) + items.append(alerts.reblog?.boolValue) + items.append(alerts.mention?.boolValue) + switches = items + who = settings?.triggerBy + } else if let triggerBy = settings?.triggerBy, + let values = self.notificationDefaultValue[triggerBy] { + switches = values + who = triggerBy + } else { + // fallback a default value + let anyone = L10n.Scene.Settings.Section.Notifications.Trigger.anyone + switches = self.notificationDefaultValue[anyone] + who = anyone + } + + // should create a subscription whenever change trigger + if let values = switches, let triggerBy = who { + self.createSubscriptionSubject.send((triggerBy: triggerBy, values: values)) + } + } + .store(in: &disposeBag) + + guard let activeMastodonAuthenticationBox = self.context.authenticationService.activeMastodonAuthenticationBox.value else { + return + } + let domain = activeMastodonAuthenticationBox.domain + let userId = activeMastodonAuthenticationBox.userID + + do { + try fetchResultsController.performFetch() + if nil == fetchResultsController.fetchedObjects?.first { + let anyone = L10n.Scene.Settings.Section.Notifications.Trigger.anyone + setting.value = self.context.apiService.createSettingIfNeed(domain: domain, + userId: userId, + triggerBy: anyone) + } else { + setting.value = fetchResultsController.fetchedObjects?.first + } + } catch { + assertionFailure(error.localizedDescription) + } + } + + deinit { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s:", ((#file as NSString).lastPathComponent), #line, #function) + } +} + +// MARK: - NSFetchedResultsControllerDelegate +extension SettingsViewModel: NSFetchedResultsControllerDelegate { + + func controllerWillChangeContent(_ controller: NSFetchedResultsController) { + os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + } + + func controllerDidChangeContent(_ controller: NSFetchedResultsController) { + guard controller === fetchResultsController else { + return + } + + setting.value = fetchResultsController.fetchedObjects?.first + } + +} + +enum SettingsSection: Hashable { + case apperance(title: String, selectedMode: SettingsItem) + case notifications(title: String, items: [SettingsItem]) + case boringZone(title: String, items: [SettingsItem]) + case spicyZone(title: String, items: [SettingsItem]) + + var title: String { + switch self { + case .apperance(let title, _), + .notifications(let title, _), + .boringZone(let title, _), + .spicyZone(let title, _): + return title + } + } +} + +enum SettingsItem: Hashable { + enum AppearanceMode: String { + case automatic + case light + case dark + } + + struct NotificationSwitch: Hashable { + let title: String + let isOn: Bool + let enable: Bool + } + + struct Link: Hashable { + let title: String + let color: UIColor + } + + case apperance(item: AppearanceMode) + case notification(item: NotificationSwitch) + case boringZone(item: Link) + case spicyZone(item: Link) +} diff --git a/Mastodon/Scene/Settings/View/Cell/SettingsAppearanceTableViewCell.swift b/Mastodon/Scene/Settings/View/Cell/SettingsAppearanceTableViewCell.swift new file mode 100644 index 00000000..a477661e --- /dev/null +++ b/Mastodon/Scene/Settings/View/Cell/SettingsAppearanceTableViewCell.swift @@ -0,0 +1,205 @@ +// +// SettingsAppearanceTableViewCell.swift +// Mastodon +// +// Created by ihugo on 2021/4/8. +// + +import UIKit + +protocol SettingsAppearanceTableViewCellDelegate: class { + func settingsAppearanceCell(_ view: SettingsAppearanceTableViewCell, didSelect: SettingsItem.AppearanceMode) +} + +class AppearanceView: UIView { + lazy var imageView: UIImageView = { + let view = UIImageView() + 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 { + 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 + }() + + // 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, delegate: SettingsAppearanceTableViewCellDelegate?) { + appearance = data + self.delegate = delegate + + 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, didSelect: appearance) + } +} diff --git a/Mastodon/Scene/Settings/View/Cell/SettingsLinkTableViewCell.swift b/Mastodon/Scene/Settings/View/Cell/SettingsLinkTableViewCell.swift new file mode 100644 index 00000000..b5d0306d --- /dev/null +++ b/Mastodon/Scene/Settings/View/Cell/SettingsLinkTableViewCell.swift @@ -0,0 +1,31 @@ +// +// 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 + func update(with data: SettingsItem.Link) { + textLabel?.text = data.title + textLabel?.textColor = data.color + } +} diff --git a/Mastodon/Scene/Settings/View/Cell/SettingsToggleTableViewCell.swift b/Mastodon/Scene/Settings/View/Cell/SettingsToggleTableViewCell.swift new file mode 100644 index 00000000..b35b2b50 --- /dev/null +++ b/Mastodon/Scene/Settings/View/Cell/SettingsToggleTableViewCell.swift @@ -0,0 +1,63 @@ +// +// SettingsToggleTableViewCell.swift +// Mastodon +// +// Created by ihugo on 2021/4/8. +// + +import UIKit + +protocol SettingsToggleCellDelegate: class { + func settingsToggleCell(_ cell: SettingsToggleTableViewCell, didChangeStatus: Bool) +} + +class SettingsToggleTableViewCell: UITableViewCell { + lazy var switchButton: UISwitch = { + let view = UISwitch(frame:.zero) + return view + }() + + var data: SettingsItem.NotificationSwitch? + weak var delegate: SettingsToggleCellDelegate? + + // MARK: - Methods + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: .default, reuseIdentifier: reuseIdentifier) + setupUI() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func update(with data: SettingsItem.NotificationSwitch, delegate: SettingsToggleCellDelegate?) { + self.delegate = delegate + self.data = data + textLabel?.text = data.title + switchButton.isOn = data.isOn + setup(enable: data.enable) + } + + // MARK: Actions + @objc func valueDidChange(sender: UISwitch) { + guard let delegate = delegate else { return } + delegate.settingsToggleCell(self, didChangeStatus: sender.isOn) + } + + // MARK: Private methods + private func setupUI() { + selectionStyle = .none + accessoryView = switchButton + + switchButton.addTarget(self, action: #selector(valueDidChange(sender:)), for: .valueChanged) + } + + private func setup(enable: Bool) { + if enable { + textLabel?.textColor = Asset.Colors.Label.primary.color + } else { + textLabel?.textColor = Asset.Colors.Label.secondary.color + } + switchButton.isEnabled = enable + } +} diff --git a/Mastodon/Scene/Settings/View/SettingsSectionHeader.swift b/Mastodon/Scene/Settings/View/SettingsSectionHeader.swift new file mode 100644 index 00000000..ccd7fd87 --- /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/View/TableviewCell/StatusTableViewCell.swift b/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift index 7184b767..d546fea6 100644 --- a/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift +++ b/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift @@ -47,7 +47,7 @@ extension StatusTableViewCellDelegate { } } -final class StatusTableViewCell: UITableViewCell { +final class StatusTableViewCell: UITableViewCell, StatusCell { static let bottomPaddingHeight: CGFloat = 10 diff --git a/Mastodon/Scene/Share/ViewModel/AudioContainerViewModel.swift b/Mastodon/Scene/Share/ViewModel/AudioContainerViewModel.swift index 56bf0cbc..2bc6db22 100644 --- a/Mastodon/Scene/Share/ViewModel/AudioContainerViewModel.swift +++ b/Mastodon/Scene/Share/ViewModel/AudioContainerViewModel.swift @@ -11,7 +11,7 @@ import UIKit class AudioContainerViewModel { static func configure( - cell: StatusTableViewCell, + cell: StatusCell, audioAttachment: Attachment, audioService: AudioPlaybackService ) { @@ -51,7 +51,7 @@ class AudioContainerViewModel { } static func observePlayer( - cell: StatusTableViewCell, + cell: StatusCell, audioAttachment: Attachment, audioService: AudioPlaybackService ) { diff --git a/Mastodon/Service/APIService/APIService+Notification.swift b/Mastodon/Service/APIService/APIService+Notification.swift new file mode 100644 index 00000000..ee8f5186 --- /dev/null +++ b/Mastodon/Service/APIService/APIService+Notification.swift @@ -0,0 +1,67 @@ +// +// 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 { + 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() + } +} diff --git a/Mastodon/Service/APIService/APIService+Subscriptions.swift b/Mastodon/Service/APIService/APIService+Subscriptions.swift new file mode 100644 index 00000000..337ab26d --- /dev/null +++ b/Mastodon/Service/APIService/APIService+Subscriptions.swift @@ -0,0 +1,163 @@ +// +// APIService+Settings.swift +// Mastodon +// +// Created by ihugo on 2021/4/9. +// + +import Combine +import CoreData +import CoreDataStack +import Foundation +import MastodonSDK + +extension APIService { + + func subscription( + domain: String, + userID: String, + mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox + ) -> AnyPublisher, Error> { + let authorization = mastodonAuthenticationBox.userAuthorization + + let findSettings: Setting? = { + let request = Setting.sortedFetchRequest + request.predicate = Setting.predicate(domain: domain, userID: userID) + request.fetchLimit = 1 + request.returnsObjectsAsFaults = false + do { + return try self.backgroundManagedObjectContext.fetch(request).first + } catch { + assertionFailure(error.localizedDescription) + return nil + } + }() + let triggerBy = findSettings?.triggerBy ?? "anyone" + let setting = self.createSettingIfNeed( + domain: domain, + userId: userID, + triggerBy: triggerBy + ) + return Mastodon.API.Subscriptions.subscription( + session: session, + domain: domain, + authorization: authorization + ) + .flatMap { response -> AnyPublisher, Error> in + return self.backgroundManagedObjectContext.performChanges { + _ = APIService.CoreData.createOrMergeSubscription( + into: self.backgroundManagedObjectContext, + entity: response.value, + domain: domain, + triggerBy: triggerBy, + setting: setting) + } + .setFailureType(to: Error.self) + .map { _ in return response } + .eraseToAnyPublisher() + }.eraseToAnyPublisher() + } + + func changeSubscription( + domain: String, + mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox, + query: Mastodon.API.Subscriptions.CreateSubscriptionQuery, + triggerBy: String, + userID: String + ) -> AnyPublisher, Error> { + let authorization = mastodonAuthenticationBox.userAuthorization + + let setting = self.createSettingIfNeed(domain: domain, + userId: userID, + triggerBy: triggerBy) + return Mastodon.API.Subscriptions.createSubscription( + session: session, + domain: domain, + authorization: authorization, + query: query + ) + .flatMap { response -> AnyPublisher, Error> in + return self.backgroundManagedObjectContext.performChanges { + _ = APIService.CoreData.createOrMergeSubscription( + into: self.backgroundManagedObjectContext, + entity: response.value, + domain: domain, + triggerBy: triggerBy, + setting: setting + ) + } + .setFailureType(to: Error.self) + .map { _ in return response } + .eraseToAnyPublisher() + }.eraseToAnyPublisher() + } + + func updateSubscription( + domain: String, + mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox, + query: Mastodon.API.Subscriptions.UpdateSubscriptionQuery, + triggerBy: String, + userID: String + ) -> AnyPublisher, Error> { + let authorization = mastodonAuthenticationBox.userAuthorization + + let setting = self.createSettingIfNeed(domain: domain, + userId: userID, + triggerBy: triggerBy) + + return Mastodon.API.Subscriptions.updateSubscription( + session: session, + domain: domain, + authorization: authorization, + query: query + ) + .flatMap { response -> AnyPublisher, Error> in + return self.backgroundManagedObjectContext.performChanges { + _ = APIService.CoreData.createOrMergeSubscription( + into: self.backgroundManagedObjectContext, + entity: response.value, + domain: domain, + triggerBy: triggerBy, + setting: setting + ) + } + .setFailureType(to: Error.self) + .map { _ in return response } + .eraseToAnyPublisher() + }.eraseToAnyPublisher() + } + + func createSettingIfNeed(domain: String, userId: String, triggerBy: String) -> Setting { + // create setting entity if possible + let oldSetting: Setting? = { + let request = Setting.sortedFetchRequest + request.predicate = Setting.predicate(domain: domain, userID: userId) + request.fetchLimit = 1 + request.returnsObjectsAsFaults = false + do { + return try backgroundManagedObjectContext.fetch(request).first + } catch { + assertionFailure(error.localizedDescription) + return nil + } + }() + var setting: Setting! + if let oldSetting = oldSetting { + setting = oldSetting + } else { + let property = Setting.Property( + appearance: "automatic", + triggerBy: triggerBy, + domain: domain, + userID: userId) + (setting, _) = APIService.CoreData.createOrMergeSetting( + into: backgroundManagedObjectContext, + domain: domain, + userID: userId, + property: property + ) + } + return setting + } +} + 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 00000000..f5a4022e --- /dev/null +++ b/Mastodon/Service/APIService/CoreData/APIService+CoreData+Subscriptions.swift @@ -0,0 +1,108 @@ +// +// 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 createOrMergeSetting( + into managedObjectContext: NSManagedObjectContext, + domain: String, + userID: String, + property: Setting.Property + ) -> (Subscription: Setting, isCreated: Bool) { + let oldSetting: Setting? = { + let request = Setting.sortedFetchRequest + request.predicate = Setting.predicate(domain: property.domain, userID: userID) + request.fetchLimit = 1 + request.returnsObjectsAsFaults = false + do { + return try managedObjectContext.fetch(request).first + } catch { + assertionFailure(error.localizedDescription) + return nil + } + }() + + if let oldSetting = oldSetting { + return (oldSetting, false) + } else { + let setting = Setting.insert( + into: managedObjectContext, + property: property) + return (setting, true) + } + } + + static func createOrMergeSubscription( + into managedObjectContext: NSManagedObjectContext, + entity: Mastodon.Entity.Subscription, + domain: String, + triggerBy: String, + setting: Setting + ) -> (Subscription: Subscription, isCreated: Bool) { + let oldSubscription: Subscription? = { + let request = Subscription.sortedFetchRequest + request.predicate = Subscription.predicate(type: triggerBy) + request.fetchLimit = 1 + request.returnsObjectsAsFaults = false + do { + return try managedObjectContext.fetch(request).first + } catch { + assertionFailure(error.localizedDescription) + return nil + } + }() + + let property = Subscription.Property( + endpoint: entity.endpoint, + id: entity.id, + serverKey: entity.serverKey, + type: triggerBy + ) + let alertEntity = entity.alerts + let alert = SubscriptionAlerts.Property( + favourite: alertEntity.favouriteNumber, + follow: alertEntity.followNumber, + mention: alertEntity.mentionNumber, + poll: alertEntity.pollNumber, + reblog: alertEntity.reblogNumber + ) + if let oldSubscription = oldSubscription { + oldSubscription.updateIfNeed(property: property) + if nil == oldSubscription.alert { + oldSubscription.alert = SubscriptionAlerts.insert( + into: managedObjectContext, + property: alert + ) + } else { + oldSubscription.alert?.updateIfNeed(property: alert) + } + + if oldSubscription.alert?.hasChanges == true || oldSubscription.hasChanges { + // don't expand subscription if add existed subscription + //setting.mutableSetValue(forKey: #keyPath(Setting.subscription)).add(oldSubscription) + oldSubscription.didUpdate(at: Date()) + } + return (oldSubscription, false) + } else { + let subscription = Subscription.insert( + into: managedObjectContext, + property: property + ) + subscription.alert = SubscriptionAlerts.insert( + into: managedObjectContext, + property: alert) + setting.mutableSetValue(forKey: #keyPath(Setting.subscription)).add(subscription) + return (subscription, true) + } + } +} diff --git a/Mastodon/Supporting Files/SceneDelegate.swift b/Mastodon/Supporting Files/SceneDelegate.swift index e13395cc..0f5e2bd5 100644 --- a/Mastodon/Supporting Files/SceneDelegate.swift +++ b/Mastodon/Supporting Files/SceneDelegate.swift @@ -27,6 +27,9 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { sceneCoordinator.setup() sceneCoordinator.setupOnboardingIfNeeds(animated: false) window.makeKeyAndVisible() + + // update `overrideUserInterfaceStyle` with current setting + SettingsViewController.updateOverrideUserInterfaceStyle(window: window) } func sceneDidDisconnect(_ scene: UIScene) { diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Notifications.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Notifications.swift index cdee8292..b0ab13ed 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Notifications.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Notifications.swift @@ -1,18 +1,19 @@ // // File.swift -// +// // // Created by BradGao on 2021/4/1. // -import Foundation import Combine +import Foundation extension Mastodon.API.Notifications { - static func notificationsEndpointURL(domain: String) -> URL { - Mastodon.API.endpointV2URL(domain: domain).appendingPathComponent("notifications") + internal static func notificationsEndpointURL(domain: String) -> URL { + Mastodon.API.endpointURL(domain: domain).appendingPathComponent("notifications") } - static func getNotificationEndpointURL(domain: String, notificationID: String) -> URL { + + internal static func getNotificationEndpointURL(domain: String, notificationID: String) -> URL { notificationsEndpointURL(domain: domain).appendingPathComponent(notificationID) } @@ -27,15 +28,15 @@ extension Mastodon.API.Notifications { /// - Parameters: /// - session: `URLSession` /// - domain: Mastodon instance domain. e.g. "example.com" - /// - query: `GetAllNotificationsQuery` with query parameters + /// - query: `NotificationsQuery` with query parameters /// - authorization: User token /// - Returns: `AnyPublisher` contains `Token` nested in the response - public static func getAll( + public static func getNotifications( session: URLSession, domain: String, - query: GetAllNotificationsQuery, - authorization: Mastodon.API.OAuth.Authorization? - ) -> AnyPublisher, Error> { + query: Mastodon.API.Notifications.Query, + authorization: Mastodon.API.OAuth.Authorization + ) -> AnyPublisher, Error> { let request = Mastodon.API.get( url: notificationsEndpointURL(domain: domain), query: query, @@ -63,12 +64,12 @@ extension Mastodon.API.Notifications { /// - notificationID: ID of the notification. /// - authorization: User token /// - Returns: `AnyPublisher` contains `Token` nested in the response - public static func get( + public static func getNotification( session: URLSession, domain: String, notificationID: String, - authorization: Mastodon.API.OAuth.Authorization? - ) -> AnyPublisher, Error> { + authorization: Mastodon.API.OAuth.Authorization + ) -> AnyPublisher, Error> { let request = Mastodon.API.get( url: getNotificationEndpointURL(domain: domain, notificationID: notificationID), query: nil, @@ -81,13 +82,15 @@ extension Mastodon.API.Notifications { } .eraseToAnyPublisher() } - - public struct GetAllNotificationsQuery: Codable, PagedQueryType, GetQuery { +} + +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: [String]? + public let excludeTypes: [Mastodon.Entity.Notification.NotificationType]? public let accountID: String? public init( @@ -95,7 +98,7 @@ extension Mastodon.API.Notifications { sinceID: Mastodon.Entity.Status.ID? = nil, minID: Mastodon.Entity.Status.ID? = nil, limit: Int? = nil, - excludeTypes: [String]? = nil, + excludeTypes: [Mastodon.Entity.Notification.NotificationType]? = nil, accountID: String? = nil ) { self.maxID = maxID @@ -114,7 +117,7 @@ extension Mastodon.API.Notifications { limit.flatMap { items.append(URLQueryItem(name: "limit", value: String($0))) } if let excludeTypes = excludeTypes { excludeTypes.forEach { - items.append(URLQueryItem(name: "exclude_types[]", value: $0)) + items.append(URLQueryItem(name: "exclude_types[]", value: $0.rawValue)) } } accountID.flatMap { items.append(URLQueryItem(name: "account_id", value: $0)) } 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 00000000..df916849 --- /dev/null +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Push.swift @@ -0,0 +1,226 @@ +// +// 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/9 + /// # 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/9 + /// # 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/9 + /// # 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() + } +} + +extension Mastodon.API.Subscriptions { + public struct CreateSubscriptionQuery: Codable, PostQuery { + let endpoint: String + let p256dh: String + let auth: String + let favourite: Bool? + let follow: Bool? + let reblog: Bool? + let mention: Bool? + let poll: Bool? + + var queryItems: [URLQueryItem]? { + var items = [URLQueryItem]() + + items.append(URLQueryItem(name: "subscription[endpoint]", value: endpoint)) + items.append(URLQueryItem(name: "subscription[keys][p256dh]", value: p256dh)) + items.append(URLQueryItem(name: "subscription[keys][auth]", value: auth)) + + if let followValue = follow?.queryItemValue { + let followItem = URLQueryItem(name: "data[alerts][follow]", value: followValue) + items.append(followItem) + } + + if let favouriteValue = favourite?.queryItemValue { + let favouriteItem = URLQueryItem(name: "data[alerts][favourite]", value: favouriteValue) + items.append(favouriteItem) + } + + if let reblogValue = reblog?.queryItemValue { + let reblogItem = URLQueryItem(name: "data[alerts][reblog]", value: reblogValue) + items.append(reblogItem) + } + + if let mentionValue = mention?.queryItemValue { + let mentionItem = URLQueryItem(name: "data[alerts][mention]", value: mentionValue) + items.append(mentionItem) + } + return items + } + + public init( + endpoint: String, + p256dh: String, + auth: String, + favourite: Bool?, + follow: Bool?, + reblog: Bool?, + mention: Bool?, + poll: Bool? + ) { + self.endpoint = endpoint + self.p256dh = p256dh + self.auth = auth + self.favourite = favourite + self.follow = follow + self.reblog = reblog + self.mention = mention + self.poll = poll + } + } + + public struct UpdateSubscriptionQuery: Codable, PutQuery { + let favourite: Bool? + let follow: Bool? + let reblog: Bool? + let mention: Bool? + let poll: Bool? + + var queryItems: [URLQueryItem]? { + var items = [URLQueryItem]() + + if let followValue = follow?.queryItemValue { + let followItem = URLQueryItem(name: "data[alerts][follow]", value: followValue) + items.append(followItem) + } + + if let favouriteValue = favourite?.queryItemValue { + let favouriteItem = URLQueryItem(name: "data[alerts][favourite]", value: favouriteValue) + items.append(favouriteItem) + } + + if let reblogValue = reblog?.queryItemValue { + let reblogItem = URLQueryItem(name: "data[alerts][reblog]", value: reblogValue) + items.append(reblogItem) + } + + if let mentionValue = mention?.queryItemValue { + let mentionItem = URLQueryItem(name: "data[alerts][mention]", value: mentionValue) + items.append(mentionItem) + } + return items + } + + 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 + } + } +} diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift index 2fdb9b34..1a4496ed 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift @@ -115,6 +115,7 @@ extension Mastodon.API { public enum Trends { } public enum Suggestions { } public enum Notifications { } + public enum Subscriptions { } } extension Mastodon.API { diff --git a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Notification.swift b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Notification.swift index 413c89bd..0cdcc2e7 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+Subscription.swift b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Subscription.swift new file mode 100644 index 00000000..3ae5718e --- /dev/null +++ b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Subscription.swift @@ -0,0 +1,77 @@ +// +// File.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/9 + /// # 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 favourite: Bool? + public let reblog: Bool? + public let mention: Bool? + public let poll: Bool? + + public var followNumber: NSNumber? { + guard let value = follow else { return nil } + return NSNumber(booleanLiteral: value) + } + public var favouriteNumber: NSNumber? { + guard let value = favourite else { return nil } + return NSNumber(booleanLiteral: value) + } + public var reblogNumber: NSNumber? { + guard let value = reblog else { return nil } + return NSNumber(booleanLiteral: value) + } + public var mentionNumber: NSNumber? { + guard let value = mention else { return nil } + return NSNumber(booleanLiteral: value) + } + public var pollNumber: NSNumber? { + guard let value = poll else { return nil } + return NSNumber(booleanLiteral: value) + } + } + + 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) + } + } +}