diff --git a/.gitignore b/.gitignore index a766fc629..24e748a9e 100644 --- a/.gitignore +++ b/.gitignore @@ -120,4 +120,6 @@ xcuserdata # End of https://www.toptal.com/developers/gitignore/api/swift,swiftpm,xcode,cocoapods Localization/StringsConvertor/input -Localization/StringsConvertor/output \ No newline at end of file +Localization/StringsConvertor/output +.DS_Store +/Mastodon.xcworkspace/xcshareddata/swiftpm diff --git a/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents b/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents index e52f2d8e7..ff26e9bab 100644 --- a/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents +++ b/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents @@ -171,6 +171,16 @@ + + + + + + + + + + @@ -209,6 +219,26 @@ + + + + + + + + + + + + + + + + + + + + @@ -232,7 +262,10 @@ + + + \ No newline at end of file diff --git a/CoreDataStack/Entity/Setting.swift b/CoreDataStack/Entity/Setting.swift new file mode 100644 index 000000000..671f9bab3 --- /dev/null +++ b/CoreDataStack/Entity/Setting.swift @@ -0,0 +1,90 @@ +// +// Setting.swift +// CoreDataStack +// +// Created by ihugo on 2021/4/9. +// + +import CoreData +import Foundation + +public final class Setting: NSManagedObject { + @NSManaged public var appearance: String? + @NSManaged public var triggerBy: String? + @NSManaged public var domain: String? + @NSManaged public var userID: String? + + @NSManaged public private(set) var createdAt: Date + @NSManaged public private(set) var updatedAt: Date + + // relationships + @NSManaged public var subscription: Set? +} + +public extension Setting { + override func awakeFromInsert() { + super.awakeFromInsert() + setPrimitiveValue(Date(), forKey: #keyPath(Setting.createdAt)) + } + + func didUpdate(at networkDate: Date) { + self.updatedAt = networkDate + } + + @discardableResult + static func insert( + into context: NSManagedObjectContext, + property: Property + ) -> Setting { + let setting: Setting = context.insertObject() + setting.appearance = property.appearance + setting.triggerBy = property.triggerBy + setting.domain = property.domain + setting.userID = property.userID + return setting + } + + func update(appearance: String?) { + guard appearance != self.appearance else { return } + self.appearance = appearance + didUpdate(at: Date()) + } + + func update(triggerBy: String?) { + guard triggerBy != self.triggerBy else { return } + self.triggerBy = triggerBy + didUpdate(at: Date()) + } +} + +public extension Setting { + struct Property { + public let appearance: String + public let triggerBy: String + public let domain: String + public let userID: String + + public init(appearance: String, triggerBy: String, domain: String, userID: String) { + self.appearance = appearance + self.triggerBy = triggerBy + self.domain = domain + self.userID = userID + } + } +} + +extension Setting: Managed { + public static var defaultSortDescriptors: [NSSortDescriptor] { + return [NSSortDescriptor(keyPath: \Setting.createdAt, ascending: false)] + } +} + +extension Setting { + public static func predicate(domain: String, userID: String) -> NSPredicate { + return NSPredicate(format: "%K == %@ AND %K == %@", + #keyPath(Setting.domain), domain, + #keyPath(Setting.userID), userID + ) + } + +} diff --git a/CoreDataStack/Entity/Subscription.swift b/CoreDataStack/Entity/Subscription.swift new file mode 100644 index 000000000..8ced945d9 --- /dev/null +++ b/CoreDataStack/Entity/Subscription.swift @@ -0,0 +1,101 @@ +// +// SettingNotification+CoreDataClass.swift +// CoreDataStack +// +// Created by ihugo on 2021/4/9. +// +// + +import Foundation +import CoreData + +public final class Subscription: NSManagedObject { + @NSManaged public var id: String + @NSManaged public var endpoint: String + @NSManaged public var serverKey: String + + /// four types: + /// - anyone + /// - a follower + /// - anyone I follow + /// - no one + @NSManaged public var type: String + + @NSManaged public private(set) var createdAt: Date + @NSManaged public private(set) var updatedAt: Date + + // MARK: - relationships + @NSManaged public var alert: SubscriptionAlerts? + // MARK: holder + @NSManaged public var setting: Setting? +} + +public extension Subscription { + override func awakeFromInsert() { + super.awakeFromInsert() + setPrimitiveValue(Date(), forKey: #keyPath(Subscription.createdAt)) + } + + func didUpdate(at networkDate: Date) { + self.updatedAt = networkDate + } + + @discardableResult + static func insert( + into context: NSManagedObjectContext, + property: Property + ) -> Subscription { + let setting: Subscription = context.insertObject() + setting.id = property.id + setting.endpoint = property.endpoint + setting.serverKey = property.serverKey + setting.type = property.type + + return setting + } +} + +public extension Subscription { + struct Property { + public let endpoint: String + public let id: String + public let serverKey: String + public let type: String + + public init(endpoint: String, id: String, serverKey: String, type: String) { + self.endpoint = endpoint + self.id = id + self.serverKey = serverKey + self.type = type + } + } + + func updateIfNeed(property: Property) { + if self.endpoint != property.endpoint { + self.endpoint = property.endpoint + } + if self.id != property.id { + self.id = property.id + } + if self.serverKey != property.serverKey { + self.serverKey = property.serverKey + } + if self.type != property.type { + self.type = property.type + } + } +} + +extension Subscription: Managed { + public static var defaultSortDescriptors: [NSSortDescriptor] { + return [NSSortDescriptor(keyPath: \Subscription.createdAt, ascending: false)] + } +} + +extension Subscription { + + public static func predicate(type: String) -> NSPredicate { + return NSPredicate(format: "%K == %@", #keyPath(Subscription.type), type) + } + +} diff --git a/CoreDataStack/Entity/SubscriptionAlerts.swift b/CoreDataStack/Entity/SubscriptionAlerts.swift new file mode 100644 index 000000000..f5abf4955 --- /dev/null +++ b/CoreDataStack/Entity/SubscriptionAlerts.swift @@ -0,0 +1,130 @@ +// +// PushSubscriptionAlerts+CoreDataClass.swift +// CoreDataStack +// +// Created by ihugo on 2021/4/9. +// +// + +import Foundation +import CoreData + +public final class SubscriptionAlerts: NSManagedObject { + @NSManaged public var follow: NSNumber? + @NSManaged public var favourite: NSNumber? + @NSManaged public var reblog: NSNumber? + @NSManaged public var mention: NSNumber? + @NSManaged public var poll: NSNumber? + + @NSManaged public private(set) var createdAt: Date + @NSManaged public private(set) var updatedAt: Date + + // MARK: - relationships + @NSManaged public var subscription: Subscription? +} + +public extension SubscriptionAlerts { + override func awakeFromInsert() { + super.awakeFromInsert() + setPrimitiveValue(Date(), forKey: #keyPath(SubscriptionAlerts.createdAt)) + } + + func didUpdate(at networkDate: Date) { + self.updatedAt = networkDate + } + + @discardableResult + static func insert( + into context: NSManagedObjectContext, + property: Property + ) -> SubscriptionAlerts { + let alerts: SubscriptionAlerts = context.insertObject() + alerts.favourite = property.favourite + alerts.follow = property.follow + alerts.mention = property.mention + alerts.poll = property.poll + alerts.reblog = property.reblog + return alerts + } + + func update(favourite: NSNumber?) { + guard self.favourite != favourite else { return } + self.favourite = favourite + + didUpdate(at: Date()) + } + + func update(follow: NSNumber?) { + guard self.follow != follow else { return } + self.follow = follow + + didUpdate(at: Date()) + } + + func update(mention: NSNumber?) { + guard self.mention != mention else { return } + self.mention = mention + + didUpdate(at: Date()) + } + + func update(poll: NSNumber?) { + guard self.poll != poll else { return } + self.poll = poll + + didUpdate(at: Date()) + } + + func update(reblog: NSNumber?) { + guard self.reblog != reblog else { return } + self.reblog = reblog + + didUpdate(at: Date()) + } +} + +public extension SubscriptionAlerts { + struct Property { + public let favourite: NSNumber? + public let follow: NSNumber? + public let mention: NSNumber? + public let poll: NSNumber? + public let reblog: NSNumber? + + public init(favourite: NSNumber?, follow: NSNumber?, mention: NSNumber?, poll: NSNumber?, reblog: NSNumber?) { + self.favourite = favourite + self.follow = follow + self.mention = mention + self.poll = poll + self.reblog = reblog + } + } + + func updateIfNeed(property: Property) { + if self.follow != property.follow { + self.follow = property.follow + } + + if self.favourite != property.favourite { + self.favourite = property.favourite + } + + if self.reblog != property.reblog { + self.reblog = property.reblog + } + + if self.mention != property.mention { + self.mention = property.mention + } + + if self.poll != property.poll { + self.poll = property.poll + } + } +} + +extension SubscriptionAlerts: Managed { + public static var defaultSortDescriptors: [NSSortDescriptor] { + return [NSSortDescriptor(keyPath: \SubscriptionAlerts.createdAt, ascending: false)] + } +} diff --git a/Localization/app.json b/Localization/app.json index 80757ff07..0aa622718 100644 --- a/Localization/app.json +++ b/Localization/app.json @@ -22,6 +22,11 @@ "publish_post_failure": { "title": "Publish Failure", "message": "Failed to publish the post.\nPlease check your internet connection." + }, + "sign_out": { + "title": "Sign out", + "message": "Are you sure you want to sign out?", + "confirm": "Sign Out" } }, "controls": { @@ -348,6 +353,41 @@ "single": "%s favorite", "multiple": "%s favorites" } + }, + "settings": { + "title": "Settings", + "section": { + "appearance": { + "title": "Appearance", + "automatic": "Automatic", + "light": "Always Light", + "dark": "Always Dark" + }, + "notifications": { + "title": "Notifications", + "favorites": "Favorites my post", + "follows": "Follows me", + "boosts": "Reblogs my post", + "mentions": "Mentions me", + "trigger": { + "anyone": "anyone", + "follower": "a follower", + "follow": "anyone I follow", + "noone": "no one", + "title": "Notify me when" + } + }, + "boringzone": { + "title": "The Boring zone", + "terms": "Terms of Service", + "privacy": "Privacy Policy" + }, + "spicyzone": { + "title": "The spicy zone", + "clear": "Clear Media Cache", + "signout": "Sign Out" + } + } } } } diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index 6c6369835..7b147991f 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -133,6 +133,17 @@ 2DF75BC725D1475D00694EC8 /* ManagedObjectContextObjectsDidChange.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DF75BC625D1475D00694EC8 /* ManagedObjectContextObjectsDidChange.swift */; }; 2DFAD5272616F9D300F9EE7C /* SearchViewController+Searching.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DFAD5262616F9D300F9EE7C /* SearchViewController+Searching.swift */; }; 2DFAD5372617010500F9EE7C /* SearchingTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DFAD5362617010500F9EE7C /* SearchingTableViewCell.swift */; }; + 5B90C45E262599800002E742 /* SettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B90C456262599800002E742 /* SettingsViewModel.swift */; }; + 5B90C45F262599800002E742 /* SettingsToggleTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B90C459262599800002E742 /* SettingsToggleTableViewCell.swift */; }; + 5B90C460262599800002E742 /* SettingsAppearanceTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B90C45A262599800002E742 /* SettingsAppearanceTableViewCell.swift */; }; + 5B90C461262599800002E742 /* SettingsLinkTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B90C45B262599800002E742 /* SettingsLinkTableViewCell.swift */; }; + 5B90C462262599800002E742 /* SettingsSectionHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B90C45C262599800002E742 /* SettingsSectionHeader.swift */; }; + 5B90C463262599800002E742 /* SettingsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B90C45D262599800002E742 /* SettingsViewController.swift */; }; + 5B90C46E26259B2C0002E742 /* Subscription.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B90C46C26259B2C0002E742 /* Subscription.swift */; }; + 5B90C46F26259B2C0002E742 /* Setting.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B90C46D26259B2C0002E742 /* Setting.swift */; }; + 5B90C47F26259BA90002E742 /* SubscriptionAlerts.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B90C47E26259BA90002E742 /* SubscriptionAlerts.swift */; }; + 5B90C48526259BF10002E742 /* APIService+Subscriptions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B90C48426259BF10002E742 /* APIService+Subscriptions.swift */; }; + 5B90C48B26259C120002E742 /* APIService+CoreData+Subscriptions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B90C48A26259C120002E742 /* APIService+CoreData+Subscriptions.swift */; }; 5D0393902612D259007FE196 /* WebViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D03938F2612D259007FE196 /* WebViewController.swift */; }; 5D0393962612D266007FE196 /* WebViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D0393952612D266007FE196 /* WebViewModel.swift */; }; 5D526FE225BE9AC400460CB9 /* MastodonSDK in Frameworks */ = {isa = PBXBuildFile; productRef = 5D526FE125BE9AC400460CB9 /* MastodonSDK */; }; @@ -536,6 +547,17 @@ 3C030226D3C73DCC23D67452 /* Pods_Mastodon_MastodonUITests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Mastodon_MastodonUITests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 452147B2903DF38070FE56A2 /* Pods_MastodonTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_MastodonTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 459EA4F43058CAB47719E963 /* Pods-Mastodon-MastodonUITests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Mastodon-MastodonUITests.debug.xcconfig"; path = "Target Support Files/Pods-Mastodon-MastodonUITests/Pods-Mastodon-MastodonUITests.debug.xcconfig"; sourceTree = ""; }; + 5B90C456262599800002E742 /* SettingsViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SettingsViewModel.swift; sourceTree = ""; }; + 5B90C459262599800002E742 /* SettingsToggleTableViewCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SettingsToggleTableViewCell.swift; sourceTree = ""; }; + 5B90C45A262599800002E742 /* SettingsAppearanceTableViewCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SettingsAppearanceTableViewCell.swift; sourceTree = ""; }; + 5B90C45B262599800002E742 /* SettingsLinkTableViewCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SettingsLinkTableViewCell.swift; sourceTree = ""; }; + 5B90C45C262599800002E742 /* SettingsSectionHeader.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SettingsSectionHeader.swift; sourceTree = ""; }; + 5B90C45D262599800002E742 /* SettingsViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SettingsViewController.swift; sourceTree = ""; }; + 5B90C46C26259B2C0002E742 /* Subscription.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Subscription.swift; sourceTree = ""; }; + 5B90C46D26259B2C0002E742 /* Setting.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Setting.swift; sourceTree = ""; }; + 5B90C47E26259BA90002E742 /* SubscriptionAlerts.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SubscriptionAlerts.swift; sourceTree = ""; }; + 5B90C48426259BF10002E742 /* APIService+Subscriptions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "APIService+Subscriptions.swift"; sourceTree = ""; }; + 5B90C48A26259C120002E742 /* APIService+CoreData+Subscriptions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "APIService+CoreData+Subscriptions.swift"; sourceTree = ""; }; 5D03938F2612D259007FE196 /* WebViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewController.swift; sourceTree = ""; }; 5D0393952612D266007FE196 /* WebViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewModel.swift; sourceTree = ""; }; 5DA732CB2629CEF500A92342 /* UIView+Remove.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIView+Remove.swift"; sourceTree = ""; }; @@ -1195,6 +1217,35 @@ name = Frameworks; sourceTree = ""; }; + 5B90C455262599800002E742 /* Settings */ = { + isa = PBXGroup; + children = ( + 5B90C457262599800002E742 /* View */, + 5B90C456262599800002E742 /* SettingsViewModel.swift */, + 5B90C45D262599800002E742 /* SettingsViewController.swift */, + ); + path = Settings; + sourceTree = ""; + }; + 5B90C457262599800002E742 /* View */ = { + isa = PBXGroup; + children = ( + 5B90C458262599800002E742 /* Cell */, + 5B90C45C262599800002E742 /* SettingsSectionHeader.swift */, + ); + path = View; + sourceTree = ""; + }; + 5B90C458262599800002E742 /* Cell */ = { + isa = PBXGroup; + children = ( + 5B90C459262599800002E742 /* SettingsToggleTableViewCell.swift */, + 5B90C45A262599800002E742 /* SettingsAppearanceTableViewCell.swift */, + 5B90C45B262599800002E742 /* SettingsLinkTableViewCell.swift */, + ); + path = Cell; + sourceTree = ""; + }; 5D03938E2612D200007FE196 /* Webview */ = { isa = PBXGroup; children = ( @@ -1381,6 +1432,7 @@ DBAE3F932616E28B004B8251 /* APIService+Follow.swift */, DBAE3F8D2616E0B1004B8251 /* APIService+Block.swift */, DBAE3F9D2616E308004B8251 /* APIService+Mute.swift */, + 5B90C48426259BF10002E742 /* APIService+Subscriptions.swift */, ); path = APIService; sourceTree = ""; @@ -1392,6 +1444,7 @@ DB45FADC25CA6F6B005A8AC7 /* APIService+CoreData+MastodonUser.swift */, DB45FAF825CA80A2005A8AC7 /* APIService+CoreData+MastodonAuthentication.swift */, 2D79E700261EA5550011E398 /* APIService+CoreData+Tag.swift */, + 5B90C48A26259C120002E742 /* APIService+CoreData+Subscriptions.swift */, ); path = CoreData; sourceTree = ""; @@ -1563,6 +1616,9 @@ DB4481AC25EE155900BEFB67 /* Poll.swift */, DB4481B225EE16D000BEFB67 /* PollOption.swift */, DBCC3B9A2615849F0045B23D /* PrivateNote.swift */, + 5B90C46D26259B2C0002E742 /* Setting.swift */, + 5B90C46C26259B2C0002E742 /* Subscription.swift */, + 5B90C47E26259BA90002E742 /* SubscriptionAlerts.swift */, ); path = Entity; sourceTree = ""; @@ -1614,6 +1670,7 @@ 2D76316325C14BAC00929FB9 /* PublicTimeline */, 0F2021F5261325ED000C64BF /* HashtagTimeline */, DB9D6BEE25E4F5370051B173 /* Search */, + 5B90C455262599800002E742 /* Settings */, DB9D6BFD25E4F57B0051B173 /* Notification */, DB9D6C0825E4F5A60051B173 /* Profile */, DB789A1025F9F29B0071ACA0 /* Compose */, @@ -2316,6 +2373,7 @@ DBE3CE07261D6A0E00430CC6 /* FavoriteViewModel+Diffable.swift in Sources */, 2D61335825C188A000CAE157 /* APIService+Persist+Status.swift in Sources */, 2D34D9DB261494120081BFC0 /* APIService+Search.swift in Sources */, + 5B90C48B26259C120002E742 /* APIService+CoreData+Subscriptions.swift in Sources */, DBB5256E2612D5A1002F1F29 /* ProfileStatusDashboardView.swift in Sources */, 2D24E1232626ED9D00A59D4F /* UIView+Gesture.swift in Sources */, DB45FAE325CA7181005A8AC7 /* MastodonUser.swift in Sources */, @@ -2330,6 +2388,7 @@ DB49A62B25FF36C700B98345 /* APIService+CustomEmoji.swift in Sources */, DBCBED1D26132E1A00B49291 /* StatusFetchedResultsController.swift in Sources */, 2D79E701261EA5550011E398 /* APIService+CoreData+Tag.swift in Sources */, + 5B90C461262599800002E742 /* SettingsLinkTableViewCell.swift in Sources */, DBE3CDEC261C6B2900430CC6 /* FavoriteViewController.swift in Sources */, DB938EE62623F50700E5B6C1 /* ThreadViewController.swift in Sources */, 2D939AB525EDD8A90076FA61 /* String.swift in Sources */, @@ -2362,6 +2421,7 @@ 5DDDF1A92617489F00311060 /* Mastodon+Entity+History.swift in Sources */, DB59F11825EFA35B001F1DAB /* StripProgressView.swift in Sources */, DB59F10425EF5EBC001F1DAB /* TableViewCellHeightCacheableContainer.swift in Sources */, + 5B90C463262599800002E742 /* SettingsViewController.swift in Sources */, DB73B490261F030A002E9E9F /* SafariActivity.swift in Sources */, DB9A48962603685D008B817C /* MastodonAttachmentService+UploadState.swift in Sources */, 2D198643261BF09500F0B013 /* SearchResultItem.swift in Sources */, @@ -2369,6 +2429,7 @@ DBE0822425CD3F1E00FD6BBD /* MastodonRegisterViewModel.swift in Sources */, 2DF75B9B25D0E27500694EC8 /* StatusProviderFacade.swift in Sources */, DB5086A525CC0B7000C2C187 /* AvatarBarButtonItem.swift in Sources */, + 5B90C45E262599800002E742 /* SettingsViewModel.swift in Sources */, 2D82B9FF25E7863200E36F0F /* OnboardingViewControllerAppearance.swift in Sources */, 5DF1054725F8870E00D6C0D4 /* VideoPlayerViewModel.swift in Sources */, 2DE0FAC12615F04D00CDF649 /* RecommendHashTagSection.swift in Sources */, @@ -2390,6 +2451,7 @@ DB7F48452620241000796008 /* ProfileHeaderViewModel.swift in Sources */, 2D3F9E0425DFA133004262D9 /* UITapGestureRecognizer.swift in Sources */, 5DDDF1992617447F00311060 /* Mastodon+Entity+Tag.swift in Sources */, + 5B90C45F262599800002E742 /* SettingsToggleTableViewCell.swift in Sources */, 2D694A7425F9EB4E0038ADDC /* ContentWarningOverlayView.swift in Sources */, DBAE3F682615DD60004B8251 /* UserProvider.swift in Sources */, DB9A486C26032AC1008B817C /* AttachmentContainerView+EmptyStateView.swift in Sources */, @@ -2399,6 +2461,7 @@ 0F20222D261457EE000C64BF /* HashtagTimelineViewModel+LoadOldestState.swift in Sources */, DB4563BD25E11A24004DA0B9 /* KeyboardResponderService.swift in Sources */, DB5086BE25CC0D9900C2C187 /* SplashPreference.swift in Sources */, + 5B90C462262599800002E742 /* SettingsSectionHeader.swift in Sources */, DB44768B260B3F2100B66B82 /* CustomEmojiPickerItem.swift in Sources */, 2DF75BA125D0E29D00694EC8 /* StatusProvider+StatusTableViewCellDelegate.swift in Sources */, 5DF1056425F887CB00D6C0D4 /* AVPlayer.swift in Sources */, @@ -2481,7 +2544,9 @@ DB98336B25C9420100AD9700 /* APIService+App.swift in Sources */, DBA0A11325FB3FC10079C110 /* ComposeToolbarView.swift in Sources */, 2D32EADA25CBCC3300C9ED86 /* PublicTimelineViewModel+LoadMiddleState.swift in Sources */, + 5B90C48526259BF10002E742 /* APIService+Subscriptions.swift in Sources */, 0F20223926146553000C64BF /* Array+removeDuplicates.swift in Sources */, + 5B90C460262599800002E742 /* SettingsAppearanceTableViewCell.swift in Sources */, DB8AF54425C13647002E6C99 /* SceneCoordinator.swift in Sources */, 5DF1058525F88AE500D6C0D4 /* NeedsDependency+AVPlayerViewControllerDelegate.swift in Sources */, DB447697260B439000B66B82 /* CustomEmojiPickerHeaderCollectionReusableView.swift in Sources */, @@ -2567,6 +2632,7 @@ 2D927F1425C7EDD9004F19B8 /* Emoji.swift in Sources */, 2DF75BC725D1475D00694EC8 /* ManagedObjectContextObjectsDidChange.swift in Sources */, DB89BA1225C1105C008580ED /* CoreDataStack.swift in Sources */, + 5B90C46F26259B2C0002E742 /* Setting.swift in Sources */, DB89BA1C25C1107F008580ED /* NSManagedObjectContext.swift in Sources */, DB9D6C2E25E504AC0051B173 /* Attachment.swift in Sources */, 2D927F0E25C7E9C9004F19B8 /* History.swift in Sources */, @@ -2588,6 +2654,8 @@ DB89BA1D25C1107F008580ED /* URL.swift in Sources */, 2D0B7A1D261D839600B44727 /* SearchHistory.swift in Sources */, 2D927F0825C7E9A8004F19B8 /* Tag.swift in Sources */, + 5B90C46E26259B2C0002E742 /* Subscription.swift in Sources */, + 5B90C47F26259BA90002E742 /* SubscriptionAlerts.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved deleted file mode 100644 index 3bd82fce8..000000000 --- a/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ /dev/null @@ -1,142 +0,0 @@ -{ - "object": { - "pins": [ - { - "package": "ActiveLabel", - "repositoryURL": "https://github.com/TwidereProject/ActiveLabel.swift", - "state": { - "branch": null, - "revision": "d6cf96e0ca4f2269021bcf8f11381ab57897f84a", - "version": "4.0.0" - } - }, - { - "package": "Alamofire", - "repositoryURL": "https://github.com/Alamofire/Alamofire.git", - "state": { - "branch": null, - "revision": "eaf6e622dd41b07b251d8f01752eab31bc811493", - "version": "5.4.1" - } - }, - { - "package": "AlamofireImage", - "repositoryURL": "https://github.com/Alamofire/AlamofireImage.git", - "state": { - "branch": null, - "revision": "3e8edbeb75227f8542aa87f90240cf0424d6362f", - "version": "4.1.0" - } - }, - { - "package": "AlamofireNetworkActivityIndicator", - "repositoryURL": "https://github.com/Alamofire/AlamofireNetworkActivityIndicator", - "state": { - "branch": null, - "revision": "392bed083e8d193aca16bfa684ee24e4bcff0510", - "version": "3.1.0" - } - }, - { - "package": "CommonOSLog", - "repositoryURL": "https://github.com/MainasuK/CommonOSLog", - "state": { - "branch": null, - "revision": "c121624a30698e9886efe38aebb36ff51c01b6c2", - "version": "0.1.1" - } - }, - { - "package": "Kingfisher", - "repositoryURL": "https://github.com/onevcat/Kingfisher.git", - "state": { - "branch": null, - "revision": "daebf8ddf974164d1b9a050c8231e263f3106b09", - "version": "6.1.0" - } - }, - { - "package": "Pageboy", - "repositoryURL": "https://github.com/uias/Pageboy", - "state": { - "branch": null, - "revision": "34ecb6e7c4e0e07494960ab2f7cc9a02293915a6", - "version": "3.6.2" - } - }, - { - "package": "swift-nio", - "repositoryURL": "https://github.com/apple/swift-nio.git", - "state": { - "branch": null, - "revision": "8da5c5a4e6c5084c296b9f39dc54f00be146e0fa", - "version": "1.14.2" - } - }, - { - "package": "swift-nio-zlib-support", - "repositoryURL": "https://github.com/apple/swift-nio-zlib-support.git", - "state": { - "branch": null, - "revision": "37760e9a52030bb9011972c5213c3350fa9d41fd", - "version": "1.0.0" - } - }, - { - "package": "SwiftyJSON", - "repositoryURL": "https://github.com/SwiftyJSON/SwiftyJSON.git", - "state": { - "branch": null, - "revision": "2b6054efa051565954e1d2b9da831680026cd768", - "version": "5.0.0" - } - }, - { - "package": "Tabman", - "repositoryURL": "https://github.com/uias/Tabman", - "state": { - "branch": null, - "revision": "bce2c87659c0ed868e6ef0aa1e05a330e202533f", - "version": "2.11.0" - } - }, - { - "package": "ThirdPartyMailer", - "repositoryURL": "https://github.com/vtourraine/ThirdPartyMailer.git", - "state": { - "branch": null, - "revision": "923c60ee7588da47db8cfc4e0f5b96e5e605ef84", - "version": "1.7.1" - } - }, - { - "package": "TOCropViewController", - "repositoryURL": "https://github.com/TimOliver/TOCropViewController.git", - "state": { - "branch": null, - "revision": "dad97167bf1be16aeecd109130900995dd01c515", - "version": "2.6.0" - } - }, - { - "package": "TwitterTextEditor", - "repositoryURL": "https://github.com/MainasuK/TwitterTextEditor", - "state": { - "branch": "feature/input-view", - "revision": "1e565d13e3c26fc2bedeb418890df42f80d6e3d5", - "version": null - } - }, - { - "package": "UITextView+Placeholder", - "repositoryURL": "https://github.com/MainasuK/UITextView-Placeholder", - "state": { - "branch": null, - "revision": "20f513ded04a040cdf5467f0891849b1763ede3b", - "version": "1.4.1" - } - } - ] - }, - "version": 1 -} diff --git a/Mastodon/Coordinator/SceneCoordinator.swift b/Mastodon/Coordinator/SceneCoordinator.swift index 8874a69e8..c2608fe83 100644 --- a/Mastodon/Coordinator/SceneCoordinator.swift +++ b/Mastodon/Coordinator/SceneCoordinator.swift @@ -68,6 +68,7 @@ extension SceneCoordinator { #if DEBUG case publicTimeline + case settings #endif var isOnboarding: Bool { @@ -269,6 +270,10 @@ private extension SceneCoordinator { let _viewController = PublicTimelineViewController() _viewController.viewModel = PublicTimelineViewModel(context: appContext) viewController = _viewController + case .settings: + let _viewController = SettingsViewController() + _viewController.viewModel = SettingsViewModel(context: appContext, coordinator: self) + viewController = _viewController #endif } diff --git a/Mastodon/Extension/UIButton.swift b/Mastodon/Extension/UIButton.swift index 916ad222d..31043157a 100644 --- a/Mastodon/Extension/UIButton.swift +++ b/Mastodon/Extension/UIButton.swift @@ -43,3 +43,11 @@ extension UIButton { } } +extension UIButton { + func setBackgroundColor(_ color: UIColor, for state: UIControl.State) { + self.setBackgroundImage( + UIImage.placeholder(color: color), + for: state + ) + } +} diff --git a/Mastodon/Generated/Assets.swift b/Mastodon/Generated/Assets.swift index 2ffc882bb..ce9e33e2b 100644 --- a/Mastodon/Generated/Assets.swift +++ b/Mastodon/Generated/Assets.swift @@ -86,6 +86,7 @@ internal enum Asset { internal static let invalid = ColorAsset(name: "Colors/TextField/invalid") internal static let valid = ColorAsset(name: "Colors/TextField/valid") } + internal static let battleshipGrey = ColorAsset(name: "Colors/battleshipGrey") internal static let brandBlue = ColorAsset(name: "Colors/brand.blue") internal static let danger = ColorAsset(name: "Colors/danger") internal static let disabled = ColorAsset(name: "Colors/disabled") @@ -126,6 +127,11 @@ internal enum Asset { internal static let mastodonLogoLarge = ImageAsset(name: "Scene/Welcome/mastodon.logo.large") } } + internal enum Settings { + internal static let appearanceAutomatic = ImageAsset(name: "Settings/appearance.automatic") + internal static let appearanceDark = ImageAsset(name: "Settings/appearance.dark") + internal static let appearanceLight = ImageAsset(name: "Settings/appearance.light") + } } // swiftlint:enable identifier_name line_length nesting type_body_length type_name diff --git a/Mastodon/Generated/Strings.swift b/Mastodon/Generated/Strings.swift index 81ac0d1f8..b7bd3d0a8 100644 --- a/Mastodon/Generated/Strings.swift +++ b/Mastodon/Generated/Strings.swift @@ -35,6 +35,14 @@ internal enum L10n { /// Server Error internal static let title = L10n.tr("Localizable", "Common.Alerts.ServerError.Title") } + internal enum SignOut { + /// Sign Out + internal static let confirm = L10n.tr("Localizable", "Common.Alerts.SignOut.Confirm") + /// Are you sure you want to sign out? + internal static let message = L10n.tr("Localizable", "Common.Alerts.SignOut.Message") + /// Sign out + internal static let title = L10n.tr("Localizable", "Common.Alerts.SignOut.Title") + } internal enum SignUpFailure { /// Sign Up Failure internal static let title = L10n.tr("Localizable", "Common.Alerts.SignUpFailure.Title") @@ -611,6 +619,62 @@ internal enum L10n { internal static let confirm = L10n.tr("Localizable", "Scene.ServerRules.Button.Confirm") } } + internal enum Settings { + /// Settings + internal static let title = L10n.tr("Localizable", "Scene.Settings.Title") + internal enum Section { + internal enum Appearance { + /// Automatic + internal static let automatic = L10n.tr("Localizable", "Scene.Settings.Section.Appearance.Automatic") + /// Always Dark + internal static let dark = L10n.tr("Localizable", "Scene.Settings.Section.Appearance.Dark") + /// Always Light + internal static let light = L10n.tr("Localizable", "Scene.Settings.Section.Appearance.Light") + /// Appearance + internal static let title = L10n.tr("Localizable", "Scene.Settings.Section.Appearance.Title") + } + internal enum Boringzone { + /// Privacy Policy + internal static let privacy = L10n.tr("Localizable", "Scene.Settings.Section.Boringzone.Privacy") + /// Terms of Service + internal static let terms = L10n.tr("Localizable", "Scene.Settings.Section.Boringzone.Terms") + /// The Boring zone + internal static let title = L10n.tr("Localizable", "Scene.Settings.Section.Boringzone.Title") + } + internal enum Notifications { + /// Reblogs my post + internal static let boosts = L10n.tr("Localizable", "Scene.Settings.Section.Notifications.Boosts") + /// Favorites my post + internal static let favorites = L10n.tr("Localizable", "Scene.Settings.Section.Notifications.Favorites") + /// Follows me + internal static let follows = L10n.tr("Localizable", "Scene.Settings.Section.Notifications.Follows") + /// Mentions me + internal static let mentions = L10n.tr("Localizable", "Scene.Settings.Section.Notifications.Mentions") + /// Notifications + internal static let title = L10n.tr("Localizable", "Scene.Settings.Section.Notifications.Title") + internal enum Trigger { + /// anyone + internal static let anyone = L10n.tr("Localizable", "Scene.Settings.Section.Notifications.Trigger.Anyone") + /// anyone I follow + internal static let follow = L10n.tr("Localizable", "Scene.Settings.Section.Notifications.Trigger.Follow") + /// a follower + internal static let follower = L10n.tr("Localizable", "Scene.Settings.Section.Notifications.Trigger.Follower") + /// no one + internal static let noone = L10n.tr("Localizable", "Scene.Settings.Section.Notifications.Trigger.Noone") + /// Notify me when + internal static let title = L10n.tr("Localizable", "Scene.Settings.Section.Notifications.Trigger.Title") + } + } + internal enum Spicyzone { + /// Clear Media Cache + internal static let clear = L10n.tr("Localizable", "Scene.Settings.Section.Spicyzone.Clear") + /// Sign Out + internal static let signout = L10n.tr("Localizable", "Scene.Settings.Section.Spicyzone.Signout") + /// The spicy zone + internal static let title = L10n.tr("Localizable", "Scene.Settings.Section.Spicyzone.Title") + } + } + } internal enum Thread { /// Post internal static let backTitle = L10n.tr("Localizable", "Scene.Thread.BackTitle") diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Label/highlight.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/Label/highlight.colorset/Contents.json index 2e1ce5f3a..d853a71aa 100644 --- a/Mastodon/Resources/Assets.xcassets/Colors/Label/highlight.colorset/Contents.json +++ b/Mastodon/Resources/Assets.xcassets/Colors/Label/highlight.colorset/Contents.json @@ -5,9 +5,9 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "0.851", - "green" : "0.565", - "red" : "0.169" + "blue" : "217", + "green" : "144", + "red" : "43" } }, "idiom" : "universal" diff --git a/Mastodon/Resources/Assets.xcassets/Colors/battleshipGrey.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/battleshipGrey.colorset/Contents.json new file mode 100644 index 000000000..37df8107f --- /dev/null +++ b/Mastodon/Resources/Assets.xcassets/Colors/battleshipGrey.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "0.200", + "blue" : "0x80", + "green" : "0x78", + "red" : "0x78" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Mastodon/Resources/Assets.xcassets/Settings/Contents.json b/Mastodon/Resources/Assets.xcassets/Settings/Contents.json new file mode 100644 index 000000000..6e965652d --- /dev/null +++ b/Mastodon/Resources/Assets.xcassets/Settings/Contents.json @@ -0,0 +1,9 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "provides-namespace" : true + } +} diff --git a/Mastodon/Resources/Assets.xcassets/Settings/appearance.automatic.imageset/Contents.json b/Mastodon/Resources/Assets.xcassets/Settings/appearance.automatic.imageset/Contents.json new file mode 100644 index 000000000..75da4a571 --- /dev/null +++ b/Mastodon/Resources/Assets.xcassets/Settings/appearance.automatic.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "iPhone 11 Pro _ X - 1.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Mastodon/Resources/Assets.xcassets/Settings/appearance.automatic.imageset/iPhone 11 Pro _ X - 1.pdf b/Mastodon/Resources/Assets.xcassets/Settings/appearance.automatic.imageset/iPhone 11 Pro _ X - 1.pdf new file mode 100644 index 000000000..868d8d8b9 Binary files /dev/null and b/Mastodon/Resources/Assets.xcassets/Settings/appearance.automatic.imageset/iPhone 11 Pro _ X - 1.pdf differ diff --git a/Mastodon/Resources/Assets.xcassets/Settings/appearance.dark.imageset/Contents.json b/Mastodon/Resources/Assets.xcassets/Settings/appearance.dark.imageset/Contents.json new file mode 100644 index 000000000..6ca47e403 --- /dev/null +++ b/Mastodon/Resources/Assets.xcassets/Settings/appearance.dark.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "iPhone 11 Pro _ X - 1 (2).pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Mastodon/Resources/Assets.xcassets/Settings/appearance.dark.imageset/iPhone 11 Pro _ X - 1 (2).pdf b/Mastodon/Resources/Assets.xcassets/Settings/appearance.dark.imageset/iPhone 11 Pro _ X - 1 (2).pdf new file mode 100644 index 000000000..a214d2853 Binary files /dev/null and b/Mastodon/Resources/Assets.xcassets/Settings/appearance.dark.imageset/iPhone 11 Pro _ X - 1 (2).pdf differ diff --git a/Mastodon/Resources/Assets.xcassets/Settings/appearance.light.imageset/Contents.json b/Mastodon/Resources/Assets.xcassets/Settings/appearance.light.imageset/Contents.json new file mode 100644 index 000000000..86e635c39 --- /dev/null +++ b/Mastodon/Resources/Assets.xcassets/Settings/appearance.light.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "iPhone 11 Pro _ X - 1 (1).pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Mastodon/Resources/Assets.xcassets/Settings/appearance.light.imageset/iPhone 11 Pro _ X - 1 (1).pdf b/Mastodon/Resources/Assets.xcassets/Settings/appearance.light.imageset/iPhone 11 Pro _ X - 1 (1).pdf new file mode 100644 index 000000000..2b8b869b0 Binary files /dev/null and b/Mastodon/Resources/Assets.xcassets/Settings/appearance.light.imageset/iPhone 11 Pro _ X - 1 (1).pdf differ diff --git a/Mastodon/Resources/en.lproj/Localizable.strings b/Mastodon/Resources/en.lproj/Localizable.strings index cdee03595..253b65d95 100644 --- a/Mastodon/Resources/en.lproj/Localizable.strings +++ b/Mastodon/Resources/en.lproj/Localizable.strings @@ -6,6 +6,9 @@ Please check your internet connection."; "Common.Alerts.PublishPostFailure.Title" = "Publish Failure"; "Common.Alerts.ServerError.Title" = "Server Error"; +"Common.Alerts.SignOut.Confirm" = "Sign Out"; +"Common.Alerts.SignOut.Message" = "Are you sure you want to sign out?"; +"Common.Alerts.SignOut.Title" = "Sign out"; "Common.Alerts.SignUpFailure.Title" = "Sign Up Failure"; "Common.Alerts.VoteFailure.PollExpired" = "The poll has expired"; "Common.Alerts.VoteFailure.Title" = "Vote Failure"; @@ -196,6 +199,27 @@ any server."; "Scene.ServerRules.Subtitle" = "These rules are set by the admins of %@."; "Scene.ServerRules.TermsOfService" = "terms of service"; "Scene.ServerRules.Title" = "Some ground rules."; +"Scene.Settings.Section.Appearance.Automatic" = "Automatic"; +"Scene.Settings.Section.Appearance.Dark" = "Always Dark"; +"Scene.Settings.Section.Appearance.Light" = "Always Light"; +"Scene.Settings.Section.Appearance.Title" = "Appearance"; +"Scene.Settings.Section.Boringzone.Privacy" = "Privacy Policy"; +"Scene.Settings.Section.Boringzone.Terms" = "Terms of Service"; +"Scene.Settings.Section.Boringzone.Title" = "The Boring zone"; +"Scene.Settings.Section.Notifications.Boosts" = "Reblogs my post"; +"Scene.Settings.Section.Notifications.Favorites" = "Favorites my post"; +"Scene.Settings.Section.Notifications.Follows" = "Follows me"; +"Scene.Settings.Section.Notifications.Mentions" = "Mentions me"; +"Scene.Settings.Section.Notifications.Title" = "Notifications"; +"Scene.Settings.Section.Notifications.Trigger.Anyone" = "anyone"; +"Scene.Settings.Section.Notifications.Trigger.Follow" = "anyone I follow"; +"Scene.Settings.Section.Notifications.Trigger.Follower" = "a follower"; +"Scene.Settings.Section.Notifications.Trigger.Noone" = "no one"; +"Scene.Settings.Section.Notifications.Trigger.Title" = "Notify me when"; +"Scene.Settings.Section.Spicyzone.Clear" = "Clear Media Cache"; +"Scene.Settings.Section.Spicyzone.Signout" = "Sign Out"; +"Scene.Settings.Section.Spicyzone.Title" = "The spicy zone"; +"Scene.Settings.Title" = "Settings"; "Scene.Thread.BackTitle" = "Post"; "Scene.Thread.Favorite.Multiple" = "%@ favorites"; "Scene.Thread.Favorite.Single" = "%@ favorite"; @@ -203,4 +227,4 @@ any server."; "Scene.Thread.Reblog.Single" = "%@ reblog"; "Scene.Thread.Title" = "Post from %@"; "Scene.Welcome.Slogan" = "Social networking -back in your hands."; \ No newline at end of file +back in your hands."; diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift index 401e4fc14..8bbf9436e 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift @@ -37,6 +37,10 @@ extension HomeTimelineViewController { guard let self = self else { return } self.showThreadAction(action) }, + UIAction(title: "Settings", image: UIImage(systemName: "gear"), attributes: []) { [weak self] action in + guard let self = self else { return } + self.showSettings(action) + }, UIAction(title: "Sign Out", image: UIImage(systemName: "escape"), attributes: .destructive) { [weak self] action in guard let self = self else { return } self.signOutAction(action) @@ -323,5 +327,8 @@ extension HomeTimelineViewController { coordinator.present(scene: .alertController(alertController: alertController), from: self, transition: .alertController(animated: true, completion: nil)) } + @objc private func showSettings(_ sender: UIAction) { + coordinator.present(scene: .settings, from: self, transition: .modal(animated: true, completion: nil)) + } } #endif diff --git a/Mastodon/Scene/Settings/SettingsViewController.swift b/Mastodon/Scene/Settings/SettingsViewController.swift new file mode 100644 index 000000000..4615f92ab --- /dev/null +++ b/Mastodon/Scene/Settings/SettingsViewController.swift @@ -0,0 +1,488 @@ +// +// SettingsViewController.swift +// Mastodon +// +// Created by ihugo on 2021/4/7. +// + +import os.log +import UIKit +import Combine +import ActiveLabel +import CoreData +import CoreDataStack +import AlamofireImage +import Kingfisher + +// iTODO: when to ask permission to Use Notifications + +class SettingsViewController: UIViewController, NeedsDependency { + + weak var context: AppContext! { willSet { precondition(!isViewLoaded) } } + weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } } + + var viewModel: SettingsViewModel! { willSet { precondition(!isViewLoaded) } } + var disposeBag = Set() + + var triggerMenu: UIMenu { + let anyone = L10n.Scene.Settings.Section.Notifications.Trigger.anyone + let follower = L10n.Scene.Settings.Section.Notifications.Trigger.follower + let follow = L10n.Scene.Settings.Section.Notifications.Trigger.follow + let noOne = L10n.Scene.Settings.Section.Notifications.Trigger.noone + let menu = UIMenu( + image: nil, + identifier: nil, + options: .displayInline, + children: [ + UIAction(title: anyone, image: UIImage(systemName: "person.3"), attributes: []) { [weak self] action in + self?.updateTrigger(by: anyone) + }, + UIAction(title: follower, image: UIImage(systemName: "person.crop.circle.badge.plus"), attributes: []) { [weak self] action in + self?.updateTrigger(by: follower) + }, + UIAction(title: follow, image: UIImage(systemName: "person.crop.circle.badge.checkmark"), attributes: []) { [weak self] action in + self?.updateTrigger(by: follow) + }, + UIAction(title: noOne, image: UIImage(systemName: "nosign"), attributes: []) { [weak self] action in + self?.updateTrigger(by: noOne) + }, + ] + ) + return menu + } + + lazy var notifySectionHeader: UIView = { + let view = UIStackView() + view.translatesAutoresizingMaskIntoConstraints = false + view.isLayoutMarginsRelativeArrangement = true + view.layoutMargins = UIEdgeInsets(top: 15, left: 4, bottom: 5, right: 4) + view.axis = .horizontal + view.alignment = .fill + view.distribution = .equalSpacing + view.spacing = 4 + + let notifyLabel = UILabel() + notifyLabel.translatesAutoresizingMaskIntoConstraints = false + notifyLabel.font = UIFontMetrics(forTextStyle: .title3).scaledFont(for: UIFont.systemFont(ofSize: 20, weight: .semibold)) + notifyLabel.textColor = Asset.Colors.Label.primary.color + notifyLabel.text = L10n.Scene.Settings.Section.Notifications.Trigger.title + view.addArrangedSubview(notifyLabel) + view.addArrangedSubview(whoButton) + return view + }() + + lazy var whoButton: UIButton = { + let whoButton = UIButton(type: .roundedRect) + whoButton.menu = triggerMenu + whoButton.showsMenuAsPrimaryAction = true + whoButton.setBackgroundColor(Asset.Colors.battleshipGrey.color, for: .normal) + whoButton.setTitleColor(Asset.Colors.Label.primary.color, for: .normal) + if let setting = self.viewModel.setting.value, let trigger = setting.triggerBy { + whoButton.setTitle(trigger, for: .normal) + } + whoButton.titleLabel?.font = UIFontMetrics(forTextStyle: .title3).scaledFont(for: UIFont.systemFont(ofSize: 20, weight: .semibold)) + whoButton.contentEdgeInsets = UIEdgeInsets(top: 5, left: 5, bottom: 5, right: 5) + whoButton.layer.cornerRadius = 10 + whoButton.clipsToBounds = true + return whoButton + }() + + lazy var tableView: UITableView = { + // init with a frame to fix a conflict ('UIView-Encapsulated-Layout-Width' UIStackView:0x7f8c2b6c0590.width == 0) + let tableView = UITableView(frame: CGRect(x: 0, y: 0, width: 320, height: 320), style: .grouped) + tableView.translatesAutoresizingMaskIntoConstraints = false + tableView.delegate = self + tableView.rowHeight = UITableView.automaticDimension + tableView.backgroundColor = Asset.Colors.Background.systemGroupedBackground.color + + tableView.register(SettingsAppearanceTableViewCell.self, forCellReuseIdentifier: "SettingsAppearanceTableViewCell") + tableView.register(SettingsToggleTableViewCell.self, forCellReuseIdentifier: "SettingsToggleTableViewCell") + tableView.register(SettingsLinkTableViewCell.self, forCellReuseIdentifier: "SettingsLinkTableViewCell") + return tableView + }() + + lazy var footerView: UIView = { + // init with a frame to fix a conflict ('UIView-Encapsulated-Layout-Height' UIStackView:0x7ffe41e47da0.height == 0) + let view = UIStackView(frame: CGRect(x: 0, y: 0, width: 320, height: 320)) + view.isLayoutMarginsRelativeArrangement = true + view.layoutMargins = UIEdgeInsets(top: 20, left: 20, bottom: 20, right: 20) + view.axis = .vertical + view.alignment = .center + + let label = ActiveLabel(style: .default) + label.textAlignment = .center + label.configure(content: "Mastodon is open source software. You can contribute or report issues on GitHub at tootsuite/mastodon (v3.3.0).") + label.delegate = self + + view.addArrangedSubview(label) + return view + }() + + override func viewDidLoad() { + super.viewDidLoad() + + setupView() + bindViewModel() + + viewModel.viewDidLoad.send() + } + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + guard let footerView = self.tableView.tableFooterView else { + return + } + + let width = self.tableView.bounds.size.width + let size = footerView.systemLayoutSizeFitting(CGSize(width: width, height: UIView.layoutFittingCompressedSize.height)) + if footerView.frame.size.height != size.height { + footerView.frame.size.height = size.height + self.tableView.tableFooterView = footerView + } + } + + // MAKR: - Private methods + private func bindViewModel() { + let input = SettingsViewModel.Input() + _ = viewModel.transform(input: input) + } + + private func setupView() { + view.backgroundColor = Asset.Colors.Background.systemGroupedBackground.color + setupNavigation() + setupTableView() + + view.addSubview(tableView) + NSLayoutConstraint.activate([ + tableView.topAnchor.constraint(equalTo: view.topAnchor), + tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + ]) + } + + private func setupNavigation() { + navigationController?.navigationBar.prefersLargeTitles = true + navigationItem.rightBarButtonItem + = UIBarButtonItem(barButtonSystemItem: UIBarButtonItem.SystemItem.done, + target: self, + action: #selector(doneButtonDidClick)) + navigationItem.title = L10n.Scene.Settings.title + + let barAppearance = UINavigationBarAppearance() + barAppearance.configureWithDefaultBackground() + navigationItem.standardAppearance = barAppearance + navigationItem.compactAppearance = barAppearance + navigationItem.scrollEdgeAppearance = barAppearance + } + + private func setupTableView() { + viewModel.dataSource = UITableViewDiffableDataSource(tableView: tableView, cellProvider: { [weak self] (tableView, indexPath, item) -> UITableViewCell? in + guard let self = self else { return nil } + + switch item { + case .apperance(let item): + guard let cell = tableView.dequeueReusableCell(withIdentifier: "SettingsAppearanceTableViewCell") as? SettingsAppearanceTableViewCell else { + assertionFailure() + return nil + } + cell.update(with: item, delegate: self) + return cell + case .notification(let item): + guard let cell = tableView.dequeueReusableCell(withIdentifier: "SettingsToggleTableViewCell") as? SettingsToggleTableViewCell else { + assertionFailure() + return nil + } + cell.update(with: item, delegate: self) + return cell + case .boringZone(let item), .spicyZone(let item): + guard let cell = tableView.dequeueReusableCell(withIdentifier: "SettingsLinkTableViewCell") as? SettingsLinkTableViewCell else { + assertionFailure() + return nil + } + cell.update(with: item) + return cell + } + }) + + tableView.tableFooterView = footerView + } + + func alertToSignout() { + let alertController = UIAlertController( + title: L10n.Common.Alerts.SignOut.title, + message: L10n.Common.Alerts.SignOut.message, + preferredStyle: .alert + ) + + let cancelAction = UIAlertAction(title: L10n.Common.Controls.Actions.cancel, style: .cancel, handler: nil) + let signOutAction = UIAlertAction(title: L10n.Common.Alerts.SignOut.confirm, style: .destructive) { [weak self] _ in + guard let self = self else { return } + self.signout() + } + alertController.addAction(cancelAction) + alertController.addAction(signOutAction) + self.coordinator.present( + scene: .alertController(alertController: alertController), + from: self, + transition: .alertController(animated: true, completion: nil) + ) + } + + func signout() { + guard let activeMastodonAuthenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { + return + } + + context.authenticationService.signOutMastodonUser( + domain: activeMastodonAuthenticationBox.domain, + userID: activeMastodonAuthenticationBox.userID + ) + .receive(on: DispatchQueue.main) + .sink { [weak self] result in + guard let self = self else { return } + switch result { + case .failure(let error): + assertionFailure(error.localizedDescription) + case .success(let isSignOut): + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: sign out %s", ((#file as NSString).lastPathComponent), #line, #function, isSignOut ? "success" : "fail") + guard isSignOut else { return } + self.coordinator.setup() + self.coordinator.setupOnboardingIfNeeds(animated: true) + } + } + .store(in: &disposeBag) + } + + deinit { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s:", ((#file as NSString).lastPathComponent), #line, #function) + } + + // Mark: - Actions + @objc func doneButtonDidClick() { + dismiss(animated: true, completion: nil) + } +} + +extension SettingsViewController: UITableViewDelegate { + func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? { + let sections = viewModel.dataSource.snapshot().sectionIdentifiers + guard section < sections.count else { return nil } + let sectionData = sections[section] + + if section == 1 { + let header = SettingsSectionHeader( + frame: CGRect(x: 0, y: 0, width: 375, height: 66), + customView: notifySectionHeader) + header.update(title: sectionData.title) + + if let setting = self.viewModel.setting.value, let trigger = setting.triggerBy { + whoButton.setTitle(trigger, for: .normal) + } else { + let anyone = L10n.Scene.Settings.Section.Notifications.Trigger.anyone + whoButton.setTitle(anyone, for: .normal) + } + return header + } else { + let header = SettingsSectionHeader(frame: CGRect(x: 0, y: 0, width: 375, height: 66)) + header.update(title: sectionData.title) + return header + } + } + + // remove the gap of table's footer + func tableView(_ tableView: UITableView, viewForFooterInSection section: Int) -> UIView? { + return UIView() + } + + // remove the gap of table's footer + func tableView(_ tableView: UITableView, heightForFooterInSection section: Int) -> CGFloat { + return 0 + } + + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + let snapshot = self.viewModel.dataSource.snapshot() + let sectionIds = snapshot.sectionIdentifiers + guard indexPath.section < sectionIds.count else { return } + let sectionIdentifier = sectionIds[indexPath.section] + let items = snapshot.itemIdentifiers(inSection: sectionIdentifier) + guard indexPath.row < items.count else { return } + let item = items[indexPath.item] + + switch item { + case .boringZone: + guard let url = viewModel.privacyURL else { break } + coordinator.present( + scene: .safari(url: url), + from: self, + transition: .safariPresent(animated: true, completion: nil) + ) + case .spicyZone(let link): + // clear media cache + if link.title == L10n.Scene.Settings.Section.Spicyzone.clear { + // clean image cache for AlamofireImage + let diskBytes = ImageDownloader.defaultURLCache().currentDiskUsage + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: diskBytes %d", ((#file as NSString).lastPathComponent), #line, #function, diskBytes) + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: clean image cache", ((#file as NSString).lastPathComponent), #line, #function) + ImageDownloader.defaultURLCache().removeAllCachedResponses() + let cleanedDiskBytes = ImageDownloader.defaultURLCache().currentDiskUsage + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: diskBytes %d", ((#file as NSString).lastPathComponent), #line, #function, cleanedDiskBytes) + + // clean Kingfisher Cache + KingfisherManager.shared.cache.clearDiskCache() + } + // logout + if link.title == L10n.Scene.Settings.Section.Spicyzone.signout { + alertToSignout() + } + default: + break + } + } +} + +// Update setting into core data +extension SettingsViewController { + func updateTrigger(by who: String) { + guard self.viewModel.triggerBy != who else { return } + guard let setting = self.viewModel.setting.value else { return } + + setting.update(triggerBy: who) + // trigger to call `subscription` API with POST method + // confirm the local data is correct even if request failed + // The asynchronous execution is to solve the problem of dropped frames for animations. + DispatchQueue.main.async { [weak self] in + self?.viewModel.setting.value = setting + } + } + + func updateAlert(title: String?, isOn: Bool) { + guard let title = title else { return } + guard let settings = self.viewModel.setting.value else { return } + guard let triggerBy = settings.triggerBy else { return } + + if let alerts = settings.subscription?.first(where: { (s) -> Bool in + return s.type == settings.triggerBy + })?.alert { + var alertValues = [Bool?]() + alertValues.append(alerts.favourite?.boolValue) + alertValues.append(alerts.follow?.boolValue) + alertValues.append(alerts.reblog?.boolValue) + alertValues.append(alerts.mention?.boolValue) + + // need to update `alerts` to make update API with correct parameter + switch title { + case L10n.Scene.Settings.Section.Notifications.favorites: + alertValues[0] = isOn + alerts.favourite = NSNumber(booleanLiteral: isOn) + case L10n.Scene.Settings.Section.Notifications.follows: + alertValues[1] = isOn + alerts.follow = NSNumber(booleanLiteral: isOn) + case L10n.Scene.Settings.Section.Notifications.boosts: + alertValues[2] = isOn + alerts.reblog = NSNumber(booleanLiteral: isOn) + case L10n.Scene.Settings.Section.Notifications.mentions: + alertValues[3] = isOn + alerts.mention = NSNumber(booleanLiteral: isOn) + default: break + } + self.viewModel.updateSubscriptionSubject.send((triggerBy: triggerBy, values: alertValues)) + } else if let alertValues = self.viewModel.notificationDefaultValue[triggerBy] { + self.viewModel.updateSubscriptionSubject.send((triggerBy: triggerBy, values: alertValues)) + } + } +} + +extension SettingsViewController: SettingsAppearanceTableViewCellDelegate { + func settingsAppearanceCell(_ view: SettingsAppearanceTableViewCell, didSelect: SettingsItem.AppearanceMode) { + guard let setting = self.viewModel.setting.value else { return } + + context.managedObjectContext.performChanges { + setting.update(appearance: didSelect.rawValue) + } + .sink { (_) in + // change light / dark mode + var overrideUserInterfaceStyle: UIUserInterfaceStyle! + switch didSelect { + case .automatic: + overrideUserInterfaceStyle = .unspecified + case .light: + overrideUserInterfaceStyle = .light + case .dark: + overrideUserInterfaceStyle = .dark + } + view.window?.overrideUserInterfaceStyle = overrideUserInterfaceStyle + }.store(in: &disposeBag) + } +} + +extension SettingsViewController: SettingsToggleCellDelegate { + func settingsToggleCell(_ cell: SettingsToggleTableViewCell, didChangeStatus: Bool) { + updateAlert(title: cell.data?.title, isOn: didChangeStatus) + } +} + +extension SettingsViewController: ActiveLabelDelegate { + func activeLabel(_ activeLabel: ActiveLabel, didSelectActiveEntity entity: ActiveEntity) { + coordinator.present( + scene: .safari(url: URL(string: "https://github.com/tootsuite/mastodon")!), + from: self, + transition: .safariPresent(animated: true, completion: nil) + ) + } +} + +extension SettingsViewController { + static func updateOverrideUserInterfaceStyle(window: UIWindow?) { + guard let box = AppContext.shared.authenticationService.activeMastodonAuthenticationBox.value else { + return + } + + guard let setting: Setting? = { + let domain = box.domain + let request = Setting.sortedFetchRequest + request.predicate = Setting.predicate(domain: domain, userID: box.userID) + request.fetchLimit = 1 + request.returnsObjectsAsFaults = false + do { + return try AppContext.shared.managedObjectContext.fetch(request).first + } catch { + assertionFailure(error.localizedDescription) + return nil + } + }() else { return } + + guard let didSelect = SettingsItem.AppearanceMode(rawValue: setting?.appearance ?? "") else { + return + } + + var overrideUserInterfaceStyle: UIUserInterfaceStyle! + switch didSelect { + case .automatic: + overrideUserInterfaceStyle = .unspecified + case .light: + overrideUserInterfaceStyle = .light + case .dark: + overrideUserInterfaceStyle = .dark + } + window?.overrideUserInterfaceStyle = overrideUserInterfaceStyle + } +} + +#if canImport(SwiftUI) && DEBUG +import SwiftUI + +struct SettingsViewController_Previews: PreviewProvider { + + static var previews: some View { + Group { + UIViewControllerPreview { () -> UIViewController in + return SettingsViewController() + } + .previewLayout(.fixed(width: 390, height: 844)) + } + } + +} + +#endif diff --git a/Mastodon/Scene/Settings/SettingsViewModel.swift b/Mastodon/Scene/Settings/SettingsViewModel.swift new file mode 100644 index 000000000..470617aeb --- /dev/null +++ b/Mastodon/Scene/Settings/SettingsViewModel.swift @@ -0,0 +1,393 @@ +// +// SettingsViewModel.swift +// Mastodon +// +// Created by ihugo on 2021/4/7. +// + +import Combine +import CoreData +import CoreDataStack +import Foundation +import MastodonSDK +import UIKit +import os.log + +class SettingsViewModel: NSObject, NeedsDependency { + // confirm set only once + weak var context: AppContext! { willSet { precondition(context == nil) } } + weak var coordinator: SceneCoordinator! { willSet { precondition(coordinator == nil) } } + + var dataSource: UITableViewDiffableDataSource! + var disposeBag = Set() + var updateDisposeBag = Set() + var createDisposeBag = Set() + + let viewDidLoad = PassthroughSubject() + lazy var fetchResultsController: NSFetchedResultsController = { + let fetchRequest = Setting.sortedFetchRequest + if let box = + self.context.authenticationService.activeMastodonAuthenticationBox.value { + let domain = box.domain + fetchRequest.predicate = Setting.predicate(domain: domain, userID: box.userID) + } + + fetchRequest.fetchLimit = 1 + fetchRequest.returnsObjectsAsFaults = false + let controller = NSFetchedResultsController( + fetchRequest: fetchRequest, + managedObjectContext: context.managedObjectContext, + sectionNameKeyPath: nil, + cacheName: nil + ) + controller.delegate = self + return controller + }() + let setting = CurrentValueSubject(nil) + + /// create a subscription when: + /// - does not has one + /// - does not find subscription for selected trigger when change trigger + let createSubscriptionSubject = PassthroughSubject<(triggerBy: String, values: [Bool?]), Never>() + + /// update a subscription when: + /// - change switch for specified alerts + let updateSubscriptionSubject = PassthroughSubject<(triggerBy: String, values: [Bool?]), Never>() + + lazy var notificationDefaultValue: [String: [Bool?]] = { + let followerSwitchItems: [Bool?] = [true, nil, true, true] + let anyoneSwitchItems: [Bool?] = [true, true, true, true] + let noOneSwitchItems: [Bool?] = [nil, nil, nil, nil] + let followSwitchItems: [Bool?] = [true, true, true, true] + + let anyone = L10n.Scene.Settings.Section.Notifications.Trigger.anyone + let follower = L10n.Scene.Settings.Section.Notifications.Trigger.follower + let follow = L10n.Scene.Settings.Section.Notifications.Trigger.follow + let noOne = L10n.Scene.Settings.Section.Notifications.Trigger.noone + return [anyone: anyoneSwitchItems, + follower: followerSwitchItems, + follow: followSwitchItems, + noOne: noOneSwitchItems] + }() + + lazy var privacyURL: URL? = { + guard let box = AppContext.shared.authenticationService.activeMastodonAuthenticationBox.value else { + return nil + } + + return Mastodon.API.privacyURL(domain: box.domain) + }() + + /// to store who trigger the notification. + var triggerBy: String? + + struct Input { + } + + struct Output { + } + + init(context: AppContext, coordinator: SceneCoordinator) { + self.context = context + self.coordinator = coordinator + + super.init() + } + + func transform(input: Input?) -> Output? { + typealias SubscriptionResponse = Mastodon.Response.Content + createSubscriptionSubject + .debounce(for: .milliseconds(300), scheduler: DispatchQueue.main) + .sink { _ in + } receiveValue: { [weak self] (arg) in + let (triggerBy, values) = arg + guard let self = self else { + return + } + guard let activeMastodonAuthenticationBox = + self.context.authenticationService.activeMastodonAuthenticationBox.value else { + return + } + guard values.count >= 4 else { + return + } + + self.createDisposeBag.removeAll() + typealias Query = Mastodon.API.Subscriptions.CreateSubscriptionQuery + let domain = activeMastodonAuthenticationBox.domain + let query = Query( + // FIXME: to replace the correct endpoint, p256dh, auth + endpoint: "http://www.google.com", + p256dh: "BLQELIDm-6b9Bl07YrEuXJ4BL_YBVQ0dvt9NQGGJxIQidJWHPNa9YrouvcQ9d7_MqzvGS9Alz60SZNCG3qfpk=", + auth: "4vQK-SvRAN5eo-8ASlrwA==", + favourite: values[0], + follow: values[1], + reblog: values[2], + mention: values[3], + poll: nil + ) + self.context.apiService.changeSubscription( + domain: domain, + mastodonAuthenticationBox: activeMastodonAuthenticationBox, + query: query, + triggerBy: triggerBy, + userID: activeMastodonAuthenticationBox.userID + ) + .sink { (_) in + } receiveValue: { (_) in + } + .store(in: &self.createDisposeBag) + } + .store(in: &disposeBag) + + updateSubscriptionSubject + .debounce(for: .milliseconds(300), scheduler: DispatchQueue.main) + .sink { _ in + } receiveValue: { [weak self] (arg) in + let (triggerBy, values) = arg + guard let self = self else { + return + } + guard let activeMastodonAuthenticationBox = + self.context.authenticationService.activeMastodonAuthenticationBox.value else { + return + } + guard values.count >= 4 else { + return + } + + self.updateDisposeBag.removeAll() + typealias Query = Mastodon.API.Subscriptions.UpdateSubscriptionQuery + let domain = activeMastodonAuthenticationBox.domain + let query = Query( + favourite: values[0], + follow: values[1], + reblog: values[2], + mention: values[3], + poll: nil) + self.context.apiService.updateSubscription( + domain: domain, + mastodonAuthenticationBox: activeMastodonAuthenticationBox, + query: query, + triggerBy: triggerBy, + userID: activeMastodonAuthenticationBox.userID + ) + .sink { (_) in + } receiveValue: { (_) in + } + .store(in: &self.updateDisposeBag) + } + .store(in: &disposeBag) + + // build data for table view + buildDataSource() + + // request subsription data for updating or initialization + requestSubscription() + return nil + } + + // MARK: - Private methods + fileprivate func processDataSource(_ settings: Setting?) { + var snapshot = NSDiffableDataSourceSnapshot() + + // appearance + let appearnceMode = SettingsItem.AppearanceMode(rawValue: settings?.appearance ?? "") ?? .automatic + let appearanceItem = SettingsItem.apperance(item: appearnceMode) + let appearance = SettingsSection.apperance(title: L10n.Scene.Settings.Section.Appearance.title, selectedMode:appearanceItem) + snapshot.appendSections([appearance]) + snapshot.appendItems([appearanceItem]) + + // notifications + var switches: [Bool?]? + if let alerts = settings?.subscription?.first(where: { (s) -> Bool in + return s.type == settings?.triggerBy + })?.alert { + var items = [Bool?]() + items.append(alerts.favourite?.boolValue) + items.append(alerts.follow?.boolValue) + items.append(alerts.reblog?.boolValue) + items.append(alerts.mention?.boolValue) + switches = items + } else if let triggerBy = settings?.triggerBy, + let values = self.notificationDefaultValue[triggerBy] { + switches = values + } else { + // fallback a default value + let anyone = L10n.Scene.Settings.Section.Notifications.Trigger.anyone + switches = self.notificationDefaultValue[anyone] + } + + let notifications = [L10n.Scene.Settings.Section.Notifications.favorites, + L10n.Scene.Settings.Section.Notifications.follows, + L10n.Scene.Settings.Section.Notifications.boosts, + L10n.Scene.Settings.Section.Notifications.mentions,] + var notificationItems = [SettingsItem]() + for (i, noti) in notifications.enumerated() { + var value: Bool? = nil + if let switches = switches, i < switches.count { + value = switches[i] + } + + let item = SettingsItem.notification(item: SettingsItem.NotificationSwitch(title: noti, isOn: value == true, enable: value != nil)) + notificationItems.append(item) + } + let notificationSection = SettingsSection.notifications(title: L10n.Scene.Settings.Section.Notifications.title, items: notificationItems) + snapshot.appendSections([notificationSection]) + snapshot.appendItems(notificationItems) + + // boring zone + let boringLinks = [L10n.Scene.Settings.Section.Boringzone.terms, + L10n.Scene.Settings.Section.Boringzone.privacy] + var boringLinkItems = [SettingsItem]() + for l in boringLinks { + let item = SettingsItem.boringZone(item: SettingsItem.Link(title: l, color: .systemBlue)) + boringLinkItems.append(item) + } + let boringSection = SettingsSection.boringZone(title: L10n.Scene.Settings.Section.Boringzone.title, items: boringLinkItems) + snapshot.appendSections([boringSection]) + snapshot.appendItems(boringLinkItems) + + // spicy zone + let spicyLinks = [L10n.Scene.Settings.Section.Spicyzone.clear, + L10n.Scene.Settings.Section.Spicyzone.signout] + var spicyLinkItems = [SettingsItem]() + for l in spicyLinks { + let item = SettingsItem.spicyZone(item: SettingsItem.Link(title: l, color: .systemRed)) + spicyLinkItems.append(item) + } + let spicySection = SettingsSection.spicyZone(title: L10n.Scene.Settings.Section.Spicyzone.title, items: spicyLinkItems) + snapshot.appendSections([spicySection]) + snapshot.appendItems(spicyLinkItems) + + self.dataSource.apply(snapshot, animatingDifferences: false) + } + + private func buildDataSource() { + setting.sink { [weak self] (settings) in + guard let self = self else { return } + self.processDataSource(settings) + } + .store(in: &disposeBag) + } + + private func requestSubscription() { + setting.sink { [weak self] (settings) in + guard let self = self else { return } + guard settings != nil else { return } + guard self.triggerBy != settings?.triggerBy else { return } + self.triggerBy = settings?.triggerBy + + var switches: [Bool?]? + var who: String? + if let alerts = settings?.subscription?.first(where: { (s) -> Bool in + return s.type == settings?.triggerBy + })?.alert { + var items = [Bool?]() + items.append(alerts.favourite?.boolValue) + items.append(alerts.follow?.boolValue) + items.append(alerts.reblog?.boolValue) + items.append(alerts.mention?.boolValue) + switches = items + who = settings?.triggerBy + } else if let triggerBy = settings?.triggerBy, + let values = self.notificationDefaultValue[triggerBy] { + switches = values + who = triggerBy + } else { + // fallback a default value + let anyone = L10n.Scene.Settings.Section.Notifications.Trigger.anyone + switches = self.notificationDefaultValue[anyone] + who = anyone + } + + // should create a subscription whenever change trigger + if let values = switches, let triggerBy = who { + self.createSubscriptionSubject.send((triggerBy: triggerBy, values: values)) + } + } + .store(in: &disposeBag) + + guard let activeMastodonAuthenticationBox = self.context.authenticationService.activeMastodonAuthenticationBox.value else { + return + } + let domain = activeMastodonAuthenticationBox.domain + let userId = activeMastodonAuthenticationBox.userID + + do { + try fetchResultsController.performFetch() + if nil == fetchResultsController.fetchedObjects?.first { + let anyone = L10n.Scene.Settings.Section.Notifications.Trigger.anyone + setting.value = self.context.apiService.createSettingIfNeed(domain: domain, + userId: userId, + triggerBy: anyone) + } else { + setting.value = fetchResultsController.fetchedObjects?.first + } + } catch { + assertionFailure(error.localizedDescription) + } + } + + deinit { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s:", ((#file as NSString).lastPathComponent), #line, #function) + } +} + +// MARK: - NSFetchedResultsControllerDelegate +extension SettingsViewModel: NSFetchedResultsControllerDelegate { + + func controllerWillChangeContent(_ controller: NSFetchedResultsController) { + os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + } + + func controllerDidChangeContent(_ controller: NSFetchedResultsController) { + guard controller === fetchResultsController else { + return + } + + setting.value = fetchResultsController.fetchedObjects?.first + } + +} + +enum SettingsSection: Hashable { + case apperance(title: String, selectedMode: SettingsItem) + case notifications(title: String, items: [SettingsItem]) + case boringZone(title: String, items: [SettingsItem]) + case spicyZone(title: String, items: [SettingsItem]) + + var title: String { + switch self { + case .apperance(let title, _), + .notifications(let title, _), + .boringZone(let title, _), + .spicyZone(let title, _): + return title + } + } +} + +enum SettingsItem: Hashable { + enum AppearanceMode: String { + case automatic + case light + case dark + } + + struct NotificationSwitch: Hashable { + let title: String + let isOn: Bool + let enable: Bool + } + + struct Link: Hashable { + let title: String + let color: UIColor + } + + case apperance(item: AppearanceMode) + case notification(item: NotificationSwitch) + case boringZone(item: Link) + case spicyZone(item: Link) +} diff --git a/Mastodon/Scene/Settings/View/Cell/SettingsAppearanceTableViewCell.swift b/Mastodon/Scene/Settings/View/Cell/SettingsAppearanceTableViewCell.swift new file mode 100644 index 000000000..a477661ee --- /dev/null +++ b/Mastodon/Scene/Settings/View/Cell/SettingsAppearanceTableViewCell.swift @@ -0,0 +1,205 @@ +// +// SettingsAppearanceTableViewCell.swift +// Mastodon +// +// Created by ihugo on 2021/4/8. +// + +import UIKit + +protocol SettingsAppearanceTableViewCellDelegate: class { + func settingsAppearanceCell(_ view: SettingsAppearanceTableViewCell, didSelect: SettingsItem.AppearanceMode) +} + +class AppearanceView: UIView { + lazy var imageView: UIImageView = { + let view = UIImageView() + return view + }() + lazy var titleLabel: UILabel = { + let label = UILabel() + label.font = .systemFont(ofSize: 12, weight: .regular) + label.textColor = Asset.Colors.Label.primary.color + label.textAlignment = .center + return label + }() + lazy var checkBox: UIButton = { + let button = UIButton() + button.isUserInteractionEnabled = false + button.setImage(UIImage(systemName: "circle"), for: .normal) + button.setImage(UIImage(systemName: "checkmark.circle.fill"), for: .selected) + button.imageView?.preferredSymbolConfiguration = UIImage.SymbolConfiguration(textStyle: .body) + button.imageView?.tintColor = Asset.Colors.Label.secondary.color + button.imageView?.contentMode = .scaleAspectFill + return button + }() + lazy var stackView: UIStackView = { + let view = UIStackView() + view.axis = .vertical + view.spacing = 10 + view.distribution = .equalSpacing + return view + }() + + var selected: Bool = false { + didSet { + checkBox.isSelected = selected + if selected { + checkBox.imageView?.tintColor = Asset.Colors.Label.highlight.color + } else { + checkBox.imageView?.tintColor = Asset.Colors.Label.secondary.color + } + } + } + + // MARK: - Methods + init(image: UIImage?, title: String) { + super.init(frame: .zero) + setupUI() + + imageView.image = image + titleLabel.text = title + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Private methods + private func setupUI() { + stackView.addArrangedSubview(imageView) + stackView.addArrangedSubview(titleLabel) + stackView.addArrangedSubview(checkBox) + + addSubview(stackView) + translatesAutoresizingMaskIntoConstraints = false + stackView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + stackView.topAnchor.constraint(equalTo: self.topAnchor), + stackView.leadingAnchor.constraint(equalTo: self.leadingAnchor), + stackView.bottomAnchor.constraint(equalTo: self.bottomAnchor), + stackView.trailingAnchor.constraint(equalTo: self.trailingAnchor), + imageView.heightAnchor.constraint(equalTo: imageView.widthAnchor, multiplier: 218.0 / 100.0), + ]) + } +} + +class SettingsAppearanceTableViewCell: UITableViewCell { + weak var delegate: SettingsAppearanceTableViewCellDelegate? + var appearance: SettingsItem.AppearanceMode = .automatic + + lazy var stackView: UIStackView = { + let view = UIStackView() + view.isLayoutMarginsRelativeArrangement = true + view.layoutMargins = UIEdgeInsets(top: 0, left: 20, bottom: 0, right: 20) + view.axis = .horizontal + view.distribution = .fillEqually + view.spacing = 18 + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + + let automatic = AppearanceView(image: Asset.Settings.appearanceAutomatic.image, + title: L10n.Scene.Settings.Section.Appearance.automatic) + let light = AppearanceView(image: Asset.Settings.appearanceLight.image, + title: L10n.Scene.Settings.Section.Appearance.light) + let dark = AppearanceView(image: Asset.Settings.appearanceDark.image, + title: L10n.Scene.Settings.Section.Appearance.dark) + + lazy var automaticTap: UITapGestureRecognizer = { + let tapGestureRecognizer = UITapGestureRecognizer.singleTapGestureRecognizer + tapGestureRecognizer.addTarget(self, action: #selector(appearanceDidTap(sender:))) + return tapGestureRecognizer + }() + + lazy var lightTap: UITapGestureRecognizer = { + let tapGestureRecognizer = UITapGestureRecognizer.singleTapGestureRecognizer + tapGestureRecognizer.addTarget(self, action: #selector(appearanceDidTap(sender:))) + return tapGestureRecognizer + }() + + lazy var darkTap: UITapGestureRecognizer = { + let tapGestureRecognizer = UITapGestureRecognizer.singleTapGestureRecognizer + tapGestureRecognizer.addTarget(self, action: #selector(appearanceDidTap(sender:))) + return tapGestureRecognizer + }() + + // MARK: - Methods + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + setupUI() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func layoutSubviews() { + super.layoutSubviews() + + // remove seperator line in section of group tableview + for subview in self.subviews { + if subview != self.contentView && subview.frame.width == self.frame.width { + subview.removeFromSuperview() + } + } + } + + func update(with data: SettingsItem.AppearanceMode, delegate: SettingsAppearanceTableViewCellDelegate?) { + appearance = data + self.delegate = delegate + + automatic.selected = false + light.selected = false + dark.selected = false + + switch data { + case .automatic: + automatic.selected = true + case .light: + light.selected = true + case .dark: + dark.selected = true + } + } + + // MARK: Private methods + private func setupUI() { + backgroundColor = Asset.Colors.Background.systemGroupedBackground.color + selectionStyle = .none + contentView.addSubview(stackView) + + stackView.addArrangedSubview(automatic) + stackView.addArrangedSubview(light) + stackView.addArrangedSubview(dark) + + automatic.addGestureRecognizer(automaticTap) + light.addGestureRecognizer(lightTap) + dark.addGestureRecognizer(darkTap) + + NSLayoutConstraint.activate([ + stackView.topAnchor.constraint(equalTo: contentView.topAnchor), + stackView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + stackView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), + stackView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + ]) + } + + // MARK: - Actions + @objc func appearanceDidTap(sender: UIGestureRecognizer) { + if sender == automaticTap { + appearance = .automatic + } + + if sender == lightTap { + appearance = .light + } + + if sender == darkTap { + appearance = .dark + } + + guard let delegate = self.delegate else { return } + delegate.settingsAppearanceCell(self, didSelect: appearance) + } +} diff --git a/Mastodon/Scene/Settings/View/Cell/SettingsLinkTableViewCell.swift b/Mastodon/Scene/Settings/View/Cell/SettingsLinkTableViewCell.swift new file mode 100644 index 000000000..b5d0306d4 --- /dev/null +++ b/Mastodon/Scene/Settings/View/Cell/SettingsLinkTableViewCell.swift @@ -0,0 +1,31 @@ +// +// SettingsLinkTableViewCell.swift +// Mastodon +// +// Created by ihugo on 2021/4/8. +// + +import UIKit + +class SettingsLinkTableViewCell: UITableViewCell { + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + + selectionStyle = .none + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func setHighlighted(_ highlighted: Bool, animated: Bool) { + super.setHighlighted(highlighted, animated: animated) + textLabel?.alpha = highlighted ? 0.6 : 1.0 + } + + // MARK: - Methods + func update(with data: SettingsItem.Link) { + textLabel?.text = data.title + textLabel?.textColor = data.color + } +} diff --git a/Mastodon/Scene/Settings/View/Cell/SettingsToggleTableViewCell.swift b/Mastodon/Scene/Settings/View/Cell/SettingsToggleTableViewCell.swift new file mode 100644 index 000000000..b35b2b50f --- /dev/null +++ b/Mastodon/Scene/Settings/View/Cell/SettingsToggleTableViewCell.swift @@ -0,0 +1,63 @@ +// +// SettingsToggleTableViewCell.swift +// Mastodon +// +// Created by ihugo on 2021/4/8. +// + +import UIKit + +protocol SettingsToggleCellDelegate: class { + func settingsToggleCell(_ cell: SettingsToggleTableViewCell, didChangeStatus: Bool) +} + +class SettingsToggleTableViewCell: UITableViewCell { + lazy var switchButton: UISwitch = { + let view = UISwitch(frame:.zero) + return view + }() + + var data: SettingsItem.NotificationSwitch? + weak var delegate: SettingsToggleCellDelegate? + + // MARK: - Methods + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: .default, reuseIdentifier: reuseIdentifier) + setupUI() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func update(with data: SettingsItem.NotificationSwitch, delegate: SettingsToggleCellDelegate?) { + self.delegate = delegate + self.data = data + textLabel?.text = data.title + switchButton.isOn = data.isOn + setup(enable: data.enable) + } + + // MARK: Actions + @objc func valueDidChange(sender: UISwitch) { + guard let delegate = delegate else { return } + delegate.settingsToggleCell(self, didChangeStatus: sender.isOn) + } + + // MARK: Private methods + private func setupUI() { + selectionStyle = .none + accessoryView = switchButton + + switchButton.addTarget(self, action: #selector(valueDidChange(sender:)), for: .valueChanged) + } + + private func setup(enable: Bool) { + if enable { + textLabel?.textColor = Asset.Colors.Label.primary.color + } else { + textLabel?.textColor = Asset.Colors.Label.secondary.color + } + switchButton.isEnabled = enable + } +} diff --git a/Mastodon/Scene/Settings/View/SettingsSectionHeader.swift b/Mastodon/Scene/Settings/View/SettingsSectionHeader.swift new file mode 100644 index 000000000..ccd7fd875 --- /dev/null +++ b/Mastodon/Scene/Settings/View/SettingsSectionHeader.swift @@ -0,0 +1,64 @@ +// +// SettingsSectionHeader.swift +// Mastodon +// +// Created by ihugo on 2021/4/8. +// + +import UIKit + +struct GroupedTableViewConstraints { + static let topMargin: CGFloat = 40 + static let bottomMargin: CGFloat = 10 +} + +/// section header which supports add a custom view blelow the title +class SettingsSectionHeader: UIView { + lazy var titleLabel: UILabel = { + let label = UILabel() + label.translatesAutoresizingMaskIntoConstraints = false + label.font = .systemFont(ofSize: 13, weight: .regular) + label.textColor = Asset.Colors.Label.secondary.color + return label + }() + + lazy var stackView: UIStackView = { + let view = UIStackView() + view.translatesAutoresizingMaskIntoConstraints = false + view.isLayoutMarginsRelativeArrangement = true + view.layoutMargins = UIEdgeInsets( + top: GroupedTableViewConstraints.topMargin, + left: 0, + bottom: GroupedTableViewConstraints.bottomMargin, + right: 0 + ) + view.axis = .vertical + return view + }() + + init(frame: CGRect, customView: UIView? = nil) { + super.init(frame: frame) + + backgroundColor = Asset.Colors.Background.systemGroupedBackground.color + stackView.addArrangedSubview(titleLabel) + if let view = customView { + stackView.addArrangedSubview(view) + } + + addSubview(stackView) + NSLayoutConstraint.activate([ + stackView.leadingAnchor.constraint(equalTo: self.readableContentGuide.leadingAnchor), + stackView.trailingAnchor.constraint(lessThanOrEqualTo: self.readableContentGuide.trailingAnchor), + stackView.bottomAnchor.constraint(equalTo: self.bottomAnchor), + stackView.topAnchor.constraint(equalTo: self.topAnchor), + ]) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func update(title: String?) { + titleLabel.text = title?.uppercased() + } +} diff --git a/Mastodon/Service/APIService/APIService+Subscriptions.swift b/Mastodon/Service/APIService/APIService+Subscriptions.swift new file mode 100644 index 000000000..337ab26d2 --- /dev/null +++ b/Mastodon/Service/APIService/APIService+Subscriptions.swift @@ -0,0 +1,163 @@ +// +// APIService+Settings.swift +// Mastodon +// +// Created by ihugo on 2021/4/9. +// + +import Combine +import CoreData +import CoreDataStack +import Foundation +import MastodonSDK + +extension APIService { + + func subscription( + domain: String, + userID: String, + mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox + ) -> AnyPublisher, Error> { + let authorization = mastodonAuthenticationBox.userAuthorization + + let findSettings: Setting? = { + let request = Setting.sortedFetchRequest + request.predicate = Setting.predicate(domain: domain, userID: userID) + request.fetchLimit = 1 + request.returnsObjectsAsFaults = false + do { + return try self.backgroundManagedObjectContext.fetch(request).first + } catch { + assertionFailure(error.localizedDescription) + return nil + } + }() + let triggerBy = findSettings?.triggerBy ?? "anyone" + let setting = self.createSettingIfNeed( + domain: domain, + userId: userID, + triggerBy: triggerBy + ) + return Mastodon.API.Subscriptions.subscription( + session: session, + domain: domain, + authorization: authorization + ) + .flatMap { response -> AnyPublisher, Error> in + return self.backgroundManagedObjectContext.performChanges { + _ = APIService.CoreData.createOrMergeSubscription( + into: self.backgroundManagedObjectContext, + entity: response.value, + domain: domain, + triggerBy: triggerBy, + setting: setting) + } + .setFailureType(to: Error.self) + .map { _ in return response } + .eraseToAnyPublisher() + }.eraseToAnyPublisher() + } + + func changeSubscription( + domain: String, + mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox, + query: Mastodon.API.Subscriptions.CreateSubscriptionQuery, + triggerBy: String, + userID: String + ) -> AnyPublisher, Error> { + let authorization = mastodonAuthenticationBox.userAuthorization + + let setting = self.createSettingIfNeed(domain: domain, + userId: userID, + triggerBy: triggerBy) + return Mastodon.API.Subscriptions.createSubscription( + session: session, + domain: domain, + authorization: authorization, + query: query + ) + .flatMap { response -> AnyPublisher, Error> in + return self.backgroundManagedObjectContext.performChanges { + _ = APIService.CoreData.createOrMergeSubscription( + into: self.backgroundManagedObjectContext, + entity: response.value, + domain: domain, + triggerBy: triggerBy, + setting: setting + ) + } + .setFailureType(to: Error.self) + .map { _ in return response } + .eraseToAnyPublisher() + }.eraseToAnyPublisher() + } + + func updateSubscription( + domain: String, + mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox, + query: Mastodon.API.Subscriptions.UpdateSubscriptionQuery, + triggerBy: String, + userID: String + ) -> AnyPublisher, Error> { + let authorization = mastodonAuthenticationBox.userAuthorization + + let setting = self.createSettingIfNeed(domain: domain, + userId: userID, + triggerBy: triggerBy) + + return Mastodon.API.Subscriptions.updateSubscription( + session: session, + domain: domain, + authorization: authorization, + query: query + ) + .flatMap { response -> AnyPublisher, Error> in + return self.backgroundManagedObjectContext.performChanges { + _ = APIService.CoreData.createOrMergeSubscription( + into: self.backgroundManagedObjectContext, + entity: response.value, + domain: domain, + triggerBy: triggerBy, + setting: setting + ) + } + .setFailureType(to: Error.self) + .map { _ in return response } + .eraseToAnyPublisher() + }.eraseToAnyPublisher() + } + + func createSettingIfNeed(domain: String, userId: String, triggerBy: String) -> Setting { + // create setting entity if possible + let oldSetting: Setting? = { + let request = Setting.sortedFetchRequest + request.predicate = Setting.predicate(domain: domain, userID: userId) + request.fetchLimit = 1 + request.returnsObjectsAsFaults = false + do { + return try backgroundManagedObjectContext.fetch(request).first + } catch { + assertionFailure(error.localizedDescription) + return nil + } + }() + var setting: Setting! + if let oldSetting = oldSetting { + setting = oldSetting + } else { + let property = Setting.Property( + appearance: "automatic", + triggerBy: triggerBy, + domain: domain, + userID: userId) + (setting, _) = APIService.CoreData.createOrMergeSetting( + into: backgroundManagedObjectContext, + domain: domain, + userID: userId, + property: property + ) + } + return setting + } +} + diff --git a/Mastodon/Service/APIService/CoreData/APIService+CoreData+Subscriptions.swift b/Mastodon/Service/APIService/CoreData/APIService+CoreData+Subscriptions.swift new file mode 100644 index 000000000..f5a4022ea --- /dev/null +++ b/Mastodon/Service/APIService/CoreData/APIService+CoreData+Subscriptions.swift @@ -0,0 +1,108 @@ +// +// APIService+CoreData+Notification.swift +// Mastodon +// +// Created by ihugo on 2021/4/11. +// + +import os.log +import Foundation +import CoreData +import CoreDataStack +import MastodonSDK + +extension APIService.CoreData { + + static func createOrMergeSetting( + into managedObjectContext: NSManagedObjectContext, + domain: String, + userID: String, + property: Setting.Property + ) -> (Subscription: Setting, isCreated: Bool) { + let oldSetting: Setting? = { + let request = Setting.sortedFetchRequest + request.predicate = Setting.predicate(domain: property.domain, userID: userID) + request.fetchLimit = 1 + request.returnsObjectsAsFaults = false + do { + return try managedObjectContext.fetch(request).first + } catch { + assertionFailure(error.localizedDescription) + return nil + } + }() + + if let oldSetting = oldSetting { + return (oldSetting, false) + } else { + let setting = Setting.insert( + into: managedObjectContext, + property: property) + return (setting, true) + } + } + + static func createOrMergeSubscription( + into managedObjectContext: NSManagedObjectContext, + entity: Mastodon.Entity.Subscription, + domain: String, + triggerBy: String, + setting: Setting + ) -> (Subscription: Subscription, isCreated: Bool) { + let oldSubscription: Subscription? = { + let request = Subscription.sortedFetchRequest + request.predicate = Subscription.predicate(type: triggerBy) + request.fetchLimit = 1 + request.returnsObjectsAsFaults = false + do { + return try managedObjectContext.fetch(request).first + } catch { + assertionFailure(error.localizedDescription) + return nil + } + }() + + let property = Subscription.Property( + endpoint: entity.endpoint, + id: entity.id, + serverKey: entity.serverKey, + type: triggerBy + ) + let alertEntity = entity.alerts + let alert = SubscriptionAlerts.Property( + favourite: alertEntity.favouriteNumber, + follow: alertEntity.followNumber, + mention: alertEntity.mentionNumber, + poll: alertEntity.pollNumber, + reblog: alertEntity.reblogNumber + ) + if let oldSubscription = oldSubscription { + oldSubscription.updateIfNeed(property: property) + if nil == oldSubscription.alert { + oldSubscription.alert = SubscriptionAlerts.insert( + into: managedObjectContext, + property: alert + ) + } else { + oldSubscription.alert?.updateIfNeed(property: alert) + } + + if oldSubscription.alert?.hasChanges == true || oldSubscription.hasChanges { + // don't expand subscription if add existed subscription + //setting.mutableSetValue(forKey: #keyPath(Setting.subscription)).add(oldSubscription) + oldSubscription.didUpdate(at: Date()) + } + return (oldSubscription, false) + } else { + let subscription = Subscription.insert( + into: managedObjectContext, + property: property + ) + subscription.alert = SubscriptionAlerts.insert( + into: managedObjectContext, + property: alert) + setting.mutableSetValue(forKey: #keyPath(Setting.subscription)).add(subscription) + return (subscription, true) + } + } +} diff --git a/Mastodon/Supporting Files/SceneDelegate.swift b/Mastodon/Supporting Files/SceneDelegate.swift index e13395ccd..0f5e2bd59 100644 --- a/Mastodon/Supporting Files/SceneDelegate.swift +++ b/Mastodon/Supporting Files/SceneDelegate.swift @@ -27,6 +27,9 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { sceneCoordinator.setup() sceneCoordinator.setupOnboardingIfNeeds(animated: false) window.makeKeyAndVisible() + + // update `overrideUserInterfaceStyle` with current setting + SettingsViewController.updateOverrideUserInterfaceStyle(window: window) } func sceneDidDisconnect(_ scene: UIScene) { diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Push.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Push.swift new file mode 100644 index 000000000..df9168499 --- /dev/null +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Push.swift @@ -0,0 +1,226 @@ +// +// File.swift +// +// +// Created by ihugo on 2021/4/9. +// + +import Foundation +import Combine + +extension Mastodon.API.Subscriptions { + + static func pushEndpointURL(domain: String) -> URL { + return Mastodon.API.endpointURL(domain: domain).appendingPathComponent("push/subscription") + } + + /// Get current subscription + /// + /// Using this endpoint to get current subscription + /// + /// - Since: 2.4.0 + /// - Version: 3.3.0 + /// # Last Update + /// 2021/4/9 + /// # Reference + /// [Document](https://docs.joinmastodon.org/methods/notifications/push/) + /// - Parameters: + /// - session: `URLSession` + /// - domain: Mastodon instance domain. e.g. "example.com" + /// - authorization: User token. Could be nil if status is public + /// - Returns: `AnyPublisher` contains `Subscription` nested in the response + public static func subscription( + session: URLSession, + domain: String, + authorization: Mastodon.API.OAuth.Authorization + ) -> AnyPublisher, Error> { + let request = Mastodon.API.get( + url: pushEndpointURL(domain: domain), + query: nil, + authorization: authorization + ) + return session.dataTaskPublisher(for: request) + .tryMap { data, response in + let value = try Mastodon.API.decode(type: Mastodon.Entity.Subscription.self, from: data, response: response) + return Mastodon.Response.Content(value: value, response: response) + } + .eraseToAnyPublisher() + } + + /// Subscribe to push notifications + /// + /// Add a Web Push API subscription to receive notifications. Each access token can have one push subscription. If you create a new subscription, the old subscription is deleted. + /// + /// - Since: 2.4.0 + /// - Version: 3.3.0 + /// # Last Update + /// 2021/4/9 + /// # Reference + /// [Document](https://docs.joinmastodon.org/methods/notifications/push/) + /// - Parameters: + /// - session: `URLSession` + /// - domain: Mastodon instance domain. e.g. "example.com" + /// - authorization: User token. Could be nil if status is public + /// - Returns: `AnyPublisher` contains `Subscription` nested in the response + public static func createSubscription( + session: URLSession, + domain: String, + authorization: Mastodon.API.OAuth.Authorization, + query: CreateSubscriptionQuery + ) -> AnyPublisher, Error> { + let request = Mastodon.API.post( + url: pushEndpointURL(domain: domain), + query: query, + authorization: authorization + ) + return session.dataTaskPublisher(for: request) + .tryMap { data, response in + let value = try Mastodon.API.decode(type: Mastodon.Entity.Subscription.self, from: data, response: response) + return Mastodon.Response.Content(value: value, response: response) + } + .eraseToAnyPublisher() + } + + /// Change types of notifications + /// + /// Updates the current push subscription. Only the data part can be updated. To change fundamentals, a new subscription must be created instead. + /// + /// - Since: 2.4.0 + /// - Version: 3.3.0 + /// # Last Update + /// 2021/4/9 + /// # Reference + /// [Document](https://docs.joinmastodon.org/methods/notifications/push/) + /// - Parameters: + /// - session: `URLSession` + /// - domain: Mastodon instance domain. e.g. "example.com" + /// - authorization: User token. Could be nil if status is public + /// - Returns: `AnyPublisher` contains `Subscription` nested in the response + public static func updateSubscription( + session: URLSession, + domain: String, + authorization: Mastodon.API.OAuth.Authorization, + query: UpdateSubscriptionQuery + ) -> AnyPublisher, Error> { + let request = Mastodon.API.put( + url: pushEndpointURL(domain: domain), + query: query, + authorization: authorization + ) + return session.dataTaskPublisher(for: request) + .tryMap { data, response in + let value = try Mastodon.API.decode(type: Mastodon.Entity.Subscription.self, from: data, response: response) + return Mastodon.Response.Content(value: value, response: response) + } + .eraseToAnyPublisher() + } +} + +extension Mastodon.API.Subscriptions { + public struct CreateSubscriptionQuery: Codable, PostQuery { + let endpoint: String + let p256dh: String + let auth: String + let favourite: Bool? + let follow: Bool? + let reblog: Bool? + let mention: Bool? + let poll: Bool? + + var queryItems: [URLQueryItem]? { + var items = [URLQueryItem]() + + items.append(URLQueryItem(name: "subscription[endpoint]", value: endpoint)) + items.append(URLQueryItem(name: "subscription[keys][p256dh]", value: p256dh)) + items.append(URLQueryItem(name: "subscription[keys][auth]", value: auth)) + + if let followValue = follow?.queryItemValue { + let followItem = URLQueryItem(name: "data[alerts][follow]", value: followValue) + items.append(followItem) + } + + if let favouriteValue = favourite?.queryItemValue { + let favouriteItem = URLQueryItem(name: "data[alerts][favourite]", value: favouriteValue) + items.append(favouriteItem) + } + + if let reblogValue = reblog?.queryItemValue { + let reblogItem = URLQueryItem(name: "data[alerts][reblog]", value: reblogValue) + items.append(reblogItem) + } + + if let mentionValue = mention?.queryItemValue { + let mentionItem = URLQueryItem(name: "data[alerts][mention]", value: mentionValue) + items.append(mentionItem) + } + return items + } + + public init( + endpoint: String, + p256dh: String, + auth: String, + favourite: Bool?, + follow: Bool?, + reblog: Bool?, + mention: Bool?, + poll: Bool? + ) { + self.endpoint = endpoint + self.p256dh = p256dh + self.auth = auth + self.favourite = favourite + self.follow = follow + self.reblog = reblog + self.mention = mention + self.poll = poll + } + } + + public struct UpdateSubscriptionQuery: Codable, PutQuery { + let favourite: Bool? + let follow: Bool? + let reblog: Bool? + let mention: Bool? + let poll: Bool? + + var queryItems: [URLQueryItem]? { + var items = [URLQueryItem]() + + if let followValue = follow?.queryItemValue { + let followItem = URLQueryItem(name: "data[alerts][follow]", value: followValue) + items.append(followItem) + } + + if let favouriteValue = favourite?.queryItemValue { + let favouriteItem = URLQueryItem(name: "data[alerts][favourite]", value: favouriteValue) + items.append(favouriteItem) + } + + if let reblogValue = reblog?.queryItemValue { + let reblogItem = URLQueryItem(name: "data[alerts][reblog]", value: reblogValue) + items.append(reblogItem) + } + + if let mentionValue = mention?.queryItemValue { + let mentionItem = URLQueryItem(name: "data[alerts][mention]", value: mentionValue) + items.append(mentionItem) + } + return items + } + + public init( + favourite: Bool?, + follow: Bool?, + reblog: Bool?, + mention: Bool?, + poll: Bool? + ) { + self.favourite = favourite + self.follow = follow + self.reblog = reblog + self.mention = mention + self.poll = poll + } + } +} diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift index 2fdb9b346..1a4496ed3 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift @@ -115,6 +115,7 @@ extension Mastodon.API { public enum Trends { } public enum Suggestions { } public enum Notifications { } + public enum Subscriptions { } } extension Mastodon.API { diff --git a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Subscription.swift b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Subscription.swift new file mode 100644 index 000000000..3ae5718e6 --- /dev/null +++ b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Subscription.swift @@ -0,0 +1,77 @@ +// +// File.swift +// +// +// Created by ihugo on 2021/4/9. +// + +import Foundation + + +extension Mastodon.Entity { + /// Subscription + /// + /// - Since: 2.4.0 + /// - Version: 3.3.0 + /// # Last Update + /// 2021/4/9 + /// # Reference + /// [Document](https://docs.joinmastodon.org/entities/pushsubscription/) + public struct Subscription: Codable { + // Base + public let id: String + public let endpoint: String + public let alerts: Alerts + public let serverKey: String + + enum CodingKeys: String, CodingKey { + case id + case endpoint + case serverKey = "server_key" + case alerts + } + + public struct Alerts: Codable { + public let follow: Bool? + public let favourite: Bool? + public let reblog: Bool? + public let mention: Bool? + public let poll: Bool? + + public var followNumber: NSNumber? { + guard let value = follow else { return nil } + return NSNumber(booleanLiteral: value) + } + public var favouriteNumber: NSNumber? { + guard let value = favourite else { return nil } + return NSNumber(booleanLiteral: value) + } + public var reblogNumber: NSNumber? { + guard let value = reblog else { return nil } + return NSNumber(booleanLiteral: value) + } + public var mentionNumber: NSNumber? { + guard let value = mention else { return nil } + return NSNumber(booleanLiteral: value) + } + public var pollNumber: NSNumber? { + guard let value = poll else { return nil } + return NSNumber(booleanLiteral: value) + } + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + var id = try? container.decode(String.self, forKey: .id) + if nil == id, let numId = try? container.decode(Int.self, forKey: .id) { + id = String(numId) + } + self.id = id ?? "" + + endpoint = try container.decode(String.self, forKey: .endpoint) + alerts = try container.decode(Alerts.self, forKey: .alerts) + serverKey = try container.decode(String.self, forKey: .serverKey) + } + } +}