Merge branch 'develop' into feature/statusMenu

This commit is contained in:
sunxiaojian 2021-04-30 12:57:43 +08:00
commit 0403cc0109
83 changed files with 3453 additions and 1142 deletions

View File

@ -1,4 +1,9 @@
#!/bin/bash
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

12
AppShared/AppName.swift Normal file
View File

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

103
AppShared/AppSecret.swift Normal file
View File

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

18
AppShared/AppShared.h Normal file
View File

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

22
AppShared/Info.plist Normal file
View File

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

View File

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

View File

@ -185,14 +185,12 @@
<relationship name="hashtag" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Tag"/>
</entity>
<entity name="Setting" representedClassName=".Setting" syncable="YES">
<attribute name="appearance" optional="YES" attributeType="String"/>
<attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="domain" optional="YES" attributeType="String"/>
<attribute name="id" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
<attribute name="triggerBy" optional="YES" attributeType="String"/>
<attribute name="updatedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="userID" optional="YES" attributeType="String"/>
<relationship name="subscription" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="Subscription" inverseName="setting" inverseEntity="Subscription"/>
<attribute name="appearanceRaw" attributeType="String"/>
<attribute name="createdAt" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="domain" attributeType="String"/>
<attribute name="updatedAt" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="userID" attributeType="String"/>
<relationship name="subscriptions" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="Subscription" inverseName="setting" inverseEntity="Subscription"/>
</entity>
<entity name="Status" representedClassName=".Status" syncable="YES">
<attribute name="content" attributeType="String"/>
@ -234,24 +232,27 @@
<relationship name="tags" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="Tag" inverseName="statuses" inverseEntity="Tag"/>
</entity>
<entity name="Subscription" representedClassName=".Subscription" syncable="YES">
<attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="activedAt" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="createdAt" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="endpoint" optional="YES" attributeType="String"/>
<attribute name="id" optional="YES" attributeType="String"/>
<attribute name="policyRaw" attributeType="String"/>
<attribute name="serverKey" optional="YES" attributeType="String"/>
<attribute name="type" optional="YES" attributeType="String"/>
<attribute name="updatedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<relationship name="alert" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SubscriptionAlerts" inverseName="subscription" inverseEntity="SubscriptionAlerts"/>
<relationship name="setting" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Setting" inverseName="subscription" inverseEntity="Setting"/>
<attribute name="updatedAt" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="userToken" optional="YES" attributeType="String"/>
<relationship name="alert" maxCount="1" deletionRule="Cascade" destinationEntity="SubscriptionAlerts" inverseName="subscription" inverseEntity="SubscriptionAlerts"/>
<relationship name="setting" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Setting" inverseName="subscriptions" inverseEntity="Setting"/>
</entity>
<entity name="SubscriptionAlerts" representedClassName=".SubscriptionAlerts" syncable="YES">
<attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="favourite" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="follow" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="mention" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="poll" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="reblog" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="favouriteRaw" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="followRaw" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="followRequestRaw" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="mentionRaw" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="pollRaw" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="reblogRaw" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="updatedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<relationship name="subscription" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Subscription" inverseName="alert" inverseEntity="Subscription"/>
<relationship name="subscription" maxCount="1" deletionRule="Nullify" destinationEntity="Subscription" inverseName="alert" inverseEntity="Subscription"/>
</entity>
<entity name="Tag" representedClassName=".Tag" syncable="YES">
<attribute name="createAt" attributeType="Date" defaultDateTimeInterval="631123200" usesScalarValueType="NO"/>
@ -277,10 +278,10 @@
<element name="PollOption" positionX="0" positionY="0" width="128" height="134"/>
<element name="PrivateNote" positionX="0" positionY="0" width="128" height="89"/>
<element name="SearchHistory" positionX="0" positionY="0" width="128" height="104"/>
<element name="Setting" positionX="72" positionY="162" width="128" height="149"/>
<element name="Setting" positionX="72" positionY="162" width="128" height="119"/>
<element name="Status" positionX="0" positionY="0" width="128" height="584"/>
<element name="Subscription" positionX="81" positionY="171" width="128" height="149"/>
<element name="SubscriptionAlerts" positionX="72" positionY="162" width="128" height="149"/>
<element name="Subscription" positionX="81" positionY="171" width="128" height="179"/>
<element name="SubscriptionAlerts" positionX="72" positionY="162" width="128" height="14"/>
<element name="Tag" positionX="0" positionY="0" width="128" height="134"/>
</elements>
</model>

View File

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

View File

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

View File

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

View File

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

View File

