feat: make push notification trigger update when change setting

This commit is contained in:
CMK 2021-04-26 16:57:50 +08:00
parent 9001289801
commit cbd598739e
39 changed files with 1356 additions and 1119 deletions

View File

@ -172,14 +172,12 @@
<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"/>
<attribute name="appearanceRaw" attributeType="String"/>
<attribute name="createdAt" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="domain" attributeType="String"/>
<attribute name="updatedAt" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="userID" attributeType="String"/>
<relationship name="subscriptions" 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"/>
@ -221,24 +219,27 @@
<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="activedAt" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="createdAt" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="endpoint" optional="YES" attributeType="String"/>
<attribute name="id" optional="YES" attributeType="String"/>
<attribute name="policyRaw" 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"/>
<attribute name="updatedAt" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="userToken" optional="YES" attributeType="String"/>
<relationship name="alert" maxCount="1" deletionRule="Cascade" destinationEntity="SubscriptionAlerts" inverseName="subscription" inverseEntity="SubscriptionAlerts"/>
<relationship name="setting" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Setting" inverseName="subscriptions" 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="favouriteRaw" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="followRaw" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="followRequestRaw" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="mentionRaw" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="pollRaw" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="reblogRaw" 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"/>
<relationship name="subscription" 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"/>
@ -263,10 +264,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="Setting" positionX="72" positionY="162" width="128" height="119"/>
<element name="Status" positionX="0" positionY="0" width="128" height="584"/>
<element name="Subscription" positionX="81" positionY="171" width="128" height="149"/>
<element name="SubscriptionAlerts" positionX="72" positionY="162" width="128" height="149"/>
<element name="Subscription" positionX="81" positionY="171" width="128" height="179"/>
<element name="SubscriptionAlerts" positionX="72" positionY="162" width="128" height="14"/>
<element name="Tag" positionX="0" positionY="0" width="128" height="134"/>
</elements>
</model>
</model>

View File

@ -18,7 +18,7 @@ public final class CoreDataStack {
}
public convenience init(databaseName: String = "shared") {
let storeURL = URL.storeURL(for: "group.org.joinmastodon.mastodon-temp", databaseName: databaseName)
let storeURL = URL.storeURL(for: AppSharedName.groupID, databaseName: databaseName)
let storeDescription = NSPersistentStoreDescription(url: storeURL)
self.init(persistentStoreDescriptions: [storeDescription])
}

View File

