Merge pull request #102 from tootsuite/feature/settings-rebase

Feature/settings
This commit is contained in:
Hugo 2021-04-19 14:17:40 +08:00 committed by GitHub
commit fdf66ee9c6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
35 changed files with 2470 additions and 147 deletions

4
.gitignore vendored
View File

@ -120,4 +120,6 @@ xcuserdata
# End of https://www.toptal.com/developers/gitignore/api/swift,swiftpm,xcode,cocoapods
Localization/StringsConvertor/input
Localization/StringsConvertor/output
Localization/StringsConvertor/output
.DS_Store
/Mastodon.xcworkspace/xcshareddata/swiftpm

View File

@ -155,6 +155,16 @@
<relationship name="account" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="MastodonUser"/>
<relationship name="hashtag" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Tag"/>
</entity>
<entity name="Setting" representedClassName=".Setting" syncable="YES">
<attribute name="appearance" optional="YES" attributeType="String"/>
<attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="domain" optional="YES" attributeType="String"/>
<attribute name="id" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
<attribute name="triggerBy" optional="YES" attributeType="String"/>
<attribute name="updatedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="userID" optional="YES" attributeType="String"/>
<relationship name="subscription" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="Subscription" inverseName="setting" inverseEntity="Subscription"/>
</entity>
<entity name="Status" representedClassName=".Status" syncable="YES">
<attribute name="content" attributeType="String"/>
<attribute name="createdAt" attributeType="Date" usesScalarValueType="NO"/>
@ -193,6 +203,26 @@
<relationship name="replyTo" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Status" inverseName="replyFrom" inverseEntity="Status"/>
<relationship name="tags" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="Tag" inverseName="statuses" inverseEntity="Tag"/>
</entity>
<entity name="Subscription" representedClassName=".Subscription" syncable="YES">
<attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="endpoint" optional="YES" attributeType="String"/>
<attribute name="id" optional="YES" attributeType="String"/>
<attribute name="serverKey" optional="YES" attributeType="String"/>
<attribute name="type" optional="YES" attributeType="String"/>
<attribute name="updatedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<relationship name="alert" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SubscriptionAlerts" inverseName="subscription" inverseEntity="SubscriptionAlerts"/>
<relationship name="setting" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Setting" inverseName="subscription" inverseEntity="Setting"/>
</entity>
<entity name="SubscriptionAlerts" representedClassName=".SubscriptionAlerts" syncable="YES">
<attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="favourite" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="follow" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="mention" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="poll" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="reblog" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="updatedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<relationship name="subscription" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Subscription" inverseName="alert" inverseEntity="Subscription"/>
</entity>
<entity name="Tag" representedClassName=".Tag" syncable="YES">
<attribute name="createAt" attributeType="Date" defaultDateTimeInterval="631123200" usesScalarValueType="NO"/>
<attribute name="identifier" attributeType="UUID" usesScalarValueType="NO"/>
@ -215,7 +245,10 @@
<element name="PollOption" positionX="0" positionY="0" width="128" height="134"/>
<element name="PrivateNote" positionX="0" positionY="0" width="128" height="89"/>
<element name="SearchHistory" positionX="0" positionY="0" width="128" height="104"/>
<element name="Setting" positionX="72" positionY="162" width="128" height="149"/>
<element name="Status" positionX="0" positionY="0" width="128" height="569"/>
<element name="Subscription" positionX="81" positionY="171" width="128" height="149"/>
<element name="SubscriptionAlerts" positionX="72" positionY="162" width="128" height="149"/>
<element name="Tag" positionX="0" positionY="0" width="128" height="134"/>
</elements>
</model>

View File

@ -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<Subscription>?
}
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
)
}
}

View File

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

View File

@ -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)]
}
}

View File

@ -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": {
@ -336,6 +341,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"
}
}
}
}
}

View File

