From cbd598739e291ab1d852407ec1bedbebe10f9f08 Mon Sep 17 00:00:00 2001 From: CMK Date: Mon, 26 Apr 2021 16:57:50 +0800 Subject: [PATCH] feat: make push notification trigger update when change setting --- .../CoreData.xcdatamodel/contents | 47 +- CoreDataStack/CoreDataStack.swift | 2 +- CoreDataStack/Entity/Setting.swift | 55 +- CoreDataStack/Entity/Subscription.swift | 76 ++- CoreDataStack/Entity/SubscriptionAlerts.swift | 168 ++++--- Mastodon.xcodeproj/project.pbxproj | 58 ++- .../xcschemes/xcschememanagement.plist | 4 +- Mastodon/Coordinator/SceneCoordinator.swift | 12 +- .../SettingFetchedResultController.swift | 64 +++ Mastodon/Diffiable/Item/SettingsItem.swift | 67 +++ .../Diffiable/Section/SettingsSection.swift | 24 + .../Extension/CoreDataStack/Setting.swift | 24 + .../CoreDataStack/Subscription.swift | 20 + .../CoreDataStack/SubscriptionAlerts.swift | 28 ++ .../Mastodon+API+Subscriptions+Policy.swift | 20 + Mastodon/Extension/UserDefaults.swift | 31 ++ .../Preference/AppearancePreference.swift | 20 + ...meTimelineViewController+DebugAction.swift | 4 +- .../HomeTimelineViewController.swift | 14 +- .../Scene/Profile/ProfileViewController.swift | 4 +- .../Settings/SettingsViewController.swift | 298 +++++------ .../Scene/Settings/SettingsViewModel.swift | 473 +++++------------- .../SettingsAppearanceTableViewCell.swift | 17 +- .../View/Cell/SettingsLinkTableViewCell.swift | 14 +- .../Cell/SettingsToggleTableViewCell.swift | 56 ++- .../View/Content/TimelineHeaderView.swift | 2 + .../APIService/APIService+Subscriptions.swift | 153 ++---- .../APIService+CoreData+Setting.swift | 61 +++ .../APIService+CoreData+Subscriptions.swift | 101 +--- Mastodon/Service/AuthenticationService.swift | 55 +- Mastodon/Service/NotificationService.swift | 184 ++----- Mastodon/Service/SettingService.swift | 162 ++++++ Mastodon/State/AppContext.swift | 11 +- Mastodon/Supporting Files/AppSharedName.swift | 12 + Mastodon/Supporting Files/SceneDelegate.swift | 10 +- .../MastodonSDK/API/Mastodon+API+Push.swift | 79 ++- .../MastodonSDK/API/Mastodon+API.swift | 8 + .../Entity/Mastodon+Entity+Subscription.swift | 35 +- .../Sources/MastodonSDK/Query/Query.swift | 2 + 39 files changed, 1356 insertions(+), 1119 deletions(-) create mode 100644 Mastodon/Diffiable/FetchedResultsController/SettingFetchedResultController.swift create mode 100644 Mastodon/Diffiable/Item/SettingsItem.swift create mode 100644 Mastodon/Diffiable/Section/SettingsSection.swift create mode 100644 Mastodon/Extension/CoreDataStack/Setting.swift create mode 100644 Mastodon/Extension/CoreDataStack/Subscription.swift create mode 100644 Mastodon/Extension/CoreDataStack/SubscriptionAlerts.swift create mode 100644 Mastodon/Extension/MastodonSDK/Mastodon+API+Subscriptions+Policy.swift create mode 100644 Mastodon/Extension/UserDefaults.swift create mode 100644 Mastodon/Preference/AppearancePreference.swift create mode 100644 Mastodon/Service/APIService/CoreData/APIService+CoreData+Setting.swift create mode 100644 Mastodon/Service/SettingService.swift create mode 100644 Mastodon/Supporting Files/AppSharedName.swift diff --git a/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents b/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents index 0d0170282..69c30e990 100644 --- a/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents +++ b/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents @@ -172,14 +172,12 @@ - - - - - - - - + + + + + + @@ -221,24 +219,27 @@ - + + + - - - - + + + + - - - - - + + + + + + - + @@ -263,10 +264,10 @@ - + - - + + - + \ No newline at end of file diff --git a/CoreDataStack/CoreDataStack.swift b/CoreDataStack/CoreDataStack.swift index 1d13ee5ee..766bcf4de 100644 --- a/CoreDataStack/CoreDataStack.swift +++ b/CoreDataStack/CoreDataStack.swift @@ -18,7 +18,7 @@ public final class CoreDataStack { } public convenience init(databaseName: String = "shared") { - let storeURL = URL.storeURL(for: "group.org.joinmastodon.mastodon-temp", databaseName: databaseName) + let storeURL = URL.storeURL(for: AppSharedName.groupID, databaseName: databaseName) let storeDescription = NSPersistentStoreDescription(url: storeURL) self.init(persistentStoreDescriptions: [storeDescription]) } diff --git a/CoreDataStack/Entity/Setting.swift b/CoreDataStack/Entity/Setting.swift index 671f9bab3..6fac8c351 100644 --- a/CoreDataStack/Entity/Setting.swift +++ b/CoreDataStack/Entity/Setting.swift @@ -9,66 +9,61 @@ 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 var appearanceRaw: String + @NSManaged public var domain: String + @NSManaged public var userID: String @NSManaged public private(set) var createdAt: Date @NSManaged public private(set) var updatedAt: Date - // relationships - @NSManaged public var subscription: Set? + // one-to-many relationships + @NSManaged public var subscriptions: Set? } -public extension Setting { - override func awakeFromInsert() { - super.awakeFromInsert() - setPrimitiveValue(Date(), forKey: #keyPath(Setting.createdAt)) - } +extension Setting { - func didUpdate(at networkDate: Date) { - self.updatedAt = networkDate + public override func awakeFromInsert() { + super.awakeFromInsert() + let now = Date() + setPrimitiveValue(now, forKey: #keyPath(Setting.createdAt)) + setPrimitiveValue(now, forKey: #keyPath(Setting.updatedAt)) } @discardableResult - static func insert( + public static func insert( into context: NSManagedObjectContext, property: Property ) -> Setting { let setting: Setting = context.insertObject() - setting.appearance = property.appearance - setting.triggerBy = property.triggerBy + setting.appearanceRaw = property.appearanceRaw setting.domain = property.domain setting.userID = property.userID return setting } - func update(appearance: String?) { - guard appearance != self.appearance else { return } - self.appearance = appearance + public func update(appearanceRaw: String) { + guard appearanceRaw != self.appearanceRaw else { return } + self.appearanceRaw = appearanceRaw didUpdate(at: Date()) } - func update(triggerBy: String?) { - guard triggerBy != self.triggerBy else { return } - self.triggerBy = triggerBy - didUpdate(at: Date()) + public func didUpdate(at networkDate: Date) { + self.updatedAt = networkDate } + } -public extension Setting { - struct Property { - public let appearance: String - public let triggerBy: String +extension Setting { + public struct Property { public let domain: String public let userID: String + public let appearanceRaw: String - public init(appearance: String, triggerBy: String, domain: String, userID: String) { - self.appearance = appearance - self.triggerBy = triggerBy + public init(domain: String, userID: String, appearanceRaw: String) { self.domain = domain self.userID = userID + self.appearanceRaw = appearanceRaw } } } diff --git a/CoreDataStack/Entity/Subscription.swift b/CoreDataStack/Entity/Subscription.swift index 8ced945d9..6cb1902a1 100644 --- a/CoreDataStack/Entity/Subscription.swift +++ b/CoreDataStack/Entity/Subscription.swift @@ -10,30 +10,35 @@ 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 var id: String? + @NSManaged public var endpoint: String? + @NSManaged public var policyRaw: String + @NSManaged public var serverKey: String? + @NSManaged public var userToken: String? @NSManaged public private(set) var createdAt: Date @NSManaged public private(set) var updatedAt: Date + @NSManaged public private(set) var activedAt: Date + + // MARK: one-to-one relationships + @NSManaged public var alert: SubscriptionAlerts - // MARK: - relationships - @NSManaged public var alert: SubscriptionAlerts? - // MARK: holder + // MARK: many-to-one relationships @NSManaged public var setting: Setting? } public extension Subscription { override func awakeFromInsert() { super.awakeFromInsert() - setPrimitiveValue(Date(), forKey: #keyPath(Subscription.createdAt)) + let now = Date() + setPrimitiveValue(now, forKey: #keyPath(Subscription.createdAt)) + setPrimitiveValue(now, forKey: #keyPath(Subscription.updatedAt)) + setPrimitiveValue(now, forKey: #keyPath(Subscription.activedAt)) + } + + func update(activedAt: Date) { + self.activedAt = activedAt } func didUpdate(at networkDate: Date) { @@ -43,45 +48,22 @@ public extension Subscription { @discardableResult static func insert( into context: NSManagedObjectContext, - property: Property + property: Property, + setting: Setting ) -> Subscription { - let setting: Subscription = context.insertObject() - setting.id = property.id - setting.endpoint = property.endpoint - setting.serverKey = property.serverKey - setting.type = property.type - - return setting + let subscription: Subscription = context.insertObject() + subscription.policyRaw = property.policyRaw + subscription.setting = setting + return subscription } } public extension Subscription { struct Property { - public let endpoint: String - public let id: String - public let serverKey: String - public let type: String + public let policyRaw: 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 + public init(policyRaw: String) { + self.policyRaw = policyRaw } } } @@ -94,8 +76,8 @@ extension Subscription: Managed { extension Subscription { - public static func predicate(type: String) -> NSPredicate { - return NSPredicate(format: "%K == %@", #keyPath(Subscription.type), type) + public static func predicate(policyRaw: String) -> NSPredicate { + return NSPredicate(format: "%K == %@", #keyPath(Subscription.policyRaw), policyRaw) } } diff --git a/CoreDataStack/Entity/SubscriptionAlerts.swift b/CoreDataStack/Entity/SubscriptionAlerts.swift index f5abf4955..613d1caf7 100644 --- a/CoreDataStack/Entity/SubscriptionAlerts.swift +++ b/CoreDataStack/Entity/SubscriptionAlerts.swift @@ -10,117 +10,165 @@ 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 var favouriteRaw: NSNumber? + @NSManaged public var followRaw: NSNumber? + @NSManaged public var followRequestRaw: NSNumber? + @NSManaged public var mentionRaw: NSNumber? + @NSManaged public var pollRaw: NSNumber? + @NSManaged public var reblogRaw: NSNumber? @NSManaged public private(set) var createdAt: Date @NSManaged public private(set) var updatedAt: Date - // MARK: - relationships - @NSManaged public var subscription: Subscription? + // MARK: one-to-one relationships + @NSManaged public var subscription: Subscription } -public extension SubscriptionAlerts { - override func awakeFromInsert() { - super.awakeFromInsert() - setPrimitiveValue(Date(), forKey: #keyPath(SubscriptionAlerts.createdAt)) - } +extension SubscriptionAlerts { - func didUpdate(at networkDate: Date) { - self.updatedAt = networkDate + public override func awakeFromInsert() { + super.awakeFromInsert() + let now = Date() + setPrimitiveValue(now, forKey: #keyPath(SubscriptionAlerts.createdAt)) + setPrimitiveValue(now, forKey: #keyPath(SubscriptionAlerts.updatedAt)) } @discardableResult - static func insert( + public static func insert( into context: NSManagedObjectContext, - property: Property + property: Property, + subscription: Subscription ) -> 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 + + alerts.favouriteRaw = property.favouriteRaw + alerts.followRaw = property.followRaw + alerts.followRequestRaw = property.followRequestRaw + alerts.mentionRaw = property.mentionRaw + alerts.pollRaw = property.pollRaw + alerts.reblogRaw = property.reblogRaw + + alerts.subscription = subscription + return alerts } - func update(favourite: NSNumber?) { + public func update(favourite: Bool?) { guard self.favourite != favourite else { return } self.favourite = favourite didUpdate(at: Date()) } - func update(follow: NSNumber?) { + public func update(follow: Bool?) { guard self.follow != follow else { return } self.follow = follow didUpdate(at: Date()) } - func update(mention: NSNumber?) { + public func update(followRequest: Bool?) { + guard self.followRequest != followRequest else { return } + self.followRequest = followRequest + + didUpdate(at: Date()) + } + + public func update(mention: Bool?) { guard self.mention != mention else { return } self.mention = mention didUpdate(at: Date()) } - func update(poll: NSNumber?) { + public func update(poll: Bool?) { guard self.poll != poll else { return } self.poll = poll didUpdate(at: Date()) } - func update(reblog: NSNumber?) { + public func update(reblog: Bool?) { guard self.reblog != reblog else { return } self.reblog = reblog didUpdate(at: Date()) } + + public func didUpdate(at networkDate: Date) { + self.updatedAt = networkDate + } + } -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? +extension SubscriptionAlerts { + + private func boolean(from number: NSNumber?) -> Bool? { + return number.flatMap { $0.intValue == 1 } + } + + private func number(from boolean: Bool?) -> NSNumber? { + return boolean.flatMap { NSNumber(integerLiteral: $0 ? 1 : 0) } + } + + public var favourite: Bool? { + get { boolean(from: favouriteRaw) } + set { favouriteRaw = number(from: newValue) } + } + + public var follow: Bool? { + get { boolean(from: followRaw) } + set { followRaw = number(from: newValue) } + } + + public var followRequest: Bool? { + get { boolean(from: followRequestRaw) } + set { followRequestRaw = number(from: newValue) } + } + + public var mention: Bool? { + get { boolean(from: mentionRaw) } + set { mentionRaw = number(from: newValue) } + } + + public var poll: Bool? { + get { boolean(from: pollRaw) } + set { pollRaw = number(from: newValue) } + } + + public var reblog: Bool? { + get { boolean(from: reblogRaw) } + set { reblogRaw = number(from: newValue) } + } + +} - 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 +extension SubscriptionAlerts { + public struct Property { + public let favouriteRaw: NSNumber? + public let followRaw: NSNumber? + public let followRequestRaw: NSNumber? + public let mentionRaw: NSNumber? + public let pollRaw: NSNumber? + public let reblogRaw: NSNumber? + + public init( + favourite: Bool?, + follow: Bool?, + followRequest: Bool?, + mention: Bool?, + poll: Bool?, + reblog: Bool? + ) { + self.favouriteRaw = favourite.flatMap { NSNumber(value: $0 ? 1 : 0) } + self.followRaw = follow.flatMap { NSNumber(value: $0 ? 1 : 0) } + self.followRequestRaw = followRequest.flatMap { NSNumber(value: $0 ? 1 : 0) } + self.mentionRaw = mention.flatMap { NSNumber(value: $0 ? 1 : 0) } + self.pollRaw = poll.flatMap { NSNumber(value: $0 ? 1 : 0) } + self.reblogRaw = reblog.flatMap { NSNumber(value: $0 ? 1 : 0) } } } - 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 { diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index 30202e558..b1bc63e6d 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -138,7 +138,6 @@ 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 */; }; @@ -250,10 +249,24 @@ DB6B35182601FA3400DC1E11 /* MastodonAttachmentService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6B35172601FA3400DC1E11 /* MastodonAttachmentService.swift */; }; DB6B351E2601FAEE00DC1E11 /* ComposeStatusAttachmentCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6B351D2601FAEE00DC1E11 /* ComposeStatusAttachmentCollectionViewCell.swift */; }; DB6C8C0F25F0A6AE00AAA452 /* Mastodon+Entity+Error.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6C8C0E25F0A6AE00AAA452 /* Mastodon+Entity+Error.swift */; }; + DB6D1B24263684C600ACB481 /* UserDefaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6D1B23263684C600ACB481 /* UserDefaults.swift */; }; + DB6D1B2B2636852000ACB481 /* AppSharedName.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6D1B2A2636852000ACB481 /* AppSharedName.swift */; }; + DB6D1B312636853100ACB481 /* AppSharedName.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6D1B2A2636852000ACB481 /* AppSharedName.swift */; }; + DB6D1B3D2636857500ACB481 /* AppearancePreference.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6D1B3C2636857500ACB481 /* AppearancePreference.swift */; }; + DB6D1B44263691CF00ACB481 /* Mastodon+API+Subscriptions+Policy.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6D1B43263691CF00ACB481 /* Mastodon+API+Subscriptions+Policy.swift */; }; DB6D9F232635195E008423CD /* String.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6D9F222635195E008423CD /* String.swift */; }; DB6D9F3526351B7A008423CD /* NotificationService+Decrypt.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6D9F3426351B7A008423CD /* NotificationService+Decrypt.swift */; }; DB6D9F3B26352019008423CD /* AppSecret.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB1E05E0263180F500201847 /* AppSecret.swift */; }; DB6D9F42263527CE008423CD /* AlamofireImage in Frameworks */ = {isa = PBXBuildFile; productRef = DB6D9F41263527CE008423CD /* AlamofireImage */; }; + DB6D9F4926353FD7008423CD /* Subscription.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6D9F4826353FD6008423CD /* Subscription.swift */; }; + DB6D9F502635761F008423CD /* SubscriptionAlerts.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6D9F4F2635761F008423CD /* SubscriptionAlerts.swift */; }; + DB6D9F57263577D2008423CD /* APIService+CoreData+Setting.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6D9F56263577D2008423CD /* APIService+CoreData+Setting.swift */; }; + DB6D9F6326357848008423CD /* SettingService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6D9F6226357848008423CD /* SettingService.swift */; }; + DB6D9F6F2635807F008423CD /* Setting.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6D9F6E2635807F008423CD /* Setting.swift */; }; + DB6D9F76263587C7008423CD /* SettingFetchedResultController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6D9F75263587C7008423CD /* SettingFetchedResultController.swift */; }; + DB6D9F7D26358ED4008423CD /* SettingsSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6D9F7C26358ED4008423CD /* SettingsSection.swift */; }; + DB6D9F8426358EEC008423CD /* SettingsItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6D9F8326358EEC008423CD /* SettingsItem.swift */; }; + DB6D9F9726367249008423CD /* SettingsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6D9F9626367249008423CD /* SettingsViewController.swift */; }; DB71FD2C25F86A5100512AE1 /* AvatarStackContainerButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB71FD2B25F86A5100512AE1 /* AvatarStackContainerButton.swift */; }; DB71FD3625F8A16C00512AE1 /* APIService+Persist+PersistMemo.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB71FD3525F8A16C00512AE1 /* APIService+Persist+PersistMemo.swift */; }; DB71FD3C25F8A1C500512AE1 /* APIService+Persist+PersistCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB71FD3B25F8A1C500512AE1 /* APIService+Persist+PersistCache.swift */; }; @@ -584,7 +597,6 @@ 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 = ""; }; @@ -703,8 +715,21 @@ DB6B35172601FA3400DC1E11 /* MastodonAttachmentService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonAttachmentService.swift; sourceTree = ""; }; DB6B351D2601FAEE00DC1E11 /* ComposeStatusAttachmentCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeStatusAttachmentCollectionViewCell.swift; sourceTree = ""; }; DB6C8C0E25F0A6AE00AAA452 /* Mastodon+Entity+Error.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Mastodon+Entity+Error.swift"; sourceTree = ""; }; + DB6D1B23263684C600ACB481 /* UserDefaults.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserDefaults.swift; sourceTree = ""; }; + DB6D1B2A2636852000ACB481 /* AppSharedName.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSharedName.swift; sourceTree = ""; }; + DB6D1B3C2636857500ACB481 /* AppearancePreference.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppearancePreference.swift; sourceTree = ""; }; + DB6D1B43263691CF00ACB481 /* Mastodon+API+Subscriptions+Policy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Mastodon+API+Subscriptions+Policy.swift"; sourceTree = ""; }; DB6D9F222635195E008423CD /* String.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = String.swift; sourceTree = ""; }; DB6D9F3426351B7A008423CD /* NotificationService+Decrypt.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NotificationService+Decrypt.swift"; sourceTree = ""; }; + DB6D9F4826353FD6008423CD /* Subscription.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Subscription.swift; sourceTree = ""; }; + DB6D9F4F2635761F008423CD /* SubscriptionAlerts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubscriptionAlerts.swift; sourceTree = ""; }; + DB6D9F56263577D2008423CD /* APIService+CoreData+Setting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+CoreData+Setting.swift"; sourceTree = ""; }; + DB6D9F6226357848008423CD /* SettingService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingService.swift; sourceTree = ""; }; + DB6D9F6E2635807F008423CD /* Setting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Setting.swift; sourceTree = ""; }; + DB6D9F75263587C7008423CD /* SettingFetchedResultController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingFetchedResultController.swift; sourceTree = ""; }; + DB6D9F7C26358ED4008423CD /* SettingsSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsSection.swift; sourceTree = ""; }; + DB6D9F8326358EEC008423CD /* SettingsItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsItem.swift; sourceTree = ""; }; + DB6D9F9626367249008423CD /* SettingsViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SettingsViewController.swift; sourceTree = ""; }; DB71FD2B25F86A5100512AE1 /* AvatarStackContainerButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AvatarStackContainerButton.swift; sourceTree = ""; }; DB71FD3525F8A16C00512AE1 /* APIService+Persist+PersistMemo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Persist+PersistMemo.swift"; sourceTree = ""; }; DB71FD3B25F8A1C500512AE1 /* APIService+Persist+PersistCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Persist+PersistCache.swift"; sourceTree = ""; }; @@ -1109,6 +1134,7 @@ DB71FD4B25F8C80E00512AE1 /* StatusPrefetchingService.swift */, DBC7A67B260DFADE00E57475 /* StatusPublishService.swift */, DB4924E126312AB200E9DB22 /* NotificationService.swift */, + DB6D9F6226357848008423CD /* SettingService.swift */, ); path = Service; sourceTree = ""; @@ -1176,6 +1202,7 @@ 2D198648261C0B8500F0B013 /* SearchResultSection.swift */, DB66729525F9F91600D60309 /* ComposeStatusSection.swift */, DB447680260B3ED600B66B82 /* CustomEmojiPickerSection.swift */, + DB6D9F7C26358ED4008423CD /* SettingsSection.swift */, ); path = Section; sourceTree = ""; @@ -1232,6 +1259,7 @@ DB1FD45925F27898004CFCFC /* CategoryPickerItem.swift */, DB66729B25F9F91F00D60309 /* ComposeStatusItem.swift */, DB44768A260B3F2100B66B82 /* CustomEmojiPickerItem.swift */, + DB6D9F8326358EEC008423CD /* SettingsItem.swift */, ); path = Item; sourceTree = ""; @@ -1285,8 +1313,8 @@ isa = PBXGroup; children = ( 5B90C457262599800002E742 /* View */, + DB6D9F9626367249008423CD /* SettingsViewController.swift */, 5B90C456262599800002E742 /* SettingsViewModel.swift */, - 5B90C45D262599800002E742 /* SettingsViewController.swift */, ); path = Settings; sourceTree = ""; @@ -1350,6 +1378,9 @@ DB084B5625CBC56C00F898ED /* Status.swift */, DB45FAE225CA7181005A8AC7 /* MastodonUser.swift */, DB9D6C3725E508BE0051B173 /* Attachment.swift */, + DB6D9F6E2635807F008423CD /* Setting.swift */, + DB6D9F4826353FD6008423CD /* Subscription.swift */, + DB6D9F4F2635761F008423CD /* SubscriptionAlerts.swift */, ); path = CoreDataStack; sourceTree = ""; @@ -1377,6 +1408,7 @@ DB427DD525BAA00100D1B89D /* AppDelegate.swift */, DB427DD725BAA00100D1B89D /* SceneDelegate.swift */, DB1E05E0263180F500201847 /* AppSecret.swift */, + DB6D1B2A2636852000ACB481 /* AppSharedName.swift */, DB427DDB25BAA00100D1B89D /* Main.storyboard */, DB427DE025BAA00100D1B89D /* LaunchScreen.storyboard */, DB68A05C25E9055900CFDF14 /* Settings.bundle */, @@ -1511,6 +1543,7 @@ DB45FADC25CA6F6B005A8AC7 /* APIService+CoreData+MastodonUser.swift */, DB45FAF825CA80A2005A8AC7 /* APIService+CoreData+MastodonAuthentication.swift */, 2D79E700261EA5550011E398 /* APIService+CoreData+Tag.swift */, + DB6D9F56263577D2008423CD /* APIService+CoreData+Setting.swift */, 5B90C48A26259C120002E742 /* APIService+CoreData+Subscriptions.swift */, ); path = CoreData; @@ -1530,6 +1563,7 @@ isa = PBXGroup; children = ( DB5086BD25CC0D9900C2C187 /* SplashPreference.swift */, + DB6D1B3C2636857500ACB481 /* AppearancePreference.swift */, ); path = Preference; sourceTree = ""; @@ -1573,6 +1607,7 @@ 5DDDF1A82617489F00311060 /* Mastodon+Entity+History.swift */, 2D650FAA25ECDC9300851B58 /* Mastodon+Entity+Error+Detail.swift */, 2DB72C8B262D764300CE6173 /* Mastodon+Entity+Notification+Type.swift */, + DB6D1B43263691CF00ACB481 /* Mastodon+API+Subscriptions+Policy.swift */, ); path = MastodonSDK; sourceTree = ""; @@ -1787,6 +1822,7 @@ 0F20223826146553000C64BF /* Array.swift */, DBCC3B2F261440A50045B23D /* UITabBarController.swift */, DBCC3B35261440BA0045B23D /* UINavigationController.swift */, + DB6D1B23263684C600ACB481 /* UserDefaults.swift */, ); path = Extension; sourceTree = ""; @@ -1994,6 +2030,7 @@ isa = PBXGroup; children = ( DBCBED1C26132E1A00B49291 /* StatusFetchedResultsController.swift */, + DB6D9F75263587C7008423CD /* SettingFetchedResultController.swift */, ); path = FetchedResultsController; sourceTree = ""; @@ -2555,6 +2592,7 @@ DB55D33025FB630A0002F825 /* TwitterTextEditor+String.swift in Sources */, 2D38F1EB25CD477000561493 /* HomeTimelineViewModel+LoadLatestState.swift in Sources */, 0F202201261326E6000C64BF /* HashtagTimelineViewModel.swift in Sources */, + DB6D9F9726367249008423CD /* SettingsViewController.swift in Sources */, DBD9149025DF6D8D00903DFD /* APIService+Onboarding.swift in Sources */, DBAE3FAF26172FC0004B8251 /* RemoteProfileViewModel.swift in Sources */, DBE3CE0D261D767100430CC6 /* FavoriteViewController+StatusProvider.swift in Sources */, @@ -2566,8 +2604,8 @@ 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 */, + DB6D1B44263691CF00ACB481 /* Mastodon+API+Subscriptions+Policy.swift in Sources */, DB9A48962603685D008B817C /* MastodonAttachmentService+UploadState.swift in Sources */, 2D198643261BF09500F0B013 /* SearchResultItem.swift in Sources */, DB66728C25F9F8DC00D60309 /* ComposeViewModel+Diffable.swift in Sources */, @@ -2599,6 +2637,7 @@ 5B90C45F262599800002E742 /* SettingsToggleTableViewCell.swift in Sources */, 2D694A7425F9EB4E0038ADDC /* ContentWarningOverlayView.swift in Sources */, DBAE3F682615DD60004B8251 /* UserProvider.swift in Sources */, + DB6D9F76263587C7008423CD /* SettingFetchedResultController.swift in Sources */, DB9A486C26032AC1008B817C /* AttachmentContainerView+EmptyStateView.swift in Sources */, 5D0393902612D259007FE196 /* WebViewController.swift in Sources */, DB4481CC25EE2AFE00BEFB67 /* PollItem.swift in Sources */, @@ -2624,9 +2663,11 @@ DB1FD43625F26899004CFCFC /* MastodonPickServerViewModel+LoadIndexedServerState.swift in Sources */, 2D939AE825EE1CF80076FA61 /* MastodonRegisterViewController+Avatar.swift in Sources */, 2D084B8D26258EA3003AA3AF /* NotificationViewModel+diffable.swift in Sources */, + DB6D1B24263684C600ACB481 /* UserDefaults.swift in Sources */, DB1D186C25EF5BA7003F1F23 /* PollTableView.swift in Sources */, 2D5981A125E4A593000FB903 /* MastodonConfirmEmailViewModel.swift in Sources */, DB87D4452609BE0500D12C0D /* ComposeStatusPollOptionCollectionViewCell.swift in Sources */, + DB6D9F7D26358ED4008423CD /* SettingsSection.swift in Sources */, DB8AF55025C13703002E6C99 /* MainTabBarController.swift in Sources */, DB9D6BE925E4F5340051B173 /* SearchViewController.swift in Sources */, 2D38F1C625CD37F400561493 /* ContentOffsetAdjustableTimelineViewControllerDelegate.swift in Sources */, @@ -2645,6 +2686,7 @@ DB2F073525E8ECF000957B2D /* AuthenticationViewModel.swift in Sources */, DB68A04A25E9027700CFDF14 /* AdaptiveStatusBarStyleNavigationController.swift in Sources */, 0FB3D33825E6401400AAD544 /* PickServerCell.swift in Sources */, + DB6D9F8426358EEC008423CD /* SettingsItem.swift in Sources */, 2D364F7825E66D8300204FDC /* MastodonResendEmailViewModel.swift in Sources */, DB8AF54525C13647002E6C99 /* NeedsDependency.swift in Sources */, DB9D6BF825E4F5690051B173 /* NotificationViewController.swift in Sources */, @@ -2664,6 +2706,7 @@ 2D76319F25C1521200929FB9 /* StatusSection.swift in Sources */, DB35FC252612FD7A006193C9 /* ProfileFieldView.swift in Sources */, DB938F0326240EA300E5B6C1 /* CachedThreadViewModel.swift in Sources */, + DB6D9F6326357848008423CD /* SettingService.swift in Sources */, 2D650FAB25ECDC9300851B58 /* Mastodon+Entity+Error+Detail.swift in Sources */, 2D24E12D2626FD2E00A59D4F /* NotificationViewModel+LoadOldestState.swift in Sources */, 2DB72C8C262D764300CE6173 /* Mastodon+Entity+Notification+Type.swift in Sources */, @@ -2678,11 +2721,13 @@ DB2B3AE925E38850007045F9 /* UIViewPreview.swift in Sources */, DBAE3F9E2616E308004B8251 /* APIService+Mute.swift in Sources */, DB427DD625BAA00100D1B89D /* AppDelegate.swift in Sources */, + DB6D9F57263577D2008423CD /* APIService+CoreData+Setting.swift in Sources */, DB9D6C0E25E4F9780051B173 /* MosaicImageViewContainer.swift in Sources */, DB71FD3625F8A16C00512AE1 /* APIService+Persist+PersistMemo.swift in Sources */, DBBE1B4525F3474B0081417A /* MastodonPickServerAppearance.swift in Sources */, DB98338725C945ED00AD9700 /* Strings.swift in Sources */, 2D7867192625B77500211898 /* NotificationItem.swift in Sources */, + DB6D1B2B2636852000ACB481 /* AppSharedName.swift in Sources */, DB45FAB625CA5485005A8AC7 /* UIAlertController.swift in Sources */, DBE0821525CD382600FD6BBD /* MastodonRegisterViewController.swift in Sources */, 2D5A3D0325CF8742002347D6 /* ControlContainableScrollViews.swift in Sources */, @@ -2701,6 +2746,7 @@ 2D76317D25C14DF500929FB9 /* PublicTimelineViewController+StatusProvider.swift in Sources */, 0F20223326145E51000C64BF /* HashtagTimelineViewModel+LoadMiddleState.swift in Sources */, 2D206B8025F5F45E00143C56 /* UIImage.swift in Sources */, + DB6D9F502635761F008423CD /* SubscriptionAlerts.swift in Sources */, 0F20220726134DA4000C64BF /* HashtagTimelineViewModel+Diffable.swift in Sources */, 2D5A3D2825CF8BC9002347D6 /* HomeTimelineViewModel+Diffable.swift in Sources */, DB98339C25C96DE600AD9700 /* APIService+Account.swift in Sources */, @@ -2734,17 +2780,20 @@ 2D5A3D6225CFD9CB002347D6 /* HomeTimelineViewController+DebugAction.swift in Sources */, DB49A62525FF334C00B98345 /* EmojiService+CustomEmojiViewModel+LoadState.swift in Sources */, DB4924E226312AB200E9DB22 /* NotificationService.swift in Sources */, + DB6D9F6F2635807F008423CD /* Setting.swift in Sources */, DBB525412611ED54002F1F29 /* ProfileHeaderViewController.swift in Sources */, DB9D6BFF25E4F5940051B173 /* ProfileViewController.swift in Sources */, 0FB3D30825E524C600AAD544 /* PickServerCategoriesCell.swift in Sources */, DB789A1225F9F2CC0071ACA0 /* ComposeViewModel.swift in Sources */, DBB525362611ECEB002F1F29 /* UserTimelineViewController.swift in Sources */, + DB6D1B3D2636857500ACB481 /* AppearancePreference.swift in Sources */, DB938F3326243D6200E5B6C1 /* TimelineTopLoaderTableViewCell.swift in Sources */, 2D38F1FE25CD481700561493 /* StatusProvider.swift in Sources */, DB938F25262438D600E5B6C1 /* ThreadViewController+StatusProvider.swift in Sources */, DB66729C25F9F91F00D60309 /* ComposeStatusItem.swift in Sources */, 0FB3D31E25E534C700AAD544 /* PickServerCategoryCollectionViewCell.swift in Sources */, 5DF1057925F88A1D00D6C0D4 /* PlayerContainerView.swift in Sources */, + DB6D9F4926353FD7008423CD /* Subscription.swift in Sources */, DB45FB0F25CA87D0005A8AC7 /* AuthenticationService.swift in Sources */, 2D6DE40026141DF600A63F6A /* SearchViewModel.swift in Sources */, DB51D172262832380062B7A1 /* BlurHashDecode.swift in Sources */, @@ -2794,6 +2843,7 @@ 2D152A9225C2980C009AA50C /* UIFont.swift in Sources */, DB4481B325EE16D000BEFB67 /* PollOption.swift in Sources */, DB89BA4425C1165F008580ED /* Managed.swift in Sources */, + DB6D1B312636853100ACB481 /* AppSharedName.swift in Sources */, 2D6125472625436B00299647 /* Notification.swift in Sources */, DB89BA4325C1165F008580ED /* NetworkUpdatable.swift in Sources */, DB8AF56825C13E2A002E6C99 /* HomeTimelineIndex.swift in Sources */, diff --git a/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist b/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist index 41711ac91..3e5f0c5d3 100644 --- a/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist +++ b/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist @@ -7,7 +7,7 @@ CoreDataStack.xcscheme_^#shared#^_ orderHint - 14 + 13 Mastodon - RTL.xcscheme_^#shared#^_ @@ -27,7 +27,7 @@ NotificationService.xcscheme_^#shared#^_ orderHint - 17 + 14 SuppressBuildableAutocreation diff --git a/Mastodon/Coordinator/SceneCoordinator.swift b/Mastodon/Coordinator/SceneCoordinator.swift index c2608fe83..9770aa9a4 100644 --- a/Mastodon/Coordinator/SceneCoordinator.swift +++ b/Mastodon/Coordinator/SceneCoordinator.swift @@ -61,6 +61,9 @@ extension SceneCoordinator { case profile(viewModel: ProfileViewModel) case favorite(viewModel: FavoriteViewModel) + // setting + case settings(viewModel: SettingsViewModel) + // misc case safari(url: URL) case alertController(alertController: UIAlertController) @@ -68,7 +71,6 @@ extension SceneCoordinator { #if DEBUG case publicTimeline - case settings #endif var isOnboarding: Bool { @@ -246,6 +248,10 @@ private extension SceneCoordinator { let _viewController = FavoriteViewController() _viewController.viewModel = viewModel viewController = _viewController + case .settings(let viewModel): + let _viewController = SettingsViewController() + _viewController.viewModel = viewModel + viewController = _viewController case .safari(let url): guard let scheme = url.scheme?.lowercased(), scheme == "http" || scheme == "https" else { @@ -270,10 +276,6 @@ 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/FetchedResultsController/SettingFetchedResultController.swift b/Mastodon/Diffiable/FetchedResultsController/SettingFetchedResultController.swift new file mode 100644 index 000000000..52eafc6b6 --- /dev/null +++ b/Mastodon/Diffiable/FetchedResultsController/SettingFetchedResultController.swift @@ -0,0 +1,64 @@ +// +// SettingFetchedResultController.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-25. +// + +import os.log +import UIKit +import Combine +import CoreData +import CoreDataStack +import MastodonSDK + +final class SettingFetchedResultController: NSObject { + + var disposeBag = Set() + + let fetchedResultsController: NSFetchedResultsController + + // input + + // output + let settings = CurrentValueSubject<[Setting], Never>([]) + + init(managedObjectContext: NSManagedObjectContext, additionalPredicate: NSPredicate?) { + self.fetchedResultsController = { + let fetchRequest = Setting.sortedFetchRequest + fetchRequest.returnsObjectsAsFaults = false + if let additionalPredicate = additionalPredicate { + fetchRequest.predicate = additionalPredicate + } + fetchRequest.fetchBatchSize = 20 + let controller = NSFetchedResultsController( + fetchRequest: fetchRequest, + managedObjectContext: managedObjectContext, + sectionNameKeyPath: nil, + cacheName: nil + ) + + return controller + }() + super.init() + + fetchedResultsController.delegate = self + + do { + try self.fetchedResultsController.performFetch() + } catch { + assertionFailure(error.localizedDescription) + } + } + +} + +// MARK: - NSFetchedResultsControllerDelegate +extension SettingFetchedResultController: NSFetchedResultsControllerDelegate { + func controller(_ controller: NSFetchedResultsController, didChangeContentWith snapshot: NSDiffableDataSourceSnapshotReference) { + os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + + let objects = fetchedResultsController.fetchedObjects ?? [] + self.settings.value = objects + } +} diff --git a/Mastodon/Diffiable/Item/SettingsItem.swift b/Mastodon/Diffiable/Item/SettingsItem.swift new file mode 100644 index 000000000..8aabdc741 --- /dev/null +++ b/Mastodon/Diffiable/Item/SettingsItem.swift @@ -0,0 +1,67 @@ +// +// SettingsItem.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-25. +// + +import UIKit +import CoreData + +enum SettingsItem: Hashable { + case apperance(settingObjectID: NSManagedObjectID) + case notification(settingObjectID: NSManagedObjectID, switchMode: NotificationSwitchMode) + case boringZone(item: Link) + case spicyZone(item: Link) +} + +extension SettingsItem { + + enum AppearanceMode: String { + case automatic + case light + case dark + } + + enum NotificationSwitchMode: CaseIterable { + case favorite + case follow + case reblog + case mention + + var title: String { + switch self { + case .favorite: return L10n.Scene.Settings.Section.Notifications.favorites + case .follow: return L10n.Scene.Settings.Section.Notifications.follows + case .reblog: return L10n.Scene.Settings.Section.Notifications.boosts + case .mention: return L10n.Scene.Settings.Section.Notifications.mentions + } + } + } + + enum Link: CaseIterable { + case termsOfService + case privacyPolicy + case clearMediaCache + case signOut + + var title: String { + switch self { + case .termsOfService: return L10n.Scene.Settings.Section.Boringzone.terms + case .privacyPolicy: return L10n.Scene.Settings.Section.Boringzone.privacy + case .clearMediaCache: return L10n.Scene.Settings.Section.Spicyzone.clear + case .signOut: return L10n.Scene.Settings.Section.Spicyzone.signout + } + } + + var textColor: UIColor { + switch self { + case .termsOfService: return .systemBlue + case .privacyPolicy: return .systemBlue + case .clearMediaCache: return .systemRed + case .signOut: return .systemRed + } + } + } + +} diff --git a/Mastodon/Diffiable/Section/SettingsSection.swift b/Mastodon/Diffiable/Section/SettingsSection.swift new file mode 100644 index 000000000..7ec78a2ed --- /dev/null +++ b/Mastodon/Diffiable/Section/SettingsSection.swift @@ -0,0 +1,24 @@ +// +// SettingsSection.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-25. +// + +import Foundation + +enum SettingsSection: Hashable { + case apperance + case notifications + case boringZone + case spicyZone + + var title: String { + switch self { + case .apperance: return L10n.Scene.Settings.Section.Appearance.title + case .notifications: return L10n.Scene.Settings.Section.Notifications.title + case .boringZone: return L10n.Scene.Settings.Section.Boringzone.title + case .spicyZone: return L10n.Scene.Settings.Section.Spicyzone.title + } + } +} diff --git a/Mastodon/Extension/CoreDataStack/Setting.swift b/Mastodon/Extension/CoreDataStack/Setting.swift new file mode 100644 index 000000000..b995b80e3 --- /dev/null +++ b/Mastodon/Extension/CoreDataStack/Setting.swift @@ -0,0 +1,24 @@ +// +// Setting.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-25. +// + +import Foundation +import CoreDataStack +import MastodonSDK + +extension Setting { + + var appearance: SettingsItem.AppearanceMode { + return SettingsItem.AppearanceMode(rawValue: appearanceRaw) ?? .automatic + } + + var activeSubscription: Subscription? { + return (subscriptions ?? Set()) + .sorted(by: { $0.activedAt > $1.activedAt }) + .first + } + +} diff --git a/Mastodon/Extension/CoreDataStack/Subscription.swift b/Mastodon/Extension/CoreDataStack/Subscription.swift new file mode 100644 index 000000000..8253264a0 --- /dev/null +++ b/Mastodon/Extension/CoreDataStack/Subscription.swift @@ -0,0 +1,20 @@ +// +// Subscription.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-25. +// + +import Foundation +import CoreDataStack +import MastodonSDK + +typealias NotificationSubscription = Subscription + +extension Subscription { + + var policy: Mastodon.API.Subscriptions.Policy { + return Mastodon.API.Subscriptions.Policy(rawValue: policyRaw) ?? .all + } + +} diff --git a/Mastodon/Extension/CoreDataStack/SubscriptionAlerts.swift b/Mastodon/Extension/CoreDataStack/SubscriptionAlerts.swift new file mode 100644 index 000000000..edf2df0c9 --- /dev/null +++ b/Mastodon/Extension/CoreDataStack/SubscriptionAlerts.swift @@ -0,0 +1,28 @@ +// +// SubscriptionAlerts.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-25. +// + + +import Foundation +import CoreDataStack +import MastodonSDK + +extension SubscriptionAlerts.Property { + + init(policy: Mastodon.API.Subscriptions.Policy) { + switch policy { + case .all: + self.init(favourite: true, follow: true, followRequest: true, mention: true, poll: true, reblog: true) + case .follower: + self.init(favourite: true, follow: nil, followRequest: nil, mention: true, poll: true, reblog: true) + case .followed: + self.init(favourite: true, follow: true, followRequest: true, mention: true, poll: true, reblog: true) + case .none, ._other: + self.init(favourite: nil, follow: nil, followRequest: nil, mention: nil, poll: nil, reblog: nil) + } + } + +} diff --git a/Mastodon/Extension/MastodonSDK/Mastodon+API+Subscriptions+Policy.swift b/Mastodon/Extension/MastodonSDK/Mastodon+API+Subscriptions+Policy.swift new file mode 100644 index 000000000..24bbfdace --- /dev/null +++ b/Mastodon/Extension/MastodonSDK/Mastodon+API+Subscriptions+Policy.swift @@ -0,0 +1,20 @@ +// +// Mastodon+API+Subscriptions+Policy.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-26. +// + +import Foundation +import MastodonSDK + +extension Mastodon.API.Subscriptions.Policy { + var title: String { + switch self { + case .all: return L10n.Scene.Settings.Section.Notifications.Trigger.anyone + case .follower: return L10n.Scene.Settings.Section.Notifications.Trigger.follower + case .followed: return L10n.Scene.Settings.Section.Notifications.Trigger.follow + case .none, ._other: return L10n.Scene.Settings.Section.Notifications.Trigger.noone + } + } +} diff --git a/Mastodon/Extension/UserDefaults.swift b/Mastodon/Extension/UserDefaults.swift new file mode 100644 index 000000000..5e067bbe9 --- /dev/null +++ b/Mastodon/Extension/UserDefaults.swift @@ -0,0 +1,31 @@ +// +// UserDefaults.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-26. +// + +import Foundation + +extension UserDefaults { + static let shared = UserDefaults(suiteName: AppSharedName.groupID)! +} + +extension UserDefaults { + + subscript(key: String) -> T? { + get { + if let rawValue = value(forKey: key) as? T.RawValue { + return T(rawValue: rawValue) + } + return nil + } + set { set(newValue?.rawValue, forKey: key) } + } + + subscript(key: String) -> T? { + get { return value(forKey: key) as? T } + set { set(newValue, forKey: key) } + } + +} diff --git a/Mastodon/Preference/AppearancePreference.swift b/Mastodon/Preference/AppearancePreference.swift new file mode 100644 index 000000000..8f2818c39 --- /dev/null +++ b/Mastodon/Preference/AppearancePreference.swift @@ -0,0 +1,20 @@ +// +// AppearancePreference.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-26. +// + +import UIKit + +extension UserDefaults { + + @objc dynamic var customUserInterfaceStyle: UIUserInterfaceStyle { + get { + register(defaults: [#function: UIUserInterfaceStyle.unspecified.rawValue]) + return UIUserInterfaceStyle(rawValue: integer(forKey: #function)) ?? .unspecified + } + set { UserDefaults.shared[#function] = newValue.rawValue } + } + +} diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift index 8bbf9436e..63f76152f 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift @@ -328,7 +328,9 @@ extension HomeTimelineViewController { } @objc private func showSettings(_ sender: UIAction) { - coordinator.present(scene: .settings, from: self, transition: .modal(animated: true, completion: nil)) + guard let currentSetting = context.settingService.currentSetting.value else { return } + let settingsViewModel = SettingsViewModel(context: context, setting: currentSetting) + coordinator.present(scene: .settings(viewModel: settingsViewModel), from: self, transition: .modal(animated: true, completion: nil)) } } #endif diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift index 53909b2df..93e632f77 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift @@ -88,14 +88,8 @@ extension HomeTimelineViewController { // long press to trigger debug menu settingBarButtonItem.menu = debugMenu #else - // settingBarButtonItem.target = self - // settingBarButtonItem.action = #selector(HomeTimelineViewController.settingBarButtonItemPressed(_:)) - settingBarButtonItem.menu = UIMenu(title: "Settings", image: nil, identifier: nil, options: .displayInline, children: [ - UIAction(title: "Sign Out", image: UIImage(systemName: "escape"), attributes: .destructive) { [weak self] action in - guard let self = self else { return } - self.signOutAction(action) - } - ]) + settingBarButtonItem.target = self + settingBarButtonItem.action = #selector(HomeTimelineViewController.settingBarButtonItemPressed(_:)) #endif navigationItem.rightBarButtonItem = composeBarButtonItem @@ -220,7 +214,9 @@ extension HomeTimelineViewController { @objc private func settingBarButtonItemPressed(_ sender: UIBarButtonItem) { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) - + guard let setting = context.settingService.currentSetting.value else { return } + let settingsViewModel = SettingsViewModel(context: context, setting: setting) + coordinator.present(scene: .settings(viewModel: settingsViewModel), from: self, transition: .modal(animated: true, completion: nil)) } @objc private func composeBarButtonItemPressed(_ sender: UIBarButtonItem) { diff --git a/Mastodon/Scene/Profile/ProfileViewController.swift b/Mastodon/Scene/Profile/ProfileViewController.swift index 8fc915a0a..e4be1eb1f 100644 --- a/Mastodon/Scene/Profile/ProfileViewController.swift +++ b/Mastodon/Scene/Profile/ProfileViewController.swift @@ -517,7 +517,9 @@ extension ProfileViewController { @objc private func settingBarButtonItemPressed(_ sender: UIBarButtonItem) { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) - + guard let setting = context.settingService.currentSetting.value else { return } + let settingsViewModel = SettingsViewModel(context: context, setting: setting) + coordinator.present(scene: .settings(viewModel: settingsViewModel), from: self, transition: .modal(animated: true, completion: nil)) } @objc private func shareBarButtonItemPressed(_ sender: UIBarButtonItem) { diff --git a/Mastodon/Scene/Settings/SettingsViewController.swift b/Mastodon/Scene/Settings/SettingsViewController.swift index b9ded67d0..aeed943eb 100644 --- a/Mastodon/Scene/Settings/SettingsViewController.swift +++ b/Mastodon/Scene/Settings/SettingsViewController.swift @@ -11,11 +11,10 @@ import Combine import ActiveLabel import CoreData import CoreDataStack +import MastodonSDK import AlamofireImage import Kingfisher -// iTODO: when to ask permission to Use Notifications - class SettingsViewController: UIViewController, NeedsDependency { weak var context: AppContext! { willSet { precondition(!isViewLoaded) } } @@ -23,6 +22,7 @@ class SettingsViewController: UIViewController, NeedsDependency { var viewModel: SettingsViewModel! { willSet { precondition(!isViewLoaded) } } var disposeBag = Set() + var notificationPolicySubscription: AnyCancellable? var triggerMenu: UIMenu { let anyone = L10n.Scene.Settings.Section.Notifications.Trigger.anyone @@ -35,23 +35,23 @@ class SettingsViewController: UIViewController, NeedsDependency { options: .displayInline, children: [ UIAction(title: anyone, image: UIImage(systemName: "person.3"), attributes: []) { [weak self] action in - self?.updateTrigger(by: anyone) + self?.updateTrigger(policy: .all) }, UIAction(title: follower, image: UIImage(systemName: "person.crop.circle.badge.plus"), attributes: []) { [weak self] action in - self?.updateTrigger(by: follower) + self?.updateTrigger(policy: .follower) }, UIAction(title: follow, image: UIImage(systemName: "person.crop.circle.badge.checkmark"), attributes: []) { [weak self] action in - self?.updateTrigger(by: follow) + self?.updateTrigger(policy: .followed) }, UIAction(title: noOne, image: UIImage(systemName: "nosign"), attributes: []) { [weak self] action in - self?.updateTrigger(by: noOne) + self?.updateTrigger(policy: .none) }, ] ) return menu } - lazy var notifySectionHeader: UIView = { + private(set) lazy var notifySectionHeader: UIView = { let view = UIStackView() view.translatesAutoresizingMaskIntoConstraints = false view.isLayoutMarginsRelativeArrangement = true @@ -71,15 +71,12 @@ class SettingsViewController: UIViewController, NeedsDependency { return view }() - lazy var whoButton: UIButton = { + private(set) lazy var whoButton: UIButton = { let whoButton = UIButton(type: .roundedRect) whoButton.menu = triggerMenu whoButton.showsMenuAsPrimaryAction = true whoButton.setBackgroundColor(Asset.Colors.battleshipGrey.color, for: .normal) whoButton.setTitleColor(Asset.Colors.Label.primary.color, for: .normal) - 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 @@ -87,7 +84,7 @@ class SettingsViewController: UIViewController, NeedsDependency { return whoButton }() - lazy var tableView: UITableView = { + private(set) lazy var tableView: UITableView = { // init with a frame to fix a conflict ('UIView-Encapsulated-Layout-Width' UIStackView:0x7f8c2b6c0590.width == 0) let tableView = UITableView(frame: CGRect(x: 0, y: 0, width: 320, height: 320), style: .grouped) tableView.translatesAutoresizingMaskIntoConstraints = false @@ -95,13 +92,13 @@ class SettingsViewController: UIViewController, NeedsDependency { 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") + tableView.register(SettingsAppearanceTableViewCell.self, forCellReuseIdentifier: String(describing: SettingsAppearanceTableViewCell.self)) + tableView.register(SettingsToggleTableViewCell.self, forCellReuseIdentifier: String(describing: SettingsToggleTableViewCell.self)) + tableView.register(SettingsLinkTableViewCell.self, forCellReuseIdentifier: String(describing: SettingsLinkTableViewCell.self)) return tableView }() - lazy var footerView: UIView = { + lazy var tableFooterView: UIView = { // init with a frame to fix a conflict ('UIView-Encapsulated-Layout-Height' UIStackView:0x7ffe41e47da0.height == 0) let view = UIStackView(frame: CGRect(x: 0, y: 0, width: 320, height: 320)) view.isLayoutMarginsRelativeArrangement = true @@ -143,14 +140,30 @@ class SettingsViewController: UIViewController, NeedsDependency { // MAKR: - Private methods private func bindViewModel() { - let input = SettingsViewModel.Input() - _ = viewModel.transform(input: input) + self.whoButton.setTitle(viewModel.setting.value.activeSubscription?.policy.title, for: .normal) + viewModel.setting + .sink { [weak self] setting in + guard let self = self else { return } + self.notificationPolicySubscription = ManagedObjectObserver.observe(object: setting) + .sink { _ in + // do nothing + } receiveValue: { [weak self] change in + guard let self = self else { return } + guard case let .update(object) = change.changeType, + let setting = object as? Setting else { return } + if let activeSubscription = setting.activeSubscription { + self.whoButton.setTitle(activeSubscription.policy.title, for: .normal) + } else { + assertionFailure() + } + } + } + .store(in: &disposeBag) } private func setupView() { view.backgroundColor = Asset.Colors.Background.systemGroupedBackground.color setupNavigation() - setupTableView() view.addSubview(tableView) NSLayoutConstraint.activate([ @@ -159,6 +172,7 @@ class SettingsViewController: UIViewController, NeedsDependency { tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor), tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor), ]) + setupTableView() } private func setupNavigation() { @@ -177,35 +191,12 @@ class SettingsViewController: UIViewController, NeedsDependency { } 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 + viewModel.setupDiffableDataSource( + for: tableView, + settingsAppearanceTableViewCellDelegate: self, + settingsToggleCellDelegate: self + ) + tableView.tableFooterView = tableFooterView } func alertToSignout() { @@ -218,7 +209,7 @@ class SettingsViewController: UIViewController, NeedsDependency { 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() + self.signOut() } alertController.addAction(cancelAction) alertController.addAction(signOutAction) @@ -229,7 +220,7 @@ class SettingsViewController: UIViewController, NeedsDependency { ) } - func signout() { + func signOut() { guard let activeMastodonAuthenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { return } @@ -258,8 +249,11 @@ class SettingsViewController: UIViewController, NeedsDependency { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s:", ((#file as NSString).lastPathComponent), #line, #function) } - // Mark: - Actions - @objc func doneButtonDidClick() { +} + +// Mark: - Actions +extension SettingsViewController { + @objc private func doneButtonDidClick() { dismiss(animated: true, completion: nil) } } @@ -268,51 +262,39 @@ 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] + + let sectionIdentifier = sections[section] let header: SettingsSectionHeader - if section == 1 { + switch sectionIdentifier { + case .notifications: 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) - } - } else { + header.update(title: sectionIdentifier.title) + default: header = SettingsSectionHeader(frame: CGRect(x: 0, y: 0, width: 375, height: 66)) - header.update(title: sectionData.title) + header.update(title: sectionIdentifier.title) } - header.preservesSuperviewLayoutMargins = true - + return header } - + // remove the gap of table's footer func tableView(_ tableView: UITableView, viewForFooterInSection section: Int) -> UIView? { - return UIView() } - + // remove the gap of table's footer func tableView(_ tableView: UITableView, heightForFooterInSection section: Int) -> CGFloat { - return 0 + return CGFloat.leastNonzeroMagnitude } - + 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] - + guard let dataSource = viewModel.dataSource else { return } + let item = dataSource.itemIdentifier(for: indexPath) + switch item { case .boringZone: guard let url = viewModel.privacyURL else { break } @@ -331,7 +313,7 @@ extension SettingsViewController: UITableViewDelegate { 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() } @@ -347,82 +329,77 @@ extension SettingsViewController: UITableViewDelegate { // 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 } + func updateTrigger(policy: Mastodon.API.Subscriptions.Policy) { + let objectID = self.viewModel.setting.value.objectID + let managedObjectContext = context.backgroundManagedObjectContext - 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 + managedObjectContext.performChanges { + let setting = managedObjectContext.object(with: objectID) as! Setting + let (subscription, _) = APIService.CoreData.createOrFetchSubscription( + into: managedObjectContext, + setting: setting, + policy: policy + ) + let now = Date() + subscription.update(activedAt: now) + setting.didUpdate(at: now) } - } - - 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)) + .sink { _ in + // do nothing + } receiveValue: { _ in + // do nohting } + .store(in: &disposeBag) } } +// MARK: - SettingsAppearanceTableViewCellDelegate extension SettingsViewController: SettingsAppearanceTableViewCellDelegate { - func settingsAppearanceCell(_ view: SettingsAppearanceTableViewCell, didSelect: SettingsItem.AppearanceMode) { - guard let setting = self.viewModel.setting.value else { return } - + func settingsAppearanceCell(_ cell: SettingsAppearanceTableViewCell, didSelectAppearanceMode appearanceMode: SettingsItem.AppearanceMode) { + guard let dataSource = viewModel.dataSource else { return } + guard let indexPath = tableView.indexPath(for: cell) else { return } + let item = dataSource.itemIdentifier(for: indexPath) + guard case let .apperance(settingObjectID) = item else { return } + context.managedObjectContext.performChanges { - setting.update(appearance: didSelect.rawValue) + let setting = self.context.managedObjectContext.object(with: settingObjectID) as! Setting + setting.update(appearanceRaw: appearanceMode.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 + .sink { _ in + // do nothing }.store(in: &disposeBag) } } extension SettingsViewController: SettingsToggleCellDelegate { - func settingsToggleCell(_ cell: SettingsToggleTableViewCell, didChangeStatus: Bool) { - updateAlert(title: cell.data?.title, isOn: didChangeStatus) + func settingsToggleCell(_ cell: SettingsToggleTableViewCell, switchValueDidChange switch: UISwitch) { + guard let dataSource = viewModel.dataSource else { return } + guard let indexPath = tableView.indexPath(for: cell) else { return } + let item = dataSource.itemIdentifier(for: indexPath) + switch item { + case .notification(let settingObjectID, let switchMode): + let isOn = `switch`.isOn + let managedObjectContext = context.backgroundManagedObjectContext + managedObjectContext.performChanges { + let setting = managedObjectContext.object(with: settingObjectID) as! Setting + guard let subscription = setting.activeSubscription else { return } + let alert = subscription.alert + switch switchMode { + case .favorite: alert.update(favourite: isOn) + case .follow: alert.update(follow: isOn) + case .reblog: alert.update(reblog: isOn) + case .mention: alert.update(mention: isOn) + } + // trigger setting update + alert.subscription.setting?.didUpdate(at: Date()) + } + .sink { _ in + // do nothing + } + .store(in: &disposeBag) + default: + break + } } } @@ -436,43 +413,6 @@ extension SettingsViewController: ActiveLabelDelegate { } } -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 diff --git a/Mastodon/Scene/Settings/SettingsViewModel.swift b/Mastodon/Scene/Settings/SettingsViewModel.swift index f7ee4c71b..c168b5611 100644 --- a/Mastodon/Scene/Settings/SettingsViewModel.swift +++ b/Mastodon/Scene/Settings/SettingsViewModel.swift @@ -13,38 +13,21 @@ 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) } } +class SettingsViewModel { - var dataSource: UITableViewDiffableDataSource! var disposeBag = Set() + + let context: AppContext + + // input + let setting: CurrentValueSubject 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) + // output + var dataSource: UITableViewDiffableDataSource! /// create a subscription when: /// - does not has one /// - does not find subscription for selected trigger when change trigger @@ -54,22 +37,6 @@ class SettingsViewModel: NSObject, NeedsDependency { /// - 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 @@ -78,321 +45,151 @@ class SettingsViewModel: NSObject, NeedsDependency { 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) { + init(context: AppContext, setting: Setting) { self.context = context - self.coordinator = coordinator + self.setting = CurrentValueSubject(setting) - 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( - data: Mastodon.API.Subscriptions.QueryData( - alerts: Mastodon.API.Subscriptions.QueryData.Alerts( - 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) - } + self.setting + .sink(receiveValue: { [weak self] setting in + guard let self = self else { return } + self.processDataSource(setting) + }) .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 { +extension SettingsViewModel { - func controllerWillChangeContent(_ controller: NSFetchedResultsController) { - os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) - } + // MARK: - Private methods + private func processDataSource(_ setting: Setting) { + guard let dataSource = self.dataSource else { return } + var snapshot = NSDiffableDataSourceSnapshot() - func controllerDidChangeContent(_ controller: NSFetchedResultsController) { - guard controller === fetchResultsController else { - return + // appearance + let appearanceItems = [SettingsItem.apperance(settingObjectID: setting.objectID)] + snapshot.appendSections([.apperance]) + snapshot.appendItems(appearanceItems, toSection: .apperance) + + let notificationItems = SettingsItem.NotificationSwitchMode.allCases.map { mode in + SettingsItem.notification(settingObjectID: setting.objectID, switchMode: mode) + } + snapshot.appendSections([.notifications]) + snapshot.appendItems(notificationItems, toSection: .notifications) + + // boring zone + let boringZoneSettingsItems: [SettingsItem] = { + let links: [SettingsItem.Link] = [ + .termsOfService, + .privacyPolicy + ] + let items = links.map { SettingsItem.boringZone(item: $0) } + return items + }() + snapshot.appendSections([.boringZone]) + snapshot.appendItems(boringZoneSettingsItems, toSection: .boringZone) + + let spicyZoneSettingsItems: [SettingsItem] = { + let links: [SettingsItem.Link] = [ + .clearMediaCache, + .signOut + ] + let items = links.map { SettingsItem.spicyZone(item: $0) } + return items + }() + snapshot.appendSections([.spicyZone]) + snapshot.appendItems(spicyZoneSettingsItems, toSection: .spicyZone) + + dataSource.apply(snapshot, animatingDifferences: false) + } + +} + +extension SettingsViewModel { + func setupDiffableDataSource( + for tableView: UITableView, + settingsAppearanceTableViewCellDelegate: SettingsAppearanceTableViewCellDelegate, + settingsToggleCellDelegate: SettingsToggleCellDelegate + ) { + dataSource = UITableViewDiffableDataSource(tableView: tableView) { [ + weak self, + weak settingsAppearanceTableViewCellDelegate, + weak settingsToggleCellDelegate + ] tableView, indexPath, item -> UITableViewCell? in + guard let self = self else { return nil } + + switch item { + case .apperance(let objectID): + let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: SettingsAppearanceTableViewCell.self), for: indexPath) as! SettingsAppearanceTableViewCell + self.context.managedObjectContext.performAndWait { + let setting = self.context.managedObjectContext.object(with: objectID) as! Setting + cell.update(with: setting.appearance) + ManagedObjectObserver.observe(object: setting) + .sink(receiveCompletion: { _ in + // do nothing + }, receiveValue: { [weak cell] change in + guard let cell = cell else { return } + guard case .update(let object) = change.changeType, + let setting = object as? Setting else { return } + cell.update(with: setting.appearance) + }) + .store(in: &cell.disposeBag) + } + cell.delegate = settingsAppearanceTableViewCellDelegate + return cell + case .notification(let objectID, let switchMode): + let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: SettingsToggleTableViewCell.self), for: indexPath) as! SettingsToggleTableViewCell + self.context.managedObjectContext.performAndWait { + let setting = self.context.managedObjectContext.object(with: objectID) as! Setting + if let subscription = setting.activeSubscription { + SettingsViewModel.configureSettingToggle(cell: cell, switchMode: switchMode, subscription: subscription) + } + ManagedObjectObserver.observe(object: setting) + .sink(receiveCompletion: { _ in + // do nothing + }, receiveValue: { [weak cell] change in + guard let cell = cell else { return } + guard case .update(let object) = change.changeType, + let setting = object as? Setting else { return } + guard let subscription = setting.activeSubscription else { return } + SettingsViewModel.configureSettingToggle(cell: cell, switchMode: switchMode, subscription: subscription) + }) + .store(in: &cell.disposeBag) + } + cell.delegate = settingsToggleCellDelegate + return cell + case .boringZone(let item), .spicyZone(let item): + let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: SettingsLinkTableViewCell.self), for: indexPath) as! SettingsLinkTableViewCell + cell.update(with: item) + return cell + } } - setting.value = fetchResultsController.fetchedObjects?.first + processDataSource(self.setting.value) } - } -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]) +extension SettingsViewModel { - var title: String { - switch self { - case .apperance(let title, _), - .notifications(let title, _), - .boringZone(let title, _), - .spicyZone(let title, _): - return title + static func configureSettingToggle( + cell: SettingsToggleTableViewCell, + switchMode: SettingsItem.NotificationSwitchMode, + subscription: NotificationSubscription + ) { + cell.textLabel?.text = switchMode.title + + let enabled: Bool? + switch switchMode { + case .favorite: enabled = subscription.alert.favourite + case .follow: enabled = subscription.alert.follow + case .reblog: enabled = subscription.alert.reblog + case .mention: enabled = subscription.alert.mention } + cell.update(enabled: enabled) } -} - -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 index a477661ee..44a7e7574 100644 --- a/Mastodon/Scene/Settings/View/Cell/SettingsAppearanceTableViewCell.swift +++ b/Mastodon/Scene/Settings/View/Cell/SettingsAppearanceTableViewCell.swift @@ -6,9 +6,10 @@ // import UIKit +import Combine protocol SettingsAppearanceTableViewCellDelegate: class { - func settingsAppearanceCell(_ view: SettingsAppearanceTableViewCell, didSelect: SettingsItem.AppearanceMode) + func settingsAppearanceCell(_ cell: SettingsAppearanceTableViewCell, didSelectAppearanceMode appearanceMode: SettingsItem.AppearanceMode) } class AppearanceView: UIView { @@ -85,6 +86,9 @@ class AppearanceView: UIView { } class SettingsAppearanceTableViewCell: UITableViewCell { + + var disposeBag = Set() + weak var delegate: SettingsAppearanceTableViewCellDelegate? var appearance: SettingsItem.AppearanceMode = .automatic @@ -123,6 +127,12 @@ class SettingsAppearanceTableViewCell: UITableViewCell { tapGestureRecognizer.addTarget(self, action: #selector(appearanceDidTap(sender:))) return tapGestureRecognizer }() + + override func prepareForReuse() { + super.prepareForReuse() + + disposeBag.removeAll() + } // MARK: - Methods override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { @@ -145,9 +155,8 @@ class SettingsAppearanceTableViewCell: UITableViewCell { } } - func update(with data: SettingsItem.AppearanceMode, delegate: SettingsAppearanceTableViewCellDelegate?) { + func update(with data: SettingsItem.AppearanceMode) { appearance = data - self.delegate = delegate automatic.selected = false light.selected = false @@ -200,6 +209,6 @@ class SettingsAppearanceTableViewCell: UITableViewCell { } guard let delegate = self.delegate else { return } - delegate.settingsAppearanceCell(self, didSelect: appearance) + delegate.settingsAppearanceCell(self, didSelectAppearanceMode: appearance) } } diff --git a/Mastodon/Scene/Settings/View/Cell/SettingsLinkTableViewCell.swift b/Mastodon/Scene/Settings/View/Cell/SettingsLinkTableViewCell.swift index b5d0306d4..7fdbf7f02 100644 --- a/Mastodon/Scene/Settings/View/Cell/SettingsLinkTableViewCell.swift +++ b/Mastodon/Scene/Settings/View/Cell/SettingsLinkTableViewCell.swift @@ -8,6 +8,7 @@ import UIKit class SettingsLinkTableViewCell: UITableViewCell { + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { super.init(style: style, reuseIdentifier: reuseIdentifier) @@ -22,10 +23,13 @@ class SettingsLinkTableViewCell: UITableViewCell { 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 + +} + +// MARK: - Methods +extension SettingsLinkTableViewCell { + func update(with link: SettingsItem.Link) { + textLabel?.text = link.title + textLabel?.textColor = link.textColor } } diff --git a/Mastodon/Scene/Settings/View/Cell/SettingsToggleTableViewCell.swift b/Mastodon/Scene/Settings/View/Cell/SettingsToggleTableViewCell.swift index b35b2b50f..b4a62635b 100644 --- a/Mastodon/Scene/Settings/View/Cell/SettingsToggleTableViewCell.swift +++ b/Mastodon/Scene/Settings/View/Cell/SettingsToggleTableViewCell.swift @@ -6,18 +6,21 @@ // import UIKit +import Combine protocol SettingsToggleCellDelegate: class { - func settingsToggleCell(_ cell: SettingsToggleTableViewCell, didChangeStatus: Bool) + func settingsToggleCell(_ cell: SettingsToggleTableViewCell, switchValueDidChange switch: UISwitch) } class SettingsToggleTableViewCell: UITableViewCell { - lazy var switchButton: UISwitch = { + + var disposeBag = Set() + + private(set) lazy var switchButton: UISwitch = { let view = UISwitch(frame:.zero) return view }() - var data: SettingsItem.NotificationSwitch? weak var delegate: SettingsToggleCellDelegate? // MARK: - Methods @@ -27,21 +30,8 @@ class SettingsToggleTableViewCell: UITableViewCell { } 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) + super.init(coder: coder) + setupUI() } // MARK: Private methods @@ -49,15 +39,27 @@ class SettingsToggleTableViewCell: UITableViewCell { selectionStyle = .none accessoryView = switchButton - switchButton.addTarget(self, action: #selector(valueDidChange(sender:)), for: .valueChanged) + switchButton.addTarget(self, action: #selector(switchValueDidChange(sender:)), for: .valueChanged) + } + +} + +// MARK: - Actions +extension SettingsToggleTableViewCell { + + @objc private func switchValueDidChange(sender: UISwitch) { + guard let delegate = delegate else { return } + delegate.settingsToggleCell(self, switchValueDidChange: sender) + } + +} + +extension SettingsToggleTableViewCell { + + func update(enabled: Bool?) { + switchButton.isEnabled = enabled != nil + textLabel?.textColor = enabled != nil ? Asset.Colors.Label.primary.color : Asset.Colors.Label.secondary.color + switchButton.isOn = enabled ?? false } - 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/Share/View/Content/TimelineHeaderView.swift b/Mastodon/Scene/Share/View/Content/TimelineHeaderView.swift index b5e4c5bde..f095f6f44 100644 --- a/Mastodon/Scene/Share/View/Content/TimelineHeaderView.swift +++ b/Mastodon/Scene/Share/View/Content/TimelineHeaderView.swift @@ -5,6 +5,8 @@ // Created by MainasuK Cirno on 2021-4-6. // +import UIKit + final class TimelineHeaderView: UIView { let iconImageView: UIImageView = { diff --git a/Mastodon/Service/APIService/APIService+Subscriptions.swift b/Mastodon/Service/APIService/APIService+Subscriptions.swift index 5260452ce..3e2d2a0aa 100644 --- a/Mastodon/Service/APIService/APIService+Subscriptions.swift +++ b/Mastodon/Service/APIService/APIService+Subscriptions.swift @@ -5,6 +5,7 @@ // Created by ihugo on 2021/4/9. // +import os.log import Combine import CoreData import CoreDataStack @@ -13,66 +14,14 @@ import MastodonSDK extension APIService { - func subscription( + func createSubscription( + subscriptionObjectID: NSManagedObjectID, + query: Mastodon.API.Subscriptions.CreateSubscriptionQuery, mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox ) -> AnyPublisher, Error> { let authorization = mastodonAuthenticationBox.userAuthorization let domain = mastodonAuthenticationBox.domain - let userID = mastodonAuthenticationBox.userID - 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 createSubscription( - mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox, - query: Mastodon.API.Subscriptions.CreateSubscriptionQuery, - triggerBy: String, - userID: String - ) -> AnyPublisher, Error> { - let authorization = mastodonAuthenticationBox.userAuthorization - let domain = mastodonAuthenticationBox.domain - let userID = mastodonAuthenticationBox.userID - - let setting = self.createSettingIfNeed( - domain: domain, - userId: userID, - triggerBy: triggerBy - ) return Mastodon.API.Subscriptions.createSubscription( session: session, domain: domain, @@ -80,14 +29,18 @@ extension APIService { 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 - ) + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: create subscription successful ", ((#file as NSString).lastPathComponent), #line, #function) + + let managedObjectContext = self.backgroundManagedObjectContext + return managedObjectContext.performChanges { + guard let subscription = managedObjectContext.object(with: subscriptionObjectID) as? NotificationSubscription else { + assertionFailure() + return + } + subscription.endpoint = response.value.endpoint + subscription.serverKey = response.value.serverKey + subscription.userToken = authorization.accessToken + subscription.didUpdate(at: response.networkDate) } .setFailureType(to: Error.self) .map { _ in return response } @@ -95,72 +48,22 @@ extension APIService { }.eraseToAnyPublisher() } - func updateSubscription( - domain: String, - mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox, - query: Mastodon.API.Subscriptions.UpdateSubscriptionQuery, - triggerBy: String, - userID: String - ) -> AnyPublisher, Error> { + func cancelSubscription( + mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox + ) -> AnyPublisher, Error> { let authorization = mastodonAuthenticationBox.userAuthorization - - let setting = self.createSettingIfNeed(domain: domain, - userId: userID, - triggerBy: triggerBy) - - return Mastodon.API.Subscriptions.updateSubscription( + let domain = mastodonAuthenticationBox.domain + + return Mastodon.API.Subscriptions.removeSubscription( session: session, domain: domain, - authorization: authorization, - query: query + 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 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 + .handleEvents(receiveOutput: { _ in + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: cancel subscription successful", ((#file as NSString).lastPathComponent), #line, #function) + }) + .eraseToAnyPublisher() } + } diff --git a/Mastodon/Service/APIService/CoreData/APIService+CoreData+Setting.swift b/Mastodon/Service/APIService/CoreData/APIService+CoreData+Setting.swift new file mode 100644 index 000000000..fb6879da9 --- /dev/null +++ b/Mastodon/Service/APIService/CoreData/APIService+CoreData+Setting.swift @@ -0,0 +1,61 @@ +// +// APIService+CoreData+Setting.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-25. +// + + +import os.log +import Foundation +import CoreData +import CoreDataStack +import MastodonSDK + +extension APIService.CoreData { + + static func createOrMergeSetting( + into managedObjectContext: NSManagedObjectContext, + property: Setting.Property + ) -> (Subscription: Setting, isCreated: Bool) { + let oldSetting: Setting? = { + let request = Setting.sortedFetchRequest + request.predicate = Setting.predicate(domain: property.domain, userID: property.userID) + request.fetchLimit = 1 + request.returnsObjectsAsFaults = false + return managedObjectContext.safeFetch(request).first + }() + + if let oldSetting = oldSetting { + return (oldSetting, false) + } else { + let setting = Setting.insert( + into: managedObjectContext, + property: property + ) + let policies: [Mastodon.API.Subscriptions.Policy] = [ + .all, + .followed, + .follower, + .none + ] + let now = Date() + policies.forEach { policy in + let (subscription, _) = createOrFetchSubscription( + into: managedObjectContext, + setting: setting, + policy: policy + ) + if policy == .all { + subscription.update(activedAt: now) + } else { + subscription.update(activedAt: now.addingTimeInterval(-10)) + } + } + + + return (setting, true) + } + } + +} diff --git a/Mastodon/Service/APIService/CoreData/APIService+CoreData+Subscriptions.swift b/Mastodon/Service/APIService/CoreData/APIService+CoreData+Subscriptions.swift index f5a4022ea..5e42a8abe 100644 --- a/Mastodon/Service/APIService/CoreData/APIService+CoreData+Subscriptions.swift +++ b/Mastodon/Service/APIService/CoreData/APIService+CoreData+Subscriptions.swift @@ -13,96 +13,49 @@ import MastodonSDK extension APIService.CoreData { - static func createOrMergeSetting( + static func createOrFetchSubscription( 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) { + setting: Setting, + policy: Mastodon.API.Subscriptions.Policy + ) -> (subscription: Subscription, isCreated: Bool) { let oldSubscription: Subscription? = { let request = Subscription.sortedFetchRequest - request.predicate = Subscription.predicate(type: triggerBy) + request.predicate = Subscription.predicate(policyRaw: policy.rawValue) request.fetchLimit = 1 request.returnsObjectsAsFaults = false - do { - return try managedObjectContext.fetch(request).first - } catch { - assertionFailure(error.localizedDescription) - return nil - } + return managedObjectContext.safeFetch(request).first }() - 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 subscriptionProperty = Subscription.Property(policyRaw: policy.rawValue) let subscription = Subscription.insert( into: managedObjectContext, - property: property + property: subscriptionProperty, + setting: setting ) + let alertProperty = SubscriptionAlerts.Property(policy: policy) subscription.alert = SubscriptionAlerts.insert( into: managedObjectContext, - property: alert) - setting.mutableSetValue(forKey: #keyPath(Setting.subscription)).add(subscription) + property: alertProperty, + subscription: subscription + ) + return (subscription, true) } } + +} + +extension APIService.CoreData { + + static func merge( + subscription: Subscription, + property: Subscription.Property, + networkDate: Date + ) { + // TODO: + } + } diff --git a/Mastodon/Service/AuthenticationService.swift b/Mastodon/Service/AuthenticationService.swift index 89ce7a182..6b35486d6 100644 --- a/Mastodon/Service/AuthenticationService.swift +++ b/Mastodon/Service/AuthenticationService.swift @@ -15,6 +15,7 @@ import MastodonSDK final class AuthenticationService: NSObject { var disposeBag = Set() + // input weak var apiService: APIService? let managedObjectContext: NSManagedObjectContext // read-only @@ -23,6 +24,7 @@ final class AuthenticationService: NSObject { // output let mastodonAuthentications = CurrentValueSubject<[MastodonAuthentication], Never>([]) + let mastodonAuthenticationBoxes = CurrentValueSubject<[AuthenticationService.MastodonAuthenticationBox], Never>([]) let activeMastodonAuthentication = CurrentValueSubject(nil) let activeMastodonAuthenticationBox = CurrentValueSubject(nil) @@ -58,16 +60,24 @@ final class AuthenticationService: NSObject { .assign(to: \.value, on: activeMastodonAuthentication) .store(in: &disposeBag) - activeMastodonAuthentication - .map { authentication -> AuthenticationService.MastodonAuthenticationBox? in - guard let authentication = authentication else { return nil } - return AuthenticationService.MastodonAuthenticationBox( - domain: authentication.domain, - userID: authentication.userID, - appAuthorization: Mastodon.API.OAuth.Authorization(accessToken: authentication.appAccessToken), - userAuthorization: Mastodon.API.OAuth.Authorization(accessToken: authentication.userAccessToken) - ) + mastodonAuthentications + .map { authentications -> [AuthenticationService.MastodonAuthenticationBox] in + return authentications + .sorted(by: { $0.activedAt > $1.activedAt }) + .compactMap { authentication -> AuthenticationService.MastodonAuthenticationBox? in + return AuthenticationService.MastodonAuthenticationBox( + domain: authentication.domain, + userID: authentication.userID, + appAuthorization: Mastodon.API.OAuth.Authorization(accessToken: authentication.appAccessToken), + userAuthorization: Mastodon.API.OAuth.Authorization(accessToken: authentication.userAccessToken) + ) + } } + .assign(to: \.value, on: mastodonAuthenticationBoxes) + .store(in: &disposeBag) + + mastodonAuthenticationBoxes + .map { $0.first } .assign(to: \.value, on: activeMastodonAuthenticationBox) .store(in: &disposeBag) @@ -114,16 +124,37 @@ extension AuthenticationService { func signOutMastodonUser(domain: String, userID: MastodonUser.ID) -> AnyPublisher, Never> { var isSignOut = false - return backgroundManagedObjectContext.performChanges { + var _mastodonAutenticationBox: MastodonAuthenticationBox? + let managedObjectContext = backgroundManagedObjectContext + return managedObjectContext.performChanges { let request = MastodonAuthentication.sortedFetchRequest request.predicate = MastodonAuthentication.predicate(domain: domain, userID: userID) request.fetchLimit = 1 - guard let mastodonAutentication = try? self.backgroundManagedObjectContext.fetch(request).first else { + guard let mastodonAutentication = try? managedObjectContext.fetch(request).first else { return } - self.backgroundManagedObjectContext.delete(mastodonAutentication) + _mastodonAutenticationBox = AuthenticationService.MastodonAuthenticationBox( + domain: mastodonAutentication.domain, + userID: mastodonAutentication.userID, + appAuthorization: Mastodon.API.OAuth.Authorization(accessToken: mastodonAutentication.appAccessToken), + userAuthorization: Mastodon.API.OAuth.Authorization(accessToken: mastodonAutentication.userAccessToken) + ) + managedObjectContext.delete(mastodonAutentication) isSignOut = true } + .flatMap { result -> AnyPublisher, Never> in + guard let apiService = self.apiService, + let mastodonAuthenticationBox = _mastodonAutenticationBox else { + return Just(result).eraseToAnyPublisher() + } + + return apiService.cancelSubscription( + mastodonAuthenticationBox: mastodonAuthenticationBox + ) + .map { _ in result } + .catch { _ in Just(result).eraseToAnyPublisher() } + .eraseToAnyPublisher() + } .map { result in return result.map { isSignOut } } diff --git a/Mastodon/Service/NotificationService.swift b/Mastodon/Service/NotificationService.swift index 098fdff62..526c35883 100644 --- a/Mastodon/Service/NotificationService.swift +++ b/Mastodon/Service/NotificationService.swift @@ -19,69 +19,28 @@ final class NotificationService { let workingQueue = DispatchQueue(label: "org.joinmastodon.Mastodon.NotificationService.working-queue") // input - weak var apiService: APIService? weak var authenticationService: AuthenticationService? let isNotificationPermissionGranted = CurrentValueSubject(false) let deviceToken = CurrentValueSubject(nil) - let mastodonAuthenticationBoxes = CurrentValueSubject<[AuthenticationService.MastodonAuthenticationBox], Never>([]) - + // output /// [Token: UserID] - let notificationSubscriptionDict: [String: NotificationSubscription] = [:] + let notificationSubscriptionDict: [String: NotificationViewModel] = [:] init( - apiService: APIService, authenticationService: AuthenticationService ) { - self.apiService = apiService self.authenticationService = authenticationService - authenticationService.mastodonAuthentications - .handleEvents(receiveOutput: { [weak self] mastodonAuthentications in - guard let self = self else { return } - - // request permission when sign-in - guard !mastodonAuthentications.isEmpty else { return } - self.requestNotificationPermission() - }) - .map { authentications -> [AuthenticationService.MastodonAuthenticationBox] in - return authentications.compactMap { authentication -> AuthenticationService.MastodonAuthenticationBox? in - return AuthenticationService.MastodonAuthenticationBox( - domain: authentication.domain, - userID: authentication.userID, - appAuthorization: Mastodon.API.OAuth.Authorization(accessToken: authentication.appAccessToken), - userAuthorization: Mastodon.API.OAuth.Authorization(accessToken: authentication.userAccessToken) - ) - } - } - .assign(to: \.value, on: mastodonAuthenticationBoxes) - .store(in: &disposeBag) - deviceToken .receive(on: DispatchQueue.main) .sink { [weak self] deviceToken in - guard let self = self else { return } + guard let _ = self else { return } guard let deviceToken = deviceToken else { return } let token = [UInt8](deviceToken).toHexString() os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: deviceToken: %s", ((#file as NSString).lastPathComponent), #line, #function, token) } .store(in: &disposeBag) - - Publishers.CombineLatest3( - isNotificationPermissionGranted, - deviceToken, - mastodonAuthenticationBoxes - ) - .sink { [weak self] isNotificationPermissionGranted, deviceToken, mastodonAuthenticationBoxes in - guard let self = self else { return } - guard isNotificationPermissionGranted else { return } - guard let deviceToken = deviceToken else { return } - self.registerNotificationSubscriptions( - deviceToken: [UInt8](deviceToken).toHexString(), - mastodonAuthenticationBoxes: mastodonAuthenticationBoxes - ) - } - .store(in: &disposeBag) } } @@ -102,35 +61,14 @@ extension NotificationService { // Enable or disable features based on the authorization. } } +} + +extension NotificationService { - private func registerNotificationSubscriptions( - deviceToken: String, - mastodonAuthenticationBoxes: [AuthenticationService.MastodonAuthenticationBox] - ) { - for mastodonAuthenticationBox in mastodonAuthenticationBoxes { - guard let notificationSubscription = dequeueNotificationSubscription(mastodonAuthenticationBox: mastodonAuthenticationBox) else { continue } - let token = NotificationSubscription.SubscribeToken( - deviceToken: deviceToken, - authenticationBox: mastodonAuthenticationBox - ) - guard let subscription = subscribe( - notificationSubscription: notificationSubscription, - token: token - ) else { continue } - - subscription - .sink { completion in - // handle error - } receiveValue: { response in - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: did create subscription %s with userToken %s", ((#file as NSString).lastPathComponent), #line, #function, response.value.id, mastodonAuthenticationBox.userAuthorization.accessToken) - // do nothing - } - .store(in: &self.disposeBag) - } - } - - private func dequeueNotificationSubscription(mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox) -> NotificationSubscription? { - var _notificationSubscription: NotificationSubscription? + func dequeueNotificationViewModel( + mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox + ) -> NotificationViewModel? { + var _notificationSubscription: NotificationViewModel? workingQueue.sync { let domain = mastodonAuthenticationBox.domain let userID = mastodonAuthenticationBox.userID @@ -139,56 +77,13 @@ extension NotificationService { if let notificationSubscription = notificationSubscriptionDict[key] { _notificationSubscription = notificationSubscription } else { - let notificationSubscription = NotificationSubscription(domain: domain, userID: userID) + let notificationSubscription = NotificationViewModel(domain: domain, userID: userID) _notificationSubscription = notificationSubscription } } return _notificationSubscription } - private func subscribe( - notificationSubscription: NotificationSubscription, - token: NotificationSubscription.SubscribeToken - ) -> AnyPublisher, Error>? { - guard let apiService = self.apiService else { return nil } - - if let oldToken = notificationSubscription.token { - guard oldToken != token else { return nil } - } - notificationSubscription.token = token - - let appSecret = AppSecret.default - let endpoint = appSecret.notificationEndpoint + "/" + token.deviceToken - let p256dh = appSecret.uncompressionNotificationPublicKeyData - let auth = appSecret.notificationAuth - - let query = Mastodon.API.Subscriptions.CreateSubscriptionQuery( - subscription: Mastodon.API.Subscriptions.QuerySubscription( - endpoint: endpoint, - keys: Mastodon.API.Subscriptions.QuerySubscription.Keys( - p256dh: p256dh, - auth: auth - ) - ), - data: Mastodon.API.Subscriptions.QueryData( - alerts: Mastodon.API.Subscriptions.QueryData.Alerts( - favourite: true, - follow: true, - reblog: true, - mention: true, - poll: true - ) - ) - ) - - return apiService.createSubscription( - mastodonAuthenticationBox: token.authenticationBox, - query: query, - triggerBy: "anyone", - userID: token.authenticationBox.userID - ) - } - static func createRandomAuthBytes() -> Data { let byteCount = 16 var bytes = Data(count: byteCount) @@ -198,7 +93,7 @@ extension NotificationService { } extension NotificationService { - final class NotificationSubscription { + final class NotificationViewModel { var disposeBag = Set() @@ -206,36 +101,39 @@ extension NotificationService { let domain: String let userID: Mastodon.Entity.Account.ID - var token: SubscribeToken? + // output init(domain: String, userID: Mastodon.Entity.Account.ID) { self.domain = domain self.userID = userID } - - struct SubscribeToken: Equatable { - - let deviceToken: String - let authenticationBox: AuthenticationService.MastodonAuthenticationBox - // TODO: set other parameter - - init( - deviceToken: String, - authenticationBox: AuthenticationService.MastodonAuthenticationBox - ) { - self.deviceToken = deviceToken - self.authenticationBox = authenticationBox - } - - static func == ( - lhs: NotificationService.NotificationSubscription.SubscribeToken, - rhs: NotificationService.NotificationSubscription.SubscribeToken - ) -> Bool { - return lhs.deviceToken == rhs.deviceToken && - lhs.authenticationBox.domain == rhs.authenticationBox.domain && - lhs.authenticationBox.userID == rhs.authenticationBox.userID && - lhs.authenticationBox.userAuthorization.accessToken == rhs.authenticationBox.userAuthorization.accessToken - } - } + } +} + +extension NotificationService.NotificationViewModel { + func createSubscribeQuery( + deviceToken: Data, + queryData: Mastodon.API.Subscriptions.QueryData, + mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox + ) -> Mastodon.API.Subscriptions.CreateSubscriptionQuery { + let deviceToken = [UInt8](deviceToken).toHexString() + + let appSecret = AppSecret.default + let endpoint = appSecret.notificationEndpoint + "/" + deviceToken + let p256dh = appSecret.uncompressionNotificationPublicKeyData + let auth = appSecret.notificationAuth + + let query = Mastodon.API.Subscriptions.CreateSubscriptionQuery( + subscription: Mastodon.API.Subscriptions.QuerySubscription( + endpoint: endpoint, + keys: Mastodon.API.Subscriptions.QuerySubscription.Keys( + p256dh: p256dh, + auth: auth + ) + ), + data: queryData + ) + + return query } } diff --git a/Mastodon/Service/SettingService.swift b/Mastodon/Service/SettingService.swift new file mode 100644 index 000000000..e097b4dc4 --- /dev/null +++ b/Mastodon/Service/SettingService.swift @@ -0,0 +1,162 @@ +// +// SettingService.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-25. +// + +import UIKit +import Combine +import CoreDataStack +import MastodonSDK + +final class SettingService { + + var disposeBag = Set() + + private var currentSettingUpdateSubscription: AnyCancellable? + + // input + weak var apiService: APIService? + weak var authenticationService: AuthenticationService? + weak var notificationService: NotificationService? + + // output + let settingFetchedResultController: SettingFetchedResultController + let currentSetting = CurrentValueSubject(nil) + + init( + apiService: APIService, + authenticationService: AuthenticationService, + notificationService: NotificationService + ) { + self.apiService = apiService + self.authenticationService = authenticationService + self.notificationService = notificationService + self.settingFetchedResultController = SettingFetchedResultController( + managedObjectContext: authenticationService.managedObjectContext, + additionalPredicate: nil + ) + + // create setting (if non-exist) for authenticated users + authenticationService.mastodonAuthenticationBoxes + .compactMap { [weak self] mastodonAuthenticationBoxes -> AnyPublisher<[AuthenticationService.MastodonAuthenticationBox], Never>? in + guard let self = self else { return nil } + guard let authenticationService = self.authenticationService else { return nil } + guard let activeMastodonAuthenticationBox = mastodonAuthenticationBoxes.first else { return nil } + + let domain = activeMastodonAuthenticationBox.domain + let userID = activeMastodonAuthenticationBox.userID + return authenticationService.backgroundManagedObjectContext.performChanges { + _ = APIService.CoreData.createOrMergeSetting( + into: authenticationService.backgroundManagedObjectContext, + property: Setting.Property( + domain: domain, + userID: userID, + appearanceRaw: SettingsItem.AppearanceMode.automatic.rawValue + ) + ) + } + .map { _ in mastodonAuthenticationBoxes } + .eraseToAnyPublisher() + } + .sink { _ in + // do nothing + } + .store(in: &disposeBag) + + // bind current setting + Publishers.CombineLatest( + authenticationService.activeMastodonAuthenticationBox, + settingFetchedResultController.settings + ) + .sink { [weak self] activeMastodonAuthenticationBox, settings in + guard let self = self else { return } + guard let activeMastodonAuthenticationBox = activeMastodonAuthenticationBox else { return } + let currentSetting = settings.first(where: { setting in + return setting.domain == activeMastodonAuthenticationBox.domain && + setting.userID == activeMastodonAuthenticationBox.userID + }) + self.currentSetting.value = currentSetting + } + .store(in: &disposeBag) + + // observe current setting + currentSetting + .receive(on: DispatchQueue.main) + .sink { [weak self] setting in + guard let self = self else { return } + guard let setting = setting else { + self.currentSettingUpdateSubscription = nil + return + } + + self.currentSettingUpdateSubscription = ManagedObjectObserver.observe(object: setting) + .sink(receiveCompletion: { _ in + // do nothing + }, receiveValue: { change in + guard case .update(let object) = change.changeType, + let setting = object as? Setting else { return } + + // observe apparance mode + switch setting.appearance { + case .automatic: UserDefaults.shared.customUserInterfaceStyle = .unspecified + case .light: UserDefaults.shared.customUserInterfaceStyle = .light + case .dark: UserDefaults.shared.customUserInterfaceStyle = .dark + } + }) + } + .store(in: &disposeBag) + + Publishers.CombineLatest( + notificationService.deviceToken, + currentSetting + ) + .compactMap { [weak self] deviceToken, setting -> AnyPublisher, Error>? in + guard let self = self else { return nil } + guard let apiService = self.apiService else { return nil } + guard let deviceToken = deviceToken else { return nil } + guard let authenticationBox = self.authenticationService?.activeMastodonAuthenticationBox.value else { return nil } + guard let setting = setting else { return nil } + guard let subscription = setting.activeSubscription else { return nil } + + guard setting.domain == authenticationBox.domain, + setting.userID == authenticationBox.userID else { return nil } + + let _viewModel = self.notificationService?.dequeueNotificationViewModel( + mastodonAuthenticationBox: authenticationBox + ) + guard let viewModel = _viewModel else { return nil } + let queryData = Mastodon.API.Subscriptions.QueryData( + policy: subscription.policy, + alerts: Mastodon.API.Subscriptions.QueryData.Alerts( + favourite: subscription.alert.favourite, + follow: subscription.alert.follow, + reblog: subscription.alert.reblog, + mention: subscription.alert.mention, + poll: subscription.alert.poll + ) + ) + let query = viewModel.createSubscribeQuery( + deviceToken: deviceToken, + queryData: queryData, + mastodonAuthenticationBox: authenticationBox + ) + + return apiService.createSubscription( + subscriptionObjectID: subscription.objectID, + query: query, + mastodonAuthenticationBox: authenticationBox + ) + } + .switchToLatest() + .sink { _ in + // do nothing + } receiveValue: { _ in + // do nothing + } + .store(in: &disposeBag) + + } + +} diff --git a/Mastodon/State/AppContext.swift b/Mastodon/State/AppContext.swift index 1ed2c283f..0c40ff127 100644 --- a/Mastodon/State/AppContext.swift +++ b/Mastodon/State/AppContext.swift @@ -29,6 +29,7 @@ class AppContext: ObservableObject { let statusPrefetchingService: StatusPrefetchingService let statusPublishService = StatusPublishService() let notificationService: NotificationService + let settingService: SettingService let documentStore: DocumentStore private var documentStoreSubscription: AnyCancellable! @@ -59,10 +60,16 @@ class AppContext: ObservableObject { statusPrefetchingService = StatusPrefetchingService( apiService: _apiService ) - notificationService = NotificationService( - apiService: _apiService, + let _notificationService = NotificationService( authenticationService: _authenticationService ) + notificationService = _notificationService + + settingService = SettingService( + apiService: _apiService, + authenticationService: _authenticationService, + notificationService: _notificationService + ) documentStore = DocumentStore() documentStoreSubscription = documentStore.objectWillChange diff --git a/Mastodon/Supporting Files/AppSharedName.swift b/Mastodon/Supporting Files/AppSharedName.swift new file mode 100644 index 000000000..3570c68da --- /dev/null +++ b/Mastodon/Supporting Files/AppSharedName.swift @@ -0,0 +1,12 @@ +// +// AppSharedName.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-26. +// + +import Foundation + +enum AppSharedName { + static let groupID = "group.org.joinmastodon.mastodon-temp" +} diff --git a/Mastodon/Supporting Files/SceneDelegate.swift b/Mastodon/Supporting Files/SceneDelegate.swift index 0f5e2bd59..1e6c13e41 100644 --- a/Mastodon/Supporting Files/SceneDelegate.swift +++ b/Mastodon/Supporting Files/SceneDelegate.swift @@ -6,10 +6,13 @@ // import UIKit +import Combine import CoreDataStack class SceneDelegate: UIResponder, UIWindowSceneDelegate { + var observations = Set() + var window: UIWindow? var coordinator: SceneCoordinator? @@ -28,8 +31,11 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { sceneCoordinator.setupOnboardingIfNeeds(animated: false) window.makeKeyAndVisible() - // update `overrideUserInterfaceStyle` with current setting - SettingsViewController.updateOverrideUserInterfaceStyle(window: window) + UserDefaults.shared.observe(\.customUserInterfaceStyle, options: [.initial, .new]) { [weak self] defaults, _ in + guard let self = self else { return } + self.window?.overrideUserInterfaceStyle = defaults.customUserInterfaceStyle + } + .store(in: &observations) } func sceneDidDisconnect(_ scene: UIScene) { diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Push.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Push.swift index f78c2c6c6..b66b89aad 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Push.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Push.swift @@ -21,7 +21,7 @@ extension Mastodon.API.Subscriptions { /// - Since: 2.4.0 /// - Version: 3.3.0 /// # Last Update - /// 2021/4/9 + /// 2021/4/25 /// # Reference /// [Document](https://docs.joinmastodon.org/methods/notifications/push/) /// - Parameters: @@ -54,7 +54,7 @@ extension Mastodon.API.Subscriptions { /// - Since: 2.4.0 /// - Version: 3.3.0 /// # Last Update - /// 2021/4/9 + /// 2021/4/25 /// # Reference /// [Document](https://docs.joinmastodon.org/methods/notifications/push/) /// - Parameters: @@ -88,7 +88,7 @@ extension Mastodon.API.Subscriptions { /// - Since: 2.4.0 /// - Version: 3.3.0 /// # Last Update - /// 2021/4/9 + /// 2021/4/25 /// # Reference /// [Document](https://docs.joinmastodon.org/methods/notifications/push/) /// - Parameters: @@ -114,10 +114,45 @@ extension Mastodon.API.Subscriptions { } .eraseToAnyPublisher() } + + /// Remove current subscription + /// + /// Removes the current Web Push API subscription. + /// + /// - Since: 2.4.0 + /// - Version: 3.3.0 + /// # Last Update + /// 2021/4/26 + /// # Reference + /// [Document](https://docs.joinmastodon.org/methods/notifications/push/) + /// - Parameters: + /// - session: `URLSession` + /// - domain: Mastodon instance domain. e.g. "example.com" + /// - authorization: User token. Could be nil if status is public + /// - Returns: `AnyPublisher` contains `Subscription` nested in the response + public static func removeSubscription( + session: URLSession, + domain: String, + authorization: Mastodon.API.OAuth.Authorization + ) -> AnyPublisher, Error> { + let request = Mastodon.API.delete( + url: pushEndpointURL(domain: domain), + query: nil, + authorization: authorization + ) + return session.dataTaskPublisher(for: request) + .tryMap { data, response in + let value = try Mastodon.API.decode(type: Mastodon.Entity.EmptySubscription.self, from: data, response: response) + return Mastodon.Response.Content(value: value, response: response) + } + .eraseToAnyPublisher() + } } extension Mastodon.API.Subscriptions { + public typealias Policy = QueryData.Policy + public struct QuerySubscription: Codable { let endpoint: String let keys: Keys @@ -142,9 +177,14 @@ extension Mastodon.API.Subscriptions { } public struct QueryData: Codable { + let policy: Policy? let alerts: Alerts - public init(alerts: Mastodon.API.Subscriptions.QueryData.Alerts) { + public init( + policy: Policy?, + alerts: Mastodon.API.Subscriptions.QueryData.Alerts + ) { + self.policy = policy self.alerts = alerts } @@ -163,8 +203,39 @@ extension Mastodon.API.Subscriptions { self.poll = poll } } + + public enum Policy: RawRepresentable, Codable { + case all + case followed + case follower + case none + + case _other(String) + + public init?(rawValue: String) { + switch rawValue { + case "all": self = .all + case "followed": self = .followed + case "follower": self = .follower + case "none": self = .none + + default: self = ._other(rawValue) + } + } + + public var rawValue: String { + switch self { + case .all: return "all" + case .followed: return "followed" + case .follower: return "follower" + case .none: return "none" + case ._other(let value): return value + } + } + } } + public struct CreateSubscriptionQuery: Codable, PostQuery { let subscription: QuerySubscription let data: QueryData diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift index 1a4496ed3..4fcc8fc9e 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift @@ -151,6 +151,14 @@ extension Mastodon.API { ) -> URLRequest { return buildRequest(url: url, method: .PUT, query: query, authorization: authorization) } + + static func delete( + url: URL, + query: DeleteQuery?, + authorization: OAuth.Authorization? + ) -> URLRequest { + return buildRequest(url: url, method: .DELETE, query: query, authorization: authorization) + } private static func buildRequest( url: URL, diff --git a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Subscription.swift b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Subscription.swift index 3ae5718e6..e968c32d6 100644 --- a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Subscription.swift +++ b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Subscription.swift @@ -1,5 +1,5 @@ // -// File.swift +// Mastodon+Entity+Subscription.swift // // // Created by ihugo on 2021/4/9. @@ -14,7 +14,7 @@ extension Mastodon.Entity { /// - Since: 2.4.0 /// - Version: 3.3.0 /// # Last Update - /// 2021/4/9 + /// 2021/4/26 /// # Reference /// [Document](https://docs.joinmastodon.org/entities/pushsubscription/) public struct Subscription: Codable { @@ -33,30 +33,19 @@ extension Mastodon.Entity { public struct Alerts: Codable { public let follow: Bool? + public let followRequest: Bool? public let favourite: Bool? public let reblog: Bool? public let mention: Bool? public let poll: Bool? - 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) + enum CodingKeys: String, CodingKey { + case follow + case followRequest = "follow_request" + case favourite + case reblog + case mention + case poll } } @@ -74,4 +63,8 @@ extension Mastodon.Entity { serverKey = try container.decode(String.self, forKey: .serverKey) } } + + public struct EmptySubscription: Codable { + + } } diff --git a/MastodonSDK/Sources/MastodonSDK/Query/Query.swift b/MastodonSDK/Sources/MastodonSDK/Query/Query.swift index 39f6e3ec4..b729129bd 100644 --- a/MastodonSDK/Sources/MastodonSDK/Query/Query.swift +++ b/MastodonSDK/Sources/MastodonSDK/Query/Query.swift @@ -58,3 +58,5 @@ protocol PatchQuery: RequestQuery { } // PUT protocol PutQuery: RequestQuery { } +// DELETE +protocol DeleteQuery: RequestQuery { }