@ -9,66 +9,61 @@ import CoreData
import Foundation
public final class Setting: NSManagedObject {
@NSManaged public var appearance: String?
@NSManaged public var triggerBy: String?
@NSManaged public var domain: String?
@NSManaged public var userID: String?
@NSManaged public var appearanceRaw: String
@NSManaged public var domain: String
@NSManaged public var userID: String
@NSManaged public private(set) var createdAt: Date
@NSManaged public private(set) var updatedAt: Date
// relationships
@NSManaged public var subscription: Set<Subscription>?
// one-to-many relationships
@NSManaged public var subscriptions: Set<Subscription>?
}
public extension Setting {
override func awakeFromInsert() {
super.awakeFromInsert()
setPrimitiveValue(Date(), forKey: #keyPath(Setting.createdAt))
}
extension Setting {
func didUpdate(at networkDate: Date) {
self.updatedAt = networkDate
public override func awakeFromInsert() {
super.awakeFromInsert()
let now = Date()
setPrimitiveValue(now, forKey: #keyPath(Setting.createdAt))
setPrimitiveValue(now, forKey: #keyPath(Setting.updatedAt))
}
@discardableResult
static func insert(
public static func insert(
into context: NSManagedObjectContext,
property: Property
) -> Setting {
let setting: Setting = context.insertObject()
setting.appearance = property.appearance
setting.triggerBy = property.triggerBy
setting.appearanceRaw = property.appearanceRaw
setting.domain = property.domain
setting.userID = property.userID
return setting
}
func update(appearance: String?) {
guard appearance != self.appearance else { return }
self.appearance = appearance
public func update(appearanceRaw: String) {
guard appearanceRaw != self.appearanceRaw else { return }
self.appearanceRaw = appearanceRaw
didUpdate(at: Date())
}
func update(triggerBy: String?) {
guard triggerBy != self.triggerBy else { return }
self.triggerBy = triggerBy
didUpdate(at: Date())
public func didUpdate(at networkDate: Date) {
self.updatedAt = networkDate
}
}
public extension Setting {
struct Property {
public let appearance: String
public let triggerBy: String
extension Setting {
public struct Property {
public let domain: String
public let userID: String
public let appearanceRaw: String
public init(appearance: String, triggerBy: String, domain: String, userID: String) {
self.appearance = appearance
self.triggerBy = triggerBy
public init(domain: String, userID: String, appearanceRaw: String) {
self.domain = domain
self.userID = userID
self.appearanceRaw = appearanceRaw
}
}
}

View File

@ -10,30 +10,35 @@ import Foundation
import CoreData
public final class Subscription: NSManagedObject {
@NSManaged public var id: String
@NSManaged public var endpoint: String
@NSManaged public var serverKey: String
/// four types:
/// - anyone
/// - a follower
/// - anyone I follow
/// - no one
@NSManaged public var type: String
@NSManaged public var id: String?
@NSManaged public var endpoint: String?
@NSManaged public var policyRaw: String
@NSManaged public var serverKey: String?
@NSManaged public var userToken: String?
@NSManaged public private(set) var createdAt: Date
@NSManaged public private(set) var updatedAt: Date
@NSManaged public private(set) var activedAt: Date
// MARK: one-to-one relationships
@NSManaged public var alert: SubscriptionAlerts
// MARK: - relationships
@NSManaged public var alert: SubscriptionAlerts?
// MARK: holder
// MARK: many-to-one relationships
@NSManaged public var setting: Setting?
}
public extension Subscription {
override func awakeFromInsert() {
super.awakeFromInsert()
setPrimitiveValue(Date(), forKey: #keyPath(Subscription.createdAt))
let now = Date()
setPrimitiveValue(now, forKey: #keyPath(Subscription.createdAt))
setPrimitiveValue(now, forKey: #keyPath(Subscription.updatedAt))
setPrimitiveValue(now, forKey: #keyPath(Subscription.activedAt))
}
func update(activedAt: Date) {
self.activedAt = activedAt
}
func didUpdate(at networkDate: Date) {
@ -43,45 +48,22 @@ public extension Subscription {
@discardableResult
static func insert(
into context: NSManagedObjectContext,
property: Property
property: Property,
setting: Setting
) -> Subscription {
let setting: Subscription = context.insertObject()
setting.id = property.id
setting.endpoint = property.endpoint
setting.serverKey = property.serverKey
setting.type = property.type
return setting
let subscription: Subscription = context.insertObject()
subscription.policyRaw = property.policyRaw
subscription.setting = setting
return subscription
}
}
public extension Subscription {
struct Property {
public let endpoint: String
public let id: String
public let serverKey: String
public let type: String
public let policyRaw: String
public init(endpoint: String, id: String, serverKey: String, type: String) {
self.endpoint = endpoint
self.id = id
self.serverKey = serverKey
self.type = type
}
}
func updateIfNeed(property: Property) {
if self.endpoint != property.endpoint {
self.endpoint = property.endpoint
}
if self.id != property.id {
self.id = property.id
}
if self.serverKey != property.serverKey {
self.serverKey = property.serverKey
}
if self.type != property.type {
self.type = property.type
public init(policyRaw: String) {
self.policyRaw = policyRaw
}
}
}
@ -94,8 +76,8 @@ extension Subscription: Managed {
extension Subscription {
public static func predicate(type: String) -> NSPredicate {
return NSPredicate(format: "%K == %@", #keyPath(Subscription.type), type)
public static func predicate(policyRaw: String) -> NSPredicate {
return NSPredicate(format: "%K == %@", #keyPath(Subscription.policyRaw), policyRaw)
}
}

View File

@ -10,117 +10,165 @@ import Foundation
import CoreData
public final class SubscriptionAlerts: NSManagedObject {
@NSManaged public var follow: NSNumber?
@NSManaged public var favourite: NSNumber?
@NSManaged public var reblog: NSNumber?
@NSManaged public var mention: NSNumber?
@NSManaged public var poll: NSNumber?
@NSManaged public var favouriteRaw: NSNumber?
@NSManaged public var followRaw: NSNumber?
@NSManaged public var followRequestRaw: NSNumber?
@NSManaged public var mentionRaw: NSNumber?
@NSManaged public var pollRaw: NSNumber?
@NSManaged public var reblogRaw: NSNumber?
@NSManaged public private(set) var createdAt: Date
@NSManaged public private(set) var updatedAt: Date
// MARK: - relationships
@NSManaged public var subscription: Subscription?
// MARK: one-to-one relationships
@NSManaged public var subscription: Subscription
}
public extension SubscriptionAlerts {
override func awakeFromInsert() {
super.awakeFromInsert()
setPrimitiveValue(Date(), forKey: #keyPath(SubscriptionAlerts.createdAt))
}
extension SubscriptionAlerts {
func didUpdate(at networkDate: Date) {
self.updatedAt = networkDate
public override func awakeFromInsert() {
super.awakeFromInsert()
let now = Date()
setPrimitiveValue(now, forKey: #keyPath(SubscriptionAlerts.createdAt))
setPrimitiveValue(now, forKey: #keyPath(SubscriptionAlerts.updatedAt))
}
@discardableResult
static func insert(
public static func insert(
into context: NSManagedObjectContext,
property: Property
property: Property,
subscription: Subscription
) -> SubscriptionAlerts {
let alerts: SubscriptionAlerts = context.insertObject()
alerts.favourite = property.favourite
alerts.follow = property.follow
alerts.mention = property.mention
alerts.poll = property.poll
alerts.reblog = property.reblog
alerts.favouriteRaw = property.favouriteRaw
alerts.followRaw = property.followRaw
alerts.followRequestRaw = property.followRequestRaw
alerts.mentionRaw = property.mentionRaw
alerts.pollRaw = property.pollRaw
alerts.reblogRaw = property.reblogRaw
alerts.subscription = subscription
return alerts
}
func update(favourite: NSNumber?) {
public func update(favourite: Bool?) {
guard self.favourite != favourite else { return }
self.favourite = favourite
didUpdate(at: Date())
}
func update(follow: NSNumber?) {
public func update(follow: Bool?) {
guard self.follow != follow else { return }
self.follow = follow
didUpdate(at: Date())
}
func update(mention: NSNumber?) {
public func update(followRequest: Bool?) {
guard self.followRequest != followRequest else { return }
self.followRequest = followRequest
didUpdate(at: Date())
}
public func update(mention: Bool?) {
guard self.mention != mention else { return }
self.mention = mention
didUpdate(at: Date())
}
func update(poll: NSNumber?) {
public func update(poll: Bool?) {
guard self.poll != poll else { return }
self.poll = poll
didUpdate(at: Date())
}
func update(reblog: NSNumber?) {
public func update(reblog: Bool?) {
guard self.reblog != reblog else { return }
self.reblog = reblog
didUpdate(at: Date())
}
public func didUpdate(at networkDate: Date) {
self.updatedAt = networkDate
}
}
public extension SubscriptionAlerts {
struct Property {
public let favourite: NSNumber?
public let follow: NSNumber?
public let mention: NSNumber?
public let poll: NSNumber?
public let reblog: NSNumber?
extension SubscriptionAlerts {
private func boolean(from number: NSNumber?) -> Bool? {
return number.flatMap { $0.intValue == 1 }
}
private func number(from boolean: Bool?) -> NSNumber? {
return boolean.flatMap { NSNumber(integerLiteral: $0 ? 1 : 0) }
}
public var favourite: Bool? {
get { boolean(from: favouriteRaw) }
set { favouriteRaw = number(from: newValue) }
}
public var follow: Bool? {
get { boolean(from: followRaw) }
set { followRaw = number(from: newValue) }
}
public var followRequest: Bool? {
get { boolean(from: followRequestRaw) }
set { followRequestRaw = number(from: newValue) }
}
public var mention: Bool? {
get { boolean(from: mentionRaw) }
set { mentionRaw = number(from: newValue) }
}
public var poll: Bool? {
get { boolean(from: pollRaw) }
set { pollRaw = number(from: newValue) }
}
public var reblog: Bool? {
get { boolean(from: reblogRaw) }
set { reblogRaw = number(from: newValue) }
}
}
public init(favourite: NSNumber?, follow: NSNumber?, mention: NSNumber?, poll: NSNumber?, reblog: NSNumber?) {
self.favourite = favourite
self.follow = follow
self.mention = mention
self.poll = poll
self.reblog = reblog
extension SubscriptionAlerts {
public struct Property {
public let favouriteRaw: NSNumber?
public let followRaw: NSNumber?
public let followRequestRaw: NSNumber?
public let mentionRaw: NSNumber?
public let pollRaw: NSNumber?
public let reblogRaw: NSNumber?
public init(
favourite: Bool?,
follow: Bool?,
followRequest: Bool?,
mention: Bool?,
poll: Bool?,
reblog: Bool?
) {
self.favouriteRaw = favourite.flatMap { NSNumber(value: $0 ? 1 : 0) }
self.followRaw = follow.flatMap { NSNumber(value: $0 ? 1 : 0) }
self.followRequestRaw = followRequest.flatMap { NSNumber(value: $0 ? 1 : 0) }
self.mentionRaw = mention.flatMap { NSNumber(value: $0 ? 1 : 0) }
self.pollRaw = poll.flatMap { NSNumber(value: $0 ? 1 : 0) }
self.reblogRaw = reblog.flatMap { NSNumber(value: $0 ? 1 : 0) }
}
}
func updateIfNeed(property: Property) {
if self.follow != property.follow {
self.follow = property.follow
}
if self.favourite != property.favourite {
self.favourite = property.favourite
}
if self.reblog != property.reblog {
self.reblog = property.reblog
}
if self.mention != property.mention {
self.mention = property.mention
}
if self.poll != property.poll {
self.poll = property.poll
}
}
}
extension SubscriptionAlerts: Managed {

View File

@ -138,7 +138,6 @@
5B90C460262599800002E742 /* SettingsAppearanceTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B90C45A262599800002E742 /* SettingsAppearanceTableViewCell.swift */; };
5B90C461262599800002E742 /* SettingsLinkTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B90C45B262599800002E742 /* SettingsLinkTableViewCell.swift */; };
5B90C462262599800002E742 /* SettingsSectionHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B90C45C262599800002E742 /* SettingsSectionHeader.swift */; };
5B90C463262599800002E742 /* SettingsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B90C45D262599800002E742 /* SettingsViewController.swift */; };
5B90C46E26259B2C0002E742 /* Subscription.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B90C46C26259B2C0002E742 /* Subscription.swift */; };
5B90C46F26259B2C0002E742 /* Setting.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B90C46D26259B2C0002E742 /* Setting.swift */; };
5B90C47F26259BA90002E742 /* SubscriptionAlerts.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B90C47E26259BA90002E742 /* SubscriptionAlerts.swift */; };
@ -250,10 +249,24 @@
DB6B35182601FA3400DC1E11 /* MastodonAttachmentService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6B35172601FA3400DC1E11 /* MastodonAttachmentService.swift */; };
DB6B351E2601FAEE00DC1E11 /* ComposeStatusAttachmentCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6B351D2601FAEE00DC1E11 /* ComposeStatusAttachmentCollectionViewCell.swift */; };
DB6C8C0F25F0A6AE00AAA452 /* Mastodon+Entity+Error.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6C8C0E25F0A6AE00AAA452 /* Mastodon+Entity+Error.swift */; };
DB6D1B24263684C600ACB481 /* UserDefaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6D1B23263684C600ACB481 /* UserDefaults.swift */; };
DB6D1B2B2636852000ACB481 /* AppSharedName.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6D1B2A2636852000ACB481 /* AppSharedName.swift */; };
DB6D1B312636853100ACB481 /* AppSharedName.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6D1B2A2636852000ACB481 /* AppSharedName.swift */; };
DB6D1B3D2636857500ACB481 /* AppearancePreference.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6D1B3C2636857500ACB481 /* AppearancePreference.swift */; };
DB6D1B44263691CF00ACB481 /* Mastodon+API+Subscriptions+Policy.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6D1B43263691CF00ACB481 /* Mastodon+API+Subscriptions+Policy.swift */; };
DB6D9F232635195E008423CD /* String.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6D9F222635195E008423CD /* String.swift */; };
DB6D9F3526351B7A008423CD /* NotificationService+Decrypt.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6D9F3426351B7A008423CD /* NotificationService+Decrypt.swift */; };
DB6D9F3B26352019008423CD /* AppSecret.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB1E05E0263180F500201847 /* AppSecret.swift */; };
DB6D9F42263527CE008423CD /* AlamofireImage in Frameworks */ = {isa = PBXBuildFile; productRef = DB6D9F41263527CE008423CD /* AlamofireImage */; };
DB6D9F4926353FD7008423CD /* Subscription.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6D9F4826353FD6008423CD /* Subscription.swift */; };
DB6D9F502635761F008423CD /* SubscriptionAlerts.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6D9F4F2635761F008423CD /* SubscriptionAlerts.swift */; };
DB6D9F57263577D2008423CD /* APIService+CoreData+Setting.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6D9F56263577D2008423CD /* APIService+CoreData+Setting.swift */; };
DB6D9F6326357848008423CD /* SettingService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6D9F6226357848008423CD /* SettingService.swift */; };
DB6D9F6F2635807F008423CD /* Setting.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6D9F6E2635807F008423CD /* Setting.swift */; };
DB6D9F76263587C7008423CD /* SettingFetchedResultController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6D9F75263587C7008423CD /* SettingFetchedResultController.swift */; };
DB6D9F7D26358ED4008423CD /* SettingsSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6D9F7C26358ED4008423CD /* SettingsSection.swift */; };
DB6D9F8426358EEC008423CD /* SettingsItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6D9F8326358EEC008423CD /* SettingsItem.swift */; };
DB6D9F9726367249008423CD /* SettingsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6D9F9626367249008423CD /* SettingsViewController.swift */; };
DB71FD2C25F86A5100512AE1 /* AvatarStackContainerButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB71FD2B25F86A5100512AE1 /* AvatarStackContainerButton.swift */; };
DB71FD3625F8A16C00512AE1 /* APIService+Persist+PersistMemo.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB71FD3525F8A16C00512AE1 /* APIService+Persist+PersistMemo.swift */; };
DB71FD3C25F8A1C500512AE1 /* APIService+Persist+PersistCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB71FD3B25F8A1C500512AE1 /* APIService+Persist+PersistCache.swift */; };
@ -584,7 +597,6 @@
5B90C45A262599800002E742 /* SettingsAppearanceTableViewCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SettingsAppearanceTableViewCell.swift; sourceTree = "<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>"; };
@ -703,8 +715,21 @@
DB6B35172601FA3400DC1E11 /* MastodonAttachmentService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonAttachmentService.swift; sourceTree = "<group>"; };
DB6B351D2601FAEE00DC1E11 /* ComposeStatusAttachmentCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeStatusAttachmentCollectionViewCell.swift; sourceTree = "<group>"; };
DB6C8C0E25F0A6AE00AAA452 /* Mastodon+Entity+Error.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Mastodon+Entity+Error.swift"; sourceTree = "<group>"; };
DB6D1B23263684C600ACB481 /* UserDefaults.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserDefaults.swift; sourceTree = "<group>"; };
DB6D1B2A2636852000ACB481 /* AppSharedName.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSharedName.swift; sourceTree = "<group>"; };
DB6D1B3C2636857500ACB481 /* AppearancePreference.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppearancePreference.swift; sourceTree = "<group>"; };
DB6D1B43263691CF00ACB481 /* Mastodon+API+Subscriptions+Policy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Mastodon+API+Subscriptions+Policy.swift"; sourceTree = "<group>"; };
DB6D9F222635195E008423CD /* String.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = String.swift; sourceTree = "<group>"; };
DB6D9F3426351B7A008423CD /* NotificationService+Decrypt.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NotificationService+Decrypt.swift"; sourceTree = "<group>"; };
DB6D9F4826353FD6008423CD /* Subscription.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Subscription.swift; sourceTree = "<group>"; };
DB6D9F4F2635761F008423CD /* SubscriptionAlerts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubscriptionAlerts.swift; sourceTree = "<group>"; };
DB6D9F56263577D2008423CD /* APIService+CoreData+Setting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+CoreData+Setting.swift"; sourceTree = "<group>"; };
DB6D9F6226357848008423CD /* SettingService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingService.swift; sourceTree = "<group>"; };
DB6D9F6E2635807F008423CD /* Setting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Setting.swift; sourceTree = "<group>"; };
DB6D9F75263587C7008423CD /* SettingFetchedResultController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingFetchedResultController.swift; sourceTree = "<group>"; };
DB6D9F7C26358ED4008423CD /* SettingsSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsSection.swift; sourceTree = "<group>"; };
DB6D9F8326358EEC008423CD /* SettingsItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsItem.swift; sourceTree = "<group>"; };
DB6D9F9626367249008423CD /* SettingsViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SettingsViewController.swift; sourceTree = "<group>"; };
DB71FD2B25F86A5100512AE1 /* AvatarStackContainerButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AvatarStackContainerButton.swift; sourceTree = "<group>"; };
DB71FD3525F8A16C00512AE1 /* APIService+Persist+PersistMemo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Persist+PersistMemo.swift"; sourceTree = "<group>"; };
DB71FD3B25F8A1C500512AE1 /* APIService+Persist+PersistCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Persist+PersistCache.swift"; sourceTree = "<group>"; };
@ -1109,6 +1134,7 @@
DB71FD4B25F8C80E00512AE1 /* StatusPrefetchingService.swift */,
DBC7A67B260DFADE00E57475 /* StatusPublishService.swift */,
DB4924E126312AB200E9DB22 /* NotificationService.swift */,
DB6D9F6226357848008423CD /* SettingService.swift */,
);
path = Service;
sourceTree = "<group>";
@ -1176,6 +1202,7 @@
2D198648261C0B8500F0B013 /* SearchResultSection.swift */,
DB66729525F9F91600D60309 /* ComposeStatusSection.swift */,
DB447680260B3ED600B66B82 /* CustomEmojiPickerSection.swift */,
DB6D9F7C26358ED4008423CD /* SettingsSection.swift */,
);
path = Section;
sourceTree = "<group>";
@ -1232,6 +1259,7 @@
DB1FD45925F27898004CFCFC /* CategoryPickerItem.swift */,
DB66729B25F9F91F00D60309 /* ComposeStatusItem.swift */,
DB44768A260B3F2100B66B82 /* CustomEmojiPickerItem.swift */,
DB6D9F8326358EEC008423CD /* SettingsItem.swift */,
);
path = Item;
sourceTree = "<group>";
@ -1285,8 +1313,8 @@
isa = PBXGroup;
children = (
5B90C457262599800002E742 /* View */,
DB6D9F9626367249008423CD /* SettingsViewController.swift */,
5B90C456262599800002E742 /* SettingsViewModel.swift */,
5B90C45D262599800002E742 /* SettingsViewController.swift */,
);
path = Settings;
sourceTree = "<group>";
@ -1350,6 +1378,9 @@
DB084B5625CBC56C00F898ED /* Status.swift */,
DB45FAE225CA7181005A8AC7 /* MastodonUser.swift */,
DB9D6C3725E508BE0051B173 /* Attachment.swift */,
DB6D9F6E2635807F008423CD /* Setting.swift */,
DB6D9F4826353FD6008423CD /* Subscription.swift */,
DB6D9F4F2635761F008423CD /* SubscriptionAlerts.swift */,
);
path = CoreDataStack;
sourceTree = "<group>";
@ -1377,6 +1408,7 @@
DB427DD525BAA00100D1B89D /* AppDelegate.swift */,
DB427DD725BAA00100D1B89D /* SceneDelegate.swift */,
DB1E05E0263180F500201847 /* AppSecret.swift */,
DB6D1B2A2636852000ACB481 /* AppSharedName.swift */,
DB427DDB25BAA00100D1B89D /* Main.storyboard */,
DB427DE025BAA00100D1B89D /* LaunchScreen.storyboard */,
DB68A05C25E9055900CFDF14 /* Settings.bundle */,
@ -1511,6 +1543,7 @@
DB45FADC25CA6F6B005A8AC7 /* APIService+CoreData+MastodonUser.swift */,
DB45FAF825CA80A2005A8AC7 /* APIService+CoreData+MastodonAuthentication.swift */,
2D79E700261EA5550011E398 /* APIService+CoreData+Tag.swift */,
DB6D9F56263577D2008423CD /* APIService+CoreData+Setting.swift */,
5B90C48A26259C120002E742 /* APIService+CoreData+Subscriptions.swift */,
);
path = CoreData;
@ -1530,6 +1563,7 @@
isa = PBXGroup;
children = (
DB5086BD25CC0D9900C2C187 /* SplashPreference.swift */,
DB6D1B3C2636857500ACB481 /* AppearancePreference.swift */,
);
path = Preference;
sourceTree = "<group>";
@ -1573,6 +1607,7 @@
5DDDF1A82617489F00311060 /* Mastodon+Entity+History.swift */,
2D650FAA25ECDC9300851B58 /* Mastodon+Entity+Error+Detail.swift */,
2DB72C8B262D764300CE6173 /* Mastodon+Entity+Notification+Type.swift */,
DB6D1B43263691CF00ACB481 /* Mastodon+API+Subscriptions+Policy.swift */,
);
path = MastodonSDK;
sourceTree = "<group>";
@ -1787,6 +1822,7 @@
0F20223826146553000C64BF /* Array.swift */,
DBCC3B2F261440A50045B23D /* UITabBarController.swift */,
DBCC3B35261440BA0045B23D /* UINavigationController.swift */,
DB6D1B23263684C600ACB481 /* UserDefaults.swift */,
);
path = Extension;
sourceTree = "<group>";
@ -1994,6 +2030,7 @@
isa = PBXGroup;
children = (
DBCBED1C26132E1A00B49291 /* StatusFetchedResultsController.swift */,
DB6D9F75263587C7008423CD /* SettingFetchedResultController.swift */,
);
path = FetchedResultsController;
sourceTree = "<group>";
@ -2555,6 +2592,7 @@
DB55D33025FB630A0002F825 /* TwitterTextEditor+String.swift in Sources */,
2D38F1EB25CD477000561493 /* HomeTimelineViewModel+LoadLatestState.swift in Sources */,
0F202201261326E6000C64BF /* HashtagTimelineViewModel.swift in Sources */,
DB6D9F9726367249008423CD /* SettingsViewController.swift in Sources */,
DBD9149025DF6D8D00903DFD /* APIService+Onboarding.swift in Sources */,
DBAE3FAF26172FC0004B8251 /* RemoteProfileViewModel.swift in Sources */,
DBE3CE0D261D767100430CC6 /* FavoriteViewController+StatusProvider.swift in Sources */,
@ -2566,8 +2604,8 @@
5DDDF1A92617489F00311060 /* Mastodon+Entity+History.swift in Sources */,
DB59F11825EFA35B001F1DAB /* StripProgressView.swift in Sources */,
DB59F10425EF5EBC001F1DAB /* TableViewCellHeightCacheableContainer.swift in Sources */,
5B90C463262599800002E742 /* SettingsViewController.swift in Sources */,
DB73B490261F030A002E9E9F /* SafariActivity.swift in Sources */,
DB6D1B44263691CF00ACB481 /* Mastodon+API+Subscriptions+Policy.swift in Sources */,
DB9A48962603685D008B817C /* MastodonAttachmentService+UploadState.swift in Sources */,
2D198643261BF09500F0B013 /* SearchResultItem.swift in Sources */,
DB66728C25F9F8DC00D60309 /* ComposeViewModel+Diffable.swift in Sources */,
@ -2599,6 +2637,7 @@
5B90C45F262599800002E742 /* SettingsToggleTableViewCell.swift in Sources */,
2D694A7425F9EB4E0038ADDC /* ContentWarningOverlayView.swift in Sources */,
DBAE3F682615DD60004B8251 /* UserProvider.swift in Sources */,
DB6D9F76263587C7008423CD /* SettingFetchedResultController.swift in Sources */,
DB9A486C26032AC1008B817C /* AttachmentContainerView+EmptyStateView.swift in Sources */,
5D0393902612D259007FE196 /* WebViewController.swift in Sources */,
DB4481CC25EE2AFE00BEFB67 /* PollItem.swift in Sources */,
@ -2624,9 +2663,11 @@
DB1FD43625F26899004CFCFC /* MastodonPickServerViewModel+LoadIndexedServerState.swift in Sources */,
2D939AE825EE1CF80076FA61 /* MastodonRegisterViewController+Avatar.swift in Sources */,
2D084B8D26258EA3003AA3AF /* NotificationViewModel+diffable.swift in Sources */,
DB6D1B24263684C600ACB481 /* UserDefaults.swift in Sources */,
DB1D186C25EF5BA7003F1F23 /* PollTableView.swift in Sources */,
2D5981A125E4A593000FB903 /* MastodonConfirmEmailViewModel.swift in Sources */,
DB87D4452609BE0500D12C0D /* ComposeStatusPollOptionCollectionViewCell.swift in Sources */,
DB6D9F7D26358ED4008423CD /* SettingsSection.swift in Sources */,
DB8AF55025C13703002E6C99 /* MainTabBarController.swift in Sources */,
DB9D6BE925E4F5340051B173 /* SearchViewController.swift in Sources */,
2D38F1C625CD37F400561493 /* ContentOffsetAdjustableTimelineViewControllerDelegate.swift in Sources */,
@ -2645,6 +2686,7 @@
DB2F073525E8ECF000957B2D /* AuthenticationViewModel.swift in Sources */,
DB68A04A25E9027700CFDF14 /* AdaptiveStatusBarStyleNavigationController.swift in Sources */,
0FB3D33825E6401400AAD544 /* PickServerCell.swift in Sources */,
DB6D9F8426358EEC008423CD /* SettingsItem.swift in Sources */,
2D364F7825E66D8300204FDC /* MastodonResendEmailViewModel.swift in Sources */,
DB8AF54525C13647002E6C99 /* NeedsDependency.swift in Sources */,
DB9D6BF825E4F5690051B173 /* NotificationViewController.swift in Sources */,
@ -2664,6 +2706,7 @@
2D76319F25C1521200929FB9 /* StatusSection.swift in Sources */,
DB35FC252612FD7A006193C9 /* ProfileFieldView.swift in Sources */,
DB938F0326240EA300E5B6C1 /* CachedThreadViewModel.swift in Sources */,
DB6D9F6326357848008423CD /* SettingService.swift in Sources */,
2D650FAB25ECDC9300851B58 /* Mastodon+Entity+Error+Detail.swift in Sources */,
2D24E12D2626FD2E00A59D4F /* NotificationViewModel+LoadOldestState.swift in Sources */,
2DB72C8C262D764300CE6173 /* Mastodon+Entity+Notification+Type.swift in Sources */,
@ -2678,11 +2721,13 @@
DB2B3AE925E38850007045F9 /* UIViewPreview.swift in Sources */,
DBAE3F9E2616E308004B8251 /* APIService+Mute.swift in Sources */,
DB427DD625BAA00100D1B89D /* AppDelegate.swift in Sources */,
DB6D9F57263577D2008423CD /* APIService+CoreData+Setting.swift in Sources */,
DB9D6C0E25E4F9780051B173 /* MosaicImageViewContainer.swift in Sources */,
DB71FD3625F8A16C00512AE1 /* APIService+Persist+PersistMemo.swift in Sources */,
DBBE1B4525F3474B0081417A /* MastodonPickServerAppearance.swift in Sources */,
DB98338725C945ED00AD9700 /* Strings.swift in Sources */,
2D7867192625B77500211898 /* NotificationItem.swift in Sources */,
DB6D1B2B2636852000ACB481 /* AppSharedName.swift in Sources */,
DB45FAB625CA5485005A8AC7 /* UIAlertController.swift in Sources */,
DBE0821525CD382600FD6BBD /* MastodonRegisterViewController.swift in Sources */,
2D5A3D0325CF8742002347D6 /* ControlContainableScrollViews.swift in Sources */,
@ -2701,6 +2746,7 @@
2D76317D25C14DF500929FB9 /* PublicTimelineViewController+StatusProvider.swift in Sources */,
0F20223326145E51000C64BF /* HashtagTimelineViewModel+LoadMiddleState.swift in Sources */,
2D206B8025F5F45E00143C56 /* UIImage.swift in Sources */,
DB6D9F502635761F008423CD /* SubscriptionAlerts.swift in Sources */,
0F20220726134DA4000C64BF /* HashtagTimelineViewModel+Diffable.swift in Sources */,
2D5A3D2825CF8BC9002347D6 /* HomeTimelineViewModel+Diffable.swift in Sources */,
DB98339C25C96DE600AD9700 /* APIService+Account.swift in Sources */,
@ -2734,17 +2780,20 @@
2D5A3D6225CFD9CB002347D6 /* HomeTimelineViewController+DebugAction.swift in Sources */,
DB49A62525FF334C00B98345 /* EmojiService+CustomEmojiViewModel+LoadState.swift in Sources */,
DB4924E226312AB200E9DB22 /* NotificationService.swift in Sources */,
DB6D9F6F2635807F008423CD /* Setting.swift in Sources */,
DBB525412611ED54002F1F29 /* ProfileHeaderViewController.swift in Sources */,
DB9D6BFF25E4F5940051B173 /* ProfileViewController.swift in Sources */,
0FB3D30825E524C600AAD544 /* PickServerCategoriesCell.swift in Sources */,
DB789A1225F9F2CC0071ACA0 /* ComposeViewModel.swift in Sources */,
DBB525362611ECEB002F1F29 /* UserTimelineViewController.swift in Sources */,
DB6D1B3D2636857500ACB481 /* AppearancePreference.swift in Sources */,
DB938F3326243D6200E5B6C1 /* TimelineTopLoaderTableViewCell.swift in Sources */,
2D38F1FE25CD481700561493 /* StatusProvider.swift in Sources */,
DB938F25262438D600E5B6C1 /* ThreadViewController+StatusProvider.swift in Sources */,
DB66729C25F9F91F00D60309 /* ComposeStatusItem.swift in Sources */,
0FB3D31E25E534C700AAD544 /* PickServerCategoryCollectionViewCell.swift in Sources */,
5DF1057925F88A1D00D6C0D4 /* PlayerContainerView.swift in Sources */,
DB6D9F4926353FD7008423CD /* Subscription.swift in Sources */,
DB45FB0F25CA87D0005A8AC7 /* AuthenticationService.swift in Sources */,
2D6DE40026141DF600A63F6A /* SearchViewModel.swift in Sources */,
DB51D172262832380062B7A1 /* BlurHashDecode.swift in Sources */,
@ -2794,6 +2843,7 @@
2D152A9225C2980C009AA50C /* UIFont.swift in Sources */,
DB4481B325EE16D000BEFB67 /* PollOption.swift in Sources */,
DB89BA4425C1165F008580ED /* Managed.swift in Sources */,
DB6D1B312636853100ACB481 /* AppSharedName.swift in Sources */,
2D6125472625436B00299647 /* Notification.swift in Sources */,
DB89BA4325C1165F008580ED /* NetworkUpdatable.swift in Sources */,
DB8AF56825C13E2A002E6C99 /* HomeTimelineIndex.swift in Sources */,