@ -123,6 +123,17 @@
2DFAD5272616F9D300F9EE7C /* SearchViewController+Searching.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DFAD5262616F9D300F9EE7C /* SearchViewController+Searching.swift */; };
2DFAD5372617010500F9EE7C /* SearchingTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DFAD5362617010500F9EE7C /* SearchingTableViewCell.swift */; };
2DFF41892614A4DC00F776A4 /* UIView+Constraint.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DFF41882614A4DC00F776A4 /* UIView+Constraint.swift */; };
5B90C45E262599800002E742 /* SettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B90C456262599800002E742 /* SettingsViewModel.swift */; };
5B90C45F262599800002E742 /* SettingsToggleTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B90C459262599800002E742 /* SettingsToggleTableViewCell.swift */; };
5B90C460262599800002E742 /* SettingsAppearanceTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B90C45A262599800002E742 /* SettingsAppearanceTableViewCell.swift */; };
5B90C461262599800002E742 /* SettingsLinkTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B90C45B262599800002E742 /* SettingsLinkTableViewCell.swift */; };
5B90C462262599800002E742 /* SettingsSectionHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B90C45C262599800002E742 /* SettingsSectionHeader.swift */; };
5B90C463262599800002E742 /* SettingsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B90C45D262599800002E742 /* SettingsViewController.swift */; };
5B90C46E26259B2C0002E742 /* Subscription.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B90C46C26259B2C0002E742 /* Subscription.swift */; };
5B90C46F26259B2C0002E742 /* Setting.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B90C46D26259B2C0002E742 /* Setting.swift */; };
5B90C47F26259BA90002E742 /* SubscriptionAlerts.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B90C47E26259BA90002E742 /* SubscriptionAlerts.swift */; };
5B90C48526259BF10002E742 /* APIService+Subscriptions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B90C48426259BF10002E742 /* APIService+Subscriptions.swift */; };
5B90C48B26259C120002E742 /* APIService+CoreData+Subscriptions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B90C48A26259C120002E742 /* APIService+CoreData+Subscriptions.swift */; };
5D0393902612D259007FE196 /* WebViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D03938F2612D259007FE196 /* WebViewController.swift */; };
5D0393962612D266007FE196 /* WebViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D0393952612D266007FE196 /* WebViewModel.swift */; };
5D526FE225BE9AC400460CB9 /* MastodonSDK in Frameworks */ = {isa = PBXBuildFile; productRef = 5D526FE125BE9AC400460CB9 /* MastodonSDK */; };
@ -515,6 +526,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 = "<group>"; };
5B90C456262599800002E742 /* SettingsViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SettingsViewModel.swift; sourceTree = "<group>"; };
5B90C459262599800002E742 /* SettingsToggleTableViewCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SettingsToggleTableViewCell.swift; sourceTree = "<group>"; };
5B90C45A262599800002E742 /* SettingsAppearanceTableViewCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SettingsAppearanceTableViewCell.swift; sourceTree = "<group>"; };
5B90C45B262599800002E742 /* SettingsLinkTableViewCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SettingsLinkTableViewCell.swift; sourceTree = "<group>"; };
5B90C45C262599800002E742 /* SettingsSectionHeader.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SettingsSectionHeader.swift; sourceTree = "<group>"; };
5B90C45D262599800002E742 /* SettingsViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SettingsViewController.swift; sourceTree = "<group>"; };
5B90C46C26259B2C0002E742 /* Subscription.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Subscription.swift; sourceTree = "<group>"; };
5B90C46D26259B2C0002E742 /* Setting.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Setting.swift; sourceTree = "<group>"; };
5B90C47E26259BA90002E742 /* SubscriptionAlerts.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SubscriptionAlerts.swift; sourceTree = "<group>"; };
5B90C48426259BF10002E742 /* APIService+Subscriptions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "APIService+Subscriptions.swift"; sourceTree = "<group>"; };
5B90C48A26259C120002E742 /* APIService+CoreData+Subscriptions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "APIService+CoreData+Subscriptions.swift"; sourceTree = "<group>"; };
5D03938F2612D259007FE196 /* WebViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewController.swift; sourceTree = "<group>"; };
5D0393952612D266007FE196 /* WebViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewModel.swift; sourceTree = "<group>"; };
5DDDF1922617442700311060 /* Mastodon+Entity+Account.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Mastodon+Entity+Account.swift"; sourceTree = "<group>"; };
@ -1163,6 +1185,35 @@
name = Frameworks;
sourceTree = "<group>";
};
5B90C455262599800002E742 /* Settings */ = {
isa = PBXGroup;
children = (
5B90C457262599800002E742 /* View */,
5B90C456262599800002E742 /* SettingsViewModel.swift */,
5B90C45D262599800002E742 /* SettingsViewController.swift */,
);
path = Settings;
sourceTree = "<group>";
};
5B90C457262599800002E742 /* View */ = {
isa = PBXGroup;
children = (
5B90C458262599800002E742 /* Cell */,
5B90C45C262599800002E742 /* SettingsSectionHeader.swift */,
);
path = View;
sourceTree = "<group>";
};
5B90C458262599800002E742 /* Cell */ = {
isa = PBXGroup;
children = (
5B90C459262599800002E742 /* SettingsToggleTableViewCell.swift */,
5B90C45A262599800002E742 /* SettingsAppearanceTableViewCell.swift */,
5B90C45B262599800002E742 /* SettingsLinkTableViewCell.swift */,
);
path = Cell;
sourceTree = "<group>";
};
5D03938E2612D200007FE196 /* Webview */ = {
isa = PBXGroup;
children = (
@ -1348,6 +1399,7 @@
DBAE3F932616E28B004B8251 /* APIService+Follow.swift */,
DBAE3F8D2616E0B1004B8251 /* APIService+Block.swift */,
DBAE3F9D2616E308004B8251 /* APIService+Mute.swift */,
5B90C48426259BF10002E742 /* APIService+Subscriptions.swift */,
);
path = APIService;
sourceTree = "<group>";
@ -1359,6 +1411,7 @@
DB45FADC25CA6F6B005A8AC7 /* APIService+CoreData+MastodonUser.swift */,
DB45FAF825CA80A2005A8AC7 /* APIService+CoreData+MastodonAuthentication.swift */,
2D79E700261EA5550011E398 /* APIService+CoreData+Tag.swift */,
5B90C48A26259C120002E742 /* APIService+CoreData+Subscriptions.swift */,
);
path = CoreData;
sourceTree = "<group>";
@ -1528,6 +1581,9 @@
DB4481AC25EE155900BEFB67 /* Poll.swift */,
DB4481B225EE16D000BEFB67 /* PollOption.swift */,
DBCC3B9A2615849F0045B23D /* PrivateNote.swift */,
5B90C46D26259B2C0002E742 /* Setting.swift */,
5B90C46C26259B2C0002E742 /* Subscription.swift */,
5B90C47E26259BA90002E742 /* SubscriptionAlerts.swift */,
);
path = Entity;
sourceTree = "<group>";
@ -1579,6 +1635,7 @@
2D76316325C14BAC00929FB9 /* PublicTimeline */,
0F2021F5261325ED000C64BF /* HashtagTimeline */,
DB9D6BEE25E4F5370051B173 /* Search */,
5B90C455262599800002E742 /* Settings */,
DB9D6BFD25E4F57B0051B173 /* Notification */,
DB9D6C0825E4F5A60051B173 /* Profile */,
DB789A1025F9F29B0071ACA0 /* Compose */,
@ -2274,6 +2331,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 */,
DB45FAE325CA7181005A8AC7 /* MastodonUser.swift in Sources */,
2D19864F261C372A00F0B013 /* SearchBottomLoader.swift in Sources */,
@ -2288,6 +2346,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 */,
@ -2318,6 +2377,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 */,
@ -2325,6 +2385,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 */,
@ -2345,6 +2406,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 */,
@ -2354,6 +2416,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 */,
@ -2430,7 +2493,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 */,
@ -2516,6 +2581,7 @@
2D927F1425C7EDD9004F19B8 /* Emoji.swift in Sources */,
2DF75BC725D1475D00694EC8 /* ManagedObjectContextObjectsDidChange.swift in Sources */,
DB89BA1225C1105C008580ED /* CoreDataStack.swift in Sources */,
5B90C46F26259B2C0002E742 /* Setting.swift in Sources */,
DB89BA1C25C1107F008580ED /* NSManagedObjectContext.swift in Sources */,
DB9D6C2E25E504AC0051B173 /* Attachment.swift in Sources */,
2D927F0E25C7E9C9004F19B8 /* History.swift in Sources */,
@ -2536,6 +2602,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;
};

