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)
+ }
+ }
+}