View File

@ -7,7 +7,7 @@
<key>CoreDataStack.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>14</integer>
<integer>13</integer>
</dict>
<key>Mastodon - RTL.xcscheme_^#shared#^_</key>
<dict>
@ -27,7 +27,7 @@
<key>NotificationService.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>17</integer>
<integer>14</integer>
</dict>
</dict>
<key>SuppressBuildableAutocreation</key>

View File

@ -61,6 +61,9 @@ extension SceneCoordinator {
case profile(viewModel: ProfileViewModel)
case favorite(viewModel: FavoriteViewModel)
// setting
case settings(viewModel: SettingsViewModel)
// misc
case safari(url: URL)
case alertController(alertController: UIAlertController)
@ -68,7 +71,6 @@ extension SceneCoordinator {
#if DEBUG
case publicTimeline
case settings
#endif
var isOnboarding: Bool {
@ -246,6 +248,10 @@ private extension SceneCoordinator {
let _viewController = FavoriteViewController()
_viewController.viewModel = viewModel
viewController = _viewController
case .settings(let viewModel):
let _viewController = SettingsViewController()
_viewController.viewModel = viewModel
viewController = _viewController
case .safari(let url):
guard let scheme = url.scheme?.lowercased(),
scheme == "http" || scheme == "https" else {
@ -270,10 +276,6 @@ private extension SceneCoordinator {
let _viewController = PublicTimelineViewController()
_viewController.viewModel = PublicTimelineViewModel(context: appContext)
viewController = _viewController
case .settings:
let _viewController = SettingsViewController()
_viewController.viewModel = SettingsViewModel(context: appContext, coordinator: self)
viewController = _viewController
#endif
}

View File

@ -0,0 +1,64 @@
//
// SettingFetchedResultController.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021-4-25.
//
import os.log
import UIKit
import Combine
import CoreData
import CoreDataStack
import MastodonSDK
final class SettingFetchedResultController: NSObject {
var disposeBag = Set<AnyCancellable>()
let fetchedResultsController: NSFetchedResultsController<Setting>
// input
// output
let settings = CurrentValueSubject<[Setting], Never>([])
init(managedObjectContext: NSManagedObjectContext, additionalPredicate: NSPredicate?) {
self.fetchedResultsController = {
let fetchRequest = Setting.sortedFetchRequest
fetchRequest.returnsObjectsAsFaults = false
if let additionalPredicate = additionalPredicate {
fetchRequest.predicate = additionalPredicate
}
fetchRequest.fetchBatchSize = 20
let controller = NSFetchedResultsController(
fetchRequest: fetchRequest,
managedObjectContext: managedObjectContext,
sectionNameKeyPath: nil,
cacheName: nil
)
return controller
}()
super.init()
fetchedResultsController.delegate = self
do {
try self.fetchedResultsController.performFetch()
} catch {
assertionFailure(error.localizedDescription)
}
}
}
// MARK: - NSFetchedResultsControllerDelegate
extension SettingFetchedResultController: NSFetchedResultsControllerDelegate {
func controller(_ controller: NSFetchedResultsController<NSFetchRequestResult>, didChangeContentWith snapshot: NSDiffableDataSourceSnapshotReference) {
os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
let objects = fetchedResultsController.fetchedObjects ?? []
self.settings.value = objects
}
}

View File

@ -0,0 +1,67 @@
//
// SettingsItem.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021-4-25.
//
import UIKit
import CoreData
enum SettingsItem: Hashable {
case apperance(settingObjectID: NSManagedObjectID)
case notification(settingObjectID: NSManagedObjectID, switchMode: NotificationSwitchMode)
case boringZone(item: Link)
case spicyZone(item: Link)
}
extension SettingsItem {
enum AppearanceMode: String {
case automatic
case light
case dark
}
enum NotificationSwitchMode: CaseIterable {
case favorite
case follow
case reblog
case mention
var title: String {
switch self {
case .favorite: return L10n.Scene.Settings.Section.Notifications.favorites
case .follow: return L10n.Scene.Settings.Section.Notifications.follows
case .reblog: return L10n.Scene.Settings.Section.Notifications.boosts
case .mention: return L10n.Scene.Settings.Section.Notifications.mentions
}
}
}
enum Link: CaseIterable {
case termsOfService
case privacyPolicy
case clearMediaCache
case signOut
var title: String {
switch self {
case .termsOfService: return L10n.Scene.Settings.Section.Boringzone.terms
case .privacyPolicy: return L10n.Scene.Settings.Section.Boringzone.privacy
case .clearMediaCache: return L10n.Scene.Settings.Section.Spicyzone.clear
case .signOut: return L10n.Scene.Settings.Section.Spicyzone.signout
}
}
var textColor: UIColor {
switch self {
case .termsOfService: return .systemBlue
case .privacyPolicy: return .systemBlue
case .clearMediaCache: return .systemRed
case .signOut: return .systemRed
}
}
}
}

View File

@ -0,0 +1,24 @@
//
// SettingsSection.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021-4-25.
//
import Foundation
enum SettingsSection: Hashable {
case apperance
case notifications
case boringZone
case spicyZone
var title: String {
switch self {
case .apperance: return L10n.Scene.Settings.Section.Appearance.title
case .notifications: return L10n.Scene.Settings.Section.Notifications.title
case .boringZone: return L10n.Scene.Settings.Section.Boringzone.title
case .spicyZone: return L10n.Scene.Settings.Section.Spicyzone.title
}
}
}

View File

@ -0,0 +1,24 @@
//
// Setting.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021-4-25.
//
import Foundation
import CoreDataStack
import MastodonSDK
extension Setting {
var appearance: SettingsItem.AppearanceMode {
return SettingsItem.AppearanceMode(rawValue: appearanceRaw) ?? .automatic
}
var activeSubscription: Subscription? {
return (subscriptions ?? Set())
.sorted(by: { $0.activedAt > $1.activedAt })
.first
}
}

View File

@ -0,0 +1,20 @@
//
// Subscription.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021-4-25.
//
import Foundation
import CoreDataStack
import MastodonSDK
typealias NotificationSubscription = Subscription
extension Subscription {
var policy: Mastodon.API.Subscriptions.Policy {
return Mastodon.API.Subscriptions.Policy(rawValue: policyRaw) ?? .all
}
}

View File

@ -0,0 +1,28 @@
//
// SubscriptionAlerts.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021-4-25.
//
import Foundation
import CoreDataStack
import MastodonSDK
extension SubscriptionAlerts.Property {
init(policy: Mastodon.API.Subscriptions.Policy) {
switch policy {
case .all:
self.init(favourite: true, follow: true, followRequest: true, mention: true, poll: true, reblog: true)
case .follower:
self.init(favourite: true, follow: nil, followRequest: nil, mention: true, poll: true, reblog: true)
case .followed:
self.init(favourite: true, follow: true, followRequest: true, mention: true, poll: true, reblog: true)
case .none, ._other:
self.init(favourite: nil, follow: nil, followRequest: nil, mention: nil, poll: nil, reblog: nil)
}
}
}

View File

@ -0,0 +1,20 @@
//
// Mastodon+API+Subscriptions+Policy.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021-4-26.
//
import Foundation
import MastodonSDK
extension Mastodon.API.Subscriptions.Policy {
var title: String {
switch self {
case .all: return L10n.Scene.Settings.Section.Notifications.Trigger.anyone
case .follower: return L10n.Scene.Settings.Section.Notifications.Trigger.follower
case .followed: return L10n.Scene.Settings.Section.Notifications.Trigger.follow
case .none, ._other: return L10n.Scene.Settings.Section.Notifications.Trigger.noone
}
}
}

View File

@ -0,0 +1,31 @@
//
// UserDefaults.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021-4-26.
//
import Foundation
extension UserDefaults {
static let shared = UserDefaults(suiteName: AppSharedName.groupID)!
}
extension UserDefaults {
subscript<T: RawRepresentable>(key: String) -> T? {
get {
if let rawValue = value(forKey: key) as? T.RawValue {
return T(rawValue: rawValue)
}
return nil
}
set { set(newValue?.rawValue, forKey: key) }
}
subscript<T>(key: String) -> T? {
get { return value(forKey: key) as? T }
set { set(newValue, forKey: key) }
}
}

View File

@ -0,0 +1,20 @@
//
// AppearancePreference.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021-4-26.
//
import UIKit
extension UserDefaults {
@objc dynamic var customUserInterfaceStyle: UIUserInterfaceStyle {
get {
register(defaults: [#function: UIUserInterfaceStyle.unspecified.rawValue])
return UIUserInterfaceStyle(rawValue: integer(forKey: #function)) ?? .unspecified
}
set { UserDefaults.shared[#function] = newValue.rawValue }
}
}

View File

@ -328,7 +328,9 @@ extension HomeTimelineViewController {
}
@objc private func showSettings(_ sender: UIAction) {
coordinator.present(scene: .settings, from: self, transition: .modal(animated: true, completion: nil))
guard let currentSetting = context.settingService.currentSetting.value else { return }
let settingsViewModel = SettingsViewModel(context: context, setting: currentSetting)
coordinator.present(scene: .settings(viewModel: settingsViewModel), from: self, transition: .modal(animated: true, completion: nil))
}
}
#endif

View File

@ -88,14 +88,8 @@ extension HomeTimelineViewController {
// long press to trigger debug menu
settingBarButtonItem.menu = debugMenu
#else
// settingBarButtonItem.target = self
// settingBarButtonItem.action = #selector(HomeTimelineViewController.settingBarButtonItemPressed(_:))
settingBarButtonItem.menu = UIMenu(title: "Settings", image: nil, identifier: nil, options: .displayInline, children: [
UIAction(title: "Sign Out", image: UIImage(systemName: "escape"), attributes: .destructive) { [weak self] action in
guard let self = self else { return }
self.signOutAction(action)
}
])
settingBarButtonItem.target = self
settingBarButtonItem.action = #selector(HomeTimelineViewController.settingBarButtonItemPressed(_:))
#endif
navigationItem.rightBarButtonItem = composeBarButtonItem
@ -220,7 +214,9 @@ extension HomeTimelineViewController {
@objc private func settingBarButtonItemPressed(_ sender: UIBarButtonItem) {
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
guard let setting = context.settingService.currentSetting.value else { return }
let settingsViewModel = SettingsViewModel(context: context, setting: setting)
coordinator.present(scene: .settings(viewModel: settingsViewModel), from: self, transition: .modal(animated: true, completion: nil))
}
@objc private func composeBarButtonItemPressed(_ sender: UIBarButtonItem) {

View File

@ -517,7 +517,9 @@ extension ProfileViewController {
@objc private func settingBarButtonItemPressed(_ sender: UIBarButtonItem) {
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
guard let setting = context.settingService.currentSetting.value else { return }
let settingsViewModel = SettingsViewModel(context: context, setting: setting)
coordinator.present(scene: .settings(viewModel: settingsViewModel), from: self, transition: .modal(animated: true, completion: nil))
}
@objc private func shareBarButtonItemPressed(_ sender: UIBarButtonItem) {

View File

@ -11,11 +11,10 @@ import Combine
import ActiveLabel
import CoreData
import CoreDataStack
import MastodonSDK
import AlamofireImage
import Kingfisher
// iTODO: when to ask permission to Use Notifications
class SettingsViewController: UIViewController, NeedsDependency {
weak var context: AppContext! { willSet { precondition(!isViewLoaded) } }
@ -23,6 +22,7 @@ class SettingsViewController: UIViewController, NeedsDependency {
var viewModel: SettingsViewModel! { willSet { precondition(!isViewLoaded) } }
var disposeBag = Set<AnyCancellable>()
var notificationPolicySubscription: AnyCancellable?
var triggerMenu: UIMenu {
let anyone = L10n.Scene.Settings.Section.Notifications.Trigger.anyone
@ -35,23 +35,23 @@ class SettingsViewController: UIViewController, NeedsDependency {
options: .displayInline,
children: [
UIAction(title: anyone, image: UIImage(systemName: "person.3"), attributes: []) { [weak self] action in
self?.updateTrigger(by: anyone)
self?.updateTrigger(policy: .all)
},
UIAction(title: follower, image: UIImage(systemName: "person.crop.circle.badge.plus"), attributes: []) { [weak self] action in
self?.updateTrigger(by: follower)
self?.updateTrigger(policy: .follower)
},
UIAction(title: follow, image: UIImage(systemName: "person.crop.circle.badge.checkmark"), attributes: []) { [weak self] action in
self?.updateTrigger(by: follow)
self?.updateTrigger(policy: .followed)
},
UIAction(title: noOne, image: UIImage(systemName: "nosign"), attributes: []) { [weak self] action in
self?.updateTrigger(by: noOne)
self?.updateTrigger(policy: .none)
},
]
)
return menu
}
lazy var notifySectionHeader: UIView = {
private(set) lazy var notifySectionHeader: UIView = {
let view = UIStackView()
view.translatesAutoresizingMaskIntoConstraints = false
view.isLayoutMarginsRelativeArrangement = true
@ -71,15 +71,12 @@ class SettingsViewController: UIViewController, NeedsDependency {
return view
}()
lazy var whoButton: UIButton = {
private(set) lazy var whoButton: UIButton = {
let whoButton = UIButton(type: .roundedRect)
whoButton.menu = triggerMenu
whoButton.showsMenuAsPrimaryAction = true
whoButton.setBackgroundColor(Asset.Colors.battleshipGrey.color, for: .normal)
whoButton.setTitleColor(Asset.Colors.Label.primary.color, for: .normal)
if let setting = self.viewModel.setting.value, let trigger = setting.triggerBy {
whoButton.setTitle(trigger, for: .normal)
}
whoButton.titleLabel?.font = UIFontMetrics(forTextStyle: .title3).scaledFont(for: UIFont.systemFont(ofSize: 20, weight: .semibold))
whoButton.contentEdgeInsets = UIEdgeInsets(top: 5, left: 5, bottom: 5, right: 5)
whoButton.layer.cornerRadius = 10
@ -87,7 +84,7 @@ class SettingsViewController: UIViewController, NeedsDependency {
return whoButton
}()
lazy var tableView: UITableView = {
private(set) lazy var tableView: UITableView = {
// init with a frame to fix a conflict ('UIView-Encapsulated-Layout-Width' UIStackView:0x7f8c2b6c0590.width == 0)
let tableView = UITableView(frame: CGRect(x: 0, y: 0, width: 320, height: 320), style: .grouped)
tableView.translatesAutoresizingMaskIntoConstraints = false
@ -95,13 +92,13 @@ class SettingsViewController: UIViewController, NeedsDependency {
tableView.rowHeight = UITableView.automaticDimension
tableView.backgroundColor = Asset.Colors.Background.systemGroupedBackground.color
tableView.register(SettingsAppearanceTableViewCell.self, forCellReuseIdentifier: "SettingsAppearanceTableViewCell")
tableView.register(SettingsToggleTableViewCell.self, forCellReuseIdentifier: "SettingsToggleTableViewCell")
tableView.register(SettingsLinkTableViewCell.self, forCellReuseIdentifier: "SettingsLinkTableViewCell")
tableView.register(SettingsAppearanceTableViewCell.self, forCellReuseIdentifier: String(describing: SettingsAppearanceTableViewCell.self))
tableView.register(SettingsToggleTableViewCell.self, forCellReuseIdentifier: String(describing: SettingsToggleTableViewCell.self))
tableView.register(SettingsLinkTableViewCell.self, forCellReuseIdentifier: String(describing: SettingsLinkTableViewCell.self))
return tableView
}()
lazy var footerView: UIView = {
lazy var tableFooterView: UIView = {
// init with a frame to fix a conflict ('UIView-Encapsulated-Layout-Height' UIStackView:0x7ffe41e47da0.height == 0)
let view = UIStackView(frame: CGRect(x: 0, y: 0, width: 320, height: 320))
view.isLayoutMarginsRelativeArrangement = true
@ -143,14 +140,30 @@ class SettingsViewController: UIViewController, NeedsDependency {
// MAKR: - Private methods
private func bindViewModel() {
let input = SettingsViewModel.Input()
_ = viewModel.transform(input: input)
self.whoButton.setTitle(viewModel.setting.value.activeSubscription?.policy.title, for: .normal)
viewModel.setting
.sink { [weak self] setting in
guard let self = self else { return }
self.notificationPolicySubscription = ManagedObjectObserver.observe(object: setting)
.sink { _ in
// do nothing
} receiveValue: { [weak self] change in
guard let self = self else { return }
guard case let .update(object) = change.changeType,
let setting = object as? Setting else { return }
if let activeSubscription = setting.activeSubscription {
self.whoButton.setTitle(activeSubscription.policy.title, for: .normal)
} else {
assertionFailure()
}
}
}
.store(in: &disposeBag)
}
private func setupView() {
view.backgroundColor = Asset.Colors.Background.systemGroupedBackground.color
setupNavigation()
setupTableView()
view.addSubview(tableView)
NSLayoutConstraint.activate([
@ -159,6 +172,7 @@ class SettingsViewController: UIViewController, NeedsDependency {
tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
])
setupTableView()
}
private func setupNavigation() {
@ -177,35 +191,12 @@ class SettingsViewController: UIViewController, NeedsDependency {
}
private func setupTableView() {
viewModel.dataSource = UITableViewDiffableDataSource(tableView: tableView, cellProvider: { [weak self] (tableView, indexPath, item) -> UITableViewCell? in
guard let self = self else { return nil }
switch item {
case .apperance(let item):
guard let cell = tableView.dequeueReusableCell(withIdentifier: "SettingsAppearanceTableViewCell") as? SettingsAppearanceTableViewCell else {
assertionFailure()
return nil
}
cell.update(with: item, delegate: self)
return cell
case .notification(let item):
guard let cell = tableView.dequeueReusableCell(withIdentifier: "SettingsToggleTableViewCell") as? SettingsToggleTableViewCell else {
assertionFailure()
return nil
}
cell.update(with: item, delegate: self)
return cell
case .boringZone(let item), .spicyZone(let item):
guard let cell = tableView.dequeueReusableCell(withIdentifier: "SettingsLinkTableViewCell") as? SettingsLinkTableViewCell else {
assertionFailure()
return nil
}
cell.update(with: item)
return cell
}
})
tableView.tableFooterView = footerView
viewModel.setupDiffableDataSource(
for: tableView,
settingsAppearanceTableViewCellDelegate: self,
settingsToggleCellDelegate: self
)
tableView.tableFooterView = tableFooterView
}
func alertToSignout() {
@ -218,7 +209,7 @@ class SettingsViewController: UIViewController, NeedsDependency {
let cancelAction = UIAlertAction(title: L10n.Common.Controls.Actions.cancel, style: .cancel, handler: nil)
let signOutAction = UIAlertAction(title: L10n.Common.Alerts.SignOut.confirm, style: .destructive) { [weak self] _ in
guard let self = self else { return }
self.signout()
self.signOut()
}
alertController.addAction(cancelAction)
alertController.addAction(signOutAction)
@ -229,7 +220,7 @@ class SettingsViewController: UIViewController, NeedsDependency {
)
}
func signout() {
func signOut() {
guard let activeMastodonAuthenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else {
return
}
@ -258,8 +249,11 @@ class SettingsViewController: UIViewController, NeedsDependency {
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s:", ((#file as NSString).lastPathComponent), #line, #function)
}
// Mark: - Actions
@objc func doneButtonDidClick() {
}
// Mark: - Actions
extension SettingsViewController {
@objc private func doneButtonDidClick() {
dismiss(animated: true, completion: nil)
}
}
@ -268,51 +262,39 @@ extension SettingsViewController: UITableViewDelegate {
func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? {
let sections = viewModel.dataSource.snapshot().sectionIdentifiers
guard section < sections.count else { return nil }
let sectionData = sections[section]
let sectionIdentifier = sections[section]
let header: SettingsSectionHeader
if section == 1 {
switch sectionIdentifier {
case .notifications:
header = SettingsSectionHeader(
frame: CGRect(x: 0, y: 0, width: 375, height: 66),
customView: notifySectionHeader)
header.update(title: sectionData.title)
if let setting = self.viewModel.setting.value, let trigger = setting.triggerBy {
whoButton.setTitle(trigger, for: .normal)
} else {
let anyone = L10n.Scene.Settings.Section.Notifications.Trigger.anyone
whoButton.setTitle(anyone, for: .normal)
}
} else {
header.update(title: sectionIdentifier.title)
default:
header = SettingsSectionHeader(frame: CGRect(x: 0, y: 0, width: 375, height: 66))
header.update(title: sectionData.title)
header.update(title: sectionIdentifier.title)
}
header.preservesSuperviewLayoutMargins = true
return header
}
// remove the gap of table's footer
func tableView(_ tableView: UITableView, viewForFooterInSection section: Int) -> UIView? {
return UIView()
}
// remove the gap of table's footer
func tableView(_ tableView: UITableView, heightForFooterInSection section: Int) -> CGFloat {
return 0
return CGFloat.leastNonzeroMagnitude
}
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
let snapshot = self.viewModel.dataSource.snapshot()
let sectionIds = snapshot.sectionIdentifiers
guard indexPath.section < sectionIds.count else { return }
let sectionIdentifier = sectionIds[indexPath.section]
let items = snapshot.itemIdentifiers(inSection: sectionIdentifier)
guard indexPath.row < items.count else { return }
let item = items[indexPath.item]
guard let dataSource = viewModel.dataSource else { return }
let item = dataSource.itemIdentifier(for: indexPath)
switch item {
case .boringZone:
guard let url = viewModel.privacyURL else { break }
@ -331,7 +313,7 @@ extension SettingsViewController: UITableViewDelegate {
ImageDownloader.defaultURLCache().removeAllCachedResponses()
let cleanedDiskBytes = ImageDownloader.defaultURLCache().currentDiskUsage
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: diskBytes %d", ((#file as NSString).lastPathComponent), #line, #function, cleanedDiskBytes)
// clean Kingfisher Cache
KingfisherManager.shared.cache.clearDiskCache()
}
@ -347,82 +329,77 @@ extension SettingsViewController: UITableViewDelegate {
// Update setting into core data
extension SettingsViewController {
func updateTrigger(by who: String) {
guard self.viewModel.triggerBy != who else { return }
guard let setting = self.viewModel.setting.value else { return }
func updateTrigger(policy: Mastodon.API.Subscriptions.Policy) {
let objectID = self.viewModel.setting.value.objectID
let managedObjectContext = context.backgroundManagedObjectContext
setting.update(triggerBy: who)
// trigger to call `subscription` API with POST method
// confirm the local data is correct even if request failed
// The asynchronous execution is to solve the problem of dropped frames for animations.
DispatchQueue.main.async { [weak self] in
self?.viewModel.setting.value = setting
managedObjectContext.performChanges {
let setting = managedObjectContext.object(with: objectID) as! Setting
let (subscription, _) = APIService.CoreData.createOrFetchSubscription(
into: managedObjectContext,
setting: setting,
policy: policy
)
let now = Date()
subscription.update(activedAt: now)
setting.didUpdate(at: now)
}
}
func updateAlert(title: String?, isOn: Bool) {
guard let title = title else { return }
guard let settings = self.viewModel.setting.value else { return }
guard let triggerBy = settings.triggerBy else { return }
if let alerts = settings.subscription?.first(where: { (s) -> Bool in
return s.type == settings.triggerBy
})?.alert {
var alertValues = [Bool?]()
alertValues.append(alerts.favourite?.boolValue)
alertValues.append(alerts.follow?.boolValue)
alertValues.append(alerts.reblog?.boolValue)
alertValues.append(alerts.mention?.boolValue)
// need to update `alerts` to make update API with correct parameter
switch title {
case L10n.Scene.Settings.Section.Notifications.favorites:
alertValues[0] = isOn
alerts.favourite = NSNumber(booleanLiteral: isOn)
case L10n.Scene.Settings.Section.Notifications.follows:
alertValues[1] = isOn
alerts.follow = NSNumber(booleanLiteral: isOn)
case L10n.Scene.Settings.Section.Notifications.boosts:
alertValues[2] = isOn
alerts.reblog = NSNumber(booleanLiteral: isOn)
case L10n.Scene.Settings.Section.Notifications.mentions:
alertValues[3] = isOn
alerts.mention = NSNumber(booleanLiteral: isOn)
default: break
}
self.viewModel.updateSubscriptionSubject.send((triggerBy: triggerBy, values: alertValues))
} else if let alertValues = self.viewModel.notificationDefaultValue[triggerBy] {
self.viewModel.updateSubscriptionSubject.send((triggerBy: triggerBy, values: alertValues))
.sink { _ in
// do nothing
} receiveValue: { _ in
// do nohting
}
.store(in: &disposeBag)
}
}
// MARK: - SettingsAppearanceTableViewCellDelegate
extension SettingsViewController: SettingsAppearanceTableViewCellDelegate {
func settingsAppearanceCell(_ view: SettingsAppearanceTableViewCell, didSelect: SettingsItem.AppearanceMode) {
guard let setting = self.viewModel.setting.value else { return }
func settingsAppearanceCell(_ cell: SettingsAppearanceTableViewCell, didSelectAppearanceMode appearanceMode: SettingsItem.AppearanceMode) {
guard let dataSource = viewModel.dataSource else { return }
guard let indexPath = tableView.indexPath(for: cell) else { return }
let item = dataSource.itemIdentifier(for: indexPath)
guard case let .apperance(settingObjectID) = item else { return }
context.managedObjectContext.performChanges {
setting.update(appearance: didSelect.rawValue)
let setting = self.context.managedObjectContext.object(with: settingObjectID) as! Setting
setting.update(appearanceRaw: appearanceMode.rawValue)
}
.sink { (_) in
// change light / dark mode
var overrideUserInterfaceStyle: UIUserInterfaceStyle!
switch didSelect {
case .automatic:
overrideUserInterfaceStyle = .unspecified
case .light:
overrideUserInterfaceStyle = .light
case .dark:
overrideUserInterfaceStyle = .dark
}
view.window?.overrideUserInterfaceStyle = overrideUserInterfaceStyle
.sink { _ in
// do nothing
}.store(in: &disposeBag)
}
}
extension SettingsViewController: SettingsToggleCellDelegate {
func settingsToggleCell(_ cell: SettingsToggleTableViewCell, didChangeStatus: Bool) {
updateAlert(title: cell.data?.title, isOn: didChangeStatus)
func settingsToggleCell(_ cell: SettingsToggleTableViewCell, switchValueDidChange switch: UISwitch) {
guard let dataSource = viewModel.dataSource else { return }
guard let indexPath = tableView.indexPath(for: cell) else { return }
let item = dataSource.itemIdentifier(for: indexPath)
switch item {
case .notification(let settingObjectID, let switchMode):
let isOn = `switch`.isOn
let managedObjectContext = context.backgroundManagedObjectContext
managedObjectContext.performChanges {
let setting = managedObjectContext.object(with: settingObjectID) as! Setting
guard let subscription = setting.activeSubscription else { return }
let alert = subscription.alert
switch switchMode {
case .favorite: alert.update(favourite: isOn)
case .follow: alert.update(follow: isOn)
case .reblog: alert.update(reblog: isOn)
case .mention: alert.update(mention: isOn)
}
// trigger setting update
alert.subscription.setting?.didUpdate(at: Date())
}
.sink { _ in
// do nothing
}
.store(in: &disposeBag)
default:
break
}
}
}
@ -436,43 +413,6 @@ extension SettingsViewController: ActiveLabelDelegate {
}
}
extension SettingsViewController {
static func updateOverrideUserInterfaceStyle(window: UIWindow?) {
guard let box = AppContext.shared.authenticationService.activeMastodonAuthenticationBox.value else {
return
}
guard let setting: Setting? = {
let domain = box.domain
let request = Setting.sortedFetchRequest
request.predicate = Setting.predicate(domain: domain, userID: box.userID)
request.fetchLimit = 1
request.returnsObjectsAsFaults = false
do {
return try AppContext.shared.managedObjectContext.fetch(request).first
} catch {
assertionFailure(error.localizedDescription)
return nil
}
}() else { return }
guard let didSelect = SettingsItem.AppearanceMode(rawValue: setting?.appearance ?? "") else {
return
}
var overrideUserInterfaceStyle: UIUserInterfaceStyle!
switch didSelect {
case .automatic:
overrideUserInterfaceStyle = .unspecified
case .light:
overrideUserInterfaceStyle = .light
case .dark:
overrideUserInterfaceStyle = .dark
}
window?.overrideUserInterfaceStyle = overrideUserInterfaceStyle
}
}
#if canImport(SwiftUI) && DEBUG
import SwiftUI

View File

@ -13,38 +13,21 @@ import MastodonSDK
import UIKit
import os.log
class SettingsViewModel: NSObject, NeedsDependency {
// confirm set only once
weak var context: AppContext! { willSet { precondition(context == nil) } }
weak var coordinator: SceneCoordinator! { willSet { precondition(coordinator == nil) } }
class SettingsViewModel {
var dataSource: UITableViewDiffableDataSource<SettingsSection, SettingsItem>!
var disposeBag = Set<AnyCancellable>()
let context: AppContext
// input
let setting: CurrentValueSubject<Setting, Never>
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)
// output
var dataSource: UITableViewDiffableDataSource<SettingsSection, SettingsItem>!
/// create a subscription when:
/// - does not has one
/// - does not find subscription for selected trigger when change trigger
@ -54,22 +37,6 @@ class SettingsViewModel: NSObject, NeedsDependency {
/// - change switch for specified alerts
let updateSubscriptionSubject = PassthroughSubject<(triggerBy: String, values: [Bool?]), Never>()
lazy var notificationDefaultValue: [String: [Bool?]] = {
let followerSwitchItems: [Bool?] = [true, nil, true, true]
let anyoneSwitchItems: [Bool?] = [true, true, true, true]
let noOneSwitchItems: [Bool?] = [nil, nil, nil, nil]
let followSwitchItems: [Bool?] = [true, true, true, true]
let anyone = L10n.Scene.Settings.Section.Notifications.Trigger.anyone
let follower = L10n.Scene.Settings.Section.Notifications.Trigger.follower
let follow = L10n.Scene.Settings.Section.Notifications.Trigger.follow
let noOne = L10n.Scene.Settings.Section.Notifications.Trigger.noone
return [anyone: anyoneSwitchItems,
follower: followerSwitchItems,
follow: followSwitchItems,
noOne: noOneSwitchItems]
}()
lazy var privacyURL: URL? = {
guard let box = AppContext.shared.authenticationService.activeMastodonAuthenticationBox.value else {
return nil
@ -78,321 +45,151 @@ class SettingsViewModel: NSObject, NeedsDependency {
return Mastodon.API.privacyURL(domain: box.domain)
}()
/// to store who trigger the notification.
var triggerBy: String?
struct Input {
}
struct Output {
}
init(context: AppContext, coordinator: SceneCoordinator) {
init(context: AppContext, setting: Setting) {
self.context = context
self.coordinator = coordinator
self.setting = CurrentValueSubject(setting)
super.init()
}
func transform(input: Input?) -> Output? {
typealias SubscriptionResponse = Mastodon.Response.Content<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(
data: Mastodon.API.Subscriptions.QueryData(
alerts: Mastodon.API.Subscriptions.QueryData.Alerts(
favourite: values[0],
follow: values[1],
reblog: values[2],
mention: values[3],
poll: nil
)
)
)
self.context.apiService.updateSubscription(
domain: domain,
mastodonAuthenticationBox: activeMastodonAuthenticationBox,
query: query,
triggerBy: triggerBy,
userID: activeMastodonAuthenticationBox.userID
)
.sink { (_) in
} receiveValue: { (_) in
}
.store(in: &self.updateDisposeBag)
}
self.setting
.sink(receiveValue: { [weak self] setting in
guard let self = self else { return }
self.processDataSource(setting)
})
.store(in: &disposeBag)
// build data for table view
buildDataSource()
// request subsription data for updating or initialization
requestSubscription()
return nil
}
// MARK: - Private methods
fileprivate func processDataSource(_ settings: Setting?) {
var snapshot = NSDiffableDataSourceSnapshot<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 {
extension SettingsViewModel {
func controllerWillChangeContent(_ controller: NSFetchedResultsController<NSFetchRequestResult>) {
os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
}
// MARK: - Private methods
private func processDataSource(_ setting: Setting) {
guard let dataSource = self.dataSource else { return }
var snapshot = NSDiffableDataSourceSnapshot<SettingsSection, SettingsItem>()
func controllerDidChangeContent(_ controller: NSFetchedResultsController<NSFetchRequestResult>) {
guard controller === fetchResultsController else {
return
// appearance
let appearanceItems = [SettingsItem.apperance(settingObjectID: setting.objectID)]
snapshot.appendSections([.apperance])
snapshot.appendItems(appearanceItems, toSection: .apperance)
let notificationItems = SettingsItem.NotificationSwitchMode.allCases.map { mode in
SettingsItem.notification(settingObjectID: setting.objectID, switchMode: mode)
}
snapshot.appendSections([.notifications])
snapshot.appendItems(notificationItems, toSection: .notifications)
// boring zone
let boringZoneSettingsItems: [SettingsItem] = {
let links: [SettingsItem.Link] = [
.termsOfService,
.privacyPolicy
]
let items = links.map { SettingsItem.boringZone(item: $0) }
return items
}()
snapshot.appendSections([.boringZone])
snapshot.appendItems(boringZoneSettingsItems, toSection: .boringZone)
let spicyZoneSettingsItems: [SettingsItem] = {
let links: [SettingsItem.Link] = [
.clearMediaCache,
.signOut
]
let items = links.map { SettingsItem.spicyZone(item: $0) }
return items
}()
snapshot.appendSections([.spicyZone])
snapshot.appendItems(spicyZoneSettingsItems, toSection: .spicyZone)
dataSource.apply(snapshot, animatingDifferences: false)
}
}
extension SettingsViewModel {
func setupDiffableDataSource(
for tableView: UITableView,
settingsAppearanceTableViewCellDelegate: SettingsAppearanceTableViewCellDelegate,
settingsToggleCellDelegate: SettingsToggleCellDelegate
) {
dataSource = UITableViewDiffableDataSource(tableView: tableView) { [
weak self,
weak settingsAppearanceTableViewCellDelegate,
weak settingsToggleCellDelegate
] tableView, indexPath, item -> UITableViewCell? in
guard let self = self else { return nil }
switch item {
case .apperance(let objectID):
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: SettingsAppearanceTableViewCell.self), for: indexPath) as! SettingsAppearanceTableViewCell
self.context.managedObjectContext.performAndWait {
let setting = self.context.managedObjectContext.object(with: objectID) as! Setting
cell.update(with: setting.appearance)
ManagedObjectObserver.observe(object: setting)
.sink(receiveCompletion: { _ in
// do nothing
}, receiveValue: { [weak cell] change in
guard let cell = cell else { return }
guard case .update(let object) = change.changeType,
let setting = object as? Setting else { return }
cell.update(with: setting.appearance)
})
.store(in: &cell.disposeBag)
}
cell.delegate = settingsAppearanceTableViewCellDelegate
return cell
case .notification(let objectID, let switchMode):
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: SettingsToggleTableViewCell.self), for: indexPath) as! SettingsToggleTableViewCell
self.context.managedObjectContext.performAndWait {
let setting = self.context.managedObjectContext.object(with: objectID) as! Setting
if let subscription = setting.activeSubscription {
SettingsViewModel.configureSettingToggle(cell: cell, switchMode: switchMode, subscription: subscription)
}
ManagedObjectObserver.observe(object: setting)
.sink(receiveCompletion: { _ in
// do nothing
}, receiveValue: { [weak cell] change in
guard let cell = cell else { return }
guard case .update(let object) = change.changeType,
let setting = object as? Setting else { return }
guard let subscription = setting.activeSubscription else { return }
SettingsViewModel.configureSettingToggle(cell: cell, switchMode: switchMode, subscription: subscription)
})
.store(in: &cell.disposeBag)
}
cell.delegate = settingsToggleCellDelegate
return cell
case .boringZone(let item), .spicyZone(let item):
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: SettingsLinkTableViewCell.self), for: indexPath) as! SettingsLinkTableViewCell
cell.update(with: item)
return cell
}
}
setting.value = fetchResultsController.fetchedObjects?.first
processDataSource(self.setting.value)
}
}
enum SettingsSection: Hashable {
case apperance(title: String, selectedMode: SettingsItem)
case notifications(title: String, items: [SettingsItem])
case boringZone(title: String, items: [SettingsItem])
case spicyZone(title: String, items: [SettingsItem])
extension SettingsViewModel {
var title: String {
switch self {
case .apperance(let title, _),
.notifications(let title, _),
.boringZone(let title, _),
.spicyZone(let title, _):
return title
static func configureSettingToggle(
cell: SettingsToggleTableViewCell,
switchMode: SettingsItem.NotificationSwitchMode,
subscription: NotificationSubscription
) {
cell.textLabel?.text = switchMode.title
let enabled: Bool?
switch switchMode {
case .favorite: enabled = subscription.alert.favourite
case .follow: enabled = subscription.alert.follow
case .reblog: enabled = subscription.alert.reblog
case .mention: enabled = subscription.alert.mention
}
cell.update(enabled: enabled)
}
}
enum SettingsItem: Hashable {
enum AppearanceMode: String {
case automatic
case light
case dark
}
struct NotificationSwitch: Hashable {
let title: String
let isOn: Bool
let enable: Bool
}
struct Link: Hashable {
let title: String
let color: UIColor
}
case apperance(item: AppearanceMode)
case notification(item: NotificationSwitch)
case boringZone(item: Link)
case spicyZone(item: Link)
}

View File

@ -6,9 +6,10 @@
//
import UIKit
import Combine
protocol SettingsAppearanceTableViewCellDelegate: class {
func settingsAppearanceCell(_ view: SettingsAppearanceTableViewCell, didSelect: SettingsItem.AppearanceMode)
func settingsAppearanceCell(_ cell: SettingsAppearanceTableViewCell, didSelectAppearanceMode appearanceMode: SettingsItem.AppearanceMode)
}
class AppearanceView: UIView {
@ -85,6 +86,9 @@ class AppearanceView: UIView {
}
class SettingsAppearanceTableViewCell: UITableViewCell {
var disposeBag = Set<AnyCancellable>()
weak var delegate: SettingsAppearanceTableViewCellDelegate?
var appearance: SettingsItem.AppearanceMode = .automatic
@ -123,6 +127,12 @@ class SettingsAppearanceTableViewCell: UITableViewCell {
tapGestureRecognizer.addTarget(self, action: #selector(appearanceDidTap(sender:)))
return tapGestureRecognizer
}()
override func prepareForReuse() {
super.prepareForReuse()
disposeBag.removeAll()
}
// MARK: - Methods
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
@ -145,9 +155,8 @@ class SettingsAppearanceTableViewCell: UITableViewCell {
}
}
func update(with data: SettingsItem.AppearanceMode, delegate: SettingsAppearanceTableViewCellDelegate?) {
func update(with data: SettingsItem.AppearanceMode) {
appearance = data
self.delegate = delegate
automatic.selected = false
light.selected = false
@ -200,6 +209,6 @@ class SettingsAppearanceTableViewCell: UITableViewCell {
}
guard let delegate = self.delegate else { return }
delegate.settingsAppearanceCell(self, didSelect: appearance)
delegate.settingsAppearanceCell(self, didSelectAppearanceMode: appearance)
}
}

View File

@ -8,6 +8,7 @@
import UIKit
class SettingsLinkTableViewCell: UITableViewCell {
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
@ -22,10 +23,13 @@ class SettingsLinkTableViewCell: UITableViewCell {
super.setHighlighted(highlighted, animated: animated)
textLabel?.alpha = highlighted ? 0.6 : 1.0
}
// MARK: - Methods
func update(with data: SettingsItem.Link) {
textLabel?.text = data.title
textLabel?.textColor = data.color
}
// MARK: - Methods
extension SettingsLinkTableViewCell {
func update(with link: SettingsItem.Link) {
textLabel?.text = link.title
textLabel?.textColor = link.textColor
}
}

View File

@ -6,18 +6,21 @@
//
import UIKit
import Combine
protocol SettingsToggleCellDelegate: class {
func settingsToggleCell(_ cell: SettingsToggleTableViewCell, didChangeStatus: Bool)
func settingsToggleCell(_ cell: SettingsToggleTableViewCell, switchValueDidChange switch: UISwitch)
}
class SettingsToggleTableViewCell: UITableViewCell {
lazy var switchButton: UISwitch = {
var disposeBag = Set<AnyCancellable>()
private(set) lazy var switchButton: UISwitch = {
let view = UISwitch(frame:.zero)
return view
}()
var data: SettingsItem.NotificationSwitch?
weak var delegate: SettingsToggleCellDelegate?
// MARK: - Methods
@ -27,21 +30,8 @@ class SettingsToggleTableViewCell: UITableViewCell {
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func update(with data: SettingsItem.NotificationSwitch, delegate: SettingsToggleCellDelegate?) {
self.delegate = delegate
self.data = data
textLabel?.text = data.title
switchButton.isOn = data.isOn
setup(enable: data.enable)
}
// MARK: Actions
@objc func valueDidChange(sender: UISwitch) {
guard let delegate = delegate else { return }
delegate.settingsToggleCell(self, didChangeStatus: sender.isOn)
super.init(coder: coder)
setupUI()
}
// MARK: Private methods
@ -49,15 +39,27 @@ class SettingsToggleTableViewCell: UITableViewCell {
selectionStyle = .none
accessoryView = switchButton
switchButton.addTarget(self, action: #selector(valueDidChange(sender:)), for: .valueChanged)
switchButton.addTarget(self, action: #selector(switchValueDidChange(sender:)), for: .valueChanged)
}
}
// MARK: - Actions
extension SettingsToggleTableViewCell {
@objc private func switchValueDidChange(sender: UISwitch) {
guard let delegate = delegate else { return }
delegate.settingsToggleCell(self, switchValueDidChange: sender)
}
}
extension SettingsToggleTableViewCell {
func update(enabled: Bool?) {
switchButton.isEnabled = enabled != nil
textLabel?.textColor = enabled != nil ? Asset.Colors.Label.primary.color : Asset.Colors.Label.secondary.color
switchButton.isOn = enabled ?? false
}
private func setup(enable: Bool) {
if enable {
textLabel?.textColor = Asset.Colors.Label.primary.color
} else {
textLabel?.textColor = Asset.Colors.Label.secondary.color
}
switchButton.isEnabled = enable
}
}

View File

@ -5,6 +5,8 @@
// Created by MainasuK Cirno on 2021-4-6.
//
import UIKit
final class TimelineHeaderView: UIView {
let iconImageView: UIImageView = {

View File

@ -5,6 +5,7 @@
// Created by ihugo on 2021/4/9.
//
import os.log
import Combine
import CoreData
import CoreDataStack
@ -13,66 +14,14 @@ import MastodonSDK
extension APIService {
func subscription(
func createSubscription(
subscriptionObjectID: NSManagedObjectID,
query: Mastodon.API.Subscriptions.CreateSubscriptionQuery,
mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox
) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Subscription>, Error> {
let authorization = mastodonAuthenticationBox.userAuthorization
let domain = mastodonAuthenticationBox.domain
let userID = mastodonAuthenticationBox.userID
let findSettings: Setting? = {
let request = Setting.sortedFetchRequest
request.predicate = Setting.predicate(domain: domain, userID: userID)
request.fetchLimit = 1
request.returnsObjectsAsFaults = false
do {
return try self.backgroundManagedObjectContext.fetch(request).first
} catch {
assertionFailure(error.localizedDescription)
return nil
}
}()
let triggerBy = findSettings?.triggerBy ?? "anyone"
let setting = self.createSettingIfNeed(
domain: domain,
userId: userID,
triggerBy: triggerBy
)
return Mastodon.API.Subscriptions.subscription(
session: session,
domain: domain,
authorization: authorization
)
.flatMap { response -> AnyPublisher<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 createSubscription(
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 domain = mastodonAuthenticationBox.domain
let userID = mastodonAuthenticationBox.userID
let setting = self.createSettingIfNeed(
domain: domain,
userId: userID,
triggerBy: triggerBy
)
return Mastodon.API.Subscriptions.createSubscription(
session: session,
domain: domain,
@ -80,14 +29,18 @@ extension APIService {
query: query
)
.flatMap { response -> AnyPublisher<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
)
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: create subscription successful ", ((#file as NSString).lastPathComponent), #line, #function)
let managedObjectContext = self.backgroundManagedObjectContext
return managedObjectContext.performChanges {
guard let subscription = managedObjectContext.object(with: subscriptionObjectID) as? NotificationSubscription else {
assertionFailure()
return
}
subscription.endpoint = response.value.endpoint
subscription.serverKey = response.value.serverKey
subscription.userToken = authorization.accessToken
subscription.didUpdate(at: response.networkDate)
}
.setFailureType(to: Error.self)
.map { _ in return response }
@ -95,72 +48,22 @@ extension APIService {
}.eraseToAnyPublisher()
}
func updateSubscription(
domain: String,
mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox,
query: Mastodon.API.Subscriptions.UpdateSubscriptionQuery,
triggerBy: String,
userID: String
) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Subscription>, Error> {
func cancelSubscription(
mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox
) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.EmptySubscription>, Error> {
let authorization = mastodonAuthenticationBox.userAuthorization
let setting = self.createSettingIfNeed(domain: domain,
userId: userID,
triggerBy: triggerBy)
return Mastodon.API.Subscriptions.updateSubscription(
let domain = mastodonAuthenticationBox.domain
return Mastodon.API.Subscriptions.removeSubscription(
session: session,
domain: domain,
authorization: authorization,
query: query
authorization: authorization
)
.flatMap { response -> AnyPublisher<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
.handleEvents(receiveOutput: { _ in
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: cancel subscription successful", ((#file as NSString).lastPathComponent), #line, #function)
})
.eraseToAnyPublisher()
}
}

View File

@ -0,0 +1,61 @@
//
// APIService+CoreData+Setting.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021-4-25.
//
import os.log
import Foundation
import CoreData
import CoreDataStack
import MastodonSDK
extension APIService.CoreData {
static func createOrMergeSetting(
into managedObjectContext: NSManagedObjectContext,
property: Setting.Property
) -> (Subscription: Setting, isCreated: Bool) {
let oldSetting: Setting? = {
let request = Setting.sortedFetchRequest
request.predicate = Setting.predicate(domain: property.domain, userID: property.userID)
request.fetchLimit = 1
request.returnsObjectsAsFaults = false
return managedObjectContext.safeFetch(request).first
}()
if let oldSetting = oldSetting {
return (oldSetting, false)
} else {
let setting = Setting.insert(
into: managedObjectContext,
property: property
)
let policies: [Mastodon.API.Subscriptions.Policy] = [
.all,
.followed,
.follower,
.none
]
let now = Date()
policies.forEach { policy in
let (subscription, _) = createOrFetchSubscription(
into: managedObjectContext,
setting: setting,
policy: policy
)
if policy == .all {
subscription.update(activedAt: now)
} else {
subscription.update(activedAt: now.addingTimeInterval(-10))
}
}
return (setting, true)
}
}
}

View File

@ -13,96 +13,49 @@ import MastodonSDK
extension APIService.CoreData {
static func createOrMergeSetting(
static func createOrFetchSubscription(
into managedObjectContext: NSManagedObjectContext,
domain: String,
userID: String,
property: Setting.Property
) -> (Subscription: Setting, isCreated: Bool) {
let oldSetting: Setting? = {
let request = Setting.sortedFetchRequest
request.predicate = Setting.predicate(domain: property.domain, userID: userID)
request.fetchLimit = 1
request.returnsObjectsAsFaults = false
do {
return try managedObjectContext.fetch(request).first
} catch {
assertionFailure(error.localizedDescription)
return nil
}
}()
if let oldSetting = oldSetting {
return (oldSetting, false)
} else {
let setting = Setting.insert(
into: managedObjectContext,
property: property)
return (setting, true)
}
}
static func createOrMergeSubscription(
into managedObjectContext: NSManagedObjectContext,
entity: Mastodon.Entity.Subscription,
domain: String,
triggerBy: String,
setting: Setting
) -> (Subscription: Subscription, isCreated: Bool) {
setting: Setting,
policy: Mastodon.API.Subscriptions.Policy
) -> (subscription: Subscription, isCreated: Bool) {
let oldSubscription: Subscription? = {
let request = Subscription.sortedFetchRequest
request.predicate = Subscription.predicate(type: triggerBy)
request.predicate = Subscription.predicate(policyRaw: policy.rawValue)
request.fetchLimit = 1
request.returnsObjectsAsFaults = false
do {
return try managedObjectContext.fetch(request).first
} catch {
assertionFailure(error.localizedDescription)
return nil
}
return managedObjectContext.safeFetch(request).first
}()
let property = Subscription.Property(
endpoint: entity.endpoint,
id: entity.id,
serverKey: entity.serverKey,
type: triggerBy
)
let alertEntity = entity.alerts
let alert = SubscriptionAlerts.Property(
favourite: alertEntity.favouriteNumber,
follow: alertEntity.followNumber,
mention: alertEntity.mentionNumber,
poll: alertEntity.pollNumber,
reblog: alertEntity.reblogNumber
)
if let oldSubscription = oldSubscription {
oldSubscription.updateIfNeed(property: property)
if nil == oldSubscription.alert {
oldSubscription.alert = SubscriptionAlerts.insert(
into: managedObjectContext,
property: alert
)
} else {
oldSubscription.alert?.updateIfNeed(property: alert)
}
if oldSubscription.alert?.hasChanges == true || oldSubscription.hasChanges {
// don't expand subscription if add existed subscription
//setting.mutableSetValue(forKey: #keyPath(Setting.subscription)).add(oldSubscription)
oldSubscription.didUpdate(at: Date())
}
return (oldSubscription, false)
} else {
let subscriptionProperty = Subscription.Property(policyRaw: policy.rawValue)
let subscription = Subscription.insert(
into: managedObjectContext,
property: property
property: subscriptionProperty,
setting: setting
)
let alertProperty = SubscriptionAlerts.Property(policy: policy)
subscription.alert = SubscriptionAlerts.insert(
into: managedObjectContext,
property: alert)
setting.mutableSetValue(forKey: #keyPath(Setting.subscription)).add(subscription)
property: alertProperty,
subscription: subscription
)
return (subscription, true)
}
}
}
extension APIService.CoreData {
static func merge(
subscription: Subscription,
property: Subscription.Property,
networkDate: Date
) {
// TODO:
}
}

View File

@ -15,6 +15,7 @@ import MastodonSDK
final class AuthenticationService: NSObject {
var disposeBag = Set<AnyCancellable>()
// input
weak var apiService: APIService?
let managedObjectContext: NSManagedObjectContext // read-only
@ -23,6 +24,7 @@ final class AuthenticationService: NSObject {
// output
let mastodonAuthentications = CurrentValueSubject<[MastodonAuthentication], Never>([])
let mastodonAuthenticationBoxes = CurrentValueSubject<[AuthenticationService.MastodonAuthenticationBox], Never>([])
let activeMastodonAuthentication = CurrentValueSubject<MastodonAuthentication?, Never>(nil)
let activeMastodonAuthenticationBox = CurrentValueSubject<AuthenticationService.MastodonAuthenticationBox?, Never>(nil)
@ -58,16 +60,24 @@ final class AuthenticationService: NSObject {
.assign(to: \.value, on: activeMastodonAuthentication)
.store(in: &disposeBag)
activeMastodonAuthentication
.map { authentication -> AuthenticationService.MastodonAuthenticationBox? in
guard let authentication = authentication else { return nil }
return AuthenticationService.MastodonAuthenticationBox(
domain: authentication.domain,
userID: authentication.userID,
appAuthorization: Mastodon.API.OAuth.Authorization(accessToken: authentication.appAccessToken),
userAuthorization: Mastodon.API.OAuth.Authorization(accessToken: authentication.userAccessToken)
)
mastodonAuthentications
.map { authentications -> [AuthenticationService.MastodonAuthenticationBox] in
return authentications
.sorted(by: { $0.activedAt > $1.activedAt })
.compactMap { authentication -> AuthenticationService.MastodonAuthenticationBox? in
return AuthenticationService.MastodonAuthenticationBox(
domain: authentication.domain,
userID: authentication.userID,
appAuthorization: Mastodon.API.OAuth.Authorization(accessToken: authentication.appAccessToken),
userAuthorization: Mastodon.API.OAuth.Authorization(accessToken: authentication.userAccessToken)
)
}
}
.assign(to: \.value, on: mastodonAuthenticationBoxes)
.store(in: &disposeBag)
mastodonAuthenticationBoxes
.map { $0.first }
.assign(to: \.value, on: activeMastodonAuthenticationBox)
.store(in: &disposeBag)
@ -114,16 +124,37 @@ extension AuthenticationService {
func signOutMastodonUser(domain: String, userID: MastodonUser.ID) -> AnyPublisher<Result<Bool, Error>, Never> {
var isSignOut = false
return backgroundManagedObjectContext.performChanges {
var _mastodonAutenticationBox: MastodonAuthenticationBox?
let managedObjectContext = backgroundManagedObjectContext
return managedObjectContext.performChanges {
let request = MastodonAuthentication.sortedFetchRequest
request.predicate = MastodonAuthentication.predicate(domain: domain, userID: userID)
request.fetchLimit = 1
guard let mastodonAutentication = try? self.backgroundManagedObjectContext.fetch(request).first else {
guard let mastodonAutentication = try? managedObjectContext.fetch(request).first else {
return
}
self.backgroundManagedObjectContext.delete(mastodonAutentication)
_mastodonAutenticationBox = AuthenticationService.MastodonAuthenticationBox(
domain: mastodonAutentication.domain,
userID: mastodonAutentication.userID,
appAuthorization: Mastodon.API.OAuth.Authorization(accessToken: mastodonAutentication.appAccessToken),
userAuthorization: Mastodon.API.OAuth.Authorization(accessToken: mastodonAutentication.userAccessToken)
)
managedObjectContext.delete(mastodonAutentication)
isSignOut = true
}
.flatMap { result -> AnyPublisher<Result<Void, Error>, Never> in
guard let apiService = self.apiService,
let mastodonAuthenticationBox = _mastodonAutenticationBox else {
return Just(result).eraseToAnyPublisher()
}
return apiService.cancelSubscription(
mastodonAuthenticationBox: mastodonAuthenticationBox
)
.map { _ in result }
.catch { _ in Just(result).eraseToAnyPublisher() }
.eraseToAnyPublisher()
}
.map { result in
return result.map { isSignOut }
}

View File

@ -19,69 +19,28 @@ final class NotificationService {
let workingQueue = DispatchQueue(label: "org.joinmastodon.Mastodon.NotificationService.working-queue")
// input
weak var apiService: APIService?
weak var authenticationService: AuthenticationService?
let isNotificationPermissionGranted = CurrentValueSubject<Bool, Never>(false)
let deviceToken = CurrentValueSubject<Data?, Never>(nil)
let mastodonAuthenticationBoxes = CurrentValueSubject<[AuthenticationService.MastodonAuthenticationBox], Never>([])
// output
/// [Token: UserID]
let notificationSubscriptionDict: [String: NotificationSubscription] = [:]
let notificationSubscriptionDict: [String: NotificationViewModel] = [:]
init(
apiService: APIService,
authenticationService: AuthenticationService
) {
self.apiService = apiService
self.authenticationService = authenticationService
authenticationService.mastodonAuthentications
.handleEvents(receiveOutput: { [weak self] mastodonAuthentications in
guard let self = self else { return }
// request permission when sign-in
guard !mastodonAuthentications.isEmpty else { return }
self.requestNotificationPermission()
})
.map { authentications -> [AuthenticationService.MastodonAuthenticationBox] in
return authentications.compactMap { authentication -> AuthenticationService.MastodonAuthenticationBox? in
return AuthenticationService.MastodonAuthenticationBox(
domain: authentication.domain,
userID: authentication.userID,
appAuthorization: Mastodon.API.OAuth.Authorization(accessToken: authentication.appAccessToken),
userAuthorization: Mastodon.API.OAuth.Authorization(accessToken: authentication.userAccessToken)
)
}
}
.assign(to: \.value, on: mastodonAuthenticationBoxes)
.store(in: &disposeBag)
deviceToken
.receive(on: DispatchQueue.main)
.sink { [weak self] deviceToken in
guard let self = self else { return }
guard let _ = self else { return }
guard let deviceToken = deviceToken else { return }
let token = [UInt8](deviceToken).toHexString()
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: deviceToken: %s", ((#file as NSString).lastPathComponent), #line, #function, token)
}
.store(in: &disposeBag)
Publishers.CombineLatest3(
isNotificationPermissionGranted,
deviceToken,
mastodonAuthenticationBoxes
)
.sink { [weak self] isNotificationPermissionGranted, deviceToken, mastodonAuthenticationBoxes in
guard let self = self else { return }
guard isNotificationPermissionGranted else { return }
guard let deviceToken = deviceToken else { return }
self.registerNotificationSubscriptions(
deviceToken: [UInt8](deviceToken).toHexString(),
mastodonAuthenticationBoxes: mastodonAuthenticationBoxes
)
}
.store(in: &disposeBag)
}
}
@ -102,35 +61,14 @@ extension NotificationService {
// Enable or disable features based on the authorization.
}
}
}
extension NotificationService {
private func registerNotificationSubscriptions(
deviceToken: String,
mastodonAuthenticationBoxes: [AuthenticationService.MastodonAuthenticationBox]
) {
for mastodonAuthenticationBox in mastodonAuthenticationBoxes {
guard let notificationSubscription = dequeueNotificationSubscription(mastodonAuthenticationBox: mastodonAuthenticationBox) else { continue }
let token = NotificationSubscription.SubscribeToken(
deviceToken: deviceToken,
authenticationBox: mastodonAuthenticationBox
)
guard let subscription = subscribe(
notificationSubscription: notificationSubscription,
token: token
) else { continue }
subscription
.sink { completion in
// handle error
} receiveValue: { response in
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: did create subscription %s with userToken %s", ((#file as NSString).lastPathComponent), #line, #function, response.value.id, mastodonAuthenticationBox.userAuthorization.accessToken)
// do nothing
}
.store(in: &self.disposeBag)
}
}
private func dequeueNotificationSubscription(mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox) -> NotificationSubscription? {
var _notificationSubscription: NotificationSubscription?
func dequeueNotificationViewModel(
mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox
) -> NotificationViewModel? {
var _notificationSubscription: NotificationViewModel?
workingQueue.sync {
let domain = mastodonAuthenticationBox.domain
let userID = mastodonAuthenticationBox.userID
@ -139,56 +77,13 @@ extension NotificationService {
if let notificationSubscription = notificationSubscriptionDict[key] {
_notificationSubscription = notificationSubscription
} else {
let notificationSubscription = NotificationSubscription(domain: domain, userID: userID)
let notificationSubscription = NotificationViewModel(domain: domain, userID: userID)
_notificationSubscription = notificationSubscription
}
}
return _notificationSubscription
}
private func subscribe(
notificationSubscription: NotificationSubscription,
token: NotificationSubscription.SubscribeToken
) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Subscription>, Error>? {
guard let apiService = self.apiService else { return nil }
if let oldToken = notificationSubscription.token {
guard oldToken != token else { return nil }
}
notificationSubscription.token = token
let appSecret = AppSecret.default
let endpoint = appSecret.notificationEndpoint + "/" + token.deviceToken
let p256dh = appSecret.uncompressionNotificationPublicKeyData
let auth = appSecret.notificationAuth
let query = Mastodon.API.Subscriptions.CreateSubscriptionQuery(
subscription: Mastodon.API.Subscriptions.QuerySubscription(
endpoint: endpoint,
keys: Mastodon.API.Subscriptions.QuerySubscription.Keys(
p256dh: p256dh,
auth: auth
)
),
data: Mastodon.API.Subscriptions.QueryData(
alerts: Mastodon.API.Subscriptions.QueryData.Alerts(
favourite: true,
follow: true,
reblog: true,
mention: true,
poll: true
)
)
)
return apiService.createSubscription(
mastodonAuthenticationBox: token.authenticationBox,
query: query,
triggerBy: "anyone",
userID: token.authenticationBox.userID
)
}
static func createRandomAuthBytes() -> Data {
let byteCount = 16
var bytes = Data(count: byteCount)
@ -198,7 +93,7 @@ extension NotificationService {
}
extension NotificationService {
final class NotificationSubscription {
final class NotificationViewModel {
var disposeBag = Set<AnyCancellable>()
@ -206,36 +101,39 @@ extension NotificationService {
let domain: String
let userID: Mastodon.Entity.Account.ID
var token: SubscribeToken?
// output
init(domain: String, userID: Mastodon.Entity.Account.ID) {
self.domain = domain
self.userID = userID
}
struct SubscribeToken: Equatable {
let deviceToken: String
let authenticationBox: AuthenticationService.MastodonAuthenticationBox
// TODO: set other parameter
init(
deviceToken: String,
authenticationBox: AuthenticationService.MastodonAuthenticationBox
) {
self.deviceToken = deviceToken
self.authenticationBox = authenticationBox
}
static func == (
lhs: NotificationService.NotificationSubscription.SubscribeToken,
rhs: NotificationService.NotificationSubscription.SubscribeToken
) -> Bool {
return lhs.deviceToken == rhs.deviceToken &&
lhs.authenticationBox.domain == rhs.authenticationBox.domain &&
lhs.authenticationBox.userID == rhs.authenticationBox.userID &&
lhs.authenticationBox.userAuthorization.accessToken == rhs.authenticationBox.userAuthorization.accessToken
}
}
}
}
extension NotificationService.NotificationViewModel {
func createSubscribeQuery(
deviceToken: Data,
queryData: Mastodon.API.Subscriptions.QueryData,
mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox
) -> Mastodon.API.Subscriptions.CreateSubscriptionQuery {
let deviceToken = [UInt8](deviceToken).toHexString()
let appSecret = AppSecret.default
let endpoint = appSecret.notificationEndpoint + "/" + deviceToken
let p256dh = appSecret.uncompressionNotificationPublicKeyData
let auth = appSecret.notificationAuth
let query = Mastodon.API.Subscriptions.CreateSubscriptionQuery(
subscription: Mastodon.API.Subscriptions.QuerySubscription(
endpoint: endpoint,
keys: Mastodon.API.Subscriptions.QuerySubscription.Keys(
p256dh: p256dh,
auth: auth
)
),
data: queryData
)
return query
}
}

View File

@ -0,0 +1,162 @@
//
// SettingService.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021-4-25.
//
import UIKit
import Combine
import CoreDataStack
import MastodonSDK
final class SettingService {
var disposeBag = Set<AnyCancellable>()
private var currentSettingUpdateSubscription: AnyCancellable?
// input
weak var apiService: APIService?
weak var authenticationService: AuthenticationService?
weak var notificationService: NotificationService?
// output
let settingFetchedResultController: SettingFetchedResultController
let currentSetting = CurrentValueSubject<Setting?, Never>(nil)
init(
apiService: APIService,
authenticationService: AuthenticationService,
notificationService: NotificationService
) {
self.apiService = apiService
self.authenticationService = authenticationService
self.notificationService = notificationService
self.settingFetchedResultController = SettingFetchedResultController(
managedObjectContext: authenticationService.managedObjectContext,
additionalPredicate: nil
)
// create setting (if non-exist) for authenticated users
authenticationService.mastodonAuthenticationBoxes
.compactMap { [weak self] mastodonAuthenticationBoxes -> AnyPublisher<[AuthenticationService.MastodonAuthenticationBox], Never>? in
guard let self = self else { return nil }
guard let authenticationService = self.authenticationService else { return nil }
guard let activeMastodonAuthenticationBox = mastodonAuthenticationBoxes.first else { return nil }
let domain = activeMastodonAuthenticationBox.domain
let userID = activeMastodonAuthenticationBox.userID
return authenticationService.backgroundManagedObjectContext.performChanges {
_ = APIService.CoreData.createOrMergeSetting(
into: authenticationService.backgroundManagedObjectContext,
property: Setting.Property(
domain: domain,
userID: userID,
appearanceRaw: SettingsItem.AppearanceMode.automatic.rawValue
)
)
}
.map { _ in mastodonAuthenticationBoxes }
.eraseToAnyPublisher()
}
.sink { _ in
// do nothing
}
.store(in: &disposeBag)
// bind current setting
Publishers.CombineLatest(
authenticationService.activeMastodonAuthenticationBox,
settingFetchedResultController.settings
)
.sink { [weak self] activeMastodonAuthenticationBox, settings in
guard let self = self else { return }
guard let activeMastodonAuthenticationBox = activeMastodonAuthenticationBox else { return }
let currentSetting = settings.first(where: { setting in
return setting.domain == activeMastodonAuthenticationBox.domain &&
setting.userID == activeMastodonAuthenticationBox.userID
})
self.currentSetting.value = currentSetting
}
.store(in: &disposeBag)
// observe current setting
currentSetting
.receive(on: DispatchQueue.main)
.sink { [weak self] setting in
guard let self = self else { return }
guard let setting = setting else {
self.currentSettingUpdateSubscription = nil
return
}
self.currentSettingUpdateSubscription = ManagedObjectObserver.observe(object: setting)
.sink(receiveCompletion: { _ in
// do nothing
}, receiveValue: { change in
guard case .update(let object) = change.changeType,
let setting = object as? Setting else { return }
// observe apparance mode
switch setting.appearance {
case .automatic: UserDefaults.shared.customUserInterfaceStyle = .unspecified
case .light: UserDefaults.shared.customUserInterfaceStyle = .light
case .dark: UserDefaults.shared.customUserInterfaceStyle = .dark
}
})
}
.store(in: &disposeBag)
Publishers.CombineLatest(
notificationService.deviceToken,
currentSetting
)
.compactMap { [weak self] deviceToken, setting -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Subscription>, Error>? in
guard let self = self else { return nil }
guard let apiService = self.apiService else { return nil }
guard let deviceToken = deviceToken else { return nil }
guard let authenticationBox = self.authenticationService?.activeMastodonAuthenticationBox.value else { return nil }
guard let setting = setting else { return nil }
guard let subscription = setting.activeSubscription else { return nil }
guard setting.domain == authenticationBox.domain,
setting.userID == authenticationBox.userID else { return nil }
let _viewModel = self.notificationService?.dequeueNotificationViewModel(
mastodonAuthenticationBox: authenticationBox
)
guard let viewModel = _viewModel else { return nil }
let queryData = Mastodon.API.Subscriptions.QueryData(
policy: subscription.policy,
alerts: Mastodon.API.Subscriptions.QueryData.Alerts(
favourite: subscription.alert.favourite,
follow: subscription.alert.follow,
reblog: subscription.alert.reblog,
mention: subscription.alert.mention,
poll: subscription.alert.poll
)
)
let query = viewModel.createSubscribeQuery(
deviceToken: deviceToken,
queryData: queryData,
mastodonAuthenticationBox: authenticationBox
)
return apiService.createSubscription(
subscriptionObjectID: subscription.objectID,
query: query,
mastodonAuthenticationBox: authenticationBox
)
}
.switchToLatest()
.sink { _ in
// do nothing
} receiveValue: { _ in
// do nothing
}
.store(in: &disposeBag)
}
}

View File

@ -29,6 +29,7 @@ class AppContext: ObservableObject {
let statusPrefetchingService: StatusPrefetchingService
let statusPublishService = StatusPublishService()
let notificationService: NotificationService
let settingService: SettingService
let documentStore: DocumentStore
private var documentStoreSubscription: AnyCancellable!
@ -59,10 +60,16 @@ class AppContext: ObservableObject {
statusPrefetchingService = StatusPrefetchingService(
apiService: _apiService
)
notificationService = NotificationService(
apiService: _apiService,
let _notificationService = NotificationService(
authenticationService: _authenticationService
)
notificationService = _notificationService
settingService = SettingService(
apiService: _apiService,
authenticationService: _authenticationService,
notificationService: _notificationService
)
documentStore = DocumentStore()
documentStoreSubscription = documentStore.objectWillChange

View File

@ -0,0 +1,12 @@
//
// AppSharedName.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021-4-26.
//
import Foundation
enum AppSharedName {
static let groupID = "group.org.joinmastodon.mastodon-temp"
}

View File

@ -6,10 +6,13 @@
//
import UIKit
import Combine
import CoreDataStack
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
var observations = Set<NSKeyValueObservation>()
var window: UIWindow?
var coordinator: SceneCoordinator?
@ -28,8 +31,11 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
sceneCoordinator.setupOnboardingIfNeeds(animated: false)
window.makeKeyAndVisible()
// update `overrideUserInterfaceStyle` with current setting
SettingsViewController.updateOverrideUserInterfaceStyle(window: window)
UserDefaults.shared.observe(\.customUserInterfaceStyle, options: [.initial, .new]) { [weak self] defaults, _ in
guard let self = self else { return }
self.window?.overrideUserInterfaceStyle = defaults.customUserInterfaceStyle
}
.store(in: &observations)
}
func sceneDidDisconnect(_ scene: UIScene) {

View File

@ -21,7 +21,7 @@ extension Mastodon.API.Subscriptions {
/// - Since: 2.4.0
/// - Version: 3.3.0
/// # Last Update
/// 2021/4/9
/// 2021/4/25
/// # Reference
/// [Document](https://docs.joinmastodon.org/methods/notifications/push/)
/// - Parameters:
@ -54,7 +54,7 @@ extension Mastodon.API.Subscriptions {
/// - Since: 2.4.0
/// - Version: 3.3.0
/// # Last Update
/// 2021/4/9
/// 2021/4/25
/// # Reference
/// [Document](https://docs.joinmastodon.org/methods/notifications/push/)
/// - Parameters:
@ -88,7 +88,7 @@ extension Mastodon.API.Subscriptions {
/// - Since: 2.4.0
/// - Version: 3.3.0
/// # Last Update
/// 2021/4/9
/// 2021/4/25
/// # Reference
/// [Document](https://docs.joinmastodon.org/methods/notifications/push/)
/// - Parameters:
@ -114,10 +114,45 @@ extension Mastodon.API.Subscriptions {
}
.eraseToAnyPublisher()
}
/// Remove current subscription
///
/// Removes the current Web Push API subscription.
///
/// - Since: 2.4.0
/// - Version: 3.3.0
/// # Last Update
/// 2021/4/26
/// # Reference
/// [Document](https://docs.joinmastodon.org/methods/notifications/push/)
/// - Parameters:
/// - session: `URLSession`
/// - domain: Mastodon instance domain. e.g. "example.com"
/// - authorization: User token. Could be nil if status is public
/// - Returns: `AnyPublisher` contains `Subscription` nested in the response
public static func removeSubscription(
session: URLSession,
domain: String,
authorization: Mastodon.API.OAuth.Authorization
) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.EmptySubscription>, Error> {
let request = Mastodon.API.delete(
url: pushEndpointURL(domain: domain),
query: nil,
authorization: authorization
)
return session.dataTaskPublisher(for: request)
.tryMap { data, response in
let value = try Mastodon.API.decode(type: Mastodon.Entity.EmptySubscription.self, from: data, response: response)
return Mastodon.Response.Content(value: value, response: response)
}
.eraseToAnyPublisher()
}
}
extension Mastodon.API.Subscriptions {
public typealias Policy = QueryData.Policy
public struct QuerySubscription: Codable {
let endpoint: String
let keys: Keys
@ -142,9 +177,14 @@ extension Mastodon.API.Subscriptions {
}
public struct QueryData: Codable {
let policy: Policy?
let alerts: Alerts
public init(alerts: Mastodon.API.Subscriptions.QueryData.Alerts) {
public init(
policy: Policy?,
alerts: Mastodon.API.Subscriptions.QueryData.Alerts
) {
self.policy = policy
self.alerts = alerts
}
@ -163,8 +203,39 @@ extension Mastodon.API.Subscriptions {
self.poll = poll
}
}
public enum Policy: RawRepresentable, Codable {
case all
case followed
case follower
case none
case _other(String)
public init?(rawValue: String) {
switch rawValue {
case "all": self = .all
case "followed": self = .followed
case "follower": self = .follower
case "none": self = .none
default: self = ._other(rawValue)
}
}
public var rawValue: String {
switch self {
case .all: return "all"
case .followed: return "followed"
case .follower: return "follower"
case .none: return "none"
case ._other(let value): return value
}
}
}
}
public struct CreateSubscriptionQuery: Codable, PostQuery {
let subscription: QuerySubscription
let data: QueryData

View File

@ -151,6 +151,14 @@ extension Mastodon.API {
) -> URLRequest {
return buildRequest(url: url, method: .PUT, query: query, authorization: authorization)
}
static func delete(
url: URL,
query: DeleteQuery?,
authorization: OAuth.Authorization?
) -> URLRequest {
return buildRequest(url: url, method: .DELETE, query: query, authorization: authorization)
}
private static func buildRequest(
url: URL,

View File

@ -1,5 +1,5 @@
//
// File.swift
// Mastodon+Entity+Subscription.swift
//
//
// Created by ihugo on 2021/4/9.
@ -14,7 +14,7 @@ extension Mastodon.Entity {
/// - Since: 2.4.0
/// - Version: 3.3.0
/// # Last Update
/// 2021/4/9
/// 2021/4/26
/// # Reference
/// [Document](https://docs.joinmastodon.org/entities/pushsubscription/)
public struct Subscription: Codable {
@ -33,30 +33,19 @@ extension Mastodon.Entity {
public struct Alerts: Codable {
public let follow: Bool?
public let followRequest: Bool?
public let favourite: Bool?
public let reblog: Bool?
public let mention: Bool?
public let poll: Bool?
public var followNumber: NSNumber? {
guard let value = follow else { return nil }
return NSNumber(booleanLiteral: value)
}
public var favouriteNumber: NSNumber? {
guard let value = favourite else { return nil }
return NSNumber(booleanLiteral: value)
}
public var reblogNumber: NSNumber? {
guard let value = reblog else { return nil }
return NSNumber(booleanLiteral: value)
}
public var mentionNumber: NSNumber? {
guard let value = mention else { return nil }
return NSNumber(booleanLiteral: value)
}
public var pollNumber: NSNumber? {
guard let value = poll else { return nil }
return NSNumber(booleanLiteral: value)
enum CodingKeys: String, CodingKey {
case follow
case followRequest = "follow_request"
case favourite
case reblog
case mention
case poll
}
}
@ -74,4 +63,8 @@ extension Mastodon.Entity {
serverKey = try container.decode(String.self, forKey: .serverKey)
}
}
public struct EmptySubscription: Codable {
}
}

View File

@ -58,3 +58,5 @@ protocol PatchQuery: RequestQuery { }
// PUT
protocol PutQuery: RequestQuery { }
// DELETE
protocol DeleteQuery: RequestQuery { }