View File

@ -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
}

View File

@ -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
}

View File

@ -43,3 +43,11 @@ extension UIButton {
}
}
extension UIButton {
func setBackgroundColor(_ color: UIColor, for state: UIControl.State) {
self.setBackgroundImage(
UIImage.placeholder(color: color),
for: state
)
}
}

View File

@ -80,6 +80,7 @@ internal enum Asset {
internal static let invalid = ColorAsset(name: "Colors/TextField/invalid")
internal static let valid = ColorAsset(name: "Colors/TextField/valid")
}
internal static let battleshipGrey = ColorAsset(name: "Colors/battleshipGrey")
internal static let brandBlue = ColorAsset(name: "Colors/brand.blue")
internal static let danger = ColorAsset(name: "Colors/danger")
internal static let disabled = ColorAsset(name: "Colors/disabled")
@ -120,6 +121,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

View File

@ -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")
@ -591,6 +599,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")

View File

@ -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"

View File

@ -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
}
}

View File

@ -0,0 +1,9 @@
{
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"provides-namespace" : true
}
}

View File

@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "iPhone 11 Pro _ X - 1.pdf",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "iPhone 11 Pro _ X - 1 (2).pdf",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "iPhone 11 Pro _ X - 1 (1).pdf",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -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";
@ -189,6 +192,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";
@ -196,4 +220,4 @@ any server.";
"Scene.Thread.Reblog.Single" = "%@ reblog";
"Scene.Thread.Title" = "Post from %@";
"Scene.Welcome.Slogan" = "Social networking
back in your hands.";
back in your hands.";

