diff --git a/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents b/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents
index 5ed4021a7..fe0c43529 100644
--- a/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents
+++ b/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents
@@ -154,6 +154,15 @@
+
+
+
+
+
+
+
+
+
@@ -192,6 +201,26 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -207,14 +236,16 @@
-
-
-
-
-
-
-
+
+
+
+
+
+
+
-
+
+
+
-
+
\ No newline at end of file
diff --git a/CoreDataStack/Entity/Setting.swift b/CoreDataStack/Entity/Setting.swift
new file mode 100644
index 000000000..a4907f0ab
--- /dev/null
+++ b/CoreDataStack/Entity/Setting.swift
@@ -0,0 +1,84 @@
+//
+// Setting.swift
+// CoreDataStack
+//
+// Created by ihugo on 2021/4/9.
+//
+
+import CoreData
+import Foundation
+
+@objc(Setting)
+public final class Setting: NSManagedObject {
+ @NSManaged public var appearance: String?
+ @NSManaged public var triggerBy: String?
+ @NSManaged public var domain: 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
+ 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 init(appearance: String, triggerBy: String, domain: String) {
+ self.appearance = appearance
+ self.triggerBy = triggerBy
+ self.domain = domain
+ }
+ }
+}
+
+extension Setting: Managed {
+ public static var defaultSortDescriptors: [NSSortDescriptor] {
+ return [NSSortDescriptor(keyPath: \Setting.createdAt, ascending: false)]
+ }
+}
+
+extension Setting {
+ public static func predicate(domain: String) -> NSPredicate {
+ return NSPredicate(format: "%K == %@", #keyPath(Setting.domain), domain)
+ }
+
+}
diff --git a/CoreDataStack/Entity/Subscription.swift b/CoreDataStack/Entity/Subscription.swift
new file mode 100644
index 000000000..5d65129e2
--- /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
+
+@objc(Subscription)
+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
+
+ 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(id: String) -> NSPredicate {
+ return NSPredicate(format: "%K == %@", #keyPath(Subscription.id), id)
+ }
+
+}
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/Extension/UIButton.swift b/Mastodon/Extension/UIButton.swift
index 916ad222d..d4334baad 100644
--- a/Mastodon/Extension/UIButton.swift
+++ b/Mastodon/Extension/UIButton.swift
@@ -43,3 +43,23 @@ extension UIButton {
}
}
+extension UIButton {
+ // https://stackoverflow.com/questions/14523348/how-to-change-the-background-color-of-a-uibutton-while-its-highlighted
+ private func image(withColor color: UIColor) -> UIImage? {
+ let rect = CGRect(x: 0.0, y: 0.0, width: 1.0, height: 1.0)
+ UIGraphicsBeginImageContext(rect.size)
+ let context = UIGraphicsGetCurrentContext()
+
+ context?.setFillColor(color.cgColor)
+ context?.fill(rect)
+
+ let image = UIGraphicsGetImageFromCurrentImageContext()
+ UIGraphicsEndImageContext()
+
+ return image
+ }
+
+ func setBackgroundColor(_ color: UIColor, for state: UIControl.State) {
+ self.setBackgroundImage(image(withColor: color), for: state)
+ }
+}
diff --git a/Mastodon/Generated/Strings.swift b/Mastodon/Generated/Strings.swift
index 14b993881..dac01a4b3 100644
--- a/Mastodon/Generated/Strings.swift
+++ b/Mastodon/Generated/Strings.swift
@@ -581,6 +581,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 {
+ /// Boosts 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 Welcome {
/// Social networking\nback in your hands.
internal static let slogan = L10n.tr("Localizable", "Scene.Welcome.Slogan")
diff --git a/Mastodon/Resources/Assets.xcassets/.DS_Store b/Mastodon/Resources/Assets.xcassets/.DS_Store
new file mode 100644
index 000000000..4fb9bcc55
Binary files /dev/null and b/Mastodon/Resources/Assets.xcassets/.DS_Store differ
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/Assets.xcassets/Welcome/.DS_Store b/Mastodon/Resources/Assets.xcassets/Welcome/.DS_Store
new file mode 100644
index 000000000..523b0748c
Binary files /dev/null and b/Mastodon/Resources/Assets.xcassets/Welcome/.DS_Store differ
diff --git a/Mastodon/Resources/en.lproj/Localizable.strings b/Mastodon/Resources/en.lproj/Localizable.strings
index 40000befa..6abcb4cfe 100644
--- a/Mastodon/Resources/en.lproj/Localizable.strings
+++ b/Mastodon/Resources/en.lproj/Localizable.strings
@@ -187,4 +187,25 @@ any server.";
"Scene.ServerRules.TermsOfService" = "terms of service";
"Scene.ServerRules.Title" = "Some ground rules.";
"Scene.Welcome.Slogan" = "Social networking
-back in your hands.";
\ No newline at end of file
+back in your hands.";
+"Scene.Settings.Title" = "Settings";
+"Scene.Settings.Section.Appearance.Title" = "Appearance";
+"Scene.Settings.Section.Appearance.Automatic" = "Automatic";
+"Scene.Settings.Section.Appearance.Light" = "Always Light";
+"Scene.Settings.Section.Appearance.Dark" = "Always Dark";
+"Scene.Settings.Section.Notifications.Title" = "Notifications";
+"Scene.Settings.Section.Notifications.Favorites" = "Favorites my post";
+"Scene.Settings.Section.Notifications.Follows" = "Follows me";
+"Scene.Settings.Section.Notifications.Boosts" = "Boosts my post";
+"Scene.Settings.Section.Notifications.Mentions" = "Mentions me";
+"Scene.Settings.Section.Notifications.Trigger.Anyone" = "anyone";
+"Scene.Settings.Section.Notifications.Trigger.Follower" = "a follower";
+"Scene.Settings.Section.Notifications.Trigger.Follow" = "anyone I follow";
+"Scene.Settings.Section.Notifications.Trigger.NoOne" = "no one";
+"Scene.Settings.Section.Notifications.Trigger.Title" = "Notify me when";
+"Scene.Settings.Section.BoringZone.Title" = "The Boring zone";
+"Scene.Settings.Section.BoringZone.Terms" = "Terms of Service";
+"Scene.Settings.Section.BoringZone.Privacy" = "Privacy Policy";
+"Scene.Settings.Section.SpicyZone.Title" = "The spicy zone";
+"Scene.Settings.Section.SpicyZone.Clear" = "Clear Media Cache";
+"Scene.Settings.Section.SpicyZone.SignOut" = "Sign Out";
diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift
index 0c43af79e..cc051f33e 100644
--- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift
+++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift
@@ -33,6 +33,9 @@ extension HomeTimelineViewController {
guard let self = self else { return }
self.showProfileAction(action)
},
+ UIAction(title: "Settings", image: UIImage(systemName: "escape"), attributes: []) { [weak self] action in
+ self?.coordinator.present(scene: .settings, from: self, transition: .modal(animated: true, completion: nil))
+ },
UIAction(title: "Sign Out", image: UIImage(systemName: "escape"), attributes: .destructive) { [weak self] action in
guard let self = self else { return }
self.signOutAction(action)
diff --git a/Mastodon/Scene/Settings/SettingsViewController.swift b/Mastodon/Scene/Settings/SettingsViewController.swift
new file mode 100644
index 000000000..95bfc8aa1
--- /dev/null
+++ b/Mastodon/Scene/Settings/SettingsViewController.swift
@@ -0,0 +1,430 @@
+//
+// SettingsViewController.swift
+// Mastodon
+//
+// Created by ihugo on 2021/4/7.
+//
+
+import os.log
+import UIKit
+import Combine
+import ActiveLabel
+import CoreData
+import CoreDataStack
+
+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: UIImage(systemName: "escape"),
+ 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)
+ },
+ ].reversed()
+ )
+ 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: { (tableView, indexPath, item) -> UITableViewCell? in
+ 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 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)
+ }
+
+ // 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) {
+ guard indexPath.section == 2 || indexPath.section == 3 else { return }
+
+ if indexPath.section == 2 {
+ coordinator.present(
+ scene: .webview(url: URL(string: "https://mastodon.online/terms")!),
+ from: self,
+ transition: .modal(animated: true, completion: nil))
+ }
+
+ // iTODO: clear media cache
+
+
+ // logout
+ if indexPath.section == 3, indexPath.row == 2 {
+ signout()
+ }
+ }
+}
+
+// Update setting into core data
+extension SettingsViewController {
+ func updateTrigger(by who: String) {
+ guard let setting = self.viewModel.setting.value else { return }
+
+ context.managedObjectContext.performChanges {
+ setting.update(triggerBy: who)
+ }
+ .sink { (_) in
+ }.store(in: &disposeBag)
+ }
+
+ 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 }
+
+ var values: [Bool?]?
+ if let alerts = settings.subscription?.first(where: { (s) -> Bool in
+ return s.type == settings.triggerBy
+ })?.alert {
+ var items = [Bool?]()
+ items.append(alerts.favourite)
+ items.append(alerts.follow)
+ items.append(alerts.reblog)
+ items.append(alerts.mention)
+ values = items
+ }
+ guard var alertValues = values else { return }
+ guard alertValues.count >= 4 else { return }
+
+ switch title {
+ case L10n.Scene.Settings.Section.Notifications.favorites:
+ alertValues[0] = isOn
+ case L10n.Scene.Settings.Section.Notifications.follows:
+ alertValues[1] = isOn
+ case L10n.Scene.Settings.Section.Notifications.boosts:
+ alertValues[2] = isOn
+ case L10n.Scene.Settings.Section.Notifications.mentions:
+ alertValues[3] = isOn
+ default: break
+ }
+ self.viewModel.alertUpdate.send((triggerBy: triggerBy, values: alertValues))
+ }
+}
+
+extension SettingsViewController: SettingsAppearanceTableViewCellDelegate {
+ func settingsAppearanceCell(_ view: SettingsAppearanceTableViewCell, didSelect: SettingsItem.AppearanceMode) {
+ print("[SettingsViewController]: didSelect \(didSelect)")
+ 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: .webview(url: URL(string: "https://github.com/tootsuite/mastodon")!),
+ from: self,
+ transition: .modal(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)
+ 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..1f5bf4e4f
--- /dev/null
+++ b/Mastodon/Scene/Settings/SettingsViewModel.swift
@@ -0,0 +1,295 @@
+//
+// 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()
+ 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)
+ }
+
+ 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)
+
+ /// trigger when
+ /// - init alerts
+ /// - change subscription status everytime
+ let alertUpdate = 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]
+ }()
+
+ struct Input {
+ }
+
+ struct Output {
+ }
+
+ init(context: AppContext, coordinator: SceneCoordinator) {
+ self.context = context
+ self.coordinator = coordinator
+
+ super.init()
+ }
+
+ func transform(input: Input?) -> Output? {
+ //guard let input = input else { return nil }
+
+ // build data for table view
+ buildDataSource()
+
+ // request subsription data for updating or initialization
+ requestSubscription()
+
+ typealias SubscriptionResponse = Mastodon.Response.Content
+ alertUpdate
+ .debounce(for: .milliseconds(300), scheduler: DispatchQueue.main)
+ .flatMap { [weak self] (arg) -> AnyPublisher in
+ let (triggerBy, values) = arg
+ guard let self = self else {
+ return Empty().eraseToAnyPublisher()
+ }
+ guard let activeMastodonAuthenticationBox =
+ self.context.authenticationService.activeMastodonAuthenticationBox.value else {
+ return Empty().eraseToAnyPublisher()
+ }
+ guard values.count >= 4 else {
+ return Empty().eraseToAnyPublisher()
+ }
+
+ typealias Query = Mastodon.API.Notification.CreateSubscriptionQuery
+ let domain = activeMastodonAuthenticationBox.domain
+ return self.context.apiService.changeSubscription(
+ domain: domain,
+ mastodonAuthenticationBox: activeMastodonAuthenticationBox,
+ query: Query(favourite: values[0], follow: values[1], reblog: values[2], mention: values[3], poll: nil),
+ triggerBy: triggerBy)
+ }
+ .sink { _ in
+ } receiveValue: { (subscription) in
+ }
+ .store(in: &disposeBag)
+
+
+ do {
+ try fetchResultsController.performFetch()
+ setting.value = fetchResultsController.fetchedObjects?.first
+ } catch {
+ assertionFailure(error.localizedDescription)
+ }
+ 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)
+ items.append(alerts.follow)
+ items.append(alerts.reblog)
+ items.append(alerts.mention)
+ switches = items
+ } else if let triggerBy = settings?.triggerBy,
+ let values = self.notificationDefaultValue[triggerBy] {
+ switches = values
+ self.alertUpdate.send((triggerBy: triggerBy, values: 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 {
+ // FIXME: update color in both light and dark mode
+ 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 {
+ // FIXME: update color in both light and dark mode
+ let item = SettingsItem.boringZone(item: SettingsItem.Link(title: l, color: .systemRed))
+ spicyLinkItems.append(item)
+ }
+ let spicySection = SettingsSection.boringZone(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.filter({ $0 != nil }).sink { [weak self] (settings) in
+ guard let self = self else { return }
+ self.processDataSource(settings)
+ }
+ .store(in: &disposeBag)
+
+ // init with no subscription for notification
+ let settings: Setting? = nil
+ self.processDataSource(settings)
+ }
+
+ private func requestSubscription() {
+ // request subscription of notifications
+ typealias SubscriptionResponse = Mastodon.Response.Content
+ viewDidLoad.flatMap { [weak self] (_) -> AnyPublisher in
+ guard let self = self,
+ let activeMastodonAuthenticationBox =
+ self.context.authenticationService.activeMastodonAuthenticationBox.value else {
+ return Empty().eraseToAnyPublisher()
+ }
+
+ let domain = activeMastodonAuthenticationBox.domain
+ return self.context.apiService.subscription(
+ domain: domain,
+ mastodonAuthenticationBox: activeMastodonAuthenticationBox)
+ }
+ .sink { _ in
+ } receiveValue: { (subscription) in
+ }
+ .store(in: &disposeBag)
+ }
+}
+
+// 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(tilte: 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..83932a62e
--- /dev/null
+++ b/Mastodon/Scene/Settings/View/Cell/SettingsAppearanceTableViewCell.swift
@@ -0,0 +1,207 @@
+//
+// 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.lightSecondaryText.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.lightBrandBlue.color
+ } else {
+ checkBox.imageView?.tintColor = Asset.Colors.lightSecondaryText.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 {
+ didSet {
+ guard let delegate = self.delegate else { return }
+ delegate.settingsAppearanceCell(self, didSelect: appearance)
+ }
+ }
+
+ 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
+ }
+ }
+}
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..374e05d6d
--- /dev/null
+++ b/Mastodon/Scene/Settings/View/Cell/SettingsToggleTableViewCell.swift
@@ -0,0 +1,70 @@
+//
+// 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)
+ view.translatesAutoresizingMaskIntoConstraints = false
+ 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
+ textLabel?.font = .systemFont(ofSize: 17, weight: .regular)
+ contentView.addSubview(switchButton)
+
+ NSLayoutConstraint.activate([
+ switchButton.trailingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.trailingAnchor),
+ switchButton.centerYAnchor.constraint(equalTo: contentView.centerYAnchor),
+ ])
+
+ 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..67dff9822
--- /dev/null
+++ b/Mastodon/Scene/Settings/View/SettingsSectionHeader.swift
@@ -0,0 +1,54 @@
+//
+// SettingsSectionHeader.swift
+// Mastodon
+//
+// Created by ihugo on 2021/4/8.
+//
+
+import UIKit
+
+/// 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: 40, left: 12, bottom: 10, right: 12)
+ 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.leadingAnchor),
+ stackView.trailingAnchor.constraint(lessThanOrEqualTo: self.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+Notifications.swift b/Mastodon/Service/APIService/APIService+Notifications.swift
new file mode 100644
index 000000000..9bbcd180f
--- /dev/null
+++ b/Mastodon/Service/APIService/APIService+Notifications.swift
@@ -0,0 +1,66 @@
+//
+// APIService+Settings.swift
+// Mastodon
+//
+// Created by ihugo on 2021/4/9.
+//
+
+import Foundation
+import MastodonSDK
+import Combine
+
+extension APIService {
+
+ func subscription(
+ domain: String,
+ mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox
+ ) -> AnyPublisher, Error> {
+ let authorization = mastodonAuthenticationBox.userAuthorization
+
+
+ return Mastodon.API.Notification.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)
+ }
+ .setFailureType(to: Error.self)
+ .map { _ in return response }
+ .eraseToAnyPublisher()
+ }.eraseToAnyPublisher()
+ }
+
+ func changeSubscription(
+ domain: String,
+ mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox,
+ query: Mastodon.API.Notification.CreateSubscriptionQuery,
+ triggerBy: String
+ ) -> AnyPublisher, Error> {
+ let authorization = mastodonAuthenticationBox.userAuthorization
+
+ return Mastodon.API.Notification.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)
+ }
+ .setFailureType(to: Error.self)
+ .map { _ in return response }
+ .eraseToAnyPublisher()
+ }.eraseToAnyPublisher()
+ }
+}
+
diff --git a/Mastodon/Service/APIService/CoreData/APIService+CoreData+Notification.swift b/Mastodon/Service/APIService/CoreData/APIService+CoreData+Notification.swift
new file mode 100644
index 000000000..b3cd004b0
--- /dev/null
+++ b/Mastodon/Service/APIService/CoreData/APIService+CoreData+Notification.swift
@@ -0,0 +1,129 @@
+//
+// 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,
+ property: Setting.Property
+ ) -> (Subscription: Setting, isCreated: Bool) {
+ let oldSetting: Setting? = {
+ let request = Setting.sortedFetchRequest
+ request.predicate = Setting.predicate(domain: property.domain)
+ 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? = nil
+ ) -> (Subscription: Subscription, isCreated: Bool) {
+ // create setting entity if possible
+ let oldSetting: Setting? = {
+ let request = Setting.sortedFetchRequest
+ request.predicate = Setting.predicate(domain: domain)
+ request.fetchLimit = 1
+ request.returnsObjectsAsFaults = false
+ do {
+ return try managedObjectContext.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: "anyone",
+ domain: domain)
+ (setting, _) = createOrMergeSetting(
+ into: managedObjectContext,
+ domain: domain,
+ property: property)
+ }
+
+ let oldSubscription: Subscription? = {
+ let request = Subscription.sortedFetchRequest
+ request.predicate = Subscription.predicate(id: entity.id)
+ 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 ?? setting.triggerBy ?? "")
+ let alertEntity = entity.alerts
+ let alert = SubscriptionAlerts.Property(
+ favourite: alertEntity.favourite,
+ follow: alertEntity.follow,
+ mention: alertEntity.mention,
+ poll: alertEntity.poll,
+ reblog: alertEntity.reblog)
+ 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 {
+ // don't expand subscription if add existed subscription
+ setting.mutableSetValue(forKey: #keyPath(Setting.subscription)).add(oldSubscription)
+ }
+ 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..555dd22d5
--- /dev/null
+++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Push.swift
@@ -0,0 +1,135 @@
+//
+// File.swift
+//
+//
+// Created by ihugo on 2021/4/9.
+//
+
+import Foundation
+import Combine
+
+extension Mastodon.API.Notification {
+
+ 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 `Poll` 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()
+ }
+
+ /// Change types of notifications
+ ///
+ /// Using this endpoint to change types of notifications
+ ///
+ /// - 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 `Poll` 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()
+ }
+}
+
+extension Mastodon.API.Notification {
+ public struct CreateSubscriptionQuery: PostQuery {
+ var queryItems: [URLQueryItem]?
+ var contentType: String?
+ var body: Data?
+
+ let follow: Bool?
+ let favourite: Bool?
+ let reblog: Bool?
+ let mention: Bool?
+ let poll: Bool?
+
+ // iTODO: missing parameters
+ // subscription[endpoint]
+ // subscription[keys][p256dh]
+ // subscription[keys][auth]
+ public init(favourite: Bool?,
+ follow: Bool?,
+ reblog: Bool?,
+ mention: Bool?,
+ poll: Bool?) {
+ self.follow = follow
+ self.favourite = favourite
+ self.reblog = reblog
+ self.mention = mention
+ self.poll = poll
+
+ queryItems = [URLQueryItem]()
+
+ if let followValue = follow?.queryItemValue {
+ let followItem = URLQueryItem(name: "data[alerts][follow]", value: followValue)
+ queryItems?.append(followItem)
+ }
+
+ if let favouriteValue = favourite?.queryItemValue {
+ let favouriteItem = URLQueryItem(name: "data[alerts][favourite]", value: favouriteValue)
+ queryItems?.append(favouriteItem)
+ }
+
+ if let reblogValue = reblog?.queryItemValue {
+ let reblogItem = URLQueryItem(name: "data[alerts][reblog]", value: reblogValue)
+ queryItems?.append(reblogItem)
+ }
+
+ if let mentionValue = mention?.queryItemValue {
+ let mentionItem = URLQueryItem(name: "data[alerts][mention]", value: mentionValue)
+ queryItems?.append(mentionItem)
+ }
+ }
+ }
+}
diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift
index 2fdb9b346..d04071e9d 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 Notification { }
}
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..24bb2c189
--- /dev/null
+++ b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Subscription.swift
@@ -0,0 +1,42 @@
+//
+// 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
+ }
+ }
+}
diff --git a/Podfile.lock b/Podfile.lock
index 4f553c4e3..cc8600c7e 100644
--- a/Podfile.lock
+++ b/Podfile.lock
@@ -25,4 +25,4 @@ SPEC CHECKSUMS:
PODFILE CHECKSUM: 30e8e3a555251a512e7b5e91183747152f126e7a
-COCOAPODS: 1.10.1
+COCOAPODS: 1.10.0
diff --git a/SubscriptionAlerts.swift b/SubscriptionAlerts.swift
new file mode 100644
index 000000000..928a777f5
--- /dev/null
+++ b/SubscriptionAlerts.swift
@@ -0,0 +1,131 @@
+//
+// PushSubscriptionAlerts+CoreDataClass.swift
+// CoreDataStack
+//
+// Created by ihugo on 2021/4/9.
+//
+//
+
+import Foundation
+import CoreData
+
+@objc(SubscriptionAlerts)
+public final class SubscriptionAlerts: NSManagedObject {
+ @NSManaged public var follow: Bool
+ @NSManaged public var favourite: Bool
+ @NSManaged public var reblog: Bool
+ @NSManaged public var mention: Bool
+ @NSManaged public var poll: Bool
+
+ @NSManaged public private(set) var createdAt: Date
+ @NSManaged public private(set) var updatedAt: Date
+
+ // MARK: - relationships
+ @NSManaged public var pushSubscription: 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: Bool) {
+ guard self.favourite != favourite else { return }
+ self.favourite = favourite
+
+ didUpdate(at: Date())
+ }
+
+ func update(follow: Bool) {
+ guard self.follow != follow else { return }
+ self.follow = follow
+
+ didUpdate(at: Date())
+ }
+
+ func update(mention: Bool) {
+ guard self.mention != mention else { return }
+ self.mention = mention
+
+ didUpdate(at: Date())
+ }
+
+ func update(poll: Bool) {
+ guard self.poll != poll else { return }
+ self.poll = poll
+
+ didUpdate(at: Date())
+ }
+
+ func update(reblog: Bool) {
+ guard self.reblog != reblog else { return }
+ self.reblog = reblog
+
+ didUpdate(at: Date())
+ }
+}
+
+public extension SubscriptionAlerts {
+ struct Property {
+ public let favourite: Bool
+ public let follow: Bool
+ public let mention: Bool
+ public let poll: Bool
+ public let reblog: Bool
+
+ public init(favourite: Bool?, follow: Bool?, mention: Bool?, poll: Bool?, reblog: Bool?) {
+ self.favourite = favourite ?? true
+ self.follow = follow ?? true
+ self.mention = mention ?? true
+ self.poll = poll ?? true
+ self.reblog = reblog ?? true
+ }
+ }
+
+ 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)]
+ }
+}