Merge pull request #116 from tootsuite/feature/push-notification
Add push notification supports
This commit is contained in:
commit
601baf3d5f
|
@ -1,4 +1,9 @@
|
||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
|
|
||||||
sudo gem install cocoapods-keys
|
sudo gem install cocoapods-keys
|
||||||
pod install
|
|
||||||
|
# stub keys. DO NOT use in production
|
||||||
|
pod keys set notification_endpoint "<endpoint>"
|
||||||
|
pod keys set notification_endpoint_debug "<endpoint>"
|
||||||
|
|
||||||
|
pod install
|
||||||
|
|
|
@ -0,0 +1,12 @@
|
||||||
|
//
|
||||||
|
// AppName.swift
|
||||||
|
// AppShared
|
||||||
|
//
|
||||||
|
// Created by MainasuK Cirno on 2021-4-27.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
public enum AppName {
|
||||||
|
public static let groupID = "group.org.joinmastodon.mastodon-temp"
|
||||||
|
}
|
|
@ -0,0 +1,103 @@
|
||||||
|
//
|
||||||
|
// AppSecret.swift
|
||||||
|
// AppShared
|
||||||
|
//
|
||||||
|
// Created by MainasuK Cirno on 2021-4-27.
|
||||||
|
//
|
||||||
|
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import CryptoKit
|
||||||
|
import KeychainAccess
|
||||||
|
import Keys
|
||||||
|
|
||||||
|
public final class AppSecret {
|
||||||
|
|
||||||
|
public static let keychain = Keychain(service: "org.joinmastodon.Mastodon.keychain", accessGroup: AppName.groupID)
|
||||||
|
|
||||||
|
static let notificationPrivateKeyName = "notification-private-key-base64"
|
||||||
|
static let notificationAuthName = "notification-auth-base64"
|
||||||
|
|
||||||
|
public let notificationEndpoint: String
|
||||||
|
|
||||||
|
public var notificationPrivateKey: P256.KeyAgreement.PrivateKey {
|
||||||
|
AppSecret.createOrFetchNotificationPrivateKey()
|
||||||
|
}
|
||||||
|
public var notificationPublicKey: P256.KeyAgreement.PublicKey {
|
||||||
|
notificationPrivateKey.publicKey
|
||||||
|
}
|
||||||
|
public var notificationAuth: Data {
|
||||||
|
AppSecret.createOrFetchNotificationAuth()
|
||||||
|
}
|
||||||
|
|
||||||
|
public static let `default`: AppSecret = {
|
||||||
|
return AppSecret()
|
||||||
|
}()
|
||||||
|
|
||||||
|
init() {
|
||||||
|
let keys = MastodonKeys()
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
|
self.notificationEndpoint = keys.notification_endpoint_debug
|
||||||
|
#else
|
||||||
|
self.notificationEndpoint = keys.notification_endpoint
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
public func register() {
|
||||||
|
_ = AppSecret.createOrFetchNotificationPrivateKey()
|
||||||
|
_ = AppSecret.createOrFetchNotificationAuth()
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
extension AppSecret {
|
||||||
|
|
||||||
|
private static func createOrFetchNotificationPrivateKey() -> P256.KeyAgreement.PrivateKey {
|
||||||
|
if let encoded = AppSecret.keychain[AppSecret.notificationPrivateKeyName],
|
||||||
|
let data = Data(base64Encoded: encoded) {
|
||||||
|
do {
|
||||||
|
let privateKey = try P256.KeyAgreement.PrivateKey(rawRepresentation: data)
|
||||||
|
return privateKey
|
||||||
|
} catch {
|
||||||
|
assertionFailure()
|
||||||
|
return AppSecret.resetNotificationPrivateKey()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return AppSecret.resetNotificationPrivateKey()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func resetNotificationPrivateKey() -> P256.KeyAgreement.PrivateKey {
|
||||||
|
let privateKey = P256.KeyAgreement.PrivateKey()
|
||||||
|
keychain[AppSecret.notificationPrivateKeyName] = privateKey.rawRepresentation.base64EncodedString()
|
||||||
|
return privateKey
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
extension AppSecret {
|
||||||
|
|
||||||
|
private static func createOrFetchNotificationAuth() -> Data {
|
||||||
|
if let encoded = keychain[AppSecret.notificationAuthName],
|
||||||
|
let data = Data(base64Encoded: encoded) {
|
||||||
|
return data
|
||||||
|
} else {
|
||||||
|
return AppSecret.resetNotificationAuth()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func resetNotificationAuth() -> Data {
|
||||||
|
let auth = AppSecret.createRandomAuthBytes()
|
||||||
|
keychain[AppSecret.notificationAuthName] = auth.base64EncodedString()
|
||||||
|
return auth
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func createRandomAuthBytes() -> Data {
|
||||||
|
let byteCount = 16
|
||||||
|
var bytes = Data(count: byteCount)
|
||||||
|
_ = bytes.withUnsafeMutableBytes { SecRandomCopyBytes(kSecRandomDefault, byteCount, $0.baseAddress!) }
|
||||||
|
return bytes
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -0,0 +1,18 @@
|
||||||
|
//
|
||||||
|
// AppShared.h
|
||||||
|
// AppShared
|
||||||
|
//
|
||||||
|
// Created by MainasuK Cirno on 2021-4-27.
|
||||||
|
//
|
||||||
|
|
||||||
|
#import <Foundation/Foundation.h>
|
||||||
|
|
||||||
|
//! Project version number for AppShared.
|
||||||
|
FOUNDATION_EXPORT double AppSharedVersionNumber;
|
||||||
|
|
||||||
|
//! Project version string for AppShared.
|
||||||
|
FOUNDATION_EXPORT const unsigned char AppSharedVersionString[];
|
||||||
|
|
||||||
|
// In this header, you should import all the public headers of your framework using statements like #import <AppShared/PublicHeader.h>
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,22 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
|
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||||
|
<key>CFBundleExecutable</key>
|
||||||
|
<string>$(EXECUTABLE_NAME)</string>
|
||||||
|
<key>CFBundleIdentifier</key>
|
||||||
|
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||||
|
<key>CFBundleInfoDictionaryVersion</key>
|
||||||
|
<string>6.0</string>
|
||||||
|
<key>CFBundleName</key>
|
||||||
|
<string>$(PRODUCT_NAME)</string>
|
||||||
|
<key>CFBundlePackageType</key>
|
||||||
|
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||||
|
<key>CFBundleShortVersionString</key>
|
||||||
|
<string>1.0</string>
|
||||||
|
<key>CFBundleVersion</key>
|
||||||
|
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
|
@ -0,0 +1,12 @@
|
||||||
|
//
|
||||||
|
// UserDefaults.swift
|
||||||
|
// AppShared
|
||||||
|
//
|
||||||
|
// Created by MainasuK Cirno on 2021-4-27.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
extension UserDefaults {
|
||||||
|
public static let shared = UserDefaults(suiteName: AppName.groupID)!
|
||||||
|
}
|
|
@ -172,14 +172,12 @@
|
||||||
<relationship name="hashtag" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Tag"/>
|
<relationship name="hashtag" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Tag"/>
|
||||||
</entity>
|
</entity>
|
||||||
<entity name="Setting" representedClassName=".Setting" syncable="YES">
|
<entity name="Setting" representedClassName=".Setting" syncable="YES">
|
||||||
<attribute name="appearance" optional="YES" attributeType="String"/>
|
<attribute name="appearanceRaw" attributeType="String"/>
|
||||||
<attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
<attribute name="createdAt" attributeType="Date" usesScalarValueType="NO"/>
|
||||||
<attribute name="domain" optional="YES" attributeType="String"/>
|
<attribute name="domain" attributeType="String"/>
|
||||||
<attribute name="id" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
|
<attribute name="updatedAt" attributeType="Date" usesScalarValueType="NO"/>
|
||||||
<attribute name="triggerBy" optional="YES" attributeType="String"/>
|
<attribute name="userID" attributeType="String"/>
|
||||||
<attribute name="updatedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
<relationship name="subscriptions" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="Subscription" inverseName="setting" inverseEntity="Subscription"/>
|
||||||
<attribute name="userID" optional="YES" attributeType="String"/>
|
|
||||||
<relationship name="subscription" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="Subscription" inverseName="setting" inverseEntity="Subscription"/>
|
|
||||||
</entity>
|
</entity>
|
||||||
<entity name="Status" representedClassName=".Status" syncable="YES">
|
<entity name="Status" representedClassName=".Status" syncable="YES">
|
||||||
<attribute name="content" attributeType="String"/>
|
<attribute name="content" attributeType="String"/>
|
||||||
|
@ -221,24 +219,27 @@
|
||||||
<relationship name="tags" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="Tag" inverseName="statuses" inverseEntity="Tag"/>
|
<relationship name="tags" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="Tag" inverseName="statuses" inverseEntity="Tag"/>
|
||||||
</entity>
|
</entity>
|
||||||
<entity name="Subscription" representedClassName=".Subscription" syncable="YES">
|
<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="endpoint" optional="YES" attributeType="String"/>
|
||||||
<attribute name="id" 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="serverKey" optional="YES" attributeType="String"/>
|
||||||
<attribute name="type" optional="YES" attributeType="String"/>
|
<attribute name="updatedAt" attributeType="Date" usesScalarValueType="NO"/>
|
||||||
<attribute name="updatedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
<attribute name="userToken" optional="YES" attributeType="String"/>
|
||||||
<relationship name="alert" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SubscriptionAlerts" inverseName="subscription" inverseEntity="SubscriptionAlerts"/>
|
<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="subscription" inverseEntity="Setting"/>
|
<relationship name="setting" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Setting" inverseName="subscriptions" inverseEntity="Setting"/>
|
||||||
</entity>
|
</entity>
|
||||||
<entity name="SubscriptionAlerts" representedClassName=".SubscriptionAlerts" syncable="YES">
|
<entity name="SubscriptionAlerts" representedClassName=".SubscriptionAlerts" syncable="YES">
|
||||||
<attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
<attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
||||||
<attribute name="favourite" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
|
<attribute name="favouriteRaw" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
|
||||||
<attribute name="follow" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
|
<attribute name="followRaw" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
|
||||||
<attribute name="mention" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
|
<attribute name="followRequestRaw" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
|
||||||
<attribute name="poll" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
|
<attribute name="mentionRaw" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
|
||||||
<attribute name="reblog" 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"/>
|
<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>
|
||||||
<entity name="Tag" representedClassName=".Tag" syncable="YES">
|
<entity name="Tag" representedClassName=".Tag" syncable="YES">
|
||||||
<attribute name="createAt" attributeType="Date" defaultDateTimeInterval="631123200" usesScalarValueType="NO"/>
|
<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="PollOption" positionX="0" positionY="0" width="128" height="134"/>
|
||||||
<element name="PrivateNote" positionX="0" positionY="0" width="128" height="89"/>
|
<element name="PrivateNote" positionX="0" positionY="0" width="128" height="89"/>
|
||||||
<element name="SearchHistory" positionX="0" positionY="0" width="128" height="104"/>
|
<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="Status" positionX="0" positionY="0" width="128" height="584"/>
|
||||||
<element name="Subscription" positionX="81" positionY="171" 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="149"/>
|
<element name="SubscriptionAlerts" positionX="72" positionY="162" width="128" height="14"/>
|
||||||
<element name="Tag" positionX="0" positionY="0" width="128" height="134"/>
|
<element name="Tag" positionX="0" positionY="0" width="128" height="134"/>
|
||||||
</elements>
|
</elements>
|
||||||
</model>
|
</model>
|
|
@ -8,6 +8,7 @@
|
||||||
import os
|
import os
|
||||||
import Foundation
|
import Foundation
|
||||||
import CoreData
|
import CoreData
|
||||||
|
import AppShared
|
||||||
|
|
||||||
public final class CoreDataStack {
|
public final class CoreDataStack {
|
||||||
|
|
||||||
|
@ -18,7 +19,7 @@ public final class CoreDataStack {
|
||||||
}
|
}
|
||||||
|
|
||||||
public convenience init(databaseName: String = "shared") {
|
public convenience init(databaseName: String = "shared") {
|
||||||
let storeURL = URL.storeURL(for: "group.org.joinmastodon.mastodon-temp", databaseName: databaseName)
|
let storeURL = URL.storeURL(for: AppName.groupID, databaseName: databaseName)
|
||||||
let storeDescription = NSPersistentStoreDescription(url: storeURL)
|
let storeDescription = NSPersistentStoreDescription(url: storeURL)
|
||||||
self.init(persistentStoreDescriptions: [storeDescription])
|
self.init(persistentStoreDescriptions: [storeDescription])
|
||||||
}
|
}
|
||||||
|
|
|
@ -9,66 +9,61 @@ import CoreData
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
public final class Setting: NSManagedObject {
|
public final class Setting: NSManagedObject {
|
||||||
@NSManaged public var appearance: String?
|
|
||||||
@NSManaged public var triggerBy: String?
|
@NSManaged public var appearanceRaw: String
|
||||||
@NSManaged public var domain: String?
|
@NSManaged public var domain: String
|
||||||
@NSManaged public var userID: String?
|
@NSManaged public var userID: String
|
||||||
|
|
||||||
@NSManaged public private(set) var createdAt: Date
|
@NSManaged public private(set) var createdAt: Date
|
||||||
@NSManaged public private(set) var updatedAt: Date
|
@NSManaged public private(set) var updatedAt: Date
|
||||||
|
|
||||||
// relationships
|
// one-to-many relationships
|
||||||
@NSManaged public var subscription: Set<Subscription>?
|
@NSManaged public var subscriptions: Set<Subscription>?
|
||||||
}
|
}
|
||||||
|
|
||||||
public extension Setting {
|
extension Setting {
|
||||||
override func awakeFromInsert() {
|
|
||||||
super.awakeFromInsert()
|
|
||||||
setPrimitiveValue(Date(), forKey: #keyPath(Setting.createdAt))
|
|
||||||
}
|
|
||||||
|
|
||||||
func didUpdate(at networkDate: Date) {
|
public override func awakeFromInsert() {
|
||||||
self.updatedAt = networkDate
|
super.awakeFromInsert()
|
||||||
|
let now = Date()
|
||||||
|
setPrimitiveValue(now, forKey: #keyPath(Setting.createdAt))
|
||||||
|
setPrimitiveValue(now, forKey: #keyPath(Setting.updatedAt))
|
||||||
}
|
}
|
||||||
|
|
||||||
@discardableResult
|
@discardableResult
|
||||||
static func insert(
|
public static func insert(
|
||||||
into context: NSManagedObjectContext,
|
into context: NSManagedObjectContext,
|
||||||
property: Property
|
property: Property
|
||||||
) -> Setting {
|
) -> Setting {
|
||||||
let setting: Setting = context.insertObject()
|
let setting: Setting = context.insertObject()
|
||||||
setting.appearance = property.appearance
|
setting.appearanceRaw = property.appearanceRaw
|
||||||
setting.triggerBy = property.triggerBy
|
|
||||||
setting.domain = property.domain
|
setting.domain = property.domain
|
||||||
setting.userID = property.userID
|
setting.userID = property.userID
|
||||||
return setting
|
return setting
|
||||||
}
|
}
|
||||||
|
|
||||||
func update(appearance: String?) {
|
public func update(appearanceRaw: String) {
|
||||||
guard appearance != self.appearance else { return }
|
guard appearanceRaw != self.appearanceRaw else { return }
|
||||||
self.appearance = appearance
|
self.appearanceRaw = appearanceRaw
|
||||||
didUpdate(at: Date())
|
didUpdate(at: Date())
|
||||||
}
|
}
|
||||||
|
|
||||||
func update(triggerBy: String?) {
|
public func didUpdate(at networkDate: Date) {
|
||||||
guard triggerBy != self.triggerBy else { return }
|
self.updatedAt = networkDate
|
||||||
self.triggerBy = triggerBy
|
|
||||||
didUpdate(at: Date())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public extension Setting {
|
extension Setting {
|
||||||
struct Property {
|
public struct Property {
|
||||||
public let appearance: String
|
|
||||||
public let triggerBy: String
|
|
||||||
public let domain: String
|
public let domain: String
|
||||||
public let userID: String
|
public let userID: String
|
||||||
|
public let appearanceRaw: String
|
||||||
|
|
||||||
public init(appearance: String, triggerBy: String, domain: String, userID: String) {
|
public init(domain: String, userID: String, appearanceRaw: String) {
|
||||||
self.appearance = appearance
|
|
||||||
self.triggerBy = triggerBy
|
|
||||||
self.domain = domain
|
self.domain = domain
|
||||||
self.userID = userID
|
self.userID = userID
|
||||||
|
self.appearanceRaw = appearanceRaw
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -10,30 +10,35 @@ import Foundation
|
||||||
import CoreData
|
import CoreData
|
||||||
|
|
||||||
public final class Subscription: NSManagedObject {
|
public final class Subscription: NSManagedObject {
|
||||||
@NSManaged public var id: String
|
|
||||||
@NSManaged public var endpoint: String
|
|
||||||
@NSManaged public var serverKey: String
|
|
||||||
|
|
||||||
/// four types:
|
@NSManaged public var id: String?
|
||||||
/// - anyone
|
@NSManaged public var endpoint: String?
|
||||||
/// - a follower
|
@NSManaged public var policyRaw: String
|
||||||
/// - anyone I follow
|
@NSManaged public var serverKey: String?
|
||||||
/// - no one
|
@NSManaged public var userToken: String?
|
||||||
@NSManaged public var type: String
|
|
||||||
|
|
||||||
@NSManaged public private(set) var createdAt: Date
|
@NSManaged public private(set) var createdAt: Date
|
||||||
@NSManaged public private(set) var updatedAt: 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
|
// MARK: many-to-one relationships
|
||||||
@NSManaged public var alert: SubscriptionAlerts?
|
|
||||||
// MARK: holder
|
|
||||||
@NSManaged public var setting: Setting?
|
@NSManaged public var setting: Setting?
|
||||||
}
|
}
|
||||||
|
|
||||||
public extension Subscription {
|
public extension Subscription {
|
||||||
override func awakeFromInsert() {
|
override func awakeFromInsert() {
|
||||||
super.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) {
|
func didUpdate(at networkDate: Date) {
|
||||||
|
@ -43,45 +48,22 @@ public extension Subscription {
|
||||||
@discardableResult
|
@discardableResult
|
||||||
static func insert(
|
static func insert(
|
||||||
into context: NSManagedObjectContext,
|
into context: NSManagedObjectContext,
|
||||||
property: Property
|
property: Property,
|
||||||
|
setting: Setting
|
||||||
) -> Subscription {
|
) -> Subscription {
|
||||||
let setting: Subscription = context.insertObject()
|
let subscription: Subscription = context.insertObject()
|
||||||
setting.id = property.id
|
subscription.policyRaw = property.policyRaw
|
||||||
setting.endpoint = property.endpoint
|
subscription.setting = setting
|
||||||
setting.serverKey = property.serverKey
|
return subscription
|
||||||
setting.type = property.type
|
|
||||||
|
|
||||||
return setting
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public extension Subscription {
|
public extension Subscription {
|
||||||
struct Property {
|
struct Property {
|
||||||
public let endpoint: String
|
public let policyRaw: String
|
||||||
public let id: String
|
|
||||||
public let serverKey: String
|
|
||||||
public let type: String
|
|
||||||
|
|
||||||
public init(endpoint: String, id: String, serverKey: String, type: String) {
|
public init(policyRaw: String) {
|
||||||
self.endpoint = endpoint
|
self.policyRaw = policyRaw
|
||||||
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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -94,8 +76,12 @@ extension Subscription: Managed {
|
||||||
|
|
||||||
extension Subscription {
|
extension Subscription {
|
||||||
|
|
||||||
public static func predicate(type: String) -> NSPredicate {
|
public static func predicate(policyRaw: String) -> NSPredicate {
|
||||||
return NSPredicate(format: "%K == %@", #keyPath(Subscription.type), type)
|
return NSPredicate(format: "%K == %@", #keyPath(Subscription.policyRaw), policyRaw)
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func predicate(userToken: String) -> NSPredicate {
|
||||||
|
return NSPredicate(format: "%K == %@", #keyPath(Subscription.userToken), userToken)
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -10,117 +10,165 @@ import Foundation
|
||||||
import CoreData
|
import CoreData
|
||||||
|
|
||||||
public final class SubscriptionAlerts: NSManagedObject {
|
public final class SubscriptionAlerts: NSManagedObject {
|
||||||
@NSManaged public var follow: NSNumber?
|
@NSManaged public var favouriteRaw: NSNumber?
|
||||||
@NSManaged public var favourite: NSNumber?
|
@NSManaged public var followRaw: NSNumber?
|
||||||
@NSManaged public var reblog: NSNumber?
|
@NSManaged public var followRequestRaw: NSNumber?
|
||||||
@NSManaged public var mention: NSNumber?
|
@NSManaged public var mentionRaw: NSNumber?
|
||||||
@NSManaged public var poll: NSNumber?
|
@NSManaged public var pollRaw: NSNumber?
|
||||||
|
@NSManaged public var reblogRaw: NSNumber?
|
||||||
|
|
||||||
@NSManaged public private(set) var createdAt: Date
|
@NSManaged public private(set) var createdAt: Date
|
||||||
@NSManaged public private(set) var updatedAt: Date
|
@NSManaged public private(set) var updatedAt: Date
|
||||||
|
|
||||||
// MARK: - relationships
|
// MARK: one-to-one relationships
|
||||||
@NSManaged public var subscription: Subscription?
|
@NSManaged public var subscription: Subscription
|
||||||
}
|
}
|
||||||
|
|
||||||
public extension SubscriptionAlerts {
|
extension SubscriptionAlerts {
|
||||||
override func awakeFromInsert() {
|
|
||||||
super.awakeFromInsert()
|
|
||||||
setPrimitiveValue(Date(), forKey: #keyPath(SubscriptionAlerts.createdAt))
|
|
||||||
}
|
|
||||||
|
|
||||||
func didUpdate(at networkDate: Date) {
|
public override func awakeFromInsert() {
|
||||||
self.updatedAt = networkDate
|
super.awakeFromInsert()
|
||||||
|
let now = Date()
|
||||||
|
setPrimitiveValue(now, forKey: #keyPath(SubscriptionAlerts.createdAt))
|
||||||
|
setPrimitiveValue(now, forKey: #keyPath(SubscriptionAlerts.updatedAt))
|
||||||
}
|
}
|
||||||
|
|
||||||
@discardableResult
|
@discardableResult
|
||||||
static func insert(
|
public static func insert(
|
||||||
into context: NSManagedObjectContext,
|
into context: NSManagedObjectContext,
|
||||||
property: Property
|
property: Property,
|
||||||
|
subscription: Subscription
|
||||||
) -> SubscriptionAlerts {
|
) -> SubscriptionAlerts {
|
||||||
let alerts: SubscriptionAlerts = context.insertObject()
|
let alerts: SubscriptionAlerts = context.insertObject()
|
||||||
alerts.favourite = property.favourite
|
|
||||||
alerts.follow = property.follow
|
alerts.favouriteRaw = property.favouriteRaw
|
||||||
alerts.mention = property.mention
|
alerts.followRaw = property.followRaw
|
||||||
alerts.poll = property.poll
|
alerts.followRequestRaw = property.followRequestRaw
|
||||||
alerts.reblog = property.reblog
|
alerts.mentionRaw = property.mentionRaw
|
||||||
|
alerts.pollRaw = property.pollRaw
|
||||||
|
alerts.reblogRaw = property.reblogRaw
|
||||||
|
|
||||||
|
alerts.subscription = subscription
|
||||||
|
|
||||||
return alerts
|
return alerts
|
||||||
}
|
}
|
||||||
|
|
||||||
func update(favourite: NSNumber?) {
|
public func update(favourite: Bool?) {
|
||||||
guard self.favourite != favourite else { return }
|
guard self.favourite != favourite else { return }
|
||||||
self.favourite = favourite
|
self.favourite = favourite
|
||||||
|
|
||||||
didUpdate(at: Date())
|
didUpdate(at: Date())
|
||||||
}
|
}
|
||||||
|
|
||||||
func update(follow: NSNumber?) {
|
public func update(follow: Bool?) {
|
||||||
guard self.follow != follow else { return }
|
guard self.follow != follow else { return }
|
||||||
self.follow = follow
|
self.follow = follow
|
||||||
|
|
||||||
didUpdate(at: Date())
|
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 }
|
guard self.mention != mention else { return }
|
||||||
self.mention = mention
|
self.mention = mention
|
||||||
|
|
||||||
didUpdate(at: Date())
|
didUpdate(at: Date())
|
||||||
}
|
}
|
||||||
|
|
||||||
func update(poll: NSNumber?) {
|
public func update(poll: Bool?) {
|
||||||
guard self.poll != poll else { return }
|
guard self.poll != poll else { return }
|
||||||
self.poll = poll
|
self.poll = poll
|
||||||
|
|
||||||
didUpdate(at: Date())
|
didUpdate(at: Date())
|
||||||
}
|
}
|
||||||
|
|
||||||
func update(reblog: NSNumber?) {
|
public func update(reblog: Bool?) {
|
||||||
guard self.reblog != reblog else { return }
|
guard self.reblog != reblog else { return }
|
||||||
self.reblog = reblog
|
self.reblog = reblog
|
||||||
|
|
||||||
didUpdate(at: Date())
|
didUpdate(at: Date())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public func didUpdate(at networkDate: Date) {
|
||||||
|
self.updatedAt = networkDate
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public extension SubscriptionAlerts {
|
extension SubscriptionAlerts {
|
||||||
struct Property {
|
|
||||||
public let favourite: NSNumber?
|
private func boolean(from number: NSNumber?) -> Bool? {
|
||||||
public let follow: NSNumber?
|
return number.flatMap { $0.intValue == 1 }
|
||||||
public let mention: NSNumber?
|
}
|
||||||
public let poll: NSNumber?
|
|
||||||
public let reblog: NSNumber?
|
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?) {
|
extension SubscriptionAlerts {
|
||||||
self.favourite = favourite
|
public struct Property {
|
||||||
self.follow = follow
|
public let favouriteRaw: NSNumber?
|
||||||
self.mention = mention
|
public let followRaw: NSNumber?
|
||||||
self.poll = poll
|
public let followRequestRaw: NSNumber?
|
||||||
self.reblog = reblog
|
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 {
|
extension SubscriptionAlerts: Managed {
|
||||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -4,25 +4,35 @@
|
||||||
<dict>
|
<dict>
|
||||||
<key>SchemeUserState</key>
|
<key>SchemeUserState</key>
|
||||||
<dict>
|
<dict>
|
||||||
|
<key>AppShared.xcscheme_^#shared#^_</key>
|
||||||
|
<dict>
|
||||||
|
<key>orderHint</key>
|
||||||
|
<integer>18</integer>
|
||||||
|
</dict>
|
||||||
<key>CoreDataStack.xcscheme_^#shared#^_</key>
|
<key>CoreDataStack.xcscheme_^#shared#^_</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>orderHint</key>
|
<key>orderHint</key>
|
||||||
<integer>13</integer>
|
<integer>17</integer>
|
||||||
</dict>
|
</dict>
|
||||||
<key>Mastodon - RTL.xcscheme_^#shared#^_</key>
|
<key>Mastodon - RTL.xcscheme_^#shared#^_</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>orderHint</key>
|
<key>orderHint</key>
|
||||||
<integer>9</integer>
|
<integer>2</integer>
|
||||||
</dict>
|
</dict>
|
||||||
<key>Mastodon - Release.xcscheme_^#shared#^_</key>
|
<key>Mastodon - Release.xcscheme_^#shared#^_</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>orderHint</key>
|
<key>orderHint</key>
|
||||||
<integer>2</integer>
|
<integer>0</integer>
|
||||||
</dict>
|
</dict>
|
||||||
<key>Mastodon.xcscheme_^#shared#^_</key>
|
<key>Mastodon.xcscheme_^#shared#^_</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>orderHint</key>
|
<key>orderHint</key>
|
||||||
<integer>7</integer>
|
<integer>1</integer>
|
||||||
|
</dict>
|
||||||
|
<key>NotificationService.xcscheme_^#shared#^_</key>
|
||||||
|
<dict>
|
||||||
|
<key>orderHint</key>
|
||||||
|
<integer>18</integer>
|
||||||
</dict>
|
</dict>
|
||||||
</dict>
|
</dict>
|
||||||
<key>SuppressBuildableAutocreation</key>
|
<key>SuppressBuildableAutocreation</key>
|
||||||
|
|
|
@ -37,6 +37,15 @@
|
||||||
"version": "3.1.0"
|
"version": "3.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"package": "Base85",
|
||||||
|
"repositoryURL": "https://github.com/MainasuK/Base85.git",
|
||||||
|
"state": {
|
||||||
|
"branch": null,
|
||||||
|
"revision": "626be96816618689627f806b5c875b5adb6346ef",
|
||||||
|
"version": "1.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"package": "CommonOSLog",
|
"package": "CommonOSLog",
|
||||||
"repositoryURL": "https://github.com/MainasuK/CommonOSLog",
|
"repositoryURL": "https://github.com/MainasuK/CommonOSLog",
|
||||||
|
@ -46,6 +55,15 @@
|
||||||
"version": "0.1.1"
|
"version": "0.1.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"package": "KeychainAccess",
|
||||||
|
"repositoryURL": "https://github.com/kishikawakatsumi/KeychainAccess.git",
|
||||||
|
"state": {
|
||||||
|
"branch": null,
|
||||||
|
"revision": "84e546727d66f1adc5439debad16270d0fdd04e7",
|
||||||
|
"version": "4.2.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"package": "Kingfisher",
|
"package": "Kingfisher",
|
||||||
"repositoryURL": "https://github.com/onevcat/Kingfisher.git",
|
"repositoryURL": "https://github.com/onevcat/Kingfisher.git",
|
||||||
|
|
|
@ -62,14 +62,19 @@ extension SceneCoordinator {
|
||||||
case profile(viewModel: ProfileViewModel)
|
case profile(viewModel: ProfileViewModel)
|
||||||
case favorite(viewModel: FavoriteViewModel)
|
case favorite(viewModel: FavoriteViewModel)
|
||||||
|
|
||||||
|
// setting
|
||||||
|
case settings(viewModel: SettingsViewModel)
|
||||||
|
|
||||||
|
// report
|
||||||
|
case report(viewModel: ReportViewModel)
|
||||||
|
|
||||||
// suggestion account
|
// suggestion account
|
||||||
case suggestionAccount(viewModel: SuggestionAccountViewModel)
|
case suggestionAccount(viewModel: SuggestionAccountViewModel)
|
||||||
|
|
||||||
// misc
|
// misc
|
||||||
case safari(url: URL)
|
case safari(url: URL)
|
||||||
case alertController(alertController: UIAlertController)
|
case alertController(alertController: UIAlertController)
|
||||||
case activityViewController(activityViewController: UIActivityViewController, sourceView: UIView?, barButtonItem: UIBarButtonItem?)
|
case activityViewController(activityViewController: UIActivityViewController, sourceView: UIView?, barButtonItem: UIBarButtonItem?)
|
||||||
case settings(viewModel: SettingsViewModel)
|
|
||||||
case report(viewModel: ReportViewModel)
|
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
case publicTimeline
|
case publicTimeline
|
||||||
#endif
|
#endif
|
||||||
|
@ -253,6 +258,10 @@ private extension SceneCoordinator {
|
||||||
let _viewController = FavoriteViewController()
|
let _viewController = FavoriteViewController()
|
||||||
_viewController.viewModel = viewModel
|
_viewController.viewModel = viewModel
|
||||||
viewController = _viewController
|
viewController = _viewController
|
||||||
|
case .settings(let viewModel):
|
||||||
|
let _viewController = SettingsViewController()
|
||||||
|
_viewController.viewModel = viewModel
|
||||||
|
viewController = _viewController
|
||||||
case .suggestionAccount(let viewModel):
|
case .suggestionAccount(let viewModel):
|
||||||
let _viewController = SuggestionAccountViewController()
|
let _viewController = SuggestionAccountViewController()
|
||||||
_viewController.viewModel = viewModel
|
_viewController.viewModel = viewModel
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,23 +0,0 @@
|
||||||
//
|
|
||||||
// Array+removeDuplicates.swift
|
|
||||||
// Mastodon
|
|
||||||
//
|
|
||||||
// Created by BradGao on 2021/3/31.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
/// https://www.hackingwithswift.com/example-code/language/how-to-remove-duplicate-items-from-an-array
|
|
||||||
extension Array where Element: Hashable {
|
|
||||||
func removingDuplicates() -> [Element] {
|
|
||||||
var addedDict = [Element: Bool]()
|
|
||||||
|
|
||||||
return filter {
|
|
||||||
addedDict.updateValue(true, forKey: $0) == nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
mutating func removeDuplicates() {
|
|
||||||
self = self.removingDuplicates()
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -0,0 +1,99 @@
|
||||||
|
//
|
||||||
|
// Array.swift
|
||||||
|
// Mastodon
|
||||||
|
//
|
||||||
|
// Created by BradGao on 2021/3/31.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// https://www.hackingwithswift.com/example-code/language/how-to-remove-duplicate-items-from-an-array
|
||||||
|
extension Array where Element: Hashable {
|
||||||
|
func removingDuplicates() -> [Element] {
|
||||||
|
var addedDict = [Element: Bool]()
|
||||||
|
|
||||||
|
return filter {
|
||||||
|
addedDict.updateValue(true, forKey: $0) == nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func removeDuplicates() {
|
||||||
|
self = self.removingDuplicates()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
// CryptoSwift
|
||||||
|
//
|
||||||
|
// Copyright (C) 2014-2017 Marcin Krzyżanowski <marcin@krzyzanowskim.com>
|
||||||
|
// This software is provided 'as-is', without any express or implied warranty.
|
||||||
|
//
|
||||||
|
// In no event will the authors be held liable for any damages arising from the use of this software.
|
||||||
|
//
|
||||||
|
// Permission is granted to anyone to use this software for any purpose,including commercial applications, and to alter it and redistribute it freely, subject to the following restrictions:
|
||||||
|
//
|
||||||
|
// - The origin of this software must not be misrepresented; you must not claim that you wrote the original software. If you use this software in a product, an acknowledgment in the product documentation is required.
|
||||||
|
// - Altered source versions must be plainly marked as such, and must not be misrepresented as being the original software.
|
||||||
|
// - This notice may not be removed or altered from any source or binary distribution.
|
||||||
|
//
|
||||||
|
|
||||||
|
extension Array {
|
||||||
|
init(reserveCapacity: Int) {
|
||||||
|
self = Array<Element>()
|
||||||
|
self.reserveCapacity(reserveCapacity)
|
||||||
|
}
|
||||||
|
|
||||||
|
var slice: ArraySlice<Element> {
|
||||||
|
self[self.startIndex ..< self.endIndex]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Array where Element == UInt8 {
|
||||||
|
public init(hex: String) {
|
||||||
|
self.init(reserveCapacity: hex.unicodeScalars.lazy.underestimatedCount)
|
||||||
|
var buffer: UInt8?
|
||||||
|
var skip = hex.hasPrefix("0x") ? 2 : 0
|
||||||
|
for char in hex.unicodeScalars.lazy {
|
||||||
|
guard skip == 0 else {
|
||||||
|
skip -= 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
guard char.value >= 48 && char.value <= 102 else {
|
||||||
|
removeAll()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let v: UInt8
|
||||||
|
let c: UInt8 = UInt8(char.value)
|
||||||
|
switch c {
|
||||||
|
case let c where c <= 57:
|
||||||
|
v = c - 48
|
||||||
|
case let c where c >= 65 && c <= 70:
|
||||||
|
v = c - 55
|
||||||
|
case let c where c >= 97:
|
||||||
|
v = c - 87
|
||||||
|
default:
|
||||||
|
removeAll()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if let b = buffer {
|
||||||
|
append(b << 4 | v)
|
||||||
|
buffer = nil
|
||||||
|
} else {
|
||||||
|
buffer = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let b = buffer {
|
||||||
|
append(b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func toHexString() -> String {
|
||||||
|
`lazy`.reduce(into: "") {
|
||||||
|
var s = String($1, radix: 16)
|
||||||
|
if s.count == 1 {
|
||||||
|
s = "0" + s
|
||||||
|
}
|
||||||
|
$0 += s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -16,3 +16,25 @@ extension String {
|
||||||
self = self.capitalizingFirstLetter()
|
self = self.capitalizingFirstLetter()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension String {
|
||||||
|
static func normalize(base64String: String) -> String {
|
||||||
|
let base64 = base64String
|
||||||
|
.replacingOccurrences(of: "-", with: "+")
|
||||||
|
.replacingOccurrences(of: "_", with: "/")
|
||||||
|
.padding()
|
||||||
|
return base64
|
||||||
|
}
|
||||||
|
|
||||||
|
private func padding() -> String {
|
||||||
|
let remainder = self.count % 4
|
||||||
|
if remainder > 0 {
|
||||||
|
return self.padding(
|
||||||
|
toLength: self.count + 4 - remainder,
|
||||||
|
withPad: "=",
|
||||||
|
startingAt: 0
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -46,6 +46,53 @@ extension UIViewController {
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension UIViewController {
|
||||||
|
|
||||||
|
func viewController<T: UIViewController>(of type: T.Type) -> T? {
|
||||||
|
if let viewController = self as? T {
|
||||||
|
return viewController
|
||||||
|
}
|
||||||
|
|
||||||
|
// UITabBarController
|
||||||
|
if let tabBarController = self as? UITabBarController {
|
||||||
|
for tab in tabBarController.viewControllers ?? [] {
|
||||||
|
if let viewController = tab.viewController(of: type) {
|
||||||
|
return viewController
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// UINavigationController
|
||||||
|
if let navigationController = self as? UINavigationController {
|
||||||
|
for page in navigationController.viewControllers {
|
||||||
|
if let viewController = page.viewController(of: type) {
|
||||||
|
return viewController
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// UIPageController
|
||||||
|
if let pageViewController = self as? UIPageViewController {
|
||||||
|
for page in pageViewController.viewControllers ?? [] {
|
||||||
|
if let viewController = page.viewController(of: type) {
|
||||||
|
return viewController
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// child view controller
|
||||||
|
for subview in self.view?.subviews ?? [] {
|
||||||
|
if let childViewController = subview.next as? UIViewController,
|
||||||
|
let viewController = childViewController.viewController(of: type) {
|
||||||
|
return viewController
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
extension UIViewController {
|
extension UIViewController {
|
||||||
|
|
||||||
/// https://bluelemonbits.com/2018/08/26/inserting-cells-at-the-top-of-a-uitableview-with-no-scrolling/
|
/// https://bluelemonbits.com/2018/08/26/inserting-cells-at-the-top-of-a-uitableview-with-no-scrolling/
|
||||||
|
|
|
@ -0,0 +1,28 @@
|
||||||
|
//
|
||||||
|
// UserDefaults.swift
|
||||||
|
// Mastodon
|
||||||
|
//
|
||||||
|
// Created by MainasuK Cirno on 2021-4-26.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import AppShared
|
||||||
|
|
||||||
|
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) }
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -2,6 +2,8 @@
|
||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
<dict>
|
<dict>
|
||||||
|
<key>aps-environment</key>
|
||||||
|
<string>development</string>
|
||||||
<key>com.apple.security.application-groups</key>
|
<key>com.apple.security.application-groups</key>
|
||||||
<array>
|
<array>
|
||||||
<string>group.org.joinmastodon.mastodon-temp</string>
|
<string>group.org.joinmastodon.mastodon-temp</string>
|
||||||
|
|
|
@ -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 { self[#function] = newValue.rawValue }
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -0,0 +1,20 @@
|
||||||
|
//
|
||||||
|
// NotificationPreference.swift
|
||||||
|
// Mastodon
|
||||||
|
//
|
||||||
|
// Created by MainasuK Cirno on 2021-4-26.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
extension UserDefaults {
|
||||||
|
|
||||||
|
@objc dynamic var notificationBadgeCount: Int {
|
||||||
|
get {
|
||||||
|
register(defaults: [#function: 0])
|
||||||
|
return integer(forKey: #function)
|
||||||
|
}
|
||||||
|
set { self[#function] = newValue }
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -336,9 +336,10 @@ extension HomeTimelineViewController {
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc private func showSettings(_ sender: UIAction) {
|
@objc private func showSettings(_ sender: UIAction) {
|
||||||
let viewModel = SettingsViewModel(context: context)
|
guard let currentSetting = context.settingService.currentSetting.value else { return }
|
||||||
|
let settingsViewModel = SettingsViewModel(context: context, setting: currentSetting)
|
||||||
coordinator.present(
|
coordinator.present(
|
||||||
scene: .settings(viewModel: viewModel),
|
scene: .settings(viewModel: settingsViewModel),
|
||||||
from: self,
|
from: self,
|
||||||
transition: .modal(animated: true, completion: nil)
|
transition: .modal(animated: true, completion: nil)
|
||||||
)
|
)
|
||||||
|
|
|
@ -97,14 +97,8 @@ extension HomeTimelineViewController {
|
||||||
// long press to trigger debug menu
|
// long press to trigger debug menu
|
||||||
settingBarButtonItem.menu = debugMenu
|
settingBarButtonItem.menu = debugMenu
|
||||||
#else
|
#else
|
||||||
// settingBarButtonItem.target = self
|
settingBarButtonItem.target = self
|
||||||
// settingBarButtonItem.action = #selector(HomeTimelineViewController.settingBarButtonItemPressed(_:))
|
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)
|
|
||||||
}
|
|
||||||
])
|
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
navigationItem.rightBarButtonItem = composeBarButtonItem
|
navigationItem.rightBarButtonItem = composeBarButtonItem
|
||||||
|
@ -296,7 +290,9 @@ extension HomeTimelineViewController {
|
||||||
|
|
||||||
@objc private func settingBarButtonItemPressed(_ sender: UIBarButtonItem) {
|
@objc private func settingBarButtonItemPressed(_ sender: UIBarButtonItem) {
|
||||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
|
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) {
|
@objc private func composeBarButtonItemPressed(_ sender: UIBarButtonItem) {
|
||||||
|
|
|
@ -85,7 +85,6 @@ class MainTabBarController: UITabBarController {
|
||||||
|
|
||||||
extension MainTabBarController {
|
extension MainTabBarController {
|
||||||
|
|
||||||
|
|
||||||
open override var childForStatusBarStyle: UIViewController? {
|
open override var childForStatusBarStyle: UIViewController? {
|
||||||
return selectedViewController
|
return selectedViewController
|
||||||
}
|
}
|
||||||
|
@ -156,9 +155,36 @@ extension MainTabBarController {
|
||||||
}
|
}
|
||||||
.store(in: &disposeBag)
|
.store(in: &disposeBag)
|
||||||
|
|
||||||
#if DEBUG
|
// handle push notification. toggle entry when finish fetch latest notification
|
||||||
// selectedIndex = 3
|
context.notificationService.hasUnreadPushNotification
|
||||||
#endif
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] hasUnreadPushNotification in
|
||||||
|
guard let self = self else { return }
|
||||||
|
guard let notificationViewController = self.notificationViewController else { return }
|
||||||
|
|
||||||
|
let image = hasUnreadPushNotification ? UIImage(systemName: "bell.badge.fill")! : UIImage(systemName: "bell.fill")!
|
||||||
|
notificationViewController.tabBarItem.image = image
|
||||||
|
notificationViewController.navigationController?.tabBarItem.image = image
|
||||||
|
}
|
||||||
|
.store(in: &disposeBag)
|
||||||
|
|
||||||
|
context.notificationService.requestRevealNotificationPublisher
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] notificationID in
|
||||||
|
guard let self = self else { return }
|
||||||
|
self.coordinator.switchToTabBar(tab: .notification)
|
||||||
|
let threadViewModel = RemoteThreadViewModel(context: self.context, notificationID: notificationID)
|
||||||
|
self.coordinator.present(scene: .thread(viewModel: threadViewModel), from: nil, transition: .show)
|
||||||
|
}
|
||||||
|
.store(in: &disposeBag)
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension MainTabBarController {
|
||||||
|
|
||||||
|
var notificationViewController: NotificationViewController? {
|
||||||
|
return viewController(of: NotificationViewController.self)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
|
@ -88,6 +88,11 @@ extension NotificationViewController {
|
||||||
|
|
||||||
tableView.deselectRow(with: transitionCoordinator, animated: animated)
|
tableView.deselectRow(with: transitionCoordinator, animated: animated)
|
||||||
|
|
||||||
|
// fetch latest if has unread push notification
|
||||||
|
if context.notificationService.hasUnreadPushNotification.value {
|
||||||
|
viewModel.loadLatestStateMachine.enter(NotificationViewModel.LoadLatestState.Loading.self)
|
||||||
|
}
|
||||||
|
|
||||||
// needs trigger manually after onboarding dismiss
|
// needs trigger manually after onboarding dismiss
|
||||||
setNeedsStatusBarAppearanceUpdate()
|
setNeedsStatusBarAppearanceUpdate()
|
||||||
}
|
}
|
||||||
|
|
|
@ -61,23 +61,25 @@ extension NotificationViewModel.LoadLatestState {
|
||||||
query: query,
|
query: query,
|
||||||
mastodonAuthenticationBox: activeMastodonAuthenticationBox
|
mastodonAuthenticationBox: activeMastodonAuthenticationBox
|
||||||
)
|
)
|
||||||
.sink { completion in
|
.sink { completion in
|
||||||
switch completion {
|
switch completion {
|
||||||
case .failure(let error):
|
case .failure(let error):
|
||||||
viewModel.isFetchingLatestNotification.value = false
|
viewModel.isFetchingLatestNotification.value = false
|
||||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: fetch notification failed. %s", (#file as NSString).lastPathComponent, #line, #function, error.localizedDescription)
|
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: fetch notification failed. %s", (#file as NSString).lastPathComponent, #line, #function, error.localizedDescription)
|
||||||
case .finished:
|
case .finished:
|
||||||
// handle isFetchingLatestTimeline in fetch controller delegate
|
// toggle unread state
|
||||||
break
|
viewModel.context.notificationService.hasUnreadPushNotification.value = false
|
||||||
}
|
// handle isFetchingLatestTimeline in fetch controller delegate
|
||||||
|
break
|
||||||
stateMachine.enter(Idle.self)
|
|
||||||
} receiveValue: { response in
|
|
||||||
if response.value.isEmpty {
|
|
||||||
viewModel.isFetchingLatestNotification.value = false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.store(in: &viewModel.disposeBag)
|
|
||||||
|
stateMachine.enter(Idle.self)
|
||||||
|
} receiveValue: { response in
|
||||||
|
if response.value.isEmpty {
|
||||||
|
viewModel.isFetchingLatestNotification.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.store(in: &viewModel.disposeBag)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -517,7 +517,9 @@ extension ProfileViewController {
|
||||||
|
|
||||||
@objc private func settingBarButtonItemPressed(_ sender: UIBarButtonItem) {
|
@objc private func settingBarButtonItemPressed(_ sender: UIBarButtonItem) {
|
||||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
|
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) {
|
@objc private func shareBarButtonItemPressed(_ sender: UIBarButtonItem) {
|
||||||
|
|
|
@ -11,11 +11,10 @@ import Combine
|
||||||
import ActiveLabel
|
import ActiveLabel
|
||||||
import CoreData
|
import CoreData
|
||||||
import CoreDataStack
|
import CoreDataStack
|
||||||
|
import MastodonSDK
|
||||||
import AlamofireImage
|
import AlamofireImage
|
||||||
import Kingfisher
|
import Kingfisher
|
||||||
|
|
||||||
// iTODO: when to ask permission to Use Notifications
|
|
||||||
|
|
||||||
class SettingsViewController: UIViewController, NeedsDependency {
|
class SettingsViewController: UIViewController, NeedsDependency {
|
||||||
|
|
||||||
weak var context: AppContext! { willSet { precondition(!isViewLoaded) } }
|
weak var context: AppContext! { willSet { precondition(!isViewLoaded) } }
|
||||||
|
@ -23,6 +22,7 @@ class SettingsViewController: UIViewController, NeedsDependency {
|
||||||
|
|
||||||
var viewModel: SettingsViewModel! { willSet { precondition(!isViewLoaded) } }
|
var viewModel: SettingsViewModel! { willSet { precondition(!isViewLoaded) } }
|
||||||
var disposeBag = Set<AnyCancellable>()
|
var disposeBag = Set<AnyCancellable>()
|
||||||
|
var notificationPolicySubscription: AnyCancellable?
|
||||||
|
|
||||||
var triggerMenu: UIMenu {
|
var triggerMenu: UIMenu {
|
||||||
let anyone = L10n.Scene.Settings.Section.Notifications.Trigger.anyone
|
let anyone = L10n.Scene.Settings.Section.Notifications.Trigger.anyone
|
||||||
|
@ -35,27 +35,27 @@ class SettingsViewController: UIViewController, NeedsDependency {
|
||||||
options: .displayInline,
|
options: .displayInline,
|
||||||
children: [
|
children: [
|
||||||
UIAction(title: anyone, image: UIImage(systemName: "person.3"), attributes: []) { [weak self] action in
|
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
|
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
|
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
|
UIAction(title: noOne, image: UIImage(systemName: "nosign"), attributes: []) { [weak self] action in
|
||||||
self?.updateTrigger(by: noOne)
|
self?.updateTrigger(policy: .none)
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
return menu
|
return menu
|
||||||
}
|
}
|
||||||
|
|
||||||
lazy var notifySectionHeader: UIView = {
|
private(set) lazy var notifySectionHeader: UIView = {
|
||||||
let view = UIStackView()
|
let view = UIStackView()
|
||||||
view.translatesAutoresizingMaskIntoConstraints = false
|
view.translatesAutoresizingMaskIntoConstraints = false
|
||||||
view.isLayoutMarginsRelativeArrangement = true
|
view.isLayoutMarginsRelativeArrangement = true
|
||||||
view.layoutMargins = UIEdgeInsets(top: 15, left: 4, bottom: 5, right: 4)
|
//view.layoutMargins = UIEdgeInsets(top: 15, left: 4, bottom: 5, right: 4)
|
||||||
view.axis = .horizontal
|
view.axis = .horizontal
|
||||||
view.alignment = .fill
|
view.alignment = .fill
|
||||||
view.distribution = .equalSpacing
|
view.distribution = .equalSpacing
|
||||||
|
@ -71,15 +71,12 @@ class SettingsViewController: UIViewController, NeedsDependency {
|
||||||
return view
|
return view
|
||||||
}()
|
}()
|
||||||
|
|
||||||
lazy var whoButton: UIButton = {
|
private(set) lazy var whoButton: UIButton = {
|
||||||
let whoButton = UIButton(type: .roundedRect)
|
let whoButton = UIButton(type: .roundedRect)
|
||||||
whoButton.menu = triggerMenu
|
whoButton.menu = triggerMenu
|
||||||
whoButton.showsMenuAsPrimaryAction = true
|
whoButton.showsMenuAsPrimaryAction = true
|
||||||
whoButton.setBackgroundColor(Asset.Colors.battleshipGrey.color, for: .normal)
|
whoButton.setBackgroundColor(Asset.Colors.battleshipGrey.color, for: .normal)
|
||||||
whoButton.setTitleColor(Asset.Colors.Label.primary.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.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.contentEdgeInsets = UIEdgeInsets(top: 5, left: 5, bottom: 5, right: 5)
|
||||||
whoButton.layer.cornerRadius = 10
|
whoButton.layer.cornerRadius = 10
|
||||||
|
@ -87,7 +84,7 @@ class SettingsViewController: UIViewController, NeedsDependency {
|
||||||
return whoButton
|
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)
|
// 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)
|
let tableView = UITableView(frame: CGRect(x: 0, y: 0, width: 320, height: 320), style: .grouped)
|
||||||
tableView.translatesAutoresizingMaskIntoConstraints = false
|
tableView.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
@ -95,13 +92,13 @@ class SettingsViewController: UIViewController, NeedsDependency {
|
||||||
tableView.rowHeight = UITableView.automaticDimension
|
tableView.rowHeight = UITableView.automaticDimension
|
||||||
tableView.backgroundColor = Asset.Colors.Background.systemGroupedBackground.color
|
tableView.backgroundColor = Asset.Colors.Background.systemGroupedBackground.color
|
||||||
|
|
||||||
tableView.register(SettingsAppearanceTableViewCell.self, forCellReuseIdentifier: "SettingsAppearanceTableViewCell")
|
tableView.register(SettingsAppearanceTableViewCell.self, forCellReuseIdentifier: String(describing: SettingsAppearanceTableViewCell.self))
|
||||||
tableView.register(SettingsToggleTableViewCell.self, forCellReuseIdentifier: "SettingsToggleTableViewCell")
|
tableView.register(SettingsToggleTableViewCell.self, forCellReuseIdentifier: String(describing: SettingsToggleTableViewCell.self))
|
||||||
tableView.register(SettingsLinkTableViewCell.self, forCellReuseIdentifier: "SettingsLinkTableViewCell")
|
tableView.register(SettingsLinkTableViewCell.self, forCellReuseIdentifier: String(describing: SettingsLinkTableViewCell.self))
|
||||||
return tableView
|
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)
|
// 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))
|
let view = UIStackView(frame: CGRect(x: 0, y: 0, width: 320, height: 320))
|
||||||
view.isLayoutMarginsRelativeArrangement = true
|
view.isLayoutMarginsRelativeArrangement = true
|
||||||
|
@ -143,14 +140,30 @@ class SettingsViewController: UIViewController, NeedsDependency {
|
||||||
|
|
||||||
// MAKR: - Private methods
|
// MAKR: - Private methods
|
||||||
private func bindViewModel() {
|
private func bindViewModel() {
|
||||||
let input = SettingsViewModel.Input()
|
self.whoButton.setTitle(viewModel.setting.value.activeSubscription?.policy.title, for: .normal)
|
||||||
_ = viewModel.transform(input: input)
|
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() {
|
private func setupView() {
|
||||||
view.backgroundColor = Asset.Colors.Background.secondarySystemBackground.color
|
view.backgroundColor = Asset.Colors.Background.secondarySystemBackground.color
|
||||||
setupNavigation()
|
setupNavigation()
|
||||||
setupTableView()
|
|
||||||
|
|
||||||
view.addSubview(tableView)
|
view.addSubview(tableView)
|
||||||
NSLayoutConstraint.activate([
|
NSLayoutConstraint.activate([
|
||||||
|
@ -159,6 +172,7 @@ class SettingsViewController: UIViewController, NeedsDependency {
|
||||||
tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||||
tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
||||||
])
|
])
|
||||||
|
setupTableView()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func setupNavigation() {
|
private func setupNavigation() {
|
||||||
|
@ -177,35 +191,12 @@ class SettingsViewController: UIViewController, NeedsDependency {
|
||||||
}
|
}
|
||||||
|
|
||||||
private func setupTableView() {
|
private func setupTableView() {
|
||||||
viewModel.dataSource = UITableViewDiffableDataSource(tableView: tableView, cellProvider: { [weak self] (tableView, indexPath, item) -> UITableViewCell? in
|
viewModel.setupDiffableDataSource(
|
||||||
guard let self = self else { return nil }
|
for: tableView,
|
||||||
|
settingsAppearanceTableViewCellDelegate: self,
|
||||||
switch item {
|
settingsToggleCellDelegate: self
|
||||||
case .apperance(let item):
|
)
|
||||||
guard let cell = tableView.dequeueReusableCell(withIdentifier: "SettingsAppearanceTableViewCell") as? SettingsAppearanceTableViewCell else {
|
tableView.tableFooterView = tableFooterView
|
||||||
assertionFailure()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
cell.update(with: item, delegate: self)
|
|
||||||
return cell
|
|
||||||
case .notification(let item):
|
|
||||||
guard let cell = tableView.dequeueReusableCell(withIdentifier: "SettingsToggleTableViewCell") as? SettingsToggleTableViewCell else {
|
|
||||||
assertionFailure()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
cell.update(with: item, delegate: self)
|
|
||||||
return cell
|
|
||||||
case .boringZone(let item), .spicyZone(let item):
|
|
||||||
guard let cell = tableView.dequeueReusableCell(withIdentifier: "SettingsLinkTableViewCell") as? SettingsLinkTableViewCell else {
|
|
||||||
assertionFailure()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
cell.update(with: item)
|
|
||||||
return cell
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
tableView.tableFooterView = footerView
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func alertToSignout() {
|
func alertToSignout() {
|
||||||
|
@ -218,7 +209,7 @@ class SettingsViewController: UIViewController, NeedsDependency {
|
||||||
let cancelAction = UIAlertAction(title: L10n.Common.Controls.Actions.cancel, style: .cancel, handler: nil)
|
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
|
let signOutAction = UIAlertAction(title: L10n.Common.Alerts.SignOut.confirm, style: .destructive) { [weak self] _ in
|
||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
self.signout()
|
self.signOut()
|
||||||
}
|
}
|
||||||
alertController.addAction(cancelAction)
|
alertController.addAction(cancelAction)
|
||||||
alertController.addAction(signOutAction)
|
alertController.addAction(signOutAction)
|
||||||
|
@ -229,7 +220,7 @@ class SettingsViewController: UIViewController, NeedsDependency {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func signout() {
|
func signOut() {
|
||||||
guard let activeMastodonAuthenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else {
|
guard let activeMastodonAuthenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else {
|
||||||
return
|
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)
|
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)
|
dismiss(animated: true, completion: nil)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -268,47 +262,39 @@ extension SettingsViewController: UITableViewDelegate {
|
||||||
func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? {
|
func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? {
|
||||||
let sections = viewModel.dataSource.snapshot().sectionIdentifiers
|
let sections = viewModel.dataSource.snapshot().sectionIdentifiers
|
||||||
guard section < sections.count else { return nil }
|
guard section < sections.count else { return nil }
|
||||||
let sectionData = sections[section]
|
|
||||||
|
|
||||||
if section == 1 {
|
let sectionIdentifier = sections[section]
|
||||||
let header = SettingsSectionHeader(
|
|
||||||
|
let header: SettingsSectionHeader
|
||||||
|
switch sectionIdentifier {
|
||||||
|
case .notifications:
|
||||||
|
header = SettingsSectionHeader(
|
||||||
frame: CGRect(x: 0, y: 0, width: 375, height: 66),
|
frame: CGRect(x: 0, y: 0, width: 375, height: 66),
|
||||||
customView: notifySectionHeader)
|
customView: notifySectionHeader)
|
||||||
header.update(title: sectionData.title)
|
header.update(title: sectionIdentifier.title)
|
||||||
|
default:
|
||||||
if let setting = self.viewModel.setting.value, let trigger = setting.triggerBy {
|
header = SettingsSectionHeader(frame: CGRect(x: 0, y: 0, width: 375, height: 66))
|
||||||
whoButton.setTitle(trigger, for: .normal)
|
header.update(title: sectionIdentifier.title)
|
||||||
} else {
|
|
||||||
let anyone = L10n.Scene.Settings.Section.Notifications.Trigger.anyone
|
|
||||||
whoButton.setTitle(anyone, for: .normal)
|
|
||||||
}
|
|
||||||
return header
|
|
||||||
} else {
|
|
||||||
let header = SettingsSectionHeader(frame: CGRect(x: 0, y: 0, width: 375, height: 66))
|
|
||||||
header.update(title: sectionData.title)
|
|
||||||
return header
|
|
||||||
}
|
}
|
||||||
|
header.preservesSuperviewLayoutMargins = true
|
||||||
|
|
||||||
|
return header
|
||||||
}
|
}
|
||||||
|
|
||||||
// remove the gap of table's footer
|
// remove the gap of table's footer
|
||||||
func tableView(_ tableView: UITableView, viewForFooterInSection section: Int) -> UIView? {
|
func tableView(_ tableView: UITableView, viewForFooterInSection section: Int) -> UIView? {
|
||||||
return UIView()
|
return UIView()
|
||||||
}
|
}
|
||||||
|
|
||||||
// remove the gap of table's footer
|
// remove the gap of table's footer
|
||||||
func tableView(_ tableView: UITableView, heightForFooterInSection section: Int) -> CGFloat {
|
func tableView(_ tableView: UITableView, heightForFooterInSection section: Int) -> CGFloat {
|
||||||
return 0
|
return CGFloat.leastNonzeroMagnitude
|
||||||
}
|
}
|
||||||
|
|
||||||
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||||
let snapshot = self.viewModel.dataSource.snapshot()
|
guard let dataSource = viewModel.dataSource else { return }
|
||||||
let sectionIds = snapshot.sectionIdentifiers
|
let item = dataSource.itemIdentifier(for: indexPath)
|
||||||
guard indexPath.section < sectionIds.count else { return }
|
|
||||||
let sectionIdentifier = sectionIds[indexPath.section]
|
|
||||||
let items = snapshot.itemIdentifiers(inSection: sectionIdentifier)
|
|
||||||
guard indexPath.row < items.count else { return }
|
|
||||||
let item = items[indexPath.item]
|
|
||||||
|
|
||||||
switch item {
|
switch item {
|
||||||
case .boringZone:
|
case .boringZone:
|
||||||
guard let url = viewModel.privacyURL else { break }
|
guard let url = viewModel.privacyURL else { break }
|
||||||
|
@ -327,7 +313,7 @@ extension SettingsViewController: UITableViewDelegate {
|
||||||
ImageDownloader.defaultURLCache().removeAllCachedResponses()
|
ImageDownloader.defaultURLCache().removeAllCachedResponses()
|
||||||
let cleanedDiskBytes = ImageDownloader.defaultURLCache().currentDiskUsage
|
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)
|
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: diskBytes %d", ((#file as NSString).lastPathComponent), #line, #function, cleanedDiskBytes)
|
||||||
|
|
||||||
// clean Kingfisher Cache
|
// clean Kingfisher Cache
|
||||||
KingfisherManager.shared.cache.clearDiskCache()
|
KingfisherManager.shared.cache.clearDiskCache()
|
||||||
}
|
}
|
||||||
|
@ -343,82 +329,77 @@ extension SettingsViewController: UITableViewDelegate {
|
||||||
|
|
||||||
// Update setting into core data
|
// Update setting into core data
|
||||||
extension SettingsViewController {
|
extension SettingsViewController {
|
||||||
func updateTrigger(by who: String) {
|
func updateTrigger(policy: Mastodon.API.Subscriptions.Policy) {
|
||||||
guard self.viewModel.triggerBy != who else { return }
|
let objectID = self.viewModel.setting.value.objectID
|
||||||
guard let setting = self.viewModel.setting.value else { return }
|
let managedObjectContext = context.backgroundManagedObjectContext
|
||||||
|
|
||||||
setting.update(triggerBy: who)
|
managedObjectContext.performChanges {
|
||||||
// trigger to call `subscription` API with POST method
|
let setting = managedObjectContext.object(with: objectID) as! Setting
|
||||||
// confirm the local data is correct even if request failed
|
let (subscription, _) = APIService.CoreData.createOrFetchSubscription(
|
||||||
// The asynchronous execution is to solve the problem of dropped frames for animations.
|
into: managedObjectContext,
|
||||||
DispatchQueue.main.async { [weak self] in
|
setting: setting,
|
||||||
self?.viewModel.setting.value = setting
|
policy: policy
|
||||||
|
)
|
||||||
|
let now = Date()
|
||||||
|
subscription.update(activedAt: now)
|
||||||
|
setting.didUpdate(at: now)
|
||||||
}
|
}
|
||||||
}
|
.sink { _ in
|
||||||
|
// do nothing
|
||||||
func updateAlert(title: String?, isOn: Bool) {
|
} receiveValue: { _ in
|
||||||
guard let title = title else { return }
|
// do nohting
|
||||||
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))
|
|
||||||
}
|
}
|
||||||
|
.store(in: &disposeBag)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - SettingsAppearanceTableViewCellDelegate
|
||||||
extension SettingsViewController: SettingsAppearanceTableViewCellDelegate {
|
extension SettingsViewController: SettingsAppearanceTableViewCellDelegate {
|
||||||
func settingsAppearanceCell(_ view: SettingsAppearanceTableViewCell, didSelect: SettingsItem.AppearanceMode) {
|
func settingsAppearanceCell(_ cell: SettingsAppearanceTableViewCell, didSelectAppearanceMode appearanceMode: SettingsItem.AppearanceMode) {
|
||||||
guard let setting = self.viewModel.setting.value else { return }
|
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 {
|
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
|
.sink { _ in
|
||||||
// change light / dark mode
|
// do nothing
|
||||||
var overrideUserInterfaceStyle: UIUserInterfaceStyle!
|
|
||||||
switch didSelect {
|
|
||||||
case .automatic:
|
|
||||||
overrideUserInterfaceStyle = .unspecified
|
|
||||||
case .light:
|
|
||||||
overrideUserInterfaceStyle = .light
|
|
||||||
case .dark:
|
|
||||||
overrideUserInterfaceStyle = .dark
|
|
||||||
}
|
|
||||||
view.window?.overrideUserInterfaceStyle = overrideUserInterfaceStyle
|
|
||||||
}.store(in: &disposeBag)
|
}.store(in: &disposeBag)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension SettingsViewController: SettingsToggleCellDelegate {
|
extension SettingsViewController: SettingsToggleCellDelegate {
|
||||||
func settingsToggleCell(_ cell: SettingsToggleTableViewCell, didChangeStatus: Bool) {
|
func settingsToggleCell(_ cell: SettingsToggleTableViewCell, switchValueDidChange switch: UISwitch) {
|
||||||
updateAlert(title: cell.data?.title, isOn: didChangeStatus)
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -432,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
|
#if canImport(SwiftUI) && DEBUG
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
|
|
|
@ -13,37 +13,21 @@ import MastodonSDK
|
||||||
import UIKit
|
import UIKit
|
||||||
import os.log
|
import os.log
|
||||||
|
|
||||||
class SettingsViewModel: NSObject {
|
class SettingsViewModel {
|
||||||
// confirm set only once
|
|
||||||
weak var context: AppContext! { willSet { precondition(context == nil) } }
|
|
||||||
|
|
||||||
var dataSource: UITableViewDiffableDataSource<SettingsSection, SettingsItem>!
|
|
||||||
var disposeBag = Set<AnyCancellable>()
|
var disposeBag = Set<AnyCancellable>()
|
||||||
|
|
||||||
|
let context: AppContext
|
||||||
|
|
||||||
|
// input
|
||||||
|
let setting: CurrentValueSubject<Setting, Never>
|
||||||
var updateDisposeBag = Set<AnyCancellable>()
|
var updateDisposeBag = Set<AnyCancellable>()
|
||||||
var createDisposeBag = Set<AnyCancellable>()
|
var createDisposeBag = Set<AnyCancellable>()
|
||||||
|
|
||||||
let viewDidLoad = PassthroughSubject<Void, Never>()
|
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:
|
/// create a subscription when:
|
||||||
/// - does not has one
|
/// - does not has one
|
||||||
/// - does not find subscription for selected trigger when change trigger
|
/// - does not find subscription for selected trigger when change trigger
|
||||||
|
@ -53,22 +37,6 @@ class SettingsViewModel: NSObject {
|
||||||
/// - change switch for specified alerts
|
/// - change switch for specified alerts
|
||||||
let updateSubscriptionSubject = PassthroughSubject<(triggerBy: String, values: [Bool?]), Never>()
|
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? = {
|
lazy var privacyURL: URL? = {
|
||||||
guard let box = AppContext.shared.authenticationService.activeMastodonAuthenticationBox.value else {
|
guard let box = AppContext.shared.authenticationService.activeMastodonAuthenticationBox.value else {
|
||||||
return nil
|
return nil
|
||||||
|
@ -77,315 +45,151 @@ class SettingsViewModel: NSObject {
|
||||||
return Mastodon.API.privacyURL(domain: box.domain)
|
return Mastodon.API.privacyURL(domain: box.domain)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
/// to store who trigger the notification.
|
init(context: AppContext, setting: Setting) {
|
||||||
var triggerBy: String?
|
|
||||||
|
|
||||||
struct Input {
|
|
||||||
}
|
|
||||||
|
|
||||||
struct Output {
|
|
||||||
}
|
|
||||||
|
|
||||||
init(context: AppContext) {
|
|
||||||
self.context = context
|
self.context = context
|
||||||
|
self.setting = CurrentValueSubject(setting)
|
||||||
|
|
||||||
super.init()
|
self.setting
|
||||||
}
|
.sink(receiveValue: { [weak self] setting in
|
||||||
|
guard let self = self else { return }
|
||||||
func transform(input: Input?) -> Output? {
|
self.processDataSource(setting)
|
||||||
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)
|
.store(in: &disposeBag)
|
||||||
|
|
||||||
updateSubscriptionSubject
|
|
||||||
.debounce(for: .milliseconds(300), scheduler: DispatchQueue.main)
|
|
||||||
.sink { _ in
|
|
||||||
} receiveValue: { [weak self] (arg) in
|
|
||||||
let (triggerBy, values) = arg
|
|
||||||
guard let self = self else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
guard let activeMastodonAuthenticationBox =
|
|
||||||
self.context.authenticationService.activeMastodonAuthenticationBox.value else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
guard values.count >= 4 else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
self.updateDisposeBag.removeAll()
|
|
||||||
typealias Query = Mastodon.API.Subscriptions.UpdateSubscriptionQuery
|
|
||||||
let domain = activeMastodonAuthenticationBox.domain
|
|
||||||
let query = Query(
|
|
||||||
favourite: values[0],
|
|
||||||
follow: values[1],
|
|
||||||
reblog: values[2],
|
|
||||||
mention: values[3],
|
|
||||||
poll: nil)
|
|
||||||
self.context.apiService.updateSubscription(
|
|
||||||
domain: domain,
|
|
||||||
mastodonAuthenticationBox: activeMastodonAuthenticationBox,
|
|
||||||
query: query,
|
|
||||||
triggerBy: triggerBy,
|
|
||||||
userID: activeMastodonAuthenticationBox.userID
|
|
||||||
)
|
|
||||||
.sink { (_) in
|
|
||||||
} receiveValue: { (_) in
|
|
||||||
}
|
|
||||||
.store(in: &self.updateDisposeBag)
|
|
||||||
}
|
|
||||||
.store(in: &disposeBag)
|
|
||||||
|
|
||||||
// build data for table view
|
|
||||||
buildDataSource()
|
|
||||||
|
|
||||||
// request subsription data for updating or initialization
|
|
||||||
requestSubscription()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Private methods
|
|
||||||
fileprivate func processDataSource(_ settings: Setting?) {
|
|
||||||
var snapshot = NSDiffableDataSourceSnapshot<SettingsSection, SettingsItem>()
|
|
||||||
|
|
||||||
// appearance
|
|
||||||
let appearnceMode = SettingsItem.AppearanceMode(rawValue: settings?.appearance ?? "") ?? .automatic
|
|
||||||
let appearanceItem = SettingsItem.apperance(item: appearnceMode)
|
|
||||||
let appearance = SettingsSection.apperance(title: L10n.Scene.Settings.Section.Appearance.title, selectedMode:appearanceItem)
|
|
||||||
snapshot.appendSections([appearance])
|
|
||||||
snapshot.appendItems([appearanceItem])
|
|
||||||
|
|
||||||
// notifications
|
|
||||||
var switches: [Bool?]?
|
|
||||||
if let alerts = settings?.subscription?.first(where: { (s) -> Bool in
|
|
||||||
return s.type == settings?.triggerBy
|
|
||||||
})?.alert {
|
|
||||||
var items = [Bool?]()
|
|
||||||
items.append(alerts.favourite?.boolValue)
|
|
||||||
items.append(alerts.follow?.boolValue)
|
|
||||||
items.append(alerts.reblog?.boolValue)
|
|
||||||
items.append(alerts.mention?.boolValue)
|
|
||||||
switches = items
|
|
||||||
} else if let triggerBy = settings?.triggerBy,
|
|
||||||
let values = self.notificationDefaultValue[triggerBy] {
|
|
||||||
switches = values
|
|
||||||
} else {
|
|
||||||
// fallback a default value
|
|
||||||
let anyone = L10n.Scene.Settings.Section.Notifications.Trigger.anyone
|
|
||||||
switches = self.notificationDefaultValue[anyone]
|
|
||||||
}
|
|
||||||
|
|
||||||
let notifications = [L10n.Scene.Settings.Section.Notifications.favorites,
|
|
||||||
L10n.Scene.Settings.Section.Notifications.follows,
|
|
||||||
L10n.Scene.Settings.Section.Notifications.boosts,
|
|
||||||
L10n.Scene.Settings.Section.Notifications.mentions,]
|
|
||||||
var notificationItems = [SettingsItem]()
|
|
||||||
for (i, noti) in notifications.enumerated() {
|
|
||||||
var value: Bool? = nil
|
|
||||||
if let switches = switches, i < switches.count {
|
|
||||||
value = switches[i]
|
|
||||||
}
|
|
||||||
|
|
||||||
let item = SettingsItem.notification(item: SettingsItem.NotificationSwitch(title: noti, isOn: value == true, enable: value != nil))
|
|
||||||
notificationItems.append(item)
|
|
||||||
}
|
|
||||||
let notificationSection = SettingsSection.notifications(title: L10n.Scene.Settings.Section.Notifications.title, items: notificationItems)
|
|
||||||
snapshot.appendSections([notificationSection])
|
|
||||||
snapshot.appendItems(notificationItems)
|
|
||||||
|
|
||||||
// boring zone
|
|
||||||
let boringLinks = [L10n.Scene.Settings.Section.Boringzone.terms,
|
|
||||||
L10n.Scene.Settings.Section.Boringzone.privacy]
|
|
||||||
var boringLinkItems = [SettingsItem]()
|
|
||||||
for l in boringLinks {
|
|
||||||
let item = SettingsItem.boringZone(item: SettingsItem.Link(title: l, color: .systemBlue))
|
|
||||||
boringLinkItems.append(item)
|
|
||||||
}
|
|
||||||
let boringSection = SettingsSection.boringZone(title: L10n.Scene.Settings.Section.Boringzone.title, items: boringLinkItems)
|
|
||||||
snapshot.appendSections([boringSection])
|
|
||||||
snapshot.appendItems(boringLinkItems)
|
|
||||||
|
|
||||||
// spicy zone
|
|
||||||
let spicyLinks = [L10n.Scene.Settings.Section.Spicyzone.clear,
|
|
||||||
L10n.Scene.Settings.Section.Spicyzone.signout]
|
|
||||||
var spicyLinkItems = [SettingsItem]()
|
|
||||||
for l in spicyLinks {
|
|
||||||
let item = SettingsItem.spicyZone(item: SettingsItem.Link(title: l, color: .systemRed))
|
|
||||||
spicyLinkItems.append(item)
|
|
||||||
}
|
|
||||||
let spicySection = SettingsSection.spicyZone(title: L10n.Scene.Settings.Section.Spicyzone.title, items: spicyLinkItems)
|
|
||||||
snapshot.appendSections([spicySection])
|
|
||||||
snapshot.appendItems(spicyLinkItems)
|
|
||||||
|
|
||||||
self.dataSource.apply(snapshot, animatingDifferences: false)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func buildDataSource() {
|
|
||||||
setting.sink { [weak self] (settings) in
|
|
||||||
guard let self = self else { return }
|
|
||||||
self.processDataSource(settings)
|
|
||||||
}
|
|
||||||
.store(in: &disposeBag)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func requestSubscription() {
|
|
||||||
setting.sink { [weak self] (settings) in
|
|
||||||
guard let self = self else { return }
|
|
||||||
guard settings != nil else { return }
|
|
||||||
guard self.triggerBy != settings?.triggerBy else { return }
|
|
||||||
self.triggerBy = settings?.triggerBy
|
|
||||||
|
|
||||||
var switches: [Bool?]?
|
|
||||||
var who: String?
|
|
||||||
if let alerts = settings?.subscription?.first(where: { (s) -> Bool in
|
|
||||||
return s.type == settings?.triggerBy
|
|
||||||
})?.alert {
|
|
||||||
var items = [Bool?]()
|
|
||||||
items.append(alerts.favourite?.boolValue)
|
|
||||||
items.append(alerts.follow?.boolValue)
|
|
||||||
items.append(alerts.reblog?.boolValue)
|
|
||||||
items.append(alerts.mention?.boolValue)
|
|
||||||
switches = items
|
|
||||||
who = settings?.triggerBy
|
|
||||||
} else if let triggerBy = settings?.triggerBy,
|
|
||||||
let values = self.notificationDefaultValue[triggerBy] {
|
|
||||||
switches = values
|
|
||||||
who = triggerBy
|
|
||||||
} else {
|
|
||||||
// fallback a default value
|
|
||||||
let anyone = L10n.Scene.Settings.Section.Notifications.Trigger.anyone
|
|
||||||
switches = self.notificationDefaultValue[anyone]
|
|
||||||
who = anyone
|
|
||||||
}
|
|
||||||
|
|
||||||
// should create a subscription whenever change trigger
|
|
||||||
if let values = switches, let triggerBy = who {
|
|
||||||
self.createSubscriptionSubject.send((triggerBy: triggerBy, values: values))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.store(in: &disposeBag)
|
|
||||||
|
|
||||||
guard let activeMastodonAuthenticationBox = self.context.authenticationService.activeMastodonAuthenticationBox.value else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
let domain = activeMastodonAuthenticationBox.domain
|
|
||||||
let userId = activeMastodonAuthenticationBox.userID
|
|
||||||
|
|
||||||
do {
|
|
||||||
try fetchResultsController.performFetch()
|
|
||||||
if nil == fetchResultsController.fetchedObjects?.first {
|
|
||||||
let anyone = L10n.Scene.Settings.Section.Notifications.Trigger.anyone
|
|
||||||
setting.value = self.context.apiService.createSettingIfNeed(domain: domain,
|
|
||||||
userId: userId,
|
|
||||||
triggerBy: anyone)
|
|
||||||
} else {
|
|
||||||
setting.value = fetchResultsController.fetchedObjects?.first
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
assertionFailure(error.localizedDescription)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
deinit {
|
deinit {
|
||||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s:", ((#file as NSString).lastPathComponent), #line, #function)
|
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s:", ((#file as NSString).lastPathComponent), #line, #function)
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - NSFetchedResultsControllerDelegate
|
extension SettingsViewModel {
|
||||||
extension SettingsViewModel: NSFetchedResultsControllerDelegate {
|
|
||||||
|
|
||||||
func controllerWillChangeContent(_ controller: NSFetchedResultsController<NSFetchRequestResult>) {
|
// MARK: - Private methods
|
||||||
os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
|
private func processDataSource(_ setting: Setting) {
|
||||||
}
|
guard let dataSource = self.dataSource else { return }
|
||||||
|
var snapshot = NSDiffableDataSourceSnapshot<SettingsSection, SettingsItem>()
|
||||||
|
|
||||||
func controllerDidChangeContent(_ controller: NSFetchedResultsController<NSFetchRequestResult>) {
|
// appearance
|
||||||
guard controller === fetchResultsController else {
|
let appearanceItems = [SettingsItem.apperance(settingObjectID: setting.objectID)]
|
||||||
return
|
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 {
|
extension SettingsViewModel {
|
||||||
case apperance(title: String, selectedMode: SettingsItem)
|
|
||||||
case notifications(title: String, items: [SettingsItem])
|
|
||||||
case boringZone(title: String, items: [SettingsItem])
|
|
||||||
case spicyZone(title: String, items: [SettingsItem])
|
|
||||||
|
|
||||||
var title: String {
|
static func configureSettingToggle(
|
||||||
switch self {
|
cell: SettingsToggleTableViewCell,
|
||||||
case .apperance(let title, _),
|
switchMode: SettingsItem.NotificationSwitchMode,
|
||||||
.notifications(let title, _),
|
subscription: NotificationSubscription
|
||||||
.boringZone(let title, _),
|
) {
|
||||||
.spicyZone(let title, _):
|
cell.textLabel?.text = switchMode.title
|
||||||
return 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)
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,9 +6,10 @@
|
||||||
//
|
//
|
||||||
|
|
||||||
import UIKit
|
import UIKit
|
||||||
|
import Combine
|
||||||
|
|
||||||
protocol SettingsAppearanceTableViewCellDelegate: class {
|
protocol SettingsAppearanceTableViewCellDelegate: class {
|
||||||
func settingsAppearanceCell(_ view: SettingsAppearanceTableViewCell, didSelect: SettingsItem.AppearanceMode)
|
func settingsAppearanceCell(_ cell: SettingsAppearanceTableViewCell, didSelectAppearanceMode appearanceMode: SettingsItem.AppearanceMode)
|
||||||
}
|
}
|
||||||
|
|
||||||
class AppearanceView: UIView {
|
class AppearanceView: UIView {
|
||||||
|
@ -85,6 +86,9 @@ class AppearanceView: UIView {
|
||||||
}
|
}
|
||||||
|
|
||||||
class SettingsAppearanceTableViewCell: UITableViewCell {
|
class SettingsAppearanceTableViewCell: UITableViewCell {
|
||||||
|
|
||||||
|
var disposeBag = Set<AnyCancellable>()
|
||||||
|
|
||||||
weak var delegate: SettingsAppearanceTableViewCellDelegate?
|
weak var delegate: SettingsAppearanceTableViewCellDelegate?
|
||||||
var appearance: SettingsItem.AppearanceMode = .automatic
|
var appearance: SettingsItem.AppearanceMode = .automatic
|
||||||
|
|
||||||
|
@ -123,6 +127,12 @@ class SettingsAppearanceTableViewCell: UITableViewCell {
|
||||||
tapGestureRecognizer.addTarget(self, action: #selector(appearanceDidTap(sender:)))
|
tapGestureRecognizer.addTarget(self, action: #selector(appearanceDidTap(sender:)))
|
||||||
return tapGestureRecognizer
|
return tapGestureRecognizer
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
override func prepareForReuse() {
|
||||||
|
super.prepareForReuse()
|
||||||
|
|
||||||
|
disposeBag.removeAll()
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Methods
|
// MARK: - Methods
|
||||||
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
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
|
appearance = data
|
||||||
self.delegate = delegate
|
|
||||||
|
|
||||||
automatic.selected = false
|
automatic.selected = false
|
||||||
light.selected = false
|
light.selected = false
|
||||||
|
@ -200,6 +209,6 @@ class SettingsAppearanceTableViewCell: UITableViewCell {
|
||||||
}
|
}
|
||||||
|
|
||||||
guard let delegate = self.delegate else { return }
|
guard let delegate = self.delegate else { return }
|
||||||
delegate.settingsAppearanceCell(self, didSelect: appearance)
|
delegate.settingsAppearanceCell(self, didSelectAppearanceMode: appearance)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -8,6 +8,7 @@
|
||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
class SettingsLinkTableViewCell: UITableViewCell {
|
class SettingsLinkTableViewCell: UITableViewCell {
|
||||||
|
|
||||||
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
||||||
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
||||||
|
|
||||||
|
@ -22,10 +23,13 @@ class SettingsLinkTableViewCell: UITableViewCell {
|
||||||
super.setHighlighted(highlighted, animated: animated)
|
super.setHighlighted(highlighted, animated: animated)
|
||||||
textLabel?.alpha = highlighted ? 0.6 : 1.0
|
textLabel?.alpha = highlighted ? 0.6 : 1.0
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Methods
|
}
|
||||||
func update(with data: SettingsItem.Link) {
|
|
||||||
textLabel?.text = data.title
|
// MARK: - Methods
|
||||||
textLabel?.textColor = data.color
|
extension SettingsLinkTableViewCell {
|
||||||
|
func update(with link: SettingsItem.Link) {
|
||||||
|
textLabel?.text = link.title
|
||||||
|
textLabel?.textColor = link.textColor
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,18 +6,21 @@
|
||||||
//
|
//
|
||||||
|
|
||||||
import UIKit
|
import UIKit
|
||||||
|
import Combine
|
||||||
|
|
||||||
protocol SettingsToggleCellDelegate: class {
|
protocol SettingsToggleCellDelegate: class {
|
||||||
func settingsToggleCell(_ cell: SettingsToggleTableViewCell, didChangeStatus: Bool)
|
func settingsToggleCell(_ cell: SettingsToggleTableViewCell, switchValueDidChange switch: UISwitch)
|
||||||
}
|
}
|
||||||
|
|
||||||
class SettingsToggleTableViewCell: UITableViewCell {
|
class SettingsToggleTableViewCell: UITableViewCell {
|
||||||
lazy var switchButton: UISwitch = {
|
|
||||||
|
var disposeBag = Set<AnyCancellable>()
|
||||||
|
|
||||||
|
private(set) lazy var switchButton: UISwitch = {
|
||||||
let view = UISwitch(frame:.zero)
|
let view = UISwitch(frame:.zero)
|
||||||
return view
|
return view
|
||||||
}()
|
}()
|
||||||
|
|
||||||
var data: SettingsItem.NotificationSwitch?
|
|
||||||
weak var delegate: SettingsToggleCellDelegate?
|
weak var delegate: SettingsToggleCellDelegate?
|
||||||
|
|
||||||
// MARK: - Methods
|
// MARK: - Methods
|
||||||
|
@ -27,21 +30,8 @@ class SettingsToggleTableViewCell: UITableViewCell {
|
||||||
}
|
}
|
||||||
|
|
||||||
required init?(coder: NSCoder) {
|
required init?(coder: NSCoder) {
|
||||||
fatalError("init(coder:) has not been implemented")
|
super.init(coder: coder)
|
||||||
}
|
setupUI()
|
||||||
|
|
||||||
func update(with data: SettingsItem.NotificationSwitch, delegate: SettingsToggleCellDelegate?) {
|
|
||||||
self.delegate = delegate
|
|
||||||
self.data = data
|
|
||||||
textLabel?.text = data.title
|
|
||||||
switchButton.isOn = data.isOn
|
|
||||||
setup(enable: data.enable)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: Actions
|
|
||||||
@objc func valueDidChange(sender: UISwitch) {
|
|
||||||
guard let delegate = delegate else { return }
|
|
||||||
delegate.settingsToggleCell(self, didChangeStatus: sender.isOn)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: Private methods
|
// MARK: Private methods
|
||||||
|
@ -49,15 +39,27 @@ class SettingsToggleTableViewCell: UITableViewCell {
|
||||||
selectionStyle = .none
|
selectionStyle = .none
|
||||||
accessoryView = switchButton
|
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -47,4 +47,43 @@ final class RemoteThreadViewModel: ThreadViewModel {
|
||||||
}
|
}
|
||||||
.store(in: &disposeBag)
|
.store(in: &disposeBag)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FIXME: multiple account supports
|
||||||
|
init(context: AppContext, notificationID: Mastodon.Entity.Notification.ID) {
|
||||||
|
super.init(context: context, optionalStatus: nil)
|
||||||
|
|
||||||
|
guard let activeMastodonAuthenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let domain = activeMastodonAuthenticationBox.domain
|
||||||
|
context.apiService.notification(
|
||||||
|
notificationID: notificationID,
|
||||||
|
mastodonAuthenticationBox: activeMastodonAuthenticationBox
|
||||||
|
)
|
||||||
|
.retry(3)
|
||||||
|
.sink { completion in
|
||||||
|
switch completion {
|
||||||
|
case .failure(let error):
|
||||||
|
// TODO: handle error
|
||||||
|
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: remote notification %s fetch failed: %s", ((#file as NSString).lastPathComponent), #line, #function, notificationID, error.localizedDescription)
|
||||||
|
case .finished:
|
||||||
|
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: remote notification %s fetched", ((#file as NSString).lastPathComponent), #line, #function, notificationID)
|
||||||
|
}
|
||||||
|
} receiveValue: { [weak self] response in
|
||||||
|
guard let self = self else { return }
|
||||||
|
guard let statusID = response.value.status?.id else { return }
|
||||||
|
|
||||||
|
let managedObjectContext = context.managedObjectContext
|
||||||
|
let request = Status.sortedFetchRequest
|
||||||
|
request.fetchLimit = 1
|
||||||
|
request.predicate = Status.predicate(domain: domain, id: statusID)
|
||||||
|
guard let status = managedObjectContext.safeFetch(request).first else {
|
||||||
|
assertionFailure()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
self.rootItem.value = .root(statusObjectID: status.objectID, attribute: Item.StatusAttribute())
|
||||||
|
}
|
||||||
|
.store(in: &disposeBag)
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -64,4 +64,48 @@ extension APIService {
|
||||||
}
|
}
|
||||||
.eraseToAnyPublisher()
|
.eraseToAnyPublisher()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func notification(
|
||||||
|
notificationID: Mastodon.Entity.Notification.ID,
|
||||||
|
mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox
|
||||||
|
) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Notification>, Error> {
|
||||||
|
let domain = mastodonAuthenticationBox.domain
|
||||||
|
let authorization = mastodonAuthenticationBox.userAuthorization
|
||||||
|
|
||||||
|
return Mastodon.API.Notifications.getNotification(
|
||||||
|
session: session,
|
||||||
|
domain: domain,
|
||||||
|
notificationID: notificationID,
|
||||||
|
authorization: authorization
|
||||||
|
)
|
||||||
|
.flatMap { response -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Notification>, Error> in
|
||||||
|
guard let status = response.value.status else {
|
||||||
|
return Just(response)
|
||||||
|
.setFailureType(to: Error.self)
|
||||||
|
.eraseToAnyPublisher()
|
||||||
|
}
|
||||||
|
|
||||||
|
return APIService.Persist.persistStatus(
|
||||||
|
managedObjectContext: self.backgroundManagedObjectContext,
|
||||||
|
domain: domain,
|
||||||
|
query: nil,
|
||||||
|
response: response.map { _ in [status] },
|
||||||
|
persistType: .lookUp,
|
||||||
|
requestMastodonUserID: nil,
|
||||||
|
log: OSLog.api
|
||||||
|
)
|
||||||
|
.setFailureType(to: Error.self)
|
||||||
|
.tryMap { result -> Mastodon.Response.Content<Mastodon.Entity.Notification> in
|
||||||
|
switch result {
|
||||||
|
case .success:
|
||||||
|
return response
|
||||||
|
case .failure(let error):
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.eraseToAnyPublisher()
|
||||||
|
}
|
||||||
|
.eraseToAnyPublisher()
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,6 +5,7 @@
|
||||||
// Created by ihugo on 2021/4/9.
|
// Created by ihugo on 2021/4/9.
|
||||||
//
|
//
|
||||||
|
|
||||||
|
import os.log
|
||||||
import Combine
|
import Combine
|
||||||
import CoreData
|
import CoreData
|
||||||
import CoreDataStack
|
import CoreDataStack
|
||||||
|
@ -13,63 +14,14 @@ import MastodonSDK
|
||||||
|
|
||||||
extension APIService {
|
extension APIService {
|
||||||
|
|
||||||
func subscription(
|
func createSubscription(
|
||||||
domain: String,
|
subscriptionObjectID: NSManagedObjectID,
|
||||||
userID: String,
|
query: Mastodon.API.Subscriptions.CreateSubscriptionQuery,
|
||||||
mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox
|
mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox
|
||||||
) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Subscription>, Error> {
|
) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Subscription>, Error> {
|
||||||
let authorization = mastodonAuthenticationBox.userAuthorization
|
let authorization = mastodonAuthenticationBox.userAuthorization
|
||||||
|
let domain = mastodonAuthenticationBox.domain
|
||||||
|
|
||||||
let findSettings: Setting? = {
|
|
||||||
let request = Setting.sortedFetchRequest
|
|
||||||
request.predicate = Setting.predicate(domain: domain, userID: userID)
|
|
||||||
request.fetchLimit = 1
|
|
||||||
request.returnsObjectsAsFaults = false
|
|
||||||
do {
|
|
||||||
return try self.backgroundManagedObjectContext.fetch(request).first
|
|
||||||
} catch {
|
|
||||||
assertionFailure(error.localizedDescription)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
let triggerBy = findSettings?.triggerBy ?? "anyone"
|
|
||||||
let setting = self.createSettingIfNeed(
|
|
||||||
domain: domain,
|
|
||||||
userId: userID,
|
|
||||||
triggerBy: triggerBy
|
|
||||||
)
|
|
||||||
return Mastodon.API.Subscriptions.subscription(
|
|
||||||
session: session,
|
|
||||||
domain: domain,
|
|
||||||
authorization: authorization
|
|
||||||
)
|
|
||||||
.flatMap { response -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Subscription>, Error> in
|
|
||||||
return self.backgroundManagedObjectContext.performChanges {
|
|
||||||
_ = APIService.CoreData.createOrMergeSubscription(
|
|
||||||
into: self.backgroundManagedObjectContext,
|
|
||||||
entity: response.value,
|
|
||||||
domain: domain,
|
|
||||||
triggerBy: triggerBy,
|
|
||||||
setting: setting)
|
|
||||||
}
|
|
||||||
.setFailureType(to: Error.self)
|
|
||||||
.map { _ in return response }
|
|
||||||
.eraseToAnyPublisher()
|
|
||||||
}.eraseToAnyPublisher()
|
|
||||||
}
|
|
||||||
|
|
||||||
func changeSubscription(
|
|
||||||
domain: String,
|
|
||||||
mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox,
|
|
||||||
query: Mastodon.API.Subscriptions.CreateSubscriptionQuery,
|
|
||||||
triggerBy: String,
|
|
||||||
userID: String
|
|
||||||
) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Subscription>, Error> {
|
|
||||||
let authorization = mastodonAuthenticationBox.userAuthorization
|
|
||||||
|
|
||||||
let setting = self.createSettingIfNeed(domain: domain,
|
|
||||||
userId: userID,
|
|
||||||
triggerBy: triggerBy)
|
|
||||||
return Mastodon.API.Subscriptions.createSubscription(
|
return Mastodon.API.Subscriptions.createSubscription(
|
||||||
session: session,
|
session: session,
|
||||||
domain: domain,
|
domain: domain,
|
||||||
|
@ -77,87 +29,42 @@ extension APIService {
|
||||||
query: query
|
query: query
|
||||||
)
|
)
|
||||||
.flatMap { response -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Subscription>, Error> in
|
.flatMap { response -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Subscription>, Error> in
|
||||||
return self.backgroundManagedObjectContext.performChanges {
|
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: create subscription successful %s", ((#file as NSString).lastPathComponent), #line, #function, response.value.endpoint)
|
||||||
_ = APIService.CoreData.createOrMergeSubscription(
|
|
||||||
into: self.backgroundManagedObjectContext,
|
let managedObjectContext = self.backgroundManagedObjectContext
|
||||||
entity: response.value,
|
return managedObjectContext.performChanges {
|
||||||
domain: domain,
|
guard let subscription = managedObjectContext.object(with: subscriptionObjectID) as? NotificationSubscription else {
|
||||||
triggerBy: triggerBy,
|
assertionFailure()
|
||||||
setting: setting
|
return
|
||||||
)
|
}
|
||||||
|
subscription.endpoint = response.value.endpoint
|
||||||
|
subscription.serverKey = response.value.serverKey
|
||||||
|
subscription.userToken = authorization.accessToken
|
||||||
|
subscription.didUpdate(at: response.networkDate)
|
||||||
}
|
}
|
||||||
.setFailureType(to: Error.self)
|
.setFailureType(to: Error.self)
|
||||||
.map { _ in return response }
|
.map { _ in return response }
|
||||||
.eraseToAnyPublisher()
|
.eraseToAnyPublisher()
|
||||||
}.eraseToAnyPublisher()
|
}
|
||||||
|
.eraseToAnyPublisher()
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateSubscription(
|
func cancelSubscription(
|
||||||
domain: String,
|
mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox
|
||||||
mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox,
|
) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.EmptySubscription>, Error> {
|
||||||
query: Mastodon.API.Subscriptions.UpdateSubscriptionQuery,
|
|
||||||
triggerBy: String,
|
|
||||||
userID: String
|
|
||||||
) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Subscription>, Error> {
|
|
||||||
let authorization = mastodonAuthenticationBox.userAuthorization
|
let authorization = mastodonAuthenticationBox.userAuthorization
|
||||||
|
let domain = mastodonAuthenticationBox.domain
|
||||||
let setting = self.createSettingIfNeed(domain: domain,
|
|
||||||
userId: userID,
|
return Mastodon.API.Subscriptions.removeSubscription(
|
||||||
triggerBy: triggerBy)
|
|
||||||
|
|
||||||
return Mastodon.API.Subscriptions.updateSubscription(
|
|
||||||
session: session,
|
session: session,
|
||||||
domain: domain,
|
domain: domain,
|
||||||
authorization: authorization,
|
authorization: authorization
|
||||||
query: query
|
|
||||||
)
|
)
|
||||||
.flatMap { response -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Subscription>, Error> in
|
.handleEvents(receiveOutput: { _ in
|
||||||
return self.backgroundManagedObjectContext.performChanges {
|
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: cancel subscription successful", ((#file as NSString).lastPathComponent), #line, #function)
|
||||||
_ = APIService.CoreData.createOrMergeSubscription(
|
})
|
||||||
into: self.backgroundManagedObjectContext,
|
.eraseToAnyPublisher()
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,76 @@
|
||||||
|
//
|
||||||
|
// 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 {
|
||||||
|
setupSettingSubscriptions(managedObjectContext: managedObjectContext, setting: oldSetting)
|
||||||
|
return (oldSetting, false)
|
||||||
|
} else {
|
||||||
|
let setting = Setting.insert(
|
||||||
|
into: managedObjectContext,
|
||||||
|
property: property
|
||||||
|
)
|
||||||
|
setupSettingSubscriptions(managedObjectContext: managedObjectContext, setting: setting)
|
||||||
|
return (setting, true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
extension APIService.CoreData {
|
||||||
|
|
||||||
|
static func setupSettingSubscriptions(
|
||||||
|
managedObjectContext: NSManagedObjectContext,
|
||||||
|
setting: Setting
|
||||||
|
) {
|
||||||
|
guard (setting.subscriptions ?? Set()).isEmpty else { return }
|
||||||
|
|
||||||
|
let now = Date()
|
||||||
|
let policies: [Mastodon.API.Subscriptions.Policy] = [
|
||||||
|
.all,
|
||||||
|
.followed,
|
||||||
|
.follower,
|
||||||
|
.none
|
||||||
|
]
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// trigger setting update
|
||||||
|
setting.didUpdate(at: now)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -13,96 +13,50 @@ import MastodonSDK
|
||||||
|
|
||||||
extension APIService.CoreData {
|
extension APIService.CoreData {
|
||||||
|
|
||||||
static func createOrMergeSetting(
|
static func createOrFetchSubscription(
|
||||||
into managedObjectContext: NSManagedObjectContext,
|
into managedObjectContext: NSManagedObjectContext,
|
||||||
domain: String,
|
setting: Setting,
|
||||||
userID: String,
|
policy: Mastodon.API.Subscriptions.Policy
|
||||||
property: Setting.Property
|
) -> (subscription: Subscription, isCreated: Bool) {
|
||||||
) -> (Subscription: Setting, isCreated: Bool) {
|
|
||||||
let oldSetting: Setting? = {
|
|
||||||
let request = Setting.sortedFetchRequest
|
|
||||||
request.predicate = Setting.predicate(domain: property.domain, userID: userID)
|
|
||||||
request.fetchLimit = 1
|
|
||||||
request.returnsObjectsAsFaults = false
|
|
||||||
do {
|
|
||||||
return try managedObjectContext.fetch(request).first
|
|
||||||
} catch {
|
|
||||||
assertionFailure(error.localizedDescription)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
if let oldSetting = oldSetting {
|
|
||||||
return (oldSetting, false)
|
|
||||||
} else {
|
|
||||||
let setting = Setting.insert(
|
|
||||||
into: managedObjectContext,
|
|
||||||
property: property)
|
|
||||||
return (setting, true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static func createOrMergeSubscription(
|
|
||||||
into managedObjectContext: NSManagedObjectContext,
|
|
||||||
entity: Mastodon.Entity.Subscription,
|
|
||||||
domain: String,
|
|
||||||
triggerBy: String,
|
|
||||||
setting: Setting
|
|
||||||
) -> (Subscription: Subscription, isCreated: Bool) {
|
|
||||||
let oldSubscription: Subscription? = {
|
let oldSubscription: Subscription? = {
|
||||||
let request = Subscription.sortedFetchRequest
|
let request = Subscription.sortedFetchRequest
|
||||||
request.predicate = Subscription.predicate(type: triggerBy)
|
request.predicate = Subscription.predicate(policyRaw: policy.rawValue)
|
||||||
request.fetchLimit = 1
|
request.fetchLimit = 1
|
||||||
request.returnsObjectsAsFaults = false
|
request.returnsObjectsAsFaults = false
|
||||||
do {
|
return managedObjectContext.safeFetch(request).first
|
||||||
return try managedObjectContext.fetch(request).first
|
|
||||||
} catch {
|
|
||||||
assertionFailure(error.localizedDescription)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}()
|
}()
|
||||||
|
|
||||||
let property = Subscription.Property(
|
|
||||||
endpoint: entity.endpoint,
|
|
||||||
id: entity.id,
|
|
||||||
serverKey: entity.serverKey,
|
|
||||||
type: triggerBy
|
|
||||||
)
|
|
||||||
let alertEntity = entity.alerts
|
|
||||||
let alert = SubscriptionAlerts.Property(
|
|
||||||
favourite: alertEntity.favouriteNumber,
|
|
||||||
follow: alertEntity.followNumber,
|
|
||||||
mention: alertEntity.mentionNumber,
|
|
||||||
poll: alertEntity.pollNumber,
|
|
||||||
reblog: alertEntity.reblogNumber
|
|
||||||
)
|
|
||||||
if let oldSubscription = oldSubscription {
|
if let oldSubscription = oldSubscription {
|
||||||
oldSubscription.updateIfNeed(property: property)
|
oldSubscription.setting = setting
|
||||||
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)
|
return (oldSubscription, false)
|
||||||
} else {
|
} else {
|
||||||
|
let subscriptionProperty = Subscription.Property(policyRaw: policy.rawValue)
|
||||||
let subscription = Subscription.insert(
|
let subscription = Subscription.insert(
|
||||||
into: managedObjectContext,
|
into: managedObjectContext,
|
||||||
property: property
|
property: subscriptionProperty,
|
||||||
|
setting: setting
|
||||||
)
|
)
|
||||||
|
let alertProperty = SubscriptionAlerts.Property(policy: policy)
|
||||||
subscription.alert = SubscriptionAlerts.insert(
|
subscription.alert = SubscriptionAlerts.insert(
|
||||||
into: managedObjectContext,
|
into: managedObjectContext,
|
||||||
property: alert)
|
property: alertProperty,
|
||||||
setting.mutableSetValue(forKey: #keyPath(Setting.subscription)).add(subscription)
|
subscription: subscription
|
||||||
|
)
|
||||||
|
|
||||||
return (subscription, true)
|
return (subscription, true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
extension APIService.CoreData {
|
||||||
|
|
||||||
|
static func merge(
|
||||||
|
subscription: Subscription,
|
||||||
|
property: Subscription.Property,
|
||||||
|
networkDate: Date
|
||||||
|
) {
|
||||||
|
// TODO:
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -15,6 +15,7 @@ import MastodonSDK
|
||||||
final class AuthenticationService: NSObject {
|
final class AuthenticationService: NSObject {
|
||||||
|
|
||||||
var disposeBag = Set<AnyCancellable>()
|
var disposeBag = Set<AnyCancellable>()
|
||||||
|
|
||||||
// input
|
// input
|
||||||
weak var apiService: APIService?
|
weak var apiService: APIService?
|
||||||
let managedObjectContext: NSManagedObjectContext // read-only
|
let managedObjectContext: NSManagedObjectContext // read-only
|
||||||
|
@ -23,6 +24,7 @@ final class AuthenticationService: NSObject {
|
||||||
|
|
||||||
// output
|
// output
|
||||||
let mastodonAuthentications = CurrentValueSubject<[MastodonAuthentication], Never>([])
|
let mastodonAuthentications = CurrentValueSubject<[MastodonAuthentication], Never>([])
|
||||||
|
let mastodonAuthenticationBoxes = CurrentValueSubject<[AuthenticationService.MastodonAuthenticationBox], Never>([])
|
||||||
let activeMastodonAuthentication = CurrentValueSubject<MastodonAuthentication?, Never>(nil)
|
let activeMastodonAuthentication = CurrentValueSubject<MastodonAuthentication?, Never>(nil)
|
||||||
let activeMastodonAuthenticationBox = CurrentValueSubject<AuthenticationService.MastodonAuthenticationBox?, Never>(nil)
|
let activeMastodonAuthenticationBox = CurrentValueSubject<AuthenticationService.MastodonAuthenticationBox?, Never>(nil)
|
||||||
|
|
||||||
|
@ -58,16 +60,24 @@ final class AuthenticationService: NSObject {
|
||||||
.assign(to: \.value, on: activeMastodonAuthentication)
|
.assign(to: \.value, on: activeMastodonAuthentication)
|
||||||
.store(in: &disposeBag)
|
.store(in: &disposeBag)
|
||||||
|
|
||||||
activeMastodonAuthentication
|
mastodonAuthentications
|
||||||
.map { authentication -> AuthenticationService.MastodonAuthenticationBox? in
|
.map { authentications -> [AuthenticationService.MastodonAuthenticationBox] in
|
||||||
guard let authentication = authentication else { return nil }
|
return authentications
|
||||||
return AuthenticationService.MastodonAuthenticationBox(
|
.sorted(by: { $0.activedAt > $1.activedAt })
|
||||||
domain: authentication.domain,
|
.compactMap { authentication -> AuthenticationService.MastodonAuthenticationBox? in
|
||||||
userID: authentication.userID,
|
return AuthenticationService.MastodonAuthenticationBox(
|
||||||
appAuthorization: Mastodon.API.OAuth.Authorization(accessToken: authentication.appAccessToken),
|
domain: authentication.domain,
|
||||||
userAuthorization: Mastodon.API.OAuth.Authorization(accessToken: authentication.userAccessToken)
|
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)
|
.assign(to: \.value, on: activeMastodonAuthenticationBox)
|
||||||
.store(in: &disposeBag)
|
.store(in: &disposeBag)
|
||||||
|
|
||||||
|
@ -114,16 +124,37 @@ extension AuthenticationService {
|
||||||
func signOutMastodonUser(domain: String, userID: MastodonUser.ID) -> AnyPublisher<Result<Bool, Error>, Never> {
|
func signOutMastodonUser(domain: String, userID: MastodonUser.ID) -> AnyPublisher<Result<Bool, Error>, Never> {
|
||||||
var isSignOut = false
|
var isSignOut = false
|
||||||
|
|
||||||
return backgroundManagedObjectContext.performChanges {
|
var _mastodonAutenticationBox: MastodonAuthenticationBox?
|
||||||
|
let managedObjectContext = backgroundManagedObjectContext
|
||||||
|
return managedObjectContext.performChanges {
|
||||||
let request = MastodonAuthentication.sortedFetchRequest
|
let request = MastodonAuthentication.sortedFetchRequest
|
||||||
request.predicate = MastodonAuthentication.predicate(domain: domain, userID: userID)
|
request.predicate = MastodonAuthentication.predicate(domain: domain, userID: userID)
|
||||||
request.fetchLimit = 1
|
request.fetchLimit = 1
|
||||||
guard let mastodonAutentication = try? self.backgroundManagedObjectContext.fetch(request).first else {
|
guard let mastodonAutentication = try? managedObjectContext.fetch(request).first else {
|
||||||
return
|
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
|
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
|
.map { result in
|
||||||
return result.map { isSignOut }
|
return result.map { isSignOut }
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,204 @@
|
||||||
|
//
|
||||||
|
// NotificationService.swift
|
||||||
|
// Mastodon
|
||||||
|
//
|
||||||
|
// Created by MainasuK Cirno on 2021-4-22.
|
||||||
|
//
|
||||||
|
|
||||||
|
import os.log
|
||||||
|
import UIKit
|
||||||
|
import Combine
|
||||||
|
import CoreData
|
||||||
|
import CoreDataStack
|
||||||
|
import MastodonSDK
|
||||||
|
import AppShared
|
||||||
|
|
||||||
|
final class NotificationService {
|
||||||
|
|
||||||
|
var disposeBag = Set<AnyCancellable>()
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
// output
|
||||||
|
/// [Token: UserID]
|
||||||
|
let notificationSubscriptionDict: [String: NotificationViewModel] = [:]
|
||||||
|
let hasUnreadPushNotification = CurrentValueSubject<Bool, Never>(false)
|
||||||
|
let requestRevealNotificationPublisher = PassthroughSubject<Mastodon.Entity.Notification.ID, Never>()
|
||||||
|
|
||||||
|
init(
|
||||||
|
apiService: APIService,
|
||||||
|
authenticationService: AuthenticationService
|
||||||
|
) {
|
||||||
|
self.apiService = apiService
|
||||||
|
self.authenticationService = authenticationService
|
||||||
|
|
||||||
|
authenticationService.mastodonAuthentications
|
||||||
|
.sink(receiveValue: { [weak self] mastodonAuthentications in
|
||||||
|
guard let self = self else { return }
|
||||||
|
|
||||||
|
// request permission when sign-in
|
||||||
|
guard !mastodonAuthentications.isEmpty else { return }
|
||||||
|
self.requestNotificationPermission()
|
||||||
|
})
|
||||||
|
.store(in: &disposeBag)
|
||||||
|
|
||||||
|
deviceToken
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] deviceToken in
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
extension NotificationService {
|
||||||
|
private func requestNotificationPermission() {
|
||||||
|
let center = UNUserNotificationCenter.current()
|
||||||
|
center.requestAuthorization(options: [.alert, .sound, .badge]) { [weak self] granted, error in
|
||||||
|
guard let self = self else { return }
|
||||||
|
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: request notification permission: %s", ((#file as NSString).lastPathComponent), #line, #function, granted ? "granted" : "fail")
|
||||||
|
|
||||||
|
self.isNotificationPermissionGranted.value = granted
|
||||||
|
|
||||||
|
if let _ = error {
|
||||||
|
// Handle the error here.
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enable or disable features based on the authorization.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension NotificationService {
|
||||||
|
|
||||||
|
func dequeueNotificationViewModel(
|
||||||
|
mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox
|
||||||
|
) -> NotificationViewModel? {
|
||||||
|
var _notificationSubscription: NotificationViewModel?
|
||||||
|
workingQueue.sync {
|
||||||
|
let domain = mastodonAuthenticationBox.domain
|
||||||
|
let userID = mastodonAuthenticationBox.userID
|
||||||
|
let key = [domain, userID].joined(separator: "@")
|
||||||
|
|
||||||
|
if let notificationSubscription = notificationSubscriptionDict[key] {
|
||||||
|
_notificationSubscription = notificationSubscription
|
||||||
|
} else {
|
||||||
|
let notificationSubscription = NotificationViewModel(domain: domain, userID: userID)
|
||||||
|
_notificationSubscription = notificationSubscription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return _notificationSubscription
|
||||||
|
}
|
||||||
|
|
||||||
|
func handle(mastodonPushNotification: MastodonPushNotification) {
|
||||||
|
hasUnreadPushNotification.value = true
|
||||||
|
|
||||||
|
// Subscription maybe failed to cancel when sign-out
|
||||||
|
// Try cancel again if receive that kind push notification
|
||||||
|
guard let managedObjectContext = authenticationService?.managedObjectContext else { return }
|
||||||
|
guard let apiService = apiService else { return }
|
||||||
|
|
||||||
|
managedObjectContext.perform {
|
||||||
|
let subscriptionRequest = NotificationSubscription.sortedFetchRequest
|
||||||
|
subscriptionRequest.predicate = NotificationSubscription.predicate(userToken: mastodonPushNotification.accessToken)
|
||||||
|
let subscriptions = managedObjectContext.safeFetch(subscriptionRequest)
|
||||||
|
|
||||||
|
// note: assert setting remove after cancel subscription
|
||||||
|
guard let subscription = subscriptions.first else { return }
|
||||||
|
guard let setting = subscription.setting else { return }
|
||||||
|
let domain = setting.domain
|
||||||
|
let userID = setting.userID
|
||||||
|
|
||||||
|
let authenticationRequest = MastodonAuthentication.sortedFetchRequest
|
||||||
|
authenticationRequest.predicate = MastodonAuthentication.predicate(domain: domain, userID: userID)
|
||||||
|
let authentication = managedObjectContext.safeFetch(authenticationRequest).first
|
||||||
|
|
||||||
|
guard authentication == nil else {
|
||||||
|
// do nothing if still sign-in
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// cancel subscription if sign-out
|
||||||
|
let accessToken = mastodonPushNotification.accessToken
|
||||||
|
let mastodonAuthenticationBox = AuthenticationService.MastodonAuthenticationBox(
|
||||||
|
domain: domain,
|
||||||
|
userID: userID,
|
||||||
|
appAuthorization: .init(accessToken: accessToken),
|
||||||
|
userAuthorization: .init(accessToken: accessToken)
|
||||||
|
)
|
||||||
|
apiService
|
||||||
|
.cancelSubscription(mastodonAuthenticationBox: mastodonAuthenticationBox)
|
||||||
|
.sink { completion in
|
||||||
|
switch completion {
|
||||||
|
case .failure(let error):
|
||||||
|
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Push Notification] failed to cancel sign-out user subscription: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription)
|
||||||
|
case .finished:
|
||||||
|
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Push Notification] cancel sign-out user subscription", ((#file as NSString).lastPathComponent), #line, #function)
|
||||||
|
}
|
||||||
|
} receiveValue: { _ in
|
||||||
|
// do nothing
|
||||||
|
}
|
||||||
|
.store(in: &self.disposeBag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - NotificationViewModel
|
||||||
|
|
||||||
|
extension NotificationService {
|
||||||
|
final class NotificationViewModel {
|
||||||
|
|
||||||
|
var disposeBag = Set<AnyCancellable>()
|
||||||
|
|
||||||
|
// input
|
||||||
|
let domain: String
|
||||||
|
let userID: Mastodon.Entity.Account.ID
|
||||||
|
|
||||||
|
// output
|
||||||
|
|
||||||
|
init(domain: String, userID: Mastodon.Entity.Account.ID) {
|
||||||
|
self.domain = domain
|
||||||
|
self.userID = userID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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.notificationPublicKey.x963Representation
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -0,0 +1,173 @@
|
||||||
|
//
|
||||||
|
// SettingService.swift
|
||||||
|
// Mastodon
|
||||||
|
//
|
||||||
|
// Created by MainasuK Cirno on 2021-4-25.
|
||||||
|
//
|
||||||
|
|
||||||
|
import os.log
|
||||||
|
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.CombineLatest3(
|
||||||
|
notificationService.deviceToken,
|
||||||
|
currentSetting.eraseToAnyPublisher(),
|
||||||
|
authenticationService.activeMastodonAuthenticationBox
|
||||||
|
)
|
||||||
|
.compactMap { [weak self] deviceToken, setting, activeMastodonAuthenticationBox -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Subscription>, Error>? in
|
||||||
|
guard let self = self else { return nil }
|
||||||
|
guard let deviceToken = deviceToken else { return nil }
|
||||||
|
guard let setting = setting else { return nil }
|
||||||
|
guard let authenticationBox = activeMastodonAuthenticationBox 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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.debounce(for: .seconds(3), scheduler: DispatchQueue.main) // limit subscribe request emit time interval
|
||||||
|
.sink(receiveValue: { [weak self] publisher in
|
||||||
|
guard let self = self else { return }
|
||||||
|
publisher
|
||||||
|
.sink { completion in
|
||||||
|
switch completion {
|
||||||
|
case .failure(let error):
|
||||||
|
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Push Notification] subscribe failure: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription)
|
||||||
|
case .finished:
|
||||||
|
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Push Notification] subscribe success", ((#file as NSString).lastPathComponent), #line, #function)
|
||||||
|
}
|
||||||
|
} receiveValue: { _ in
|
||||||
|
// do nothing
|
||||||
|
}
|
||||||
|
.store(in: &self.disposeBag)
|
||||||
|
})
|
||||||
|
.store(in: &disposeBag)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -28,6 +28,8 @@ class AppContext: ObservableObject {
|
||||||
let videoPlaybackService = VideoPlaybackService()
|
let videoPlaybackService = VideoPlaybackService()
|
||||||
let statusPrefetchingService: StatusPrefetchingService
|
let statusPrefetchingService: StatusPrefetchingService
|
||||||
let statusPublishService = StatusPublishService()
|
let statusPublishService = StatusPublishService()
|
||||||
|
let notificationService: NotificationService
|
||||||
|
let settingService: SettingService
|
||||||
|
|
||||||
let documentStore: DocumentStore
|
let documentStore: DocumentStore
|
||||||
private var documentStoreSubscription: AnyCancellable!
|
private var documentStoreSubscription: AnyCancellable!
|
||||||
|
@ -45,11 +47,12 @@ class AppContext: ObservableObject {
|
||||||
let _apiService = APIService(backgroundManagedObjectContext: _backgroundManagedObjectContext)
|
let _apiService = APIService(backgroundManagedObjectContext: _backgroundManagedObjectContext)
|
||||||
apiService = _apiService
|
apiService = _apiService
|
||||||
|
|
||||||
authenticationService = AuthenticationService(
|
let _authenticationService = AuthenticationService(
|
||||||
managedObjectContext: _managedObjectContext,
|
managedObjectContext: _managedObjectContext,
|
||||||
backgroundManagedObjectContext: _backgroundManagedObjectContext,
|
backgroundManagedObjectContext: _backgroundManagedObjectContext,
|
||||||
apiService: _apiService
|
apiService: _apiService
|
||||||
)
|
)
|
||||||
|
authenticationService = _authenticationService
|
||||||
|
|
||||||
emojiService = EmojiService(
|
emojiService = EmojiService(
|
||||||
apiService: apiService
|
apiService: apiService
|
||||||
|
@ -57,6 +60,17 @@ class AppContext: ObservableObject {
|
||||||
statusPrefetchingService = StatusPrefetchingService(
|
statusPrefetchingService = StatusPrefetchingService(
|
||||||
apiService: _apiService
|
apiService: _apiService
|
||||||
)
|
)
|
||||||
|
let _notificationService = NotificationService(
|
||||||
|
apiService: _apiService,
|
||||||
|
authenticationService: _authenticationService
|
||||||
|
)
|
||||||
|
notificationService = _notificationService
|
||||||
|
|
||||||
|
settingService = SettingService(
|
||||||
|
apiService: _apiService,
|
||||||
|
authenticationService: _authenticationService,
|
||||||
|
notificationService: _notificationService
|
||||||
|
)
|
||||||
|
|
||||||
documentStore = DocumentStore()
|
documentStore = DocumentStore()
|
||||||
documentStoreSubscription = documentStore.objectWillChange
|
documentStoreSubscription = documentStore.objectWillChange
|
||||||
|
|
|
@ -5,7 +5,10 @@
|
||||||
// Created by MainasuK Cirno on 2021/1/22.
|
// Created by MainasuK Cirno on 2021/1/22.
|
||||||
//
|
//
|
||||||
|
|
||||||
|
import os.log
|
||||||
import UIKit
|
import UIKit
|
||||||
|
import UserNotifications
|
||||||
|
import AppShared
|
||||||
|
|
||||||
@main
|
@main
|
||||||
class AppDelegate: UIResponder, UIApplicationDelegate {
|
class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||||
|
@ -14,10 +17,15 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||||
|
|
||||||
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
|
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
|
||||||
|
|
||||||
|
AppSecret.default.register()
|
||||||
|
|
||||||
// Update app version info. See: `Settings.bundle`
|
// Update app version info. See: `Settings.bundle`
|
||||||
UserDefaults.standard.setValue(UIApplication.appVersion(), forKey: "Mastodon.appVersion")
|
UserDefaults.standard.setValue(UIApplication.appVersion(), forKey: "Mastodon.appVersion")
|
||||||
UserDefaults.standard.setValue(UIApplication.appBuild(), forKey: "Mastodon.appBundle")
|
UserDefaults.standard.setValue(UIApplication.appBuild(), forKey: "Mastodon.appBundle")
|
||||||
|
|
||||||
|
UNUserNotificationCenter.current().delegate = self
|
||||||
|
application.registerForRemoteNotifications()
|
||||||
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -38,13 +46,70 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
extension AppDelegate {
|
extension AppDelegate {
|
||||||
func application(_ application: UIApplication, supportedInterfaceOrientationsFor window: UIWindow?) -> UIInterfaceOrientationMask {
|
func application(_ application: UIApplication, supportedInterfaceOrientationsFor window: UIWindow?) -> UIInterfaceOrientationMask {
|
||||||
return UIDevice.current.userInterfaceIdiom == .phone ? .portrait : .all
|
return UIDevice.current.userInterfaceIdiom == .phone ? .portrait : .all
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension AppDelegate {
|
||||||
|
|
||||||
|
func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
|
||||||
|
appContext.notificationService.deviceToken.value = deviceToken
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - UNUserNotificationCenterDelegate
|
||||||
|
extension AppDelegate: UNUserNotificationCenterDelegate {
|
||||||
|
|
||||||
|
// notification present in the foreground
|
||||||
|
func userNotificationCenter(
|
||||||
|
_ center: UNUserNotificationCenter,
|
||||||
|
willPresent notification: UNNotification,
|
||||||
|
withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void
|
||||||
|
) {
|
||||||
|
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Push Notification]", ((#file as NSString).lastPathComponent), #line, #function)
|
||||||
|
guard let mastodonPushNotification = AppDelegate.mastodonPushNotification(from: notification) else {
|
||||||
|
completionHandler([])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let notificationID = String(mastodonPushNotification.notificationID)
|
||||||
|
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Push Notification] notification %s", ((#file as NSString).lastPathComponent), #line, #function, notificationID)
|
||||||
|
appContext.notificationService.handle(mastodonPushNotification: mastodonPushNotification)
|
||||||
|
completionHandler([.sound])
|
||||||
|
}
|
||||||
|
|
||||||
|
// response to user action for notification
|
||||||
|
func userNotificationCenter(
|
||||||
|
_ center: UNUserNotificationCenter,
|
||||||
|
didReceive response: UNNotificationResponse,
|
||||||
|
withCompletionHandler completionHandler: @escaping () -> Void
|
||||||
|
) {
|
||||||
|
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Push Notification]", ((#file as NSString).lastPathComponent), #line, #function)
|
||||||
|
|
||||||
|
guard let mastodonPushNotification = AppDelegate.mastodonPushNotification(from: response.notification) else {
|
||||||
|
completionHandler()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let notificationID = String(mastodonPushNotification.notificationID)
|
||||||
|
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Push Notification] notification %s", ((#file as NSString).lastPathComponent), #line, #function, notificationID)
|
||||||
|
appContext.notificationService.handle(mastodonPushNotification: mastodonPushNotification)
|
||||||
|
appContext.notificationService.requestRevealNotificationPublisher.send(notificationID)
|
||||||
|
completionHandler()
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func mastodonPushNotification(from notification: UNNotification) -> MastodonPushNotification? {
|
||||||
|
guard let plaintext = notification.request.content.userInfo["plaintext"] as? Data,
|
||||||
|
let mastodonPushNotification = try? JSONDecoder().decode(MastodonPushNotification.self, from: plaintext) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return mastodonPushNotification
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
extension AppContext {
|
extension AppContext {
|
||||||
static var shared: AppContext {
|
static var shared: AppContext {
|
||||||
|
|
|
@ -6,10 +6,13 @@
|
||||||
//
|
//
|
||||||
|
|
||||||
import UIKit
|
import UIKit
|
||||||
|
import Combine
|
||||||
import CoreDataStack
|
import CoreDataStack
|
||||||
|
|
||||||
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
|
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
|
||||||
|
|
||||||
|
var observations = Set<NSKeyValueObservation>()
|
||||||
|
|
||||||
var window: UIWindow?
|
var window: UIWindow?
|
||||||
var coordinator: SceneCoordinator?
|
var coordinator: SceneCoordinator?
|
||||||
|
|
||||||
|
@ -28,8 +31,11 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
|
||||||
sceneCoordinator.setupOnboardingIfNeeds(animated: false)
|
sceneCoordinator.setupOnboardingIfNeeds(animated: false)
|
||||||
window.makeKeyAndVisible()
|
window.makeKeyAndVisible()
|
||||||
|
|
||||||
// update `overrideUserInterfaceStyle` with current setting
|
UserDefaults.shared.observe(\.customUserInterfaceStyle, options: [.initial, .new]) { [weak self] defaults, _ in
|
||||||
SettingsViewController.updateOverrideUserInterfaceStyle(window: window)
|
guard let self = self else { return }
|
||||||
|
self.window?.overrideUserInterfaceStyle = defaults.customUserInterfaceStyle
|
||||||
|
}
|
||||||
|
.store(in: &observations)
|
||||||
}
|
}
|
||||||
|
|
||||||
func sceneDidDisconnect(_ scene: UIScene) {
|
func sceneDidDisconnect(_ scene: UIScene) {
|
||||||
|
@ -42,6 +48,10 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
|
||||||
func sceneDidBecomeActive(_ scene: UIScene) {
|
func sceneDidBecomeActive(_ scene: UIScene) {
|
||||||
// Called when the scene has moved from an inactive state to an active state.
|
// Called when the scene has moved from an inactive state to an active state.
|
||||||
// Use this method to restart any tasks that were paused (or not yet started) when the scene was inactive.
|
// Use this method to restart any tasks that were paused (or not yet started) when the scene was inactive.
|
||||||
|
|
||||||
|
// reset notification badge
|
||||||
|
UserDefaults.shared.notificationBadgeCount = 0
|
||||||
|
UIApplication.shared.applicationIconBadgeNumber = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
func sceneWillResignActive(_ scene: UIScene) {
|
func sceneWillResignActive(_ scene: UIScene) {
|
||||||
|
|
|
@ -67,7 +67,7 @@ extension Mastodon.API.Notifications {
|
||||||
public static func getNotification(
|
public static func getNotification(
|
||||||
session: URLSession,
|
session: URLSession,
|
||||||
domain: String,
|
domain: String,
|
||||||
notificationID: String,
|
notificationID: Mastodon.Entity.Notification.ID,
|
||||||
authorization: Mastodon.API.OAuth.Authorization
|
authorization: Mastodon.API.OAuth.Authorization
|
||||||
) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Notification>, Error> {
|
) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Notification>, Error> {
|
||||||
let request = Mastodon.API.get(
|
let request = Mastodon.API.get(
|
||||||
|
|
|
@ -21,7 +21,7 @@ extension Mastodon.API.Subscriptions {
|
||||||
/// - Since: 2.4.0
|
/// - Since: 2.4.0
|
||||||
/// - Version: 3.3.0
|
/// - Version: 3.3.0
|
||||||
/// # Last Update
|
/// # Last Update
|
||||||
/// 2021/4/9
|
/// 2021/4/25
|
||||||
/// # Reference
|
/// # Reference
|
||||||
/// [Document](https://docs.joinmastodon.org/methods/notifications/push/)
|
/// [Document](https://docs.joinmastodon.org/methods/notifications/push/)
|
||||||
/// - Parameters:
|
/// - Parameters:
|
||||||
|
@ -54,7 +54,7 @@ extension Mastodon.API.Subscriptions {
|
||||||
/// - Since: 2.4.0
|
/// - Since: 2.4.0
|
||||||
/// - Version: 3.3.0
|
/// - Version: 3.3.0
|
||||||
/// # Last Update
|
/// # Last Update
|
||||||
/// 2021/4/9
|
/// 2021/4/25
|
||||||
/// # Reference
|
/// # Reference
|
||||||
/// [Document](https://docs.joinmastodon.org/methods/notifications/push/)
|
/// [Document](https://docs.joinmastodon.org/methods/notifications/push/)
|
||||||
/// - Parameters:
|
/// - Parameters:
|
||||||
|
@ -88,7 +88,7 @@ extension Mastodon.API.Subscriptions {
|
||||||
/// - Since: 2.4.0
|
/// - Since: 2.4.0
|
||||||
/// - Version: 3.3.0
|
/// - Version: 3.3.0
|
||||||
/// # Last Update
|
/// # Last Update
|
||||||
/// 2021/4/9
|
/// 2021/4/25
|
||||||
/// # Reference
|
/// # Reference
|
||||||
/// [Document](https://docs.joinmastodon.org/methods/notifications/push/)
|
/// [Document](https://docs.joinmastodon.org/methods/notifications/push/)
|
||||||
/// - Parameters:
|
/// - Parameters:
|
||||||
|
@ -114,113 +114,149 @@ extension Mastodon.API.Subscriptions {
|
||||||
}
|
}
|
||||||
.eraseToAnyPublisher()
|
.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 {
|
extension Mastodon.API.Subscriptions {
|
||||||
public struct CreateSubscriptionQuery: Codable, PostQuery {
|
|
||||||
|
public typealias Policy = QueryData.Policy
|
||||||
|
|
||||||
|
public struct QuerySubscription: Codable {
|
||||||
let endpoint: String
|
let endpoint: String
|
||||||
let p256dh: String
|
let keys: Keys
|
||||||
let auth: String
|
|
||||||
let favourite: Bool?
|
|
||||||
let follow: Bool?
|
|
||||||
let reblog: Bool?
|
|
||||||
let mention: Bool?
|
|
||||||
let poll: Bool?
|
|
||||||
|
|
||||||
var queryItems: [URLQueryItem]? {
|
|
||||||
var items = [URLQueryItem]()
|
|
||||||
|
|
||||||
items.append(URLQueryItem(name: "subscription[endpoint]", value: endpoint))
|
|
||||||
items.append(URLQueryItem(name: "subscription[keys][p256dh]", value: p256dh))
|
|
||||||
items.append(URLQueryItem(name: "subscription[keys][auth]", value: auth))
|
|
||||||
|
|
||||||
if let followValue = follow?.queryItemValue {
|
|
||||||
let followItem = URLQueryItem(name: "data[alerts][follow]", value: followValue)
|
|
||||||
items.append(followItem)
|
|
||||||
}
|
|
||||||
|
|
||||||
if let favouriteValue = favourite?.queryItemValue {
|
|
||||||
let favouriteItem = URLQueryItem(name: "data[alerts][favourite]", value: favouriteValue)
|
|
||||||
items.append(favouriteItem)
|
|
||||||
}
|
|
||||||
|
|
||||||
if let reblogValue = reblog?.queryItemValue {
|
|
||||||
let reblogItem = URLQueryItem(name: "data[alerts][reblog]", value: reblogValue)
|
|
||||||
items.append(reblogItem)
|
|
||||||
}
|
|
||||||
|
|
||||||
if let mentionValue = mention?.queryItemValue {
|
|
||||||
let mentionItem = URLQueryItem(name: "data[alerts][mention]", value: mentionValue)
|
|
||||||
items.append(mentionItem)
|
|
||||||
}
|
|
||||||
return items
|
|
||||||
}
|
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
endpoint: String,
|
endpoint: String,
|
||||||
p256dh: String,
|
keys: Keys
|
||||||
auth: String,
|
|
||||||
favourite: Bool?,
|
|
||||||
follow: Bool?,
|
|
||||||
reblog: Bool?,
|
|
||||||
mention: Bool?,
|
|
||||||
poll: Bool?
|
|
||||||
) {
|
) {
|
||||||
self.endpoint = endpoint
|
self.endpoint = endpoint
|
||||||
self.p256dh = p256dh
|
self.keys = keys
|
||||||
self.auth = auth
|
}
|
||||||
self.favourite = favourite
|
|
||||||
self.follow = follow
|
public struct Keys: Codable {
|
||||||
self.reblog = reblog
|
let p256dh: String
|
||||||
self.mention = mention
|
let auth: String
|
||||||
self.poll = poll
|
|
||||||
|
public init(p256dh: Data, auth: Data) {
|
||||||
|
self.p256dh = p256dh.base64UrlEncodedString()
|
||||||
|
self.auth = auth.base64UrlEncodedString()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct QueryData: Codable {
|
||||||
|
let policy: Policy?
|
||||||
|
let alerts: Alerts
|
||||||
|
|
||||||
|
public init(
|
||||||
|
policy: Policy?,
|
||||||
|
alerts: Mastodon.API.Subscriptions.QueryData.Alerts
|
||||||
|
) {
|
||||||
|
self.policy = policy
|
||||||
|
self.alerts = alerts
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct Alerts: Codable {
|
||||||
|
let favourite: Bool?
|
||||||
|
let follow: Bool?
|
||||||
|
let reblog: Bool?
|
||||||
|
let mention: Bool?
|
||||||
|
let poll: Bool?
|
||||||
|
|
||||||
|
public init(favourite: Bool?, follow: Bool?, reblog: Bool?, mention: Bool?, poll: Bool?) {
|
||||||
|
self.favourite = favourite
|
||||||
|
self.follow = follow
|
||||||
|
self.reblog = reblog
|
||||||
|
self.mention = mention
|
||||||
|
self.poll = poll
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
public init(
|
||||||
|
subscription: Mastodon.API.Subscriptions.QuerySubscription,
|
||||||
|
data: Mastodon.API.Subscriptions.QueryData
|
||||||
|
) {
|
||||||
|
self.subscription = subscription
|
||||||
|
self.data = data
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public struct UpdateSubscriptionQuery: Codable, PutQuery {
|
public struct UpdateSubscriptionQuery: Codable, PutQuery {
|
||||||
let favourite: Bool?
|
|
||||||
let follow: Bool?
|
|
||||||
let reblog: Bool?
|
|
||||||
let mention: Bool?
|
|
||||||
let poll: Bool?
|
|
||||||
|
|
||||||
var queryItems: [URLQueryItem]? {
|
let data: QueryData
|
||||||
var items = [URLQueryItem]()
|
|
||||||
|
public init(data: Mastodon.API.Subscriptions.QueryData) {
|
||||||
if let followValue = follow?.queryItemValue {
|
self.data = data
|
||||||
let followItem = URLQueryItem(name: "data[alerts][follow]", value: followValue)
|
|
||||||
items.append(followItem)
|
|
||||||
}
|
|
||||||
|
|
||||||
if let favouriteValue = favourite?.queryItemValue {
|
|
||||||
let favouriteItem = URLQueryItem(name: "data[alerts][favourite]", value: favouriteValue)
|
|
||||||
items.append(favouriteItem)
|
|
||||||
}
|
|
||||||
|
|
||||||
if let reblogValue = reblog?.queryItemValue {
|
|
||||||
let reblogItem = URLQueryItem(name: "data[alerts][reblog]", value: reblogValue)
|
|
||||||
items.append(reblogItem)
|
|
||||||
}
|
|
||||||
|
|
||||||
if let mentionValue = mention?.queryItemValue {
|
|
||||||
let mentionItem = URLQueryItem(name: "data[alerts][mention]", value: mentionValue)
|
|
||||||
items.append(mentionItem)
|
|
||||||
}
|
|
||||||
return items
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public init(
|
var queryItems: [URLQueryItem]? { nil }
|
||||||
favourite: Bool?,
|
|
||||||
follow: Bool?,
|
|
||||||
reblog: Bool?,
|
|
||||||
mention: Bool?,
|
|
||||||
poll: Bool?
|
|
||||||
) {
|
|
||||||
self.favourite = favourite
|
|
||||||
self.follow = follow
|
|
||||||
self.reblog = reblog
|
|
||||||
self.mention = mention
|
|
||||||
self.poll = poll
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -157,6 +157,14 @@ extension Mastodon.API {
|
||||||
) -> URLRequest {
|
) -> URLRequest {
|
||||||
return buildRequest(url: url, method: .PUT, query: query, authorization: authorization)
|
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(
|
private static func buildRequest(
|
||||||
url: URL,
|
url: URL,
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
//
|
//
|
||||||
// File.swift
|
// Mastodon+Entity+Subscription.swift
|
||||||
//
|
//
|
||||||
//
|
//
|
||||||
// Created by ihugo on 2021/4/9.
|
// Created by ihugo on 2021/4/9.
|
||||||
|
@ -14,7 +14,7 @@ extension Mastodon.Entity {
|
||||||
/// - Since: 2.4.0
|
/// - Since: 2.4.0
|
||||||
/// - Version: 3.3.0
|
/// - Version: 3.3.0
|
||||||
/// # Last Update
|
/// # Last Update
|
||||||
/// 2021/4/9
|
/// 2021/4/26
|
||||||
/// # Reference
|
/// # Reference
|
||||||
/// [Document](https://docs.joinmastodon.org/entities/pushsubscription/)
|
/// [Document](https://docs.joinmastodon.org/entities/pushsubscription/)
|
||||||
public struct Subscription: Codable {
|
public struct Subscription: Codable {
|
||||||
|
@ -33,30 +33,19 @@ extension Mastodon.Entity {
|
||||||
|
|
||||||
public struct Alerts: Codable {
|
public struct Alerts: Codable {
|
||||||
public let follow: Bool?
|
public let follow: Bool?
|
||||||
|
public let followRequest: Bool?
|
||||||
public let favourite: Bool?
|
public let favourite: Bool?
|
||||||
public let reblog: Bool?
|
public let reblog: Bool?
|
||||||
public let mention: Bool?
|
public let mention: Bool?
|
||||||
public let poll: Bool?
|
public let poll: Bool?
|
||||||
|
|
||||||
public var followNumber: NSNumber? {
|
enum CodingKeys: String, CodingKey {
|
||||||
guard let value = follow else { return nil }
|
case follow
|
||||||
return NSNumber(booleanLiteral: value)
|
case followRequest = "follow_request"
|
||||||
}
|
case favourite
|
||||||
public var favouriteNumber: NSNumber? {
|
case reblog
|
||||||
guard let value = favourite else { return nil }
|
case mention
|
||||||
return NSNumber(booleanLiteral: value)
|
case poll
|
||||||
}
|
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -74,4 +63,8 @@ extension Mastodon.Entity {
|
||||||
serverKey = try container.decode(String.self, forKey: .serverKey)
|
serverKey = try container.decode(String.self, forKey: .serverKey)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public struct EmptySubscription: Codable {
|
||||||
|
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -35,3 +35,12 @@ extension Data {
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension Data {
|
||||||
|
func base64UrlEncodedString() -> String {
|
||||||
|
return base64EncodedString()
|
||||||
|
.replacingOccurrences(of: "+", with: "-")
|
||||||
|
.replacingOccurrences(of: "/", with: "_")
|
||||||
|
.replacingOccurrences(of: "=", with: "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -58,3 +58,5 @@ protocol PatchQuery: RequestQuery { }
|
||||||
// PUT
|
// PUT
|
||||||
protocol PutQuery: RequestQuery { }
|
protocol PutQuery: RequestQuery { }
|
||||||
|
|
||||||
|
// DELETE
|
||||||
|
protocol DeleteQuery: RequestQuery { }
|
||||||
|
|
|
@ -0,0 +1,31 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
|
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||||
|
<key>CFBundleDisplayName</key>
|
||||||
|
<string>NotificationService</string>
|
||||||
|
<key>CFBundleExecutable</key>
|
||||||
|
<string>$(EXECUTABLE_NAME)</string>
|
||||||
|
<key>CFBundleIdentifier</key>
|
||||||
|
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||||
|
<key>CFBundleInfoDictionaryVersion</key>
|
||||||
|
<string>6.0</string>
|
||||||
|
<key>CFBundleName</key>
|
||||||
|
<string>$(PRODUCT_NAME)</string>
|
||||||
|
<key>CFBundlePackageType</key>
|
||||||
|
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||||
|
<key>CFBundleShortVersionString</key>
|
||||||
|
<string>1.0</string>
|
||||||
|
<key>CFBundleVersion</key>
|
||||||
|
<string>1</string>
|
||||||
|
<key>NSExtension</key>
|
||||||
|
<dict>
|
||||||
|
<key>NSExtensionPointIdentifier</key>
|
||||||
|
<string>com.apple.usernotifications.service</string>
|
||||||
|
<key>NSExtensionPrincipalClass</key>
|
||||||
|
<string>$(PRODUCT_MODULE_NAME).NotificationService</string>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
|
@ -0,0 +1,35 @@
|
||||||
|
//
|
||||||
|
// MastodonNotification.swift
|
||||||
|
// NotificationService
|
||||||
|
//
|
||||||
|
// Created by MainasuK Cirno on 2021-4-26.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct MastodonPushNotification: Codable {
|
||||||
|
|
||||||
|
private let _accessToken: String
|
||||||
|
var accessToken: String {
|
||||||
|
return String.normalize(base64String: _accessToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
let notificationID: Int
|
||||||
|
let notificationType: String
|
||||||
|
|
||||||
|
let preferredLocale: String?
|
||||||
|
let icon: String?
|
||||||
|
let title: String
|
||||||
|
let body: String
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case _accessToken = "access_token"
|
||||||
|
case notificationID = "notification_id"
|
||||||
|
case notificationType = "notification_type"
|
||||||
|
case preferredLocale = "preferred_locale"
|
||||||
|
case icon
|
||||||
|
case title
|
||||||
|
case body
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -0,0 +1,73 @@
|
||||||
|
//
|
||||||
|
// NotificationService+Decrypt.swift
|
||||||
|
// NotificationService
|
||||||
|
//
|
||||||
|
// Created by MainasuK Cirno on 2021-4-25.
|
||||||
|
//
|
||||||
|
|
||||||
|
import os.log
|
||||||
|
import Foundation
|
||||||
|
import CryptoKit
|
||||||
|
|
||||||
|
extension NotificationService {
|
||||||
|
|
||||||
|
static func decrypt(payload: Data, salt: Data, auth: Data, privateKey: P256.KeyAgreement.PrivateKey, publicKey: P256.KeyAgreement.PublicKey) -> Data? {
|
||||||
|
guard let sharedSecret = try? privateKey.sharedSecretFromKeyAgreement(with: publicKey) else {
|
||||||
|
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: failed to craete shared secret", ((#file as NSString).lastPathComponent), #line, #function)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
let keyMaterial = sharedSecret.hkdfDerivedSymmetricKey(using: SHA256.self, salt: auth, sharedInfo: Data("Content-Encoding: auth\0".utf8), outputByteCount: 32)
|
||||||
|
|
||||||
|
let keyInfo = info(type: "aesgcm", clientPublicKey: privateKey.publicKey.x963Representation, serverPublicKey: publicKey.x963Representation)
|
||||||
|
let key = HKDF<SHA256>.deriveKey(inputKeyMaterial: keyMaterial, salt: salt, info: keyInfo, outputByteCount: 16)
|
||||||
|
|
||||||
|
let nonceInfo = info(type: "nonce", clientPublicKey: privateKey.publicKey.x963Representation, serverPublicKey: publicKey.x963Representation)
|
||||||
|
let nonce = HKDF<SHA256>.deriveKey(inputKeyMaterial: keyMaterial, salt: salt, info: nonceInfo, outputByteCount: 12)
|
||||||
|
|
||||||
|
let nonceData = nonce.withUnsafeBytes(Array.init)
|
||||||
|
|
||||||
|
guard let sealedBox = try? AES.GCM.SealedBox(combined: nonceData + payload) else {
|
||||||
|
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: failed to create sealedBox", ((#file as NSString).lastPathComponent), #line, #function)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var _plaintext: Data?
|
||||||
|
do {
|
||||||
|
_plaintext = try AES.GCM.open(sealedBox, using: key)
|
||||||
|
} catch {
|
||||||
|
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: sealedBox open fail %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription)
|
||||||
|
}
|
||||||
|
guard let plaintext = _plaintext else {
|
||||||
|
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: failed to open sealedBox", ((#file as NSString).lastPathComponent), #line, #function)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
let paddingLength = Int(plaintext[0]) * 256 + Int(plaintext[1])
|
||||||
|
guard plaintext.count >= 2 + paddingLength else {
|
||||||
|
print("1")
|
||||||
|
fatalError()
|
||||||
|
}
|
||||||
|
let unpadded = plaintext.suffix(from: paddingLength + 2)
|
||||||
|
|
||||||
|
return Data(unpadded)
|
||||||
|
}
|
||||||
|
|
||||||
|
static private func info(type: String, clientPublicKey: Data, serverPublicKey: Data) -> Data {
|
||||||
|
var info = Data()
|
||||||
|
|
||||||
|
info.append("Content-Encoding: ".data(using: .utf8)!)
|
||||||
|
info.append(type.data(using: .utf8)!)
|
||||||
|
info.append(0)
|
||||||
|
info.append("P-256".data(using: .utf8)!)
|
||||||
|
info.append(0)
|
||||||
|
info.append(0)
|
||||||
|
info.append(65)
|
||||||
|
info.append(clientPublicKey)
|
||||||
|
info.append(0)
|
||||||
|
info.append(65)
|
||||||
|
info.append(serverPublicKey)
|
||||||
|
|
||||||
|
return info
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,10 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>com.apple.security.application-groups</key>
|
||||||
|
<array>
|
||||||
|
<string>group.org.joinmastodon.mastodon-temp</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
|
@ -0,0 +1,108 @@
|
||||||
|
//
|
||||||
|
// NotificationService.swift
|
||||||
|
// NotificationService
|
||||||
|
//
|
||||||
|
// Created by MainasuK Cirno on 2021-4-23.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UserNotifications
|
||||||
|
import CommonOSLog
|
||||||
|
import CryptoKit
|
||||||
|
import AlamofireImage
|
||||||
|
import Base85
|
||||||
|
import AppShared
|
||||||
|
|
||||||
|
class NotificationService: UNNotificationServiceExtension {
|
||||||
|
|
||||||
|
var contentHandler: ((UNNotificationContent) -> Void)?
|
||||||
|
var bestAttemptContent: UNMutableNotificationContent?
|
||||||
|
|
||||||
|
override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) {
|
||||||
|
self.contentHandler = contentHandler
|
||||||
|
bestAttemptContent = (request.content.mutableCopy() as? UNMutableNotificationContent)
|
||||||
|
|
||||||
|
if let bestAttemptContent = bestAttemptContent {
|
||||||
|
// Modify the notification content here...
|
||||||
|
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
|
||||||
|
|
||||||
|
let privateKey = AppSecret.default.notificationPrivateKey
|
||||||
|
let auth = AppSecret.default.notificationAuth
|
||||||
|
|
||||||
|
guard let encodedPayload = bestAttemptContent.userInfo["p"] as? String,
|
||||||
|
let payload = Data(base85Encoded: encodedPayload, options: [], encoding: .z85) else {
|
||||||
|
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: invalid payload", ((#file as NSString).lastPathComponent), #line, #function)
|
||||||
|
contentHandler(bestAttemptContent)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let encodedPublicKey = bestAttemptContent.userInfo["k"] as? String,
|
||||||
|
let publicKey = NotificationService.publicKey(encodedPublicKey: encodedPublicKey) else {
|
||||||
|
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: invalid public key", ((#file as NSString).lastPathComponent), #line, #function)
|
||||||
|
contentHandler(bestAttemptContent)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let encodedSalt = bestAttemptContent.userInfo["s"] as? String,
|
||||||
|
let salt = Data(base85Encoded: encodedSalt, options: [], encoding: .z85) else {
|
||||||
|
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: invalid salt", ((#file as NSString).lastPathComponent), #line, #function)
|
||||||
|
contentHandler(bestAttemptContent)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let plaintextData = NotificationService.decrypt(payload: payload, salt: salt, auth: auth, privateKey: privateKey, publicKey: publicKey),
|
||||||
|
let notification = try? JSONDecoder().decode(MastodonPushNotification.self, from: plaintextData) else {
|
||||||
|
contentHandler(bestAttemptContent)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
bestAttemptContent.title = notification.title
|
||||||
|
bestAttemptContent.subtitle = ""
|
||||||
|
bestAttemptContent.body = notification.body
|
||||||
|
bestAttemptContent.sound = .default
|
||||||
|
bestAttemptContent.userInfo["plaintext"] = plaintextData
|
||||||
|
|
||||||
|
UserDefaults.shared.notificationBadgeCount += 1
|
||||||
|
bestAttemptContent.badge = NSNumber(integerLiteral: UserDefaults.shared.notificationBadgeCount)
|
||||||
|
|
||||||
|
if let urlString = notification.icon, let url = URL(string: urlString) {
|
||||||
|
let temporaryDirectoryURL = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("notification-attachments")
|
||||||
|
try? FileManager.default.createDirectory(at: temporaryDirectoryURL, withIntermediateDirectories: true, attributes: nil)
|
||||||
|
let filename = url.lastPathComponent
|
||||||
|
let fileURL = temporaryDirectoryURL.appendingPathComponent(filename)
|
||||||
|
|
||||||
|
ImageDownloader.default.download(URLRequest(url: url), completion: { [weak self] response in
|
||||||
|
guard let _ = self else { return }
|
||||||
|
switch response.result {
|
||||||
|
case .failure(let error):
|
||||||
|
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: download image %s fail: %s", ((#file as NSString).lastPathComponent), #line, #function, url.debugDescription, error.localizedDescription)
|
||||||
|
case .success(let image):
|
||||||
|
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: download image %s success", ((#file as NSString).lastPathComponent), #line, #function, url.debugDescription)
|
||||||
|
try? image.pngData()?.write(to: fileURL)
|
||||||
|
if let attachment = try? UNNotificationAttachment(identifier: filename, url: fileURL, options: nil) {
|
||||||
|
bestAttemptContent.attachments = [attachment]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
contentHandler(bestAttemptContent)
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
contentHandler(bestAttemptContent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override func serviceExtensionTimeWillExpire() {
|
||||||
|
// Called just before the extension will be terminated by the system.
|
||||||
|
// Use this as an opportunity to deliver your "best attempt" at modified content, otherwise the original push payload will be used.
|
||||||
|
if let contentHandler = contentHandler, let bestAttemptContent = bestAttemptContent {
|
||||||
|
contentHandler(bestAttemptContent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
extension NotificationService {
|
||||||
|
static func publicKey(encodedPublicKey: String) -> P256.KeyAgreement.PublicKey? {
|
||||||
|
guard let publicKeyData = Data(base85Encoded: encodedPublicKey, options: [], encoding: .z85) else { return nil }
|
||||||
|
return try? P256.KeyAgreement.PublicKey(x963Representation: publicKeyData)
|
||||||
|
}
|
||||||
|
}
|
16
Podfile
16
Podfile
|
@ -23,4 +23,20 @@ target 'Mastodon' do
|
||||||
# Pods for testing
|
# Pods for testing
|
||||||
end
|
end
|
||||||
|
|
||||||
|
target 'NotificationService' do
|
||||||
|
|
||||||
|
end
|
||||||
|
|
||||||
|
target 'AppShared' do
|
||||||
|
|
||||||
|
end
|
||||||
|
|
||||||
end
|
end
|
||||||
|
|
||||||
|
plugin 'cocoapods-keys', {
|
||||||
|
:project => "Mastodon",
|
||||||
|
:keys => [
|
||||||
|
"notification_endpoint",
|
||||||
|
"notification_endpoint_debug"
|
||||||
|
]
|
||||||
|
}
|
|
@ -1,12 +1,14 @@
|
||||||
PODS:
|
PODS:
|
||||||
- DateToolsSwift (5.0.0)
|
- DateToolsSwift (5.0.0)
|
||||||
- Kanna (5.2.4)
|
- Kanna (5.2.4)
|
||||||
|
- Keys (1.0.1)
|
||||||
- SwiftGen (6.4.0)
|
- SwiftGen (6.4.0)
|
||||||
- "UITextField+Shake (1.2.1)"
|
- "UITextField+Shake (1.2.1)"
|
||||||
|
|
||||||
DEPENDENCIES:
|
DEPENDENCIES:
|
||||||
- DateToolsSwift (~> 5.0.0)
|
- DateToolsSwift (~> 5.0.0)
|
||||||
- Kanna (~> 5.2.2)
|
- Kanna (~> 5.2.2)
|
||||||
|
- Keys (from `Pods/CocoaPodsKeys`)
|
||||||
- SwiftGen (~> 6.4.0)
|
- SwiftGen (~> 6.4.0)
|
||||||
- "UITextField+Shake (~> 1.2)"
|
- "UITextField+Shake (~> 1.2)"
|
||||||
|
|
||||||
|
@ -17,12 +19,17 @@ SPEC REPOS:
|
||||||
- SwiftGen
|
- SwiftGen
|
||||||
- "UITextField+Shake"
|
- "UITextField+Shake"
|
||||||
|
|
||||||
|
EXTERNAL SOURCES:
|
||||||
|
Keys:
|
||||||
|
:path: Pods/CocoaPodsKeys
|
||||||
|
|
||||||
SPEC CHECKSUMS:
|
SPEC CHECKSUMS:
|
||||||
DateToolsSwift: 4207ada6ad615d8dc076323d27037c94916dbfa6
|
DateToolsSwift: 4207ada6ad615d8dc076323d27037c94916dbfa6
|
||||||
Kanna: b9d00d7c11428308c7f95e1f1f84b8205f567a8f
|
Kanna: b9d00d7c11428308c7f95e1f1f84b8205f567a8f
|
||||||
|
Keys: a576f4c9c1c641ca913a959a9c62ed3f215a8de9
|
||||||
SwiftGen: 67860cc7c3cfc2ed25b9b74cfd55495fc89f9108
|
SwiftGen: 67860cc7c3cfc2ed25b9b74cfd55495fc89f9108
|
||||||
"UITextField+Shake": 298ac5a0f239d731bdab999b19b628c956ca0ac3
|
"UITextField+Shake": 298ac5a0f239d731bdab999b19b628c956ca0ac3
|
||||||
|
|
||||||
PODFILE CHECKSUM: 30e8e3a555251a512e7b5e91183747152f126e7a
|
PODFILE CHECKSUM: a8dbae22e6e0bfb84f7db59aef1aa1716793d287
|
||||||
|
|
||||||
COCOAPODS: 1.10.1
|
COCOAPODS: 1.10.1
|
||||||
|
|
|
@ -48,8 +48,10 @@ arch -x86_64 pod install
|
||||||
- [AlamofireNetworkActivityIndicator](https://github.com/Alamofire/AlamofireNetworkActivityIndicator)
|
- [AlamofireNetworkActivityIndicator](https://github.com/Alamofire/AlamofireNetworkActivityIndicator)
|
||||||
- [Alamofire](https://github.com/Alamofire/Alamofire)
|
- [Alamofire](https://github.com/Alamofire/Alamofire)
|
||||||
- [CommonOSLog](https://github.com/mainasuk/CommonOSLog)
|
- [CommonOSLog](https://github.com/mainasuk/CommonOSLog)
|
||||||
|
- [CryptoSwift](https://github.com/krzyzanowskim/CryptoSwift)
|
||||||
- [DateToolSwift](https://github.com/MatthewYork/DateTools)
|
- [DateToolSwift](https://github.com/MatthewYork/DateTools)
|
||||||
- [Kanna](https://github.com/tid-kijyun/Kanna)
|
- [Kanna](https://github.com/tid-kijyun/Kanna)
|
||||||
|
- [KeychainAccess](https://github.com/kishikawakatsumi/KeychainAccess.git)
|
||||||
- [Kingfisher](https://github.com/onevcat/Kingfisher)
|
- [Kingfisher](https://github.com/onevcat/Kingfisher)
|
||||||
- [SwiftGen](https://github.com/SwiftGen/SwiftGen)
|
- [SwiftGen](https://github.com/SwiftGen/SwiftGen)
|
||||||
- [SwiftyJSON](https://github.com/SwiftyJSON/SwiftyJSON)
|
- [SwiftyJSON](https://github.com/SwiftyJSON/SwiftyJSON)
|
||||||
|
|
Loading…
Reference in New Issue