View File

@ -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

View File

@ -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<AnyCancellable>()
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 <a href=\"https://github.com/tootsuite/mastodon\">tootsuite/mastodon</a> (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

View File

@ -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<SettingsSection, SettingsItem>!
var disposeBag = Set<AnyCancellable>()
var updateDisposeBag = Set<AnyCancellable>()
var createDisposeBag = Set<AnyCancellable>()
let viewDidLoad = PassthroughSubject<Void, Never>()
lazy var fetchResultsController: NSFetchedResultsController<Setting> = {
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<Setting?, Never>(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<Mastodon.Entity.Subscription>
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<SettingsSection, SettingsItem>()
// 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<NSFetchRequestResult>) {
os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
}
func controllerDidChangeContent(_ controller: NSFetchedResultsController<NSFetchRequestResult>) {
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)
}

View File

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

View File

@ -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
}
}

View File

@ -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
}
}

View File

@ -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()
}
}

View File

@ -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<Mastodon.Response.Content<Mastodon.Entity.Subscription>, 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<Mastodon.Response.Content<Mastodon.Entity.Subscription>, 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<Mastodon.Response.Content<Mastodon.Entity.Subscription>, 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<Mastodon.Response.Content<Mastodon.Entity.Subscription>, 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<Mastodon.Response.Content<Mastodon.Entity.Subscription>, 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<Mastodon.Response.Content<Mastodon.Entity.Subscription>, 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
}
}

View File

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

View File

@ -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) {

View File

@ -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<Mastodon.Response.Content<Mastodon.Entity.Subscription>, 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<Mastodon.Response.Content<Mastodon.Entity.Subscription>, 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<Mastodon.Response.Content<Mastodon.Entity.Subscription>, 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
}
}
}

View File

@ -115,6 +115,7 @@ extension Mastodon.API {
public enum Trends { }
public enum Suggestions { }
public enum Notifications { }
public enum Subscriptions { }
}
extension Mastodon.API {

View File

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