@ -356,7 +356,8 @@
"favourite": "favorited your post",
"reblog": "rebloged your post",
"poll": "Your poll has ended",
"mention": "mentioned you"
"mention": "mentioned you",
"follow_request": "request to follow you"
}
},
"thread": {

File diff suppressed because it is too large Load Diff

View File

@ -4,25 +4,35 @@
<dict>
<key>SchemeUserState</key>
<dict>
<key>AppShared.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>18</integer>
</dict>
<key>CoreDataStack.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>13</integer>
<integer>17</integer>
</dict>
<key>Mastodon - RTL.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>9</integer>
<integer>2</integer>
</dict>
<key>Mastodon - Release.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>2</integer>
<integer>0</integer>
</dict>
<key>Mastodon.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>7</integer>
<integer>1</integer>
</dict>
<key>NotificationService.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>18</integer>
</dict>
</dict>
<key>SuppressBuildableAutocreation</key>

View File

@ -37,6 +37,15 @@
"version": "3.1.0"
}
},
{
"package": "Base85",
"repositoryURL": "https://github.com/MainasuK/Base85.git",
"state": {
"branch": null,
"revision": "626be96816618689627f806b5c875b5adb6346ef",
"version": "1.0.1"
}
},
{
"package": "CommonOSLog",
"repositoryURL": "https://github.com/MainasuK/CommonOSLog",
@ -46,6 +55,15 @@
"version": "0.1.1"
}
},
{
"package": "KeychainAccess",
"repositoryURL": "https://github.com/kishikawakatsumi/KeychainAccess.git",
"state": {
"branch": null,
"revision": "84e546727d66f1adc5439debad16270d0fdd04e7",
"version": "4.2.2"
}
},
{
"package": "Kingfisher",
"repositoryURL": "https://github.com/onevcat/Kingfisher.git",

View File

@ -62,14 +62,19 @@ extension SceneCoordinator {
case profile(viewModel: ProfileViewModel)
case favorite(viewModel: FavoriteViewModel)
// setting
case settings(viewModel: SettingsViewModel)
// report
case report(viewModel: ReportViewModel)
// suggestion account
case suggestionAccount(viewModel: SuggestionAccountViewModel)
// misc
case safari(url: URL)
case alertController(alertController: UIAlertController)
case activityViewController(activityViewController: UIActivityViewController, sourceView: UIView?, barButtonItem: UIBarButtonItem?)
case settings(viewModel: SettingsViewModel)
case report(viewModel: ReportViewModel)
#if DEBUG
case publicTimeline
#endif
@ -253,6 +258,10 @@ private extension SceneCoordinator {
let _viewController = FavoriteViewController()
_viewController.viewModel = viewModel
viewController = _viewController
case .settings(let viewModel):
let _viewController = SettingsViewController()
_viewController.viewModel = viewModel
viewController = _viewController
case .suggestionAccount(let viewModel):
let _viewController = SuggestionAccountViewController()
_viewController.viewModel = viewModel

View File

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

View File

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

View File

@ -92,6 +92,18 @@ extension NotificationSection {
cell.actionLabel.text = actionText + " · " + timeText
}
.store(in: &cell.disposeBag)
cell.acceptButton.publisher(for: .touchUpInside)
.sink { [weak cell] _ in
guard let cell = cell else { return }
cell.delegate?.notificationTableViewCell(cell, notification: notification, acceptButtonDidPressed: cell.acceptButton)
}
.store(in: &cell.disposeBag)
cell.rejectButton.publisher(for: .touchUpInside)
.sink { [weak cell] _ in
guard let cell = cell else { return }
cell.delegate?.notificationTableViewCell(cell, notification: notification, rejectButtonDidPressed: cell.rejectButton)
}
.store(in: &cell.disposeBag)
cell.actionImageBackground.backgroundColor = color
cell.actionLabel.text = actionText + " · " + timeText
cell.nameLabel.text = notification.account.displayName.isEmpty ? notification.account.username : notification.account.displayName
@ -109,6 +121,7 @@ extension NotificationSection {
if let actionImage = UIImage(systemName: actionImageName, withConfiguration: UIImage.SymbolConfiguration(pointSize: 12, weight: .semibold))?.withRenderingMode(.alwaysTemplate) {
cell.actionImageView.image = actionImage
}
cell.buttonStackView.isHidden = (type != .followRequest)
return cell
}
case .bottomLoader:

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -24,6 +24,8 @@ extension Mastodon.Entity.Notification.NotificationType {
color = Asset.Colors.Notification.mention.color
case .poll:
color = Asset.Colors.brandBlue.color
case .followRequest:
color = Asset.Colors.brandBlue.color
default:
color = .clear
}
@ -45,6 +47,8 @@ extension Mastodon.Entity.Notification.NotificationType {
actionText = L10n.Scene.Notification.Action.mention
case .poll:
actionText = L10n.Scene.Notification.Action.poll
case .followRequest:
actionText = L10n.Scene.Notification.Action.followRequest
default:
actionText = ""
}
@ -66,6 +70,8 @@ extension Mastodon.Entity.Notification.NotificationType {
actionImageName = "at"
case .poll:
actionImageName = "list.bullet"
case .followRequest:
actionImageName = "person.crop.circle"
default:
actionImageName = ""
}

View File

@ -16,3 +16,25 @@ extension String {
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
}
}

View File

@ -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 {
/// https://bluelemonbits.com/2018/08/26/inserting-cells-at-the-top-of-a-uitableview-with-no-scrolling/

View File

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

View File

@ -393,6 +393,8 @@ internal enum L10n {
internal static let favourite = L10n.tr("Localizable", "Scene.Notification.Action.Favourite")
/// followed you
internal static let follow = L10n.tr("Localizable", "Scene.Notification.Action.Follow")
/// request to follow you
internal static let followRequest = L10n.tr("Localizable", "Scene.Notification.Action.FollowRequest")
/// mentioned you
internal static let mention = L10n.tr("Localizable", "Scene.Notification.Action.Mention")
/// Your poll has ended

View File

@ -2,6 +2,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>aps-environment</key>
<string>development</string>
<key>com.apple.security.application-groups</key>
<array>
<string>group.org.joinmastodon.mastodon-temp</string>

View File

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

View File

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

View File

@ -44,8 +44,7 @@ extension UserProviderFacade {
return context.apiService.toggleFollow(
for: mastodonUser,
activeMastodonAuthenticationBox: activeMastodonAuthenticationBox,
needFeedback: true
activeMastodonAuthenticationBox: activeMastodonAuthenticationBox
)
}
.switchToLatest()

View File

@ -132,6 +132,7 @@ tap the link to confirm your account.";
"Scene.HomeTimeline.Title" = "Home";
"Scene.Notification.Action.Favourite" = "favorited your post";
"Scene.Notification.Action.Follow" = "followed you";
"Scene.Notification.Action.FollowRequest" = "request to follow you";
"Scene.Notification.Action.Mention" = "mentioned you";
"Scene.Notification.Action.Poll" = "Your poll has ended";
"Scene.Notification.Action.Reblog" = "rebloged your post";

View File

@ -336,9 +336,10 @@ extension HomeTimelineViewController {
}
@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(
scene: .settings(viewModel: viewModel),
scene: .settings(viewModel: settingsViewModel),
from: self,
transition: .modal(animated: true, completion: nil)
)

View File

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

View File

@ -85,7 +85,6 @@ class MainTabBarController: UITabBarController {
extension MainTabBarController {
open override var childForStatusBarStyle: UIViewController? {
return selectedViewController
}
@ -156,9 +155,36 @@ extension MainTabBarController {
}
.store(in: &disposeBag)
#if DEBUG
// selectedIndex = 3
#endif
// handle push notification. toggle entry when finish fetch latest notification
context.notificationService.hasUnreadPushNotification
.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)
}
}

View File

@ -88,6 +88,11 @@ extension NotificationViewController {
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
setNeedsStatusBarAppearanceUpdate()
}
@ -205,6 +210,14 @@ extension NotificationViewController: ContentOffsetAdjustableTimelineViewControl
// MARK: - NotificationTableViewCellDelegate
extension NotificationViewController: NotificationTableViewCellDelegate {
func notificationTableViewCell(_ cell: NotificationTableViewCell, notification: MastodonNotification, acceptButtonDidPressed button: UIButton) {
viewModel.acceptFollowRequest(notification: notification)
}
func notificationTableViewCell(_ cell: NotificationTableViewCell, notification: MastodonNotification, rejectButtonDidPressed button: UIButton) {
viewModel.rejectFollowRequest(notification: notification)
}
func userAvatarDidPressed(notification: MastodonNotification) {
let viewModel = ProfileViewModel(context: context, optionalMastodonUser: notification.account)
DispatchQueue.main.async {

View File

@ -53,7 +53,7 @@ extension NotificationViewModel.LoadLatestState {
sinceID: nil,
minID: nil,
limit: nil,
excludeTypes: [.followRequest],
excludeTypes: [],
accountID: nil
)
viewModel.context.apiService.allNotifications(
@ -61,23 +61,25 @@ extension NotificationViewModel.LoadLatestState {
query: query,
mastodonAuthenticationBox: activeMastodonAuthenticationBox
)
.sink { completion in
switch completion {
case .failure(let error):
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)
case .finished:
// handle isFetchingLatestTimeline in fetch controller delegate
break
}
stateMachine.enter(Idle.self)
} receiveValue: { response in
if response.value.isEmpty {
viewModel.isFetchingLatestNotification.value = false
}
.sink { completion in
switch completion {
case .failure(let error):
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)
case .finished:
// toggle unread state
viewModel.context.notificationService.hasUnreadPushNotification.value = false
// handle isFetchingLatestTimeline in fetch controller delegate
break
}
.store(in: &viewModel.disposeBag)
stateMachine.enter(Idle.self)
} receiveValue: { response in
if response.value.isEmpty {
viewModel.isFetchingLatestNotification.value = false
}
}
.store(in: &viewModel.disposeBag)
}
}

View File

@ -12,6 +12,7 @@ import Foundation
import GameplayKit
import MastodonSDK
import UIKit
import OSLog
final class NotificationViewModel: NSObject {
var disposeBag = Set<AnyCancellable>()
@ -120,6 +121,38 @@ final class NotificationViewModel: NSObject {
}
.store(in: &disposeBag)
}
func acceptFollowRequest(notification: MastodonNotification) {
guard let activeMastodonAuthenticationBox = self.activeMastodonAuthenticationBox.value else { return }
context.apiService.acceptFollowRequest(mastodonUserID: notification.account.id, mastodonAuthenticationBox: activeMastodonAuthenticationBox)
.sink { [weak self] completion in
switch completion {
case .failure(let error):
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: accept FollowRequest fail: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription)
case .finished:
self?.loadLatestStateMachine.enter(NotificationViewModel.LoadLatestState.Loading.self)
}
} receiveValue: { _ in
}
.store(in: &disposeBag)
}
func rejectFollowRequest(notification: MastodonNotification) {
guard let activeMastodonAuthenticationBox = self.activeMastodonAuthenticationBox.value else { return }
context.apiService.rejectFollowRequest(mastodonUserID: notification.account.id, mastodonAuthenticationBox: activeMastodonAuthenticationBox)
.sink { [weak self] completion in
switch completion {
case .failure(let error):
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: reject FollowRequest fail: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription)
case .finished:
self?.loadLatestStateMachine.enter(NotificationViewModel.LoadLatestState.Loading.self)
}
} receiveValue: { _ in
}
.store(in: &disposeBag)
}
}
extension NotificationViewModel {

View File

@ -21,6 +21,10 @@ protocol NotificationTableViewCellDelegate: AnyObject {
func notificationStatusTableViewCell(_ cell: NotificationStatusTableViewCell, statusView: StatusView, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView)
func notificationStatusTableViewCell(_ cell: NotificationStatusTableViewCell, statusView: StatusView, playerContainerView: PlayerContainerView, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView)
func notificationTableViewCell(_ cell: NotificationTableViewCell, notification: MastodonNotification, acceptButtonDidPressed button: UIButton)
func notificationTableViewCell(_ cell: NotificationTableViewCell, notification: MastodonNotification, rejectButtonDidPressed button: UIButton)
}
final class NotificationTableViewCell: UITableViewCell {
@ -76,6 +80,24 @@ final class NotificationTableViewCell: UITableViewCell {
return label
}()
let acceptButton: UIButton = {
let button = UIButton(type: .custom)
let actionImage = UIImage(systemName: "checkmark.circle.fill", withConfiguration: UIImage.SymbolConfiguration(pointSize: 28, weight: .semibold))?.withRenderingMode(.alwaysTemplate)
button.setImage(actionImage, for: .normal)
button.tintColor = Asset.Colors.Label.secondary.color
return button
}()
let rejectButton: UIButton = {
let button = UIButton(type: .custom)
let actionImage = UIImage(systemName: "xmark.circle.fill", withConfiguration: UIImage.SymbolConfiguration(pointSize: 28, weight: .semibold))?.withRenderingMode(.alwaysTemplate)
button.setImage(actionImage, for: .normal)
button.tintColor = Asset.Colors.Label.secondary.color
return button
}()
let buttonStackView = UIStackView()
override func prepareForReuse() {
super.prepareForReuse()
avatatImageView.af.cancelImageRequest()
@ -97,9 +119,8 @@ extension NotificationTableViewCell {
func configure() {
let containerStackView = UIStackView()
containerStackView.axis = .horizontal
containerStackView.alignment = .center
containerStackView.spacing = 4
containerStackView.axis = .vertical
containerStackView.alignment = .fill
containerStackView.layoutMargins = UIEdgeInsets(top: 14, left: 0, bottom: 12, right: 0)
containerStackView.isLayoutMarginsRelativeArrangement = true
containerStackView.translatesAutoresizingMaskIntoConstraints = false
@ -110,8 +131,13 @@ extension NotificationTableViewCell {
contentView.readableContentGuide.trailingAnchor.constraint(equalTo: containerStackView.trailingAnchor),
contentView.bottomAnchor.constraint(equalTo: containerStackView.bottomAnchor),
])
let horizontalStackView = UIStackView()
horizontalStackView.translatesAutoresizingMaskIntoConstraints = false
horizontalStackView.axis = .horizontal
horizontalStackView.spacing = 6
containerStackView.addArrangedSubview(avatarContainer)
horizontalStackView.addArrangedSubview(avatarContainer)
avatarContainer.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
avatarContainer.heightAnchor.constraint(equalToConstant: 47).priority(.required - 1),
@ -144,13 +170,23 @@ extension NotificationTableViewCell {
])
nameLabel.translatesAutoresizingMaskIntoConstraints = false
containerStackView.addArrangedSubview(nameLabel)
horizontalStackView.addArrangedSubview(nameLabel)
actionLabel.translatesAutoresizingMaskIntoConstraints = false
containerStackView.addArrangedSubview(actionLabel)
nameLabel.setContentCompressionResistancePriority(.required - 1, for: .vertical)
nameLabel.setContentHuggingPriority(.defaultHigh, for: .horizontal)
horizontalStackView.addArrangedSubview(actionLabel)
nameLabel.setContentCompressionResistancePriority(.required - 1, for: .horizontal)
nameLabel.setContentHuggingPriority(.required - 1, for: .horizontal)
actionLabel.setContentHuggingPriority(.defaultLow, for: .horizontal)
containerStackView.addArrangedSubview(horizontalStackView)
buttonStackView.translatesAutoresizingMaskIntoConstraints = false
buttonStackView.axis = .horizontal
buttonStackView.distribution = .fillEqually
acceptButton.translatesAutoresizingMaskIntoConstraints = false
rejectButton.translatesAutoresizingMaskIntoConstraints = false
buttonStackView.addArrangedSubview(acceptButton)
buttonStackView.addArrangedSubview(rejectButton)
containerStackView.addArrangedSubview(buttonStackView)
}
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {

View File

@ -336,6 +336,7 @@ extension MastodonPickServerViewController {
} else {
let mastodonRegisterViewModel = MastodonRegisterViewModel(
domain: server.domain,
context: self.context,
authenticateInfo: response.authenticateInfo,
instance: response.instance.value,
applicationToken: response.applicationToken.value

View File

@ -18,6 +18,7 @@ final class MastodonRegisterViewModel {
let authenticateInfo: AuthenticationViewModel.AuthenticateInfo
let instance: Mastodon.Entity.Instance
let applicationToken: Mastodon.Entity.Token
let context: AppContext
let username = CurrentValueSubject<String, Never>("")
let displayName = CurrentValueSubject<String, Never>("")
@ -46,11 +47,13 @@ final class MastodonRegisterViewModel {
init(
domain: String,
context: AppContext,
authenticateInfo: AuthenticationViewModel.AuthenticateInfo,
instance: Mastodon.Entity.Instance,
applicationToken: Mastodon.Entity.Token
) {
self.domain = domain
self.context = context
self.authenticateInfo = authenticateInfo
self.instance = instance
self.applicationToken = applicationToken
@ -78,6 +81,45 @@ final class MastodonRegisterViewModel {
}
.assign(to: \.value, on: usernameValidateState)
.store(in: &disposeBag)
username
.filter { !$0.isEmpty }
.debounce(for: .milliseconds(300), scheduler: DispatchQueue.main)
.removeDuplicates()
.compactMap { [weak self] text -> AnyPublisher<Result<Mastodon.Response.Content<Mastodon.Entity.Account>, Error>, Never>? in
guard let self = self else { return nil }
let query = Mastodon.API.Account.AccountLookupQuery(acct: text)
return context.apiService.accountLookup(domain: domain, query: query, authorization: self.applicationAuthorization)
.map {
response -> Result<Mastodon.Response.Content<Mastodon.Entity.Account>, Error> in
Result.success(response)
}
.catch { error in
Just(Result.failure(error))
}
.eraseToAnyPublisher()
}
.switchToLatest()
.sink { [weak self] result in
guard let self = self else { return }
switch result {
case .success:
let text = L10n.Scene.Register.Error.Reason.taken(L10n.Scene.Register.Error.Item.username)
self.usernameErrorPrompt.value = MastodonRegisterViewModel.errorPromptAttributedString(for: text)
case .failure:
break
}
}
.store(in: &disposeBag)
usernameValidateState
.sink { [weak self] validateState in
if validateState == .valid {
self?.usernameErrorPrompt.value = nil
}
}
.store(in: &disposeBag)
displayName
.map { displayname in
guard !displayname.isEmpty else { return .empty }
@ -115,7 +157,8 @@ final class MastodonRegisterViewModel {
let error = error as? Mastodon.API.Error
let mastodonError = error?.mastodonError
if case let .generic(genericMastodonError) = mastodonError,
let details = genericMastodonError.details {
let details = genericMastodonError.details
{
self.usernameErrorPrompt.value = details.usernameErrorDescriptions.first.flatMap { MastodonRegisterViewModel.errorPromptAttributedString(for: $0) }
self.emailErrorPrompt.value = details.emailErrorDescriptions.first.flatMap { MastodonRegisterViewModel.errorPromptAttributedString(for: $0) }
self.passwordErrorPrompt.value = details.passwordErrorDescriptions.first.flatMap { MastodonRegisterViewModel.errorPromptAttributedString(for: $0) }
@ -139,7 +182,7 @@ final class MastodonRegisterViewModel {
Publishers.CombineLatest(
publisherOne,
approvalRequired ? reasonValidateState.map {$0 == .valid}.eraseToAnyPublisher() : Just(true).eraseToAnyPublisher()
approvalRequired ? reasonValidateState.map { $0 == .valid }.eraseToAnyPublisher() : Just(true).eraseToAnyPublisher()
)
.map { $0 && $1 }
.assign(to: \.value, on: isAllValid)
@ -156,7 +199,6 @@ extension MastodonRegisterViewModel {
}
extension MastodonRegisterViewModel {
static func isValidEmail(_ email: String) -> Bool {
let emailRegEx = "[A-Z0-9a-z._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,64}"
@ -206,5 +248,4 @@ extension MastodonRegisterViewModel {
return attributeString
}
}

View File

@ -204,7 +204,7 @@ extension MastodonServerRulesViewController {
@objc private func confirmButtonPressed(_ sender: UIButton) {
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
let viewModel = MastodonRegisterViewModel(domain: self.viewModel.domain, authenticateInfo: self.viewModel.authenticateInfo, instance: self.viewModel.instance, applicationToken: self.viewModel.applicationToken)
let viewModel = MastodonRegisterViewModel(domain: self.viewModel.domain, context: self.context, authenticateInfo: self.viewModel.authenticateInfo, instance: self.viewModel.instance, applicationToken: self.viewModel.applicationToken)
self.coordinator.present(scene: .mastodonRegister(viewModel: viewModel), from: self, transition: .show)
}
}

View File

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

View File

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

View File

@ -13,37 +13,21 @@ import MastodonSDK
import UIKit
import os.log
class SettingsViewModel: NSObject {
// confirm set only once
weak var context: AppContext! { willSet { precondition(context == nil) } }
class SettingsViewModel {
var dataSource: UITableViewDiffableDataSource<SettingsSection, SettingsItem>!
var disposeBag = Set<AnyCancellable>()
let context: AppContext
// input
let setting: CurrentValueSubject<Setting, Never>
var updateDisposeBag = Set<AnyCancellable>()
var createDisposeBag = Set<AnyCancellable>()
let viewDidLoad = PassthroughSubject<Void, Never>()
lazy var fetchResultsController: NSFetchedResultsController<Setting> = {
let fetchRequest = Setting.sortedFetchRequest
if let box =
self.context.authenticationService.activeMastodonAuthenticationBox.value {
let domain = box.domain
fetchRequest.predicate = Setting.predicate(domain: domain, userID: box.userID)
}
fetchRequest.fetchLimit = 1
fetchRequest.returnsObjectsAsFaults = false
let controller = NSFetchedResultsController(
fetchRequest: fetchRequest,
managedObjectContext: context.managedObjectContext,
sectionNameKeyPath: nil,
cacheName: nil
)
controller.delegate = self
return controller
}()
let setting = CurrentValueSubject<Setting?, Never>(nil)
// output
var dataSource: UITableViewDiffableDataSource<SettingsSection, SettingsItem>!
/// create a subscription when:
/// - does not has one
/// - does not find subscription for selected trigger when change trigger
@ -53,22 +37,6 @@ class SettingsViewModel: NSObject {
/// - change switch for specified alerts
let updateSubscriptionSubject = PassthroughSubject<(triggerBy: String, values: [Bool?]), Never>()
lazy var notificationDefaultValue: [String: [Bool?]] = {
let followerSwitchItems: [Bool?] = [true, nil, true, true]
let anyoneSwitchItems: [Bool?] = [true, true, true, true]
let noOneSwitchItems: [Bool?] = [nil, nil, nil, nil]
let followSwitchItems: [Bool?] = [true, true, true, true]
let anyone = L10n.Scene.Settings.Section.Notifications.Trigger.anyone
let follower = L10n.Scene.Settings.Section.Notifications.Trigger.follower
let follow = L10n.Scene.Settings.Section.Notifications.Trigger.follow
let noOne = L10n.Scene.Settings.Section.Notifications.Trigger.noone
return [anyone: anyoneSwitchItems,
follower: followerSwitchItems,
follow: followSwitchItems,
noOne: noOneSwitchItems]
}()
lazy var privacyURL: URL? = {
guard let box = AppContext.shared.authenticationService.activeMastodonAuthenticationBox.value else {
return nil
@ -77,315 +45,151 @@ class SettingsViewModel: NSObject {
return Mastodon.API.privacyURL(domain: box.domain)
}()
/// to store who trigger the notification.
var triggerBy: String?
struct Input {
}
struct Output {
}
init(context: AppContext) {
init(context: AppContext, setting: Setting) {
self.context = context
self.setting = CurrentValueSubject(setting)
super.init()
}
func transform(input: Input?) -> Output? {
typealias SubscriptionResponse = Mastodon.Response.Content<Mastodon.Entity.Subscription>
createSubscriptionSubject
.debounce(for: .milliseconds(300), scheduler: DispatchQueue.main)
.sink { _ in
} receiveValue: { [weak self] (arg) in
let (triggerBy, values) = arg
guard let self = self else {
return
}
guard let activeMastodonAuthenticationBox =
self.context.authenticationService.activeMastodonAuthenticationBox.value else {
return
}
guard values.count >= 4 else {
return
}
self.createDisposeBag.removeAll()
typealias Query = Mastodon.API.Subscriptions.CreateSubscriptionQuery
let domain = activeMastodonAuthenticationBox.domain
let query = Query(
// FIXME: to replace the correct endpoint, p256dh, auth
endpoint: "http://www.google.com",
p256dh: "BLQELIDm-6b9Bl07YrEuXJ4BL_YBVQ0dvt9NQGGJxIQidJWHPNa9YrouvcQ9d7_MqzvGS9Alz60SZNCG3qfpk=",
auth: "4vQK-SvRAN5eo-8ASlrwA==",
favourite: values[0],
follow: values[1],
reblog: values[2],
mention: values[3],
poll: nil
)
self.context.apiService.changeSubscription(
domain: domain,
mastodonAuthenticationBox: activeMastodonAuthenticationBox,
query: query,
triggerBy: triggerBy,
userID: activeMastodonAuthenticationBox.userID
)
.sink { (_) in
} receiveValue: { (_) in
}
.store(in: &self.createDisposeBag)
}
self.setting
.sink(receiveValue: { [weak self] setting in
guard let self = self else { return }
self.processDataSource(setting)
})
.store(in: &disposeBag)
updateSubscriptionSubject
.debounce(for: .milliseconds(300), scheduler: DispatchQueue.main)
.sink { _ in
} receiveValue: { [weak self] (arg) in
let (triggerBy, values) = arg
guard let self = self else {
return
}
guard let activeMastodonAuthenticationBox =
self.context.authenticationService.activeMastodonAuthenticationBox.value else {
return
}
guard values.count >= 4 else {
return
}
self.updateDisposeBag.removeAll()
typealias Query = Mastodon.API.Subscriptions.UpdateSubscriptionQuery
let domain = activeMastodonAuthenticationBox.domain
let query = Query(
favourite: values[0],
follow: values[1],
reblog: values[2],
mention: values[3],
poll: nil)
self.context.apiService.updateSubscription(
domain: domain,
mastodonAuthenticationBox: activeMastodonAuthenticationBox,
query: query,
triggerBy: triggerBy,
userID: activeMastodonAuthenticationBox.userID
)
.sink { (_) in
} receiveValue: { (_) in
}
.store(in: &self.updateDisposeBag)
}
.store(in: &disposeBag)
// build data for table view
buildDataSource()
// request subsription data for updating or initialization
requestSubscription()
return nil
}
// MARK: - Private methods
fileprivate func processDataSource(_ settings: Setting?) {
var snapshot = NSDiffableDataSourceSnapshot<SettingsSection, SettingsItem>()
// appearance
let appearnceMode = SettingsItem.AppearanceMode(rawValue: settings?.appearance ?? "") ?? .automatic
let appearanceItem = SettingsItem.apperance(item: appearnceMode)
let appearance = SettingsSection.apperance(title: L10n.Scene.Settings.Section.Appearance.title, selectedMode:appearanceItem)
snapshot.appendSections([appearance])
snapshot.appendItems([appearanceItem])
// notifications
var switches: [Bool?]?
if let alerts = settings?.subscription?.first(where: { (s) -> Bool in
return s.type == settings?.triggerBy
})?.alert {
var items = [Bool?]()
items.append(alerts.favourite?.boolValue)
items.append(alerts.follow?.boolValue)
items.append(alerts.reblog?.boolValue)
items.append(alerts.mention?.boolValue)
switches = items
} else if let triggerBy = settings?.triggerBy,
let values = self.notificationDefaultValue[triggerBy] {
switches = values
} else {
// fallback a default value
let anyone = L10n.Scene.Settings.Section.Notifications.Trigger.anyone
switches = self.notificationDefaultValue[anyone]
}
let notifications = [L10n.Scene.Settings.Section.Notifications.favorites,
L10n.Scene.Settings.Section.Notifications.follows,
L10n.Scene.Settings.Section.Notifications.boosts,
L10n.Scene.Settings.Section.Notifications.mentions,]
var notificationItems = [SettingsItem]()
for (i, noti) in notifications.enumerated() {
var value: Bool? = nil
if let switches = switches, i < switches.count {
value = switches[i]
}
let item = SettingsItem.notification(item: SettingsItem.NotificationSwitch(title: noti, isOn: value == true, enable: value != nil))
notificationItems.append(item)
}
let notificationSection = SettingsSection.notifications(title: L10n.Scene.Settings.Section.Notifications.title, items: notificationItems)
snapshot.appendSections([notificationSection])
snapshot.appendItems(notificationItems)
// boring zone
let boringLinks = [L10n.Scene.Settings.Section.Boringzone.terms,
L10n.Scene.Settings.Section.Boringzone.privacy]
var boringLinkItems = [SettingsItem]()
for l in boringLinks {
let item = SettingsItem.boringZone(item: SettingsItem.Link(title: l, color: .systemBlue))
boringLinkItems.append(item)
}
let boringSection = SettingsSection.boringZone(title: L10n.Scene.Settings.Section.Boringzone.title, items: boringLinkItems)
snapshot.appendSections([boringSection])
snapshot.appendItems(boringLinkItems)
// spicy zone
let spicyLinks = [L10n.Scene.Settings.Section.Spicyzone.clear,
L10n.Scene.Settings.Section.Spicyzone.signout]
var spicyLinkItems = [SettingsItem]()
for l in spicyLinks {
let item = SettingsItem.spicyZone(item: SettingsItem.Link(title: l, color: .systemRed))
spicyLinkItems.append(item)
}
let spicySection = SettingsSection.spicyZone(title: L10n.Scene.Settings.Section.Spicyzone.title, items: spicyLinkItems)
snapshot.appendSections([spicySection])
snapshot.appendItems(spicyLinkItems)
self.dataSource.apply(snapshot, animatingDifferences: false)
}
private func buildDataSource() {
setting.sink { [weak self] (settings) in
guard let self = self else { return }
self.processDataSource(settings)
}
.store(in: &disposeBag)
}
private func requestSubscription() {
setting.sink { [weak self] (settings) in
guard let self = self else { return }
guard settings != nil else { return }
guard self.triggerBy != settings?.triggerBy else { return }
self.triggerBy = settings?.triggerBy
var switches: [Bool?]?
var who: String?
if let alerts = settings?.subscription?.first(where: { (s) -> Bool in
return s.type == settings?.triggerBy
})?.alert {
var items = [Bool?]()
items.append(alerts.favourite?.boolValue)
items.append(alerts.follow?.boolValue)
items.append(alerts.reblog?.boolValue)
items.append(alerts.mention?.boolValue)
switches = items
who = settings?.triggerBy
} else if let triggerBy = settings?.triggerBy,
let values = self.notificationDefaultValue[triggerBy] {
switches = values
who = triggerBy
} else {
// fallback a default value
let anyone = L10n.Scene.Settings.Section.Notifications.Trigger.anyone
switches = self.notificationDefaultValue[anyone]
who = anyone
}
// should create a subscription whenever change trigger
if let values = switches, let triggerBy = who {
self.createSubscriptionSubject.send((triggerBy: triggerBy, values: values))
}
}
.store(in: &disposeBag)
guard let activeMastodonAuthenticationBox = self.context.authenticationService.activeMastodonAuthenticationBox.value else {
return
}
let domain = activeMastodonAuthenticationBox.domain
let userId = activeMastodonAuthenticationBox.userID
do {
try fetchResultsController.performFetch()
if nil == fetchResultsController.fetchedObjects?.first {
let anyone = L10n.Scene.Settings.Section.Notifications.Trigger.anyone
setting.value = self.context.apiService.createSettingIfNeed(domain: domain,
userId: userId,
triggerBy: anyone)
} else {
setting.value = fetchResultsController.fetchedObjects?.first
}
} catch {
assertionFailure(error.localizedDescription)
}
}
deinit {
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s:", ((#file as NSString).lastPathComponent), #line, #function)
}
}
// MARK: - NSFetchedResultsControllerDelegate
extension SettingsViewModel: NSFetchedResultsControllerDelegate {
extension SettingsViewModel {
func controllerWillChangeContent(_ controller: NSFetchedResultsController<NSFetchRequestResult>) {
os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
}
// MARK: - Private methods
private func processDataSource(_ setting: Setting) {
guard let dataSource = self.dataSource else { return }
var snapshot = NSDiffableDataSourceSnapshot<SettingsSection, SettingsItem>()
func controllerDidChangeContent(_ controller: NSFetchedResultsController<NSFetchRequestResult>) {
guard controller === fetchResultsController else {
return
// appearance
let appearanceItems = [SettingsItem.apperance(settingObjectID: setting.objectID)]
snapshot.appendSections([.apperance])
snapshot.appendItems(appearanceItems, toSection: .apperance)
let notificationItems = SettingsItem.NotificationSwitchMode.allCases.map { mode in
SettingsItem.notification(settingObjectID: setting.objectID, switchMode: mode)
}
snapshot.appendSections([.notifications])
snapshot.appendItems(notificationItems, toSection: .notifications)
// boring zone
let boringZoneSettingsItems: [SettingsItem] = {
let links: [SettingsItem.Link] = [
.termsOfService,
.privacyPolicy
]
let items = links.map { SettingsItem.boringZone(item: $0) }
return items
}()
snapshot.appendSections([.boringZone])
snapshot.appendItems(boringZoneSettingsItems, toSection: .boringZone)
let spicyZoneSettingsItems: [SettingsItem] = {
let links: [SettingsItem.Link] = [
.clearMediaCache,
.signOut
]
let items = links.map { SettingsItem.spicyZone(item: $0) }
return items
}()
snapshot.appendSections([.spicyZone])
snapshot.appendItems(spicyZoneSettingsItems, toSection: .spicyZone)
dataSource.apply(snapshot, animatingDifferences: false)
}
}
extension SettingsViewModel {
func setupDiffableDataSource(
for tableView: UITableView,
settingsAppearanceTableViewCellDelegate: SettingsAppearanceTableViewCellDelegate,
settingsToggleCellDelegate: SettingsToggleCellDelegate
) {
dataSource = UITableViewDiffableDataSource(tableView: tableView) { [
weak self,
weak settingsAppearanceTableViewCellDelegate,
weak settingsToggleCellDelegate
] tableView, indexPath, item -> UITableViewCell? in
guard let self = self else { return nil }
switch item {
case .apperance(let objectID):
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: SettingsAppearanceTableViewCell.self), for: indexPath) as! SettingsAppearanceTableViewCell
self.context.managedObjectContext.performAndWait {
let setting = self.context.managedObjectContext.object(with: objectID) as! Setting
cell.update(with: setting.appearance)
ManagedObjectObserver.observe(object: setting)
.sink(receiveCompletion: { _ in
// do nothing
}, receiveValue: { [weak cell] change in
guard let cell = cell else { return }
guard case .update(let object) = change.changeType,
let setting = object as? Setting else { return }
cell.update(with: setting.appearance)
})
.store(in: &cell.disposeBag)
}
cell.delegate = settingsAppearanceTableViewCellDelegate
return cell
case .notification(let objectID, let switchMode):
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: SettingsToggleTableViewCell.self), for: indexPath) as! SettingsToggleTableViewCell
self.context.managedObjectContext.performAndWait {
let setting = self.context.managedObjectContext.object(with: objectID) as! Setting
if let subscription = setting.activeSubscription {
SettingsViewModel.configureSettingToggle(cell: cell, switchMode: switchMode, subscription: subscription)
}
ManagedObjectObserver.observe(object: setting)
.sink(receiveCompletion: { _ in
// do nothing
}, receiveValue: { [weak cell] change in
guard let cell = cell else { return }
guard case .update(let object) = change.changeType,
let setting = object as? Setting else { return }
guard let subscription = setting.activeSubscription else { return }
SettingsViewModel.configureSettingToggle(cell: cell, switchMode: switchMode, subscription: subscription)
})
.store(in: &cell.disposeBag)
}
cell.delegate = settingsToggleCellDelegate
return cell
case .boringZone(let item), .spicyZone(let item):
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: SettingsLinkTableViewCell.self), for: indexPath) as! SettingsLinkTableViewCell
cell.update(with: item)
return cell
}
}
setting.value = fetchResultsController.fetchedObjects?.first
processDataSource(self.setting.value)
}
}
enum SettingsSection: Hashable {
case apperance(title: String, selectedMode: SettingsItem)
case notifications(title: String, items: [SettingsItem])
case boringZone(title: String, items: [SettingsItem])
case spicyZone(title: String, items: [SettingsItem])
extension SettingsViewModel {
var title: String {
switch self {
case .apperance(let title, _),
.notifications(let title, _),
.boringZone(let title, _),
.spicyZone(let title, _):
return title
static func configureSettingToggle(
cell: SettingsToggleTableViewCell,
switchMode: SettingsItem.NotificationSwitchMode,
subscription: NotificationSubscription
) {
cell.textLabel?.text = switchMode.title
let enabled: Bool?
switch switchMode {
case .favorite: enabled = subscription.alert.favourite
case .follow: enabled = subscription.alert.follow
case .reblog: enabled = subscription.alert.reblog
case .mention: enabled = subscription.alert.mention
}
cell.update(enabled: enabled)
}
}
enum SettingsItem: Hashable {
enum AppearanceMode: String {
case automatic
case light
case dark
}
struct NotificationSwitch: Hashable {
let title: String
let isOn: Bool
let enable: Bool
}
struct Link: Hashable {
let title: String
let color: UIColor
}
case apperance(item: AppearanceMode)
case notification(item: NotificationSwitch)
case boringZone(item: Link)
case spicyZone(item: Link)
}

View File

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

View File

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

View File

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

View File

@ -188,8 +188,7 @@ final class SuggestionAccountViewModel: NSObject {
let mastodonUser = context.managedObjectContext.object(with: objectID) as! MastodonUser
return context.apiService.toggleFollow(
for: mastodonUser,
activeMastodonAuthenticationBox: activeMastodonAuthenticationBox,
needFeedback: false
activeMastodonAuthenticationBox: activeMastodonAuthenticationBox
)
}

View File

@ -47,4 +47,43 @@ final class RemoteThreadViewModel: ThreadViewModel {
}
.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)
}
}

View File

@ -152,4 +152,17 @@ extension APIService {
)
}
func accountLookup(
domain: String,
query: Mastodon.API.Account.AccountLookupQuery,
authorization: Mastodon.API.OAuth.Authorization
) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Account>, Error> {
return Mastodon.API.Account.lookupAccount(
session: session,
domain: domain,
query: query,
authorization: authorization
)
}
}

View File

@ -24,15 +24,12 @@ extension APIService {
/// - Returns: publisher for `Relationship`
func toggleFollow(
for mastodonUser: MastodonUser,
activeMastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox,
needFeedback: Bool
activeMastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox
) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Relationship>, Error> {
var impactFeedbackGenerator: UIImpactFeedbackGenerator?
var notificationFeedbackGenerator: UINotificationFeedbackGenerator?
if needFeedback {
impactFeedbackGenerator = UIImpactFeedbackGenerator(style: .light)
notificationFeedbackGenerator = UINotificationFeedbackGenerator()
}
let impactFeedbackGenerator = UIImpactFeedbackGenerator(style: .light)
let notificationFeedbackGenerator = UINotificationFeedbackGenerator()
return followUpdateLocal(
mastodonUserObjectID: mastodonUser.objectID,
@ -40,9 +37,9 @@ extension APIService {
)
.receive(on: DispatchQueue.main)
.handleEvents { _ in
impactFeedbackGenerator?.prepare()
impactFeedbackGenerator.prepare()
} receiveOutput: { _ in
impactFeedbackGenerator?.impactOccurred()
impactFeedbackGenerator.impactOccurred()
} receiveCompletion: { completion in
switch completion {
case .failure(let error):
@ -79,13 +76,13 @@ extension APIService {
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Friendship] rollback finish", ((#file as NSString).lastPathComponent), #line, #function)
} receiveValue: { _ in
// do nothing
notificationFeedbackGenerator?.prepare()
notificationFeedbackGenerator?.notificationOccurred(.error)
notificationFeedbackGenerator.prepare()
notificationFeedbackGenerator.notificationOccurred(.error)
}
.store(in: &self.disposeBag)
case .finished:
notificationFeedbackGenerator?.notificationOccurred(.success)
notificationFeedbackGenerator.notificationOccurred(.success)
os_log("%{public}s[%{public}ld], %{public}s: [Friendship] remote friendship update success", ((#file as NSString).lastPathComponent), #line, #function)
}
})

View File

@ -0,0 +1,105 @@
//
// APIService+FollowRequest.swift
// Mastodon
//
// Created by sxiaojian on 2021/4/27.
//
import Foundation
import UIKit
import Combine
import CoreData
import CoreDataStack
import CommonOSLog
import MastodonSDK
extension APIService {
func acceptFollowRequest(
mastodonUserID: MastodonUser.ID,
mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox
) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Relationship>, Error> {
let domain = mastodonAuthenticationBox.domain
let authorization = mastodonAuthenticationBox.userAuthorization
let requestMastodonUserID = mastodonAuthenticationBox.userID
return Mastodon.API.Account.acceptFollowRequest(
session: session,
domain: domain,
userID: mastodonUserID,
authorization: authorization)
.flatMap { response -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Relationship>, Error> in
let managedObjectContext = self.backgroundManagedObjectContext
return managedObjectContext.performChanges {
let requestMastodonUserRequest = MastodonUser.sortedFetchRequest
requestMastodonUserRequest.predicate = MastodonUser.predicate(domain: domain, id: requestMastodonUserID)
requestMastodonUserRequest.fetchLimit = 1
guard let requestMastodonUser = managedObjectContext.safeFetch(requestMastodonUserRequest).first else { return }
let lookUpMastodonUserRequest = MastodonUser.sortedFetchRequest
lookUpMastodonUserRequest.predicate = MastodonUser.predicate(domain: domain, id: mastodonUserID)
lookUpMastodonUserRequest.fetchLimit = 1
let lookUpMastodonuser = managedObjectContext.safeFetch(lookUpMastodonUserRequest).first
if let lookUpMastodonuser = lookUpMastodonuser {
let entity = response.value
APIService.CoreData.update(user: lookUpMastodonuser, entity: entity, requestMastodonUser: requestMastodonUser, domain: domain, networkDate: response.networkDate)
}
}
.tryMap { result -> Mastodon.Response.Content<Mastodon.Entity.Relationship> in
switch result {
case .success:
return response
case .failure(let error):
throw error
}
}
.eraseToAnyPublisher()
}
.eraseToAnyPublisher()
}
func rejectFollowRequest(
mastodonUserID: MastodonUser.ID,
mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox
) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Relationship>, Error> {
let domain = mastodonAuthenticationBox.domain
let authorization = mastodonAuthenticationBox.userAuthorization
let requestMastodonUserID = mastodonAuthenticationBox.userID
return Mastodon.API.Account.rejectFollowRequest(
session: session,
domain: domain,
userID: mastodonUserID,
authorization: authorization)
.flatMap { response -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Relationship>, Error> in
let managedObjectContext = self.backgroundManagedObjectContext
return managedObjectContext.performChanges {
let requestMastodonUserRequest = MastodonUser.sortedFetchRequest
requestMastodonUserRequest.predicate = MastodonUser.predicate(domain: domain, id: requestMastodonUserID)
requestMastodonUserRequest.fetchLimit = 1
guard let requestMastodonUser = managedObjectContext.safeFetch(requestMastodonUserRequest).first else { return }
let lookUpMastodonUserRequest = MastodonUser.sortedFetchRequest
lookUpMastodonUserRequest.predicate = MastodonUser.predicate(domain: domain, id: mastodonUserID)
lookUpMastodonUserRequest.fetchLimit = 1
let lookUpMastodonuser = managedObjectContext.safeFetch(lookUpMastodonUserRequest).first
if let lookUpMastodonuser = lookUpMastodonuser {
let entity = response.value
APIService.CoreData.update(user: lookUpMastodonuser, entity: entity, requestMastodonUser: requestMastodonUser, domain: domain, networkDate: response.networkDate)
}
}
.tryMap { result -> Mastodon.Response.Content<Mastodon.Entity.Relationship> in
switch result {
case .success:
return response
case .failure(let error):
throw error
}
}
.eraseToAnyPublisher()
}
.eraseToAnyPublisher()
}
}

View File

@ -29,6 +29,14 @@ extension APIService {
.flatMap { response -> AnyPublisher<Mastodon.Response.Content<[Mastodon.Entity.Notification]>, Error> in
let log = OSLog.api
return self.backgroundManagedObjectContext.performChanges {
if query.maxID == nil {
let requestMastodonNotificationRequest = MastodonNotification.sortedFetchRequest
requestMastodonNotificationRequest.predicate = MastodonNotification.predicate(domain: domain, userID: userID)
let oldNotifications = self.backgroundManagedObjectContext.safeFetch(requestMastodonNotificationRequest)
oldNotifications.forEach { notification in
self.backgroundManagedObjectContext.delete(notification)
}
}
response.value.forEach { notification in
let (mastodonUser, _) = APIService.CoreData.createOrMergeMastodonUser(into: self.backgroundManagedObjectContext, for: nil, in: domain, entity: notification.account, userCache: nil, networkDate: Date(), log: log)
var status: Status?
@ -64,4 +72,48 @@ extension APIService {
}
.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()
}
}

View File

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

View File

@ -0,0 +1,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)
}
}

View File

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

View File

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

View File

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

View File

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

View File

@ -28,6 +28,8 @@ class AppContext: ObservableObject {
let videoPlaybackService = VideoPlaybackService()
let statusPrefetchingService: StatusPrefetchingService
let statusPublishService = StatusPublishService()
let notificationService: NotificationService
let settingService: SettingService
let documentStore: DocumentStore
private var documentStoreSubscription: AnyCancellable!
@ -45,11 +47,12 @@ class AppContext: ObservableObject {
let _apiService = APIService(backgroundManagedObjectContext: _backgroundManagedObjectContext)
apiService = _apiService
authenticationService = AuthenticationService(
let _authenticationService = AuthenticationService(
managedObjectContext: _managedObjectContext,
backgroundManagedObjectContext: _backgroundManagedObjectContext,
apiService: _apiService
)
authenticationService = _authenticationService
emojiService = EmojiService(
apiService: apiService
@ -57,6 +60,17 @@ class AppContext: ObservableObject {
statusPrefetchingService = StatusPrefetchingService(
apiService: _apiService
)
let _notificationService = NotificationService(
apiService: _apiService,
authenticationService: _authenticationService
)
notificationService = _notificationService
settingService = SettingService(
apiService: _apiService,
authenticationService: _authenticationService,
notificationService: _notificationService
)
documentStore = DocumentStore()
documentStoreSubscription = documentStore.objectWillChange

View File

@ -5,7 +5,10 @@
// Created by MainasuK Cirno on 2021/1/22.
//
import os.log
import UIKit
import UserNotifications
import AppShared
@main
class AppDelegate: UIResponder, UIApplicationDelegate {
@ -14,10 +17,15 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
AppSecret.default.register()
// Update app version info. See: `Settings.bundle`
UserDefaults.standard.setValue(UIApplication.appVersion(), forKey: "Mastodon.appVersion")
UserDefaults.standard.setValue(UIApplication.appBuild(), forKey: "Mastodon.appBundle")
UNUserNotificationCenter.current().delegate = self
application.registerForRemoteNotifications()
return true
}
@ -38,13 +46,70 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
}
extension AppDelegate {
func application(_ application: UIApplication, supportedInterfaceOrientationsFor window: UIWindow?) -> UIInterfaceOrientationMask {
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 {
static var shared: AppContext {

View File

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

View File

@ -0,0 +1,89 @@
//
// Mastodon+API+Account+FollowRequest.swift
//
//
// Created by sxiaojian on 2021/4/27.
//
import Foundation
import Combine
// MARK: - Account credentials
extension Mastodon.API.Account {
static func acceptFollowRequestEndpointURL(domain: String, userID: Mastodon.Entity.Account.ID) -> URL {
return Mastodon.API.endpointURL(domain: domain).appendingPathComponent("follow_requests")
.appendingPathComponent(userID)
.appendingPathComponent("authorize")
}
static func rejectFollowRequestEndpointURL(domain: String, userID: Mastodon.Entity.Account.ID) -> URL {
return Mastodon.API.endpointURL(domain: domain).appendingPathComponent("follow_requests")
.appendingPathComponent(userID)
.appendingPathComponent("reject")
}
/// Accept Follow
///
///
/// - Since: 0.0.0
/// - Version: 3.3.0
/// # Reference
/// [Document](https://docs.joinmastodon.org/methods/accounts/follow_requests/)
/// - Parameters:
/// - session: `URLSession`
/// - domain: Mastodon instance domain. e.g. "example.com"
/// - userID: ID of the account in the database
/// - authorization: User token
/// - Returns: `AnyPublisher` contains `Relationship` nested in the response
public static func acceptFollowRequest(
session: URLSession,
domain: String,
userID: Mastodon.Entity.Account.ID,
authorization: Mastodon.API.OAuth.Authorization
) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Relationship>, Error> {
let request = Mastodon.API.post(
url: acceptFollowRequestEndpointURL(domain: domain, userID: userID),
query: nil,
authorization: authorization
)
return session.dataTaskPublisher(for: request)
.tryMap { data, response in
let value = try Mastodon.API.decode(type: Mastodon.Entity.Relationship.self, from: data, response: response)
return Mastodon.Response.Content(value: value, response: response)
}
.eraseToAnyPublisher()
}
/// Reject Follow
///
///
/// - Since: 0.0.0
/// - Version: 3.3.0
/// # Reference
/// [Document](https://docs.joinmastodon.org/methods/accounts/follow_requests/)
/// - Parameters:
/// - session: `URLSession`
/// - domain: Mastodon instance domain. e.g. "example.com"
/// - userID: ID of the account in the database
/// - authorization: User token
/// - Returns: `AnyPublisher` contains `Relationship` nested in the response
public static func rejectFollowRequest(
session: URLSession,
domain: String,
userID: Mastodon.Entity.Account.ID,
authorization: Mastodon.API.OAuth.Authorization
) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Relationship>, Error> {
let request = Mastodon.API.post(
url: rejectFollowRequestEndpointURL(domain: domain, userID: userID),
query: nil,
authorization: authorization
)
return session.dataTaskPublisher(for: request)
.tryMap { data, response in
let value = try Mastodon.API.decode(type: Mastodon.Entity.Relationship.self, from: data, response: response)
return Mastodon.Response.Content(value: value, response: response)
}
.eraseToAnyPublisher()
}
}

View File

@ -132,3 +132,54 @@ extension Mastodon.API.Account {
}
}
extension Mastodon.API.Account {
static func accountsLookupEndpointURL(domain: String) -> URL {
return Mastodon.API.endpointURL(domain: domain).appendingPathComponent("accounts/lookup")
}
public struct AccountLookupQuery: GetQuery {
public var acct: String
public init(acct: String) {
self.acct = acct
}
var queryItems: [URLQueryItem]? {
var items: [URLQueryItem] = []
items.append(URLQueryItem(name: "acct", value: acct))
return items
}
}
/// lookup account by acct.
///
/// - Version: 3.3.1
/// - Parameters:
/// - session: `URLSession`
/// - domain: Mastodon instance domain. e.g. "example.com"
/// - query: `AccountInfoQuery` with account query information,
/// - authorization: app token
/// - Returns: `AnyPublisher` contains `Account` nested in the response
public static func lookupAccount(
session: URLSession,
domain: String,
query: AccountLookupQuery,
authorization: Mastodon.API.OAuth.Authorization?
) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Account>, Error> {
let request = Mastodon.API.get(
url: accountsLookupEndpointURL(domain: domain),
query: query,
authorization: authorization
)
return session.dataTaskPublisher(for: request)
.tryMap { data, response in
let value = try Mastodon.API.decode(type: Mastodon.Entity.Account.self, from: data, response: response)
return Mastodon.Response.Content(value: value, response: response)
}
.eraseToAnyPublisher()
}
}

View File

@ -67,7 +67,7 @@ extension Mastodon.API.Notifications {
public static func getNotification(
session: URLSession,
domain: String,
notificationID: String,
notificationID: Mastodon.Entity.Notification.ID,
authorization: Mastodon.API.OAuth.Authorization
) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Notification>, Error> {
let request = Mastodon.API.get(

View File

@ -21,7 +21,7 @@ extension Mastodon.API.Subscriptions {
/// - Since: 2.4.0
/// - Version: 3.3.0
/// # Last Update
/// 2021/4/9
/// 2021/4/25
/// # Reference
/// [Document](https://docs.joinmastodon.org/methods/notifications/push/)
/// - Parameters:
@ -54,7 +54,7 @@ extension Mastodon.API.Subscriptions {
/// - Since: 2.4.0
/// - Version: 3.3.0
/// # Last Update
/// 2021/4/9
/// 2021/4/25
/// # Reference
/// [Document](https://docs.joinmastodon.org/methods/notifications/push/)
/// - Parameters:
@ -88,7 +88,7 @@ extension Mastodon.API.Subscriptions {
/// - Since: 2.4.0
/// - Version: 3.3.0
/// # Last Update
/// 2021/4/9
/// 2021/4/25
/// # Reference
/// [Document](https://docs.joinmastodon.org/methods/notifications/push/)
/// - Parameters:
@ -114,113 +114,149 @@ extension Mastodon.API.Subscriptions {
}
.eraseToAnyPublisher()
}
/// Remove current subscription
///
/// Removes the current Web Push API subscription.
///
/// - Since: 2.4.0
/// - Version: 3.3.0
/// # Last Update
/// 2021/4/26
/// # Reference
/// [Document](https://docs.joinmastodon.org/methods/notifications/push/)
/// - Parameters:
/// - session: `URLSession`
/// - domain: Mastodon instance domain. e.g. "example.com"
/// - authorization: User token. Could be nil if status is public
/// - Returns: `AnyPublisher` contains `Subscription` nested in the response
public static func removeSubscription(
session: URLSession,
domain: String,
authorization: Mastodon.API.OAuth.Authorization
) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.EmptySubscription>, Error> {
let request = Mastodon.API.delete(
url: pushEndpointURL(domain: domain),
query: nil,
authorization: authorization
)
return session.dataTaskPublisher(for: request)
.tryMap { data, response in
let value = try Mastodon.API.decode(type: Mastodon.Entity.EmptySubscription.self, from: data, response: response)
return Mastodon.Response.Content(value: value, response: response)
}
.eraseToAnyPublisher()
}
}
extension Mastodon.API.Subscriptions {
public struct CreateSubscriptionQuery: Codable, PostQuery {
public typealias Policy = QueryData.Policy
public struct QuerySubscription: Codable {
let endpoint: String
let p256dh: String
let auth: String
let favourite: Bool?
let follow: Bool?
let reblog: Bool?
let mention: Bool?
let poll: Bool?
var queryItems: [URLQueryItem]? {
var items = [URLQueryItem]()
items.append(URLQueryItem(name: "subscription[endpoint]", value: endpoint))
items.append(URLQueryItem(name: "subscription[keys][p256dh]", value: p256dh))
items.append(URLQueryItem(name: "subscription[keys][auth]", value: auth))
if let followValue = follow?.queryItemValue {
let followItem = URLQueryItem(name: "data[alerts][follow]", value: followValue)
items.append(followItem)
}
if let favouriteValue = favourite?.queryItemValue {
let favouriteItem = URLQueryItem(name: "data[alerts][favourite]", value: favouriteValue)
items.append(favouriteItem)
}
if let reblogValue = reblog?.queryItemValue {
let reblogItem = URLQueryItem(name: "data[alerts][reblog]", value: reblogValue)
items.append(reblogItem)
}
if let mentionValue = mention?.queryItemValue {
let mentionItem = URLQueryItem(name: "data[alerts][mention]", value: mentionValue)
items.append(mentionItem)
}
return items
}
let keys: Keys
public init(
endpoint: String,
p256dh: String,
auth: String,
favourite: Bool?,
follow: Bool?,
reblog: Bool?,
mention: Bool?,
poll: Bool?
keys: Keys
) {
self.endpoint = endpoint
self.p256dh = p256dh
self.auth = auth
self.favourite = favourite
self.follow = follow
self.reblog = reblog
self.mention = mention
self.poll = poll
self.keys = keys
}
public struct Keys: Codable {
let p256dh: String
let auth: String
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 {
let favourite: Bool?
let follow: Bool?
let reblog: Bool?
let mention: Bool?
let poll: Bool?
var queryItems: [URLQueryItem]? {
var items = [URLQueryItem]()
if let followValue = follow?.queryItemValue {
let followItem = URLQueryItem(name: "data[alerts][follow]", value: followValue)
items.append(followItem)
}
if let favouriteValue = favourite?.queryItemValue {
let favouriteItem = URLQueryItem(name: "data[alerts][favourite]", value: favouriteValue)
items.append(favouriteItem)
}
if let reblogValue = reblog?.queryItemValue {
let reblogItem = URLQueryItem(name: "data[alerts][reblog]", value: reblogValue)
items.append(reblogItem)
}
if let mentionValue = mention?.queryItemValue {
let mentionItem = URLQueryItem(name: "data[alerts][mention]", value: mentionValue)
items.append(mentionItem)
}
return items
let data: QueryData
public init(data: Mastodon.API.Subscriptions.QueryData) {
self.data = data
}
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
}
var queryItems: [URLQueryItem]? { nil }
}
}

View File

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

View File

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

View File

@ -35,3 +35,12 @@ extension Data {
}
}
extension Data {
func base64UrlEncodedString() -> String {
return base64EncodedString()
.replacingOccurrences(of: "+", with: "-")
.replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: "=", with: "")
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

@ -23,4 +23,20 @@ target 'Mastodon' do
# Pods for testing
end
target 'NotificationService' do
end
target 'AppShared' do
end
end
plugin 'cocoapods-keys', {
:project => "Mastodon",
:keys => [
"notification_endpoint",
"notification_endpoint_debug"
]
}

View File

@ -1,12 +1,14 @@
PODS:
- DateToolsSwift (5.0.0)
- Kanna (5.2.4)
- Keys (1.0.1)
- SwiftGen (6.4.0)
- "UITextField+Shake (1.2.1)"
DEPENDENCIES:
- DateToolsSwift (~> 5.0.0)
- Kanna (~> 5.2.2)
- Keys (from `Pods/CocoaPodsKeys`)
- SwiftGen (~> 6.4.0)
- "UITextField+Shake (~> 1.2)"
@ -17,12 +19,17 @@ SPEC REPOS:
- SwiftGen
- "UITextField+Shake"
EXTERNAL SOURCES:
Keys:
:path: Pods/CocoaPodsKeys
SPEC CHECKSUMS:
DateToolsSwift: 4207ada6ad615d8dc076323d27037c94916dbfa6
Kanna: b9d00d7c11428308c7f95e1f1f84b8205f567a8f
Keys: a576f4c9c1c641ca913a959a9c62ed3f215a8de9
SwiftGen: 67860cc7c3cfc2ed25b9b74cfd55495fc89f9108
"UITextField+Shake": 298ac5a0f239d731bdab999b19b628c956ca0ac3
PODFILE CHECKSUM: 30e8e3a555251a512e7b5e91183747152f126e7a
PODFILE CHECKSUM: a8dbae22e6e0bfb84f7db59aef1aa1716793d287
COCOAPODS: 1.10.1

View File

@ -48,8 +48,10 @@ arch -x86_64 pod install
- [AlamofireNetworkActivityIndicator](https://github.com/Alamofire/AlamofireNetworkActivityIndicator)
- [Alamofire](https://github.com/Alamofire/Alamofire)
- [CommonOSLog](https://github.com/mainasuk/CommonOSLog)
- [CryptoSwift](https://github.com/krzyzanowskim/CryptoSwift)
- [DateToolSwift](https://github.com/MatthewYork/DateTools)
- [Kanna](https://github.com/tid-kijyun/Kanna)
- [KeychainAccess](https://github.com/kishikawakatsumi/KeychainAccess.git)
- [Kingfisher](https://github.com/onevcat/Kingfisher)
- [SwiftGen](https://github.com/SwiftGen/SwiftGen)
- [SwiftyJSON](https://github.com/SwiftyJSON/SwiftyJSON)