diff --git a/.gitignore b/.gitignore
index a766fc62..24e748a9 100644
--- a/.gitignore
+++ b/.gitignore
@@ -120,4 +120,6 @@ xcuserdata
# End of https://www.toptal.com/developers/gitignore/api/swift,swiftpm,xcode,cocoapods
Localization/StringsConvertor/input
-Localization/StringsConvertor/output
\ No newline at end of file
+Localization/StringsConvertor/output
+.DS_Store
+/Mastodon.xcworkspace/xcshareddata/swiftpm
diff --git a/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents b/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents
index 3b558f9f..0d017028 100644
--- a/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents
+++ b/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents
@@ -65,6 +65,22 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -155,6 +171,16 @@
+
+
+
+
+
+
+
+
+
+
@@ -194,6 +220,26 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -210,13 +256,17 @@
+
+
+
+
-
\ No newline at end of file
+
diff --git a/CoreDataStack/Entity/Notification.swift b/CoreDataStack/Entity/Notification.swift
new file mode 100644
index 00000000..31c361aa
--- /dev/null
+++ b/CoreDataStack/Entity/Notification.swift
@@ -0,0 +1,111 @@
+//
+// MastodonNotification.swift
+// CoreDataStack
+//
+// Created by sxiaojian on 2021/4/13.
+//
+
+import Foundation
+import CoreData
+
+public final class MastodonNotification: NSManagedObject {
+ public typealias ID = UUID
+ @NSManaged public private(set) var identifier: ID
+ @NSManaged public private(set) var id: String
+ @NSManaged public private(set) var createAt: Date
+ @NSManaged public private(set) var updatedAt: Date
+ @NSManaged public private(set) var typeRaw: String
+ @NSManaged public private(set) var account: MastodonUser
+ @NSManaged public private(set) var status: Status?
+
+ @NSManaged public private(set) var domain: String
+ @NSManaged public private(set) var userID: String
+}
+
+extension MastodonNotification {
+ public override func awakeFromInsert() {
+ super.awakeFromInsert()
+ setPrimitiveValue(UUID(), forKey: #keyPath(MastodonNotification.identifier))
+ }
+}
+
+public extension MastodonNotification {
+ @discardableResult
+ static func insert(
+ into context: NSManagedObjectContext,
+ domain: String,
+ userID: String,
+ networkDate: Date,
+ property: Property
+ ) -> MastodonNotification {
+ let notification: MastodonNotification = context.insertObject()
+ notification.id = property.id
+ notification.createAt = property.createdAt
+ notification.updatedAt = networkDate
+ notification.typeRaw = property.typeRaw
+ notification.account = property.account
+ notification.status = property.status
+ notification.domain = domain
+ notification.userID = userID
+ return notification
+ }
+}
+
+public extension MastodonNotification {
+ struct Property {
+ public init(id: String,
+ typeRaw: String,
+ account: MastodonUser,
+ status: Status?,
+ createdAt: Date
+ ) {
+ self.id = id
+ self.typeRaw = typeRaw
+ self.account = account
+ self.status = status
+ self.createdAt = createdAt
+ }
+
+ public let id: String
+ public let typeRaw: String
+ public let account: MastodonUser
+ public let status: Status?
+ public let createdAt: Date
+ }
+}
+
+extension MastodonNotification {
+ static func predicate(domain: String) -> NSPredicate {
+ return NSPredicate(format: "%K == %@", #keyPath(MastodonNotification.domain), domain)
+ }
+
+ static func predicate(userID: String) -> NSPredicate {
+ return NSPredicate(format: "%K == %@", #keyPath(MastodonNotification.userID), userID)
+ }
+
+ static func predicate(typeRaw: String) -> NSPredicate {
+ return NSPredicate(format: "%K == %@", #keyPath(MastodonNotification.typeRaw), typeRaw)
+ }
+
+ public static func predicate(domain: String, userID: String, typeRaw: String? = nil) -> NSPredicate {
+ if let typeRaw = typeRaw {
+ return NSCompoundPredicate(andPredicateWithSubpredicates: [
+ MastodonNotification.predicate(domain: domain),
+ MastodonNotification.predicate(typeRaw: typeRaw),
+ MastodonNotification.predicate(userID: userID),
+ ])
+ } else {
+ return NSCompoundPredicate(andPredicateWithSubpredicates: [
+ MastodonNotification.predicate(domain: domain),
+ MastodonNotification.predicate(userID: userID)
+ ])
+ }
+ }
+
+}
+
+extension MastodonNotification: Managed {
+ public static var defaultSortDescriptors: [NSSortDescriptor] {
+ return [NSSortDescriptor(keyPath: \MastodonNotification.createAt, ascending: false)]
+ }
+}
diff --git a/CoreDataStack/Entity/Setting.swift b/CoreDataStack/Entity/Setting.swift
new file mode 100644
index 00000000..671f9bab
--- /dev/null
+++ b/CoreDataStack/Entity/Setting.swift
@@ -0,0 +1,90 @@
+//
+// Setting.swift
+// CoreDataStack
+//
+// Created by ihugo on 2021/4/9.
+//
+
+import CoreData
+import Foundation
+
+public final class Setting: NSManagedObject {
+ @NSManaged public var appearance: String?
+ @NSManaged public var triggerBy: String?
+ @NSManaged public var domain: String?
+ @NSManaged public var userID: String?
+
+ @NSManaged public private(set) var createdAt: Date
+ @NSManaged public private(set) var updatedAt: Date
+
+ // relationships
+ @NSManaged public var subscription: Set?
+}
+
+public extension Setting {
+ override func awakeFromInsert() {
+ super.awakeFromInsert()
+ setPrimitiveValue(Date(), forKey: #keyPath(Setting.createdAt))
+ }
+
+ func didUpdate(at networkDate: Date) {
+ self.updatedAt = networkDate
+ }
+
+ @discardableResult
+ static func insert(
+ into context: NSManagedObjectContext,
+ property: Property
+ ) -> Setting {
+ let setting: Setting = context.insertObject()
+ setting.appearance = property.appearance
+ setting.triggerBy = property.triggerBy
+ setting.domain = property.domain
+ setting.userID = property.userID
+ return setting
+ }
+
+ func update(appearance: String?) {
+ guard appearance != self.appearance else { return }
+ self.appearance = appearance
+ didUpdate(at: Date())
+ }
+
+ func update(triggerBy: String?) {
+ guard triggerBy != self.triggerBy else { return }
+ self.triggerBy = triggerBy
+ didUpdate(at: Date())
+ }
+}
+
+public extension Setting {
+ struct Property {
+ public let appearance: String
+ public let triggerBy: String
+ public let domain: String
+ public let userID: String
+
+ public init(appearance: String, triggerBy: String, domain: String, userID: String) {
+ self.appearance = appearance
+ self.triggerBy = triggerBy
+ self.domain = domain
+ self.userID = userID
+ }
+ }
+}
+
+extension Setting: Managed {
+ public static var defaultSortDescriptors: [NSSortDescriptor] {
+ return [NSSortDescriptor(keyPath: \Setting.createdAt, ascending: false)]
+ }
+}
+
+extension Setting {
+ public static func predicate(domain: String, userID: String) -> NSPredicate {
+ return NSPredicate(format: "%K == %@ AND %K == %@",
+ #keyPath(Setting.domain), domain,
+ #keyPath(Setting.userID), userID
+ )
+ }
+
+}
diff --git a/CoreDataStack/Entity/Subscription.swift b/CoreDataStack/Entity/Subscription.swift
new file mode 100644
index 00000000..8ced945d
--- /dev/null
+++ b/CoreDataStack/Entity/Subscription.swift
@@ -0,0 +1,101 @@
+//
+// SettingNotification+CoreDataClass.swift
+// CoreDataStack
+//
+// Created by ihugo on 2021/4/9.
+//
+//
+
+import Foundation
+import CoreData
+
+public final class Subscription: NSManagedObject {
+ @NSManaged public var id: String
+ @NSManaged public var endpoint: String
+ @NSManaged public var serverKey: String
+
+ /// four types:
+ /// - anyone
+ /// - a follower
+ /// - anyone I follow
+ /// - no one
+ @NSManaged public var type: String
+
+ @NSManaged public private(set) var createdAt: Date
+ @NSManaged public private(set) var updatedAt: Date
+
+ // MARK: - relationships
+ @NSManaged public var alert: SubscriptionAlerts?
+ // MARK: holder
+ @NSManaged public var setting: Setting?
+}
+
+public extension Subscription {
+ override func awakeFromInsert() {
+ super.awakeFromInsert()
+ setPrimitiveValue(Date(), forKey: #keyPath(Subscription.createdAt))
+ }
+
+ func didUpdate(at networkDate: Date) {
+ self.updatedAt = networkDate
+ }
+
+ @discardableResult
+ static func insert(
+ into context: NSManagedObjectContext,
+ property: Property
+ ) -> Subscription {
+ let setting: Subscription = context.insertObject()
+ setting.id = property.id
+ setting.endpoint = property.endpoint
+ setting.serverKey = property.serverKey
+ setting.type = property.type
+
+ return setting
+ }
+}
+
+public extension Subscription {
+ struct Property {
+ public let endpoint: String
+ public let id: String
+ public let serverKey: String
+ public let type: String
+
+ public init(endpoint: String, id: String, serverKey: String, type: String) {
+ self.endpoint = endpoint
+ self.id = id
+ self.serverKey = serverKey
+ self.type = type
+ }
+ }
+
+ func updateIfNeed(property: Property) {
+ if self.endpoint != property.endpoint {
+ self.endpoint = property.endpoint
+ }
+ if self.id != property.id {
+ self.id = property.id
+ }
+ if self.serverKey != property.serverKey {
+ self.serverKey = property.serverKey
+ }
+ if self.type != property.type {
+ self.type = property.type
+ }
+ }
+}
+
+extension Subscription: Managed {
+ public static var defaultSortDescriptors: [NSSortDescriptor] {
+ return [NSSortDescriptor(keyPath: \Subscription.createdAt, ascending: false)]
+ }
+}
+
+extension Subscription {
+
+ public static func predicate(type: String) -> NSPredicate {
+ return NSPredicate(format: "%K == %@", #keyPath(Subscription.type), type)
+ }
+
+}
diff --git a/CoreDataStack/Entity/SubscriptionAlerts.swift b/CoreDataStack/Entity/SubscriptionAlerts.swift
new file mode 100644
index 00000000..f5abf495
--- /dev/null
+++ b/CoreDataStack/Entity/SubscriptionAlerts.swift
@@ -0,0 +1,130 @@
+//
+// PushSubscriptionAlerts+CoreDataClass.swift
+// CoreDataStack
+//
+// Created by ihugo on 2021/4/9.
+//
+//
+
+import Foundation
+import CoreData
+
+public final class SubscriptionAlerts: NSManagedObject {
+ @NSManaged public var follow: NSNumber?
+ @NSManaged public var favourite: NSNumber?
+ @NSManaged public var reblog: NSNumber?
+ @NSManaged public var mention: NSNumber?
+ @NSManaged public var poll: NSNumber?
+
+ @NSManaged public private(set) var createdAt: Date
+ @NSManaged public private(set) var updatedAt: Date
+
+ // MARK: - relationships
+ @NSManaged public var subscription: Subscription?
+}
+
+public extension SubscriptionAlerts {
+ override func awakeFromInsert() {
+ super.awakeFromInsert()
+ setPrimitiveValue(Date(), forKey: #keyPath(SubscriptionAlerts.createdAt))
+ }
+
+ func didUpdate(at networkDate: Date) {
+ self.updatedAt = networkDate
+ }
+
+ @discardableResult
+ static func insert(
+ into context: NSManagedObjectContext,
+ property: Property
+ ) -> SubscriptionAlerts {
+ let alerts: SubscriptionAlerts = context.insertObject()
+ alerts.favourite = property.favourite
+ alerts.follow = property.follow
+ alerts.mention = property.mention
+ alerts.poll = property.poll
+ alerts.reblog = property.reblog
+ return alerts
+ }
+
+ func update(favourite: NSNumber?) {
+ guard self.favourite != favourite else { return }
+ self.favourite = favourite
+
+ didUpdate(at: Date())
+ }
+
+ func update(follow: NSNumber?) {
+ guard self.follow != follow else { return }
+ self.follow = follow
+
+ didUpdate(at: Date())
+ }
+
+ func update(mention: NSNumber?) {
+ guard self.mention != mention else { return }
+ self.mention = mention
+
+ didUpdate(at: Date())
+ }
+
+ func update(poll: NSNumber?) {
+ guard self.poll != poll else { return }
+ self.poll = poll
+
+ didUpdate(at: Date())
+ }
+
+ func update(reblog: NSNumber?) {
+ guard self.reblog != reblog else { return }
+ self.reblog = reblog
+
+ didUpdate(at: Date())
+ }
+}
+
+public extension SubscriptionAlerts {
+ struct Property {
+ public let favourite: NSNumber?
+ public let follow: NSNumber?
+ public let mention: NSNumber?
+ public let poll: NSNumber?
+ public let reblog: NSNumber?
+
+ public init(favourite: NSNumber?, follow: NSNumber?, mention: NSNumber?, poll: NSNumber?, reblog: NSNumber?) {
+ self.favourite = favourite
+ self.follow = follow
+ self.mention = mention
+ self.poll = poll
+ self.reblog = reblog
+ }
+ }
+
+ func updateIfNeed(property: Property) {
+ if self.follow != property.follow {
+ self.follow = property.follow
+ }
+
+ if self.favourite != property.favourite {
+ self.favourite = property.favourite
+ }
+
+ if self.reblog != property.reblog {
+ self.reblog = property.reblog
+ }
+
+ if self.mention != property.mention {
+ self.mention = property.mention
+ }
+
+ if self.poll != property.poll {
+ self.poll = property.poll
+ }
+ }
+}
+
+extension SubscriptionAlerts: Managed {
+ public static var defaultSortDescriptors: [NSSortDescriptor] {
+ return [NSSortDescriptor(keyPath: \SubscriptionAlerts.createdAt, ascending: false)]
+ }
+}
diff --git a/Localization/app.json b/Localization/app.json
index e3ae30e9..bd1b29fe 100644
--- a/Localization/app.json
+++ b/Localization/app.json
@@ -22,6 +22,11 @@
"publish_post_failure": {
"title": "Publish Failure",
"message": "Failed to publish the post.\nPlease check your internet connection."
+ },
+ "sign_out": {
+ "title": "Sign out",
+ "message": "Are you sure you want to sign out?",
+ "confirm": "Sign Out"
}
},
"controls": {
@@ -325,6 +330,18 @@
},
"favorite": {
"title": "Your Favorites"
+ },
+ "notification": {
+ "title": {
+ "Everything": "Everything",
+ "Mentions": "Mentions"
+ },
+ "action": {
+ "follow": "followed you",
+ "favourite": "favorited your post",
+ "reblog": "rebloged your post",
+ "poll": "Your poll has ended",
+ "mention": "mentioned you"
},
"thread": {
"back_title": "Post",
@@ -337,6 +354,41 @@
"single": "%s favorite",
"multiple": "%s favorites"
}
+ },
+ "settings": {
+ "title": "Settings",
+ "section": {
+ "appearance": {
+ "title": "Appearance",
+ "automatic": "Automatic",
+ "light": "Always Light",
+ "dark": "Always Dark"
+ },
+ "notifications": {
+ "title": "Notifications",
+ "favorites": "Favorites my post",
+ "follows": "Follows me",
+ "boosts": "Reblogs my post",
+ "mentions": "Mentions me",
+ "trigger": {
+ "anyone": "anyone",
+ "follower": "a follower",
+ "follow": "anyone I follow",
+ "noone": "no one",
+ "title": "Notify me when"
+ }
+ },
+ "boringzone": {
+ "title": "The Boring zone",
+ "terms": "Terms of Service",
+ "privacy": "Privacy Policy"
+ },
+ "spicyzone": {
+ "title": "The spicy zone",
+ "clear": "Clear Media Cache",
+ "signout": "Sign Out"
+ }
+ }
}
}
-}
\ No newline at end of file
+}
diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj
index ff243e8b..0f8236e6 100644
--- a/Mastodon.xcodeproj/project.pbxproj
+++ b/Mastodon.xcodeproj/project.pbxproj
@@ -30,18 +30,22 @@
0FB3D33825E6401400AAD544 /* PickServerCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FB3D33725E6401400AAD544 /* PickServerCell.swift */; };
18BC7629F65E6DB12CB8416D /* Pods_Mastodon_MastodonUITests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3C030226D3C73DCC23D67452 /* Pods_Mastodon_MastodonUITests.framework */; };
2D04F42525C255B9003F936F /* APIService+PublicTimeline.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D04F42425C255B9003F936F /* APIService+PublicTimeline.swift */; };
+ 2D084B8D26258EA3003AA3AF /* NotificationViewModel+diffable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D084B8C26258EA3003AA3AF /* NotificationViewModel+diffable.swift */; };
+ 2D084B9326259545003AA3AF /* NotificationViewModel+LoadLatestState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D084B9226259545003AA3AF /* NotificationViewModel+LoadLatestState.swift */; };
2D0B7A1D261D839600B44727 /* SearchHistory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D0B7A1C261D839600B44727 /* SearchHistory.swift */; };
2D152A8C25C295CC009AA50C /* StatusView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D152A8B25C295CC009AA50C /* StatusView.swift */; };
2D152A9225C2980C009AA50C /* UIFont.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D152A9125C2980C009AA50C /* UIFont.swift */; };
2D198643261BF09500F0B013 /* SearchResultItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D198642261BF09500F0B013 /* SearchResultItem.swift */; };
2D198649261C0B8500F0B013 /* SearchResultSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D198648261C0B8500F0B013 /* SearchResultSection.swift */; };
- 2D19864F261C372A00F0B013 /* SearchBottomLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D19864E261C372A00F0B013 /* SearchBottomLoader.swift */; };
2D198655261C3C4300F0B013 /* SearchViewModel+LoadOldestState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D198654261C3C4300F0B013 /* SearchViewModel+LoadOldestState.swift */; };
2D206B7225F5D27F00143C56 /* AudioContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D206B7125F5D27F00143C56 /* AudioContainerView.swift */; };
2D206B8025F5F45E00143C56 /* UIImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D206B7F25F5F45E00143C56 /* UIImage.swift */; };
2D206B8625F5FB0900143C56 /* Double.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D206B8525F5FB0900143C56 /* Double.swift */; };
2D206B8C25F6015000143C56 /* AudioPlaybackService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D206B8B25F6015000143C56 /* AudioPlaybackService.swift */; };
2D206B9225F60EA700143C56 /* UIControl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D206B9125F60EA700143C56 /* UIControl.swift */; };
+ 2D24E11D2626D8B100A59D4F /* NotificationStatusTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D24E11C2626D8B100A59D4F /* NotificationStatusTableViewCell.swift */; };
+ 2D24E1232626ED9D00A59D4F /* UIView+Gesture.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D24E1222626ED9D00A59D4F /* UIView+Gesture.swift */; };
+ 2D24E12D2626FD2E00A59D4F /* NotificationViewModel+LoadOldestState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D24E12C2626FD2E00A59D4F /* NotificationViewModel+LoadOldestState.swift */; };
2D32EAAC25CB96DC00C9ED86 /* TimelineMiddleLoaderTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D32EAAB25CB96DC00C9ED86 /* TimelineMiddleLoaderTableViewCell.swift */; };
2D32EABA25CB9B0500C9ED86 /* UIView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D32EAB925CB9B0500C9ED86 /* UIView.swift */; };
2D32EADA25CBCC3300C9ED86 /* PublicTimelineViewModel+LoadMiddleState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D32EAD925CBCC3300C9ED86 /* PublicTimelineViewModel+LoadMiddleState.swift */; };
@@ -49,6 +53,8 @@
2D34D9D126148D9E0081BFC0 /* APIService+Recommend.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D34D9D026148D9E0081BFC0 /* APIService+Recommend.swift */; };
2D34D9DB261494120081BFC0 /* APIService+Search.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D34D9DA261494120081BFC0 /* APIService+Search.swift */; };
2D34D9E226149C920081BFC0 /* SearchRecommendTagsCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D34D9E126149C920081BFC0 /* SearchRecommendTagsCollectionViewCell.swift */; };
+ 2D35237A26256D920031AF25 /* NotificationSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D35237926256D920031AF25 /* NotificationSection.swift */; };
+ 2D35238126256F690031AF25 /* NotificationTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D35238026256F690031AF25 /* NotificationTableViewCell.swift */; };
2D364F7225E66D7500204FDC /* MastodonResendEmailViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D364F7125E66D7500204FDC /* MastodonResendEmailViewController.swift */; };
2D364F7825E66D8300204FDC /* MastodonResendEmailViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D364F7725E66D8300204FDC /* MastodonResendEmailViewModel.swift */; };
2D38F1C625CD37F400561493 /* ContentOffsetAdjustableTimelineViewControllerDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D38F1C525CD37F400561493 /* ContentOffsetAdjustableTimelineViewControllerDelegate.swift */; };
@@ -76,6 +82,9 @@
2D5A3D2825CF8BC9002347D6 /* HomeTimelineViewModel+Diffable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D5A3D2725CF8BC9002347D6 /* HomeTimelineViewModel+Diffable.swift */; };
2D5A3D3825CF8D9F002347D6 /* ScrollViewContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D5A3D3725CF8D9F002347D6 /* ScrollViewContainer.swift */; };
2D5A3D6225CFD9CB002347D6 /* HomeTimelineViewController+DebugAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D5A3D6125CFD9CB002347D6 /* HomeTimelineViewController+DebugAction.swift */; };
+ 2D607AD826242FC500B70763 /* NotificationViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D607AD726242FC500B70763 /* NotificationViewModel.swift */; };
+ 2D6125472625436B00299647 /* Notification.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D6125462625436B00299647 /* Notification.swift */; };
+ 2D61254D262547C200299647 /* APIService+Notification.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D61254C262547C200299647 /* APIService+Notification.swift */; };
2D61335825C188A000CAE157 /* APIService+Persist+Status.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D61335725C188A000CAE157 /* APIService+Persist+Status.swift */; };
2D61335E25C1894B00CAE157 /* APIService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D61335D25C1894B00CAE157 /* APIService.swift */; };
2D61336925C18A4F00CAE157 /* AlamofireNetworkActivityIndicator in Frameworks */ = {isa = PBXBuildFile; productRef = 2D61336825C18A4F00CAE157 /* AlamofireNetworkActivityIndicator */; };
@@ -91,6 +100,7 @@
2D76319F25C1521200929FB9 /* StatusSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D76319E25C1521200929FB9 /* StatusSection.swift */; };
2D7631A825C1535600929FB9 /* StatusTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D7631A725C1535600929FB9 /* StatusTableViewCell.swift */; };
2D7631B325C159F700929FB9 /* Item.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D7631B225C159F700929FB9 /* Item.swift */; };
+ 2D7867192625B77500211898 /* NotificationItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D7867182625B77500211898 /* NotificationItem.swift */; };
2D79E701261EA5550011E398 /* APIService+CoreData+Tag.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D79E700261EA5550011E398 /* APIService+CoreData+Tag.swift */; };
2D82B9FF25E7863200E36F0F /* OnboardingViewControllerAppearance.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D82B9FE25E7863200E36F0F /* OnboardingViewControllerAppearance.swift */; };
2D82BA0525E7897700E36F0F /* MastodonResendEmailViewModelNavigationDelegateShim.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D82BA0425E7897700E36F0F /* MastodonResendEmailViewModelNavigationDelegateShim.swift */; };
@@ -110,6 +120,7 @@
2DA7D04425CA52B200804E11 /* TimelineLoaderTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DA7D04325CA52B200804E11 /* TimelineLoaderTableViewCell.swift */; };
2DA7D04A25CA52CB00804E11 /* TimelineBottomLoaderTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DA7D04925CA52CB00804E11 /* TimelineBottomLoaderTableViewCell.swift */; };
2DA7D05725CA693F00804E11 /* Application.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DA7D05625CA693F00804E11 /* Application.swift */; };
+ 2DB72C8C262D764300CE6173 /* Mastodon+Entity+Notification+Type.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DB72C8B262D764300CE6173 /* Mastodon+Entity+Notification+Type.swift */; };
2DCB73FD2615C13900EC03D4 /* SearchRecommendCollectionHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DCB73FC2615C13900EC03D4 /* SearchRecommendCollectionHeader.swift */; };
2DE0FAC12615F04D00CDF649 /* RecommendHashTagSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DE0FAC02615F04D00CDF649 /* RecommendHashTagSection.swift */; };
2DE0FAC82615F5F000CDF649 /* SearchRecommendAccountsCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DE0FAC72615F5F000CDF649 /* SearchRecommendAccountsCollectionViewCell.swift */; };
@@ -122,10 +133,21 @@
2DF75BC725D1475D00694EC8 /* ManagedObjectContextObjectsDidChange.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DF75BC625D1475D00694EC8 /* ManagedObjectContextObjectsDidChange.swift */; };
2DFAD5272616F9D300F9EE7C /* SearchViewController+Searching.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DFAD5262616F9D300F9EE7C /* SearchViewController+Searching.swift */; };
2DFAD5372617010500F9EE7C /* SearchingTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DFAD5362617010500F9EE7C /* SearchingTableViewCell.swift */; };
- 2DFF41892614A4DC00F776A4 /* UIView+Constraint.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DFF41882614A4DC00F776A4 /* UIView+Constraint.swift */; };
+ 5B90C45E262599800002E742 /* SettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B90C456262599800002E742 /* SettingsViewModel.swift */; };
+ 5B90C45F262599800002E742 /* SettingsToggleTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B90C459262599800002E742 /* SettingsToggleTableViewCell.swift */; };
+ 5B90C460262599800002E742 /* SettingsAppearanceTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B90C45A262599800002E742 /* SettingsAppearanceTableViewCell.swift */; };
+ 5B90C461262599800002E742 /* SettingsLinkTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B90C45B262599800002E742 /* SettingsLinkTableViewCell.swift */; };
+ 5B90C462262599800002E742 /* SettingsSectionHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B90C45C262599800002E742 /* SettingsSectionHeader.swift */; };
+ 5B90C463262599800002E742 /* SettingsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B90C45D262599800002E742 /* SettingsViewController.swift */; };
+ 5B90C46E26259B2C0002E742 /* Subscription.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B90C46C26259B2C0002E742 /* Subscription.swift */; };
+ 5B90C46F26259B2C0002E742 /* Setting.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B90C46D26259B2C0002E742 /* Setting.swift */; };
+ 5B90C47F26259BA90002E742 /* SubscriptionAlerts.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B90C47E26259BA90002E742 /* SubscriptionAlerts.swift */; };
+ 5B90C48526259BF10002E742 /* APIService+Subscriptions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B90C48426259BF10002E742 /* APIService+Subscriptions.swift */; };
+ 5B90C48B26259C120002E742 /* APIService+CoreData+Subscriptions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B90C48A26259C120002E742 /* APIService+CoreData+Subscriptions.swift */; };
5D0393902612D259007FE196 /* WebViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D03938F2612D259007FE196 /* WebViewController.swift */; };
5D0393962612D266007FE196 /* WebViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D0393952612D266007FE196 /* WebViewModel.swift */; };
5D526FE225BE9AC400460CB9 /* MastodonSDK in Frameworks */ = {isa = PBXBuildFile; productRef = 5D526FE125BE9AC400460CB9 /* MastodonSDK */; };
+ 5DA732CC2629CEF500A92342 /* UIView+Remove.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DA732CB2629CEF500A92342 /* UIView+Remove.swift */; };
5DDDF1932617442700311060 /* Mastodon+Entity+Account.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DDDF1922617442700311060 /* Mastodon+Entity+Account.swift */; };
5DDDF1992617447F00311060 /* Mastodon+Entity+Tag.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DDDF1982617447F00311060 /* Mastodon+Entity+Tag.swift */; };
5DDDF1A92617489F00311060 /* Mastodon+Entity+History.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DDDF1A82617489F00311060 /* Mastodon+Entity+History.swift */; };
@@ -423,18 +445,22 @@
0FB3D33125E5F50E00AAD544 /* PickServerSearchCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PickServerSearchCell.swift; sourceTree = ""; };
0FB3D33725E6401400AAD544 /* PickServerCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PickServerCell.swift; sourceTree = ""; };
2D04F42425C255B9003F936F /* APIService+PublicTimeline.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+PublicTimeline.swift"; sourceTree = ""; };
+ 2D084B8C26258EA3003AA3AF /* NotificationViewModel+diffable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NotificationViewModel+diffable.swift"; sourceTree = ""; };
+ 2D084B9226259545003AA3AF /* NotificationViewModel+LoadLatestState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NotificationViewModel+LoadLatestState.swift"; sourceTree = ""; };
2D0B7A1C261D839600B44727 /* SearchHistory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchHistory.swift; sourceTree = ""; };
2D152A8B25C295CC009AA50C /* StatusView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusView.swift; sourceTree = ""; };
2D152A9125C2980C009AA50C /* UIFont.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIFont.swift; sourceTree = ""; };
2D198642261BF09500F0B013 /* SearchResultItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultItem.swift; sourceTree = ""; };
2D198648261C0B8500F0B013 /* SearchResultSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultSection.swift; sourceTree = ""; };
- 2D19864E261C372A00F0B013 /* SearchBottomLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchBottomLoader.swift; sourceTree = ""; };
2D198654261C3C4300F0B013 /* SearchViewModel+LoadOldestState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SearchViewModel+LoadOldestState.swift"; sourceTree = ""; };
2D206B7125F5D27F00143C56 /* AudioContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioContainerView.swift; sourceTree = ""; };
2D206B7F25F5F45E00143C56 /* UIImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIImage.swift; sourceTree = ""; };
2D206B8525F5FB0900143C56 /* Double.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Double.swift; sourceTree = ""; };
2D206B8B25F6015000143C56 /* AudioPlaybackService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioPlaybackService.swift; sourceTree = ""; };
2D206B9125F60EA700143C56 /* UIControl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIControl.swift; sourceTree = ""; };
+ 2D24E11C2626D8B100A59D4F /* NotificationStatusTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationStatusTableViewCell.swift; sourceTree = ""; };
+ 2D24E1222626ED9D00A59D4F /* UIView+Gesture.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIView+Gesture.swift"; sourceTree = ""; };
+ 2D24E12C2626FD2E00A59D4F /* NotificationViewModel+LoadOldestState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NotificationViewModel+LoadOldestState.swift"; sourceTree = ""; };
2D32EAAB25CB96DC00C9ED86 /* TimelineMiddleLoaderTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineMiddleLoaderTableViewCell.swift; sourceTree = ""; };
2D32EAB925CB9B0500C9ED86 /* UIView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIView.swift; sourceTree = ""; };
2D32EAD925CBCC3300C9ED86 /* PublicTimelineViewModel+LoadMiddleState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PublicTimelineViewModel+LoadMiddleState.swift"; sourceTree = ""; };
@@ -442,6 +468,8 @@
2D34D9D026148D9E0081BFC0 /* APIService+Recommend.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Recommend.swift"; sourceTree = ""; };
2D34D9DA261494120081BFC0 /* APIService+Search.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Search.swift"; sourceTree = ""; };
2D34D9E126149C920081BFC0 /* SearchRecommendTagsCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchRecommendTagsCollectionViewCell.swift; sourceTree = ""; };
+ 2D35237926256D920031AF25 /* NotificationSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationSection.swift; sourceTree = ""; };
+ 2D35238026256F690031AF25 /* NotificationTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationTableViewCell.swift; sourceTree = ""; };
2D364F7125E66D7500204FDC /* MastodonResendEmailViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonResendEmailViewController.swift; sourceTree = ""; };
2D364F7725E66D8300204FDC /* MastodonResendEmailViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonResendEmailViewModel.swift; sourceTree = ""; };
2D38F1C525CD37F400561493 /* ContentOffsetAdjustableTimelineViewControllerDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentOffsetAdjustableTimelineViewControllerDelegate.swift; sourceTree = ""; };
@@ -467,6 +495,9 @@
2D5A3D2725CF8BC9002347D6 /* HomeTimelineViewModel+Diffable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HomeTimelineViewModel+Diffable.swift"; sourceTree = ""; };
2D5A3D3725CF8D9F002347D6 /* ScrollViewContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScrollViewContainer.swift; sourceTree = ""; };
2D5A3D6125CFD9CB002347D6 /* HomeTimelineViewController+DebugAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HomeTimelineViewController+DebugAction.swift"; sourceTree = ""; };
+ 2D607AD726242FC500B70763 /* NotificationViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationViewModel.swift; sourceTree = ""; };
+ 2D6125462625436B00299647 /* Notification.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Notification.swift; sourceTree = ""; };
+ 2D61254C262547C200299647 /* APIService+Notification.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Notification.swift"; sourceTree = ""; };
2D61335725C188A000CAE157 /* APIService+Persist+Status.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Persist+Status.swift"; sourceTree = ""; };
2D61335D25C1894B00CAE157 /* APIService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIService.swift; sourceTree = ""; };
2D650FAA25ECDC9300851B58 /* Mastodon+Entity+Error+Detail.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Mastodon+Entity+Error+Detail.swift"; sourceTree = ""; };
@@ -481,6 +512,7 @@
2D76319E25C1521200929FB9 /* StatusSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusSection.swift; sourceTree = ""; };
2D7631A725C1535600929FB9 /* StatusTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusTableViewCell.swift; sourceTree = ""; };
2D7631B225C159F700929FB9 /* Item.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Item.swift; sourceTree = ""; };
+ 2D7867182625B77500211898 /* NotificationItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationItem.swift; sourceTree = ""; };
2D79E700261EA5550011E398 /* APIService+CoreData+Tag.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "APIService+CoreData+Tag.swift"; sourceTree = ""; };
2D82B9FE25E7863200E36F0F /* OnboardingViewControllerAppearance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingViewControllerAppearance.swift; sourceTree = ""; };
2D82BA0425E7897700E36F0F /* MastodonResendEmailViewModelNavigationDelegateShim.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonResendEmailViewModelNavigationDelegateShim.swift; sourceTree = ""; };
@@ -500,6 +532,7 @@
2DA7D04925CA52CB00804E11 /* TimelineBottomLoaderTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineBottomLoaderTableViewCell.swift; sourceTree = ""; };
2DA7D05025CA545E00804E11 /* LoadMoreConfigurableTableViewContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadMoreConfigurableTableViewContainer.swift; sourceTree = ""; };
2DA7D05625CA693F00804E11 /* Application.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Application.swift; sourceTree = ""; };
+ 2DB72C8B262D764300CE6173 /* Mastodon+Entity+Notification+Type.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Mastodon+Entity+Notification+Type.swift"; sourceTree = ""; };
2DCB73FC2615C13900EC03D4 /* SearchRecommendCollectionHeader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchRecommendCollectionHeader.swift; sourceTree = ""; };
2DE0FAC02615F04D00CDF649 /* RecommendHashTagSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecommendHashTagSection.swift; sourceTree = ""; };
2DE0FAC72615F5F000CDF649 /* SearchRecommendAccountsCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchRecommendAccountsCollectionViewCell.swift; sourceTree = ""; };
@@ -512,13 +545,24 @@
2DF75BC625D1475D00694EC8 /* ManagedObjectContextObjectsDidChange.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ManagedObjectContextObjectsDidChange.swift; sourceTree = ""; };
2DFAD5262616F9D300F9EE7C /* SearchViewController+Searching.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SearchViewController+Searching.swift"; sourceTree = ""; };
2DFAD5362617010500F9EE7C /* SearchingTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchingTableViewCell.swift; sourceTree = ""; };
- 2DFF41882614A4DC00F776A4 /* UIView+Constraint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIView+Constraint.swift"; sourceTree = ""; };
2E1F6A67FDF9771D3E064FDC /* Pods-Mastodon.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Mastodon.debug.xcconfig"; path = "Target Support Files/Pods-Mastodon/Pods-Mastodon.debug.xcconfig"; sourceTree = ""; };
3C030226D3C73DCC23D67452 /* Pods_Mastodon_MastodonUITests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Mastodon_MastodonUITests.framework; sourceTree = BUILT_PRODUCTS_DIR; };
452147B2903DF38070FE56A2 /* Pods_MastodonTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_MastodonTests.framework; sourceTree = BUILT_PRODUCTS_DIR; };
459EA4F43058CAB47719E963 /* Pods-Mastodon-MastodonUITests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Mastodon-MastodonUITests.debug.xcconfig"; path = "Target Support Files/Pods-Mastodon-MastodonUITests/Pods-Mastodon-MastodonUITests.debug.xcconfig"; sourceTree = ""; };
+ 5B90C456262599800002E742 /* SettingsViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SettingsViewModel.swift; sourceTree = ""; };
+ 5B90C459262599800002E742 /* SettingsToggleTableViewCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SettingsToggleTableViewCell.swift; sourceTree = ""; };
+ 5B90C45A262599800002E742 /* SettingsAppearanceTableViewCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SettingsAppearanceTableViewCell.swift; sourceTree = ""; };
+ 5B90C45B262599800002E742 /* SettingsLinkTableViewCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SettingsLinkTableViewCell.swift; sourceTree = ""; };
+ 5B90C45C262599800002E742 /* SettingsSectionHeader.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SettingsSectionHeader.swift; sourceTree = ""; };
+ 5B90C45D262599800002E742 /* SettingsViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SettingsViewController.swift; sourceTree = ""; };
+ 5B90C46C26259B2C0002E742 /* Subscription.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Subscription.swift; sourceTree = ""; };
+ 5B90C46D26259B2C0002E742 /* Setting.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Setting.swift; sourceTree = ""; };
+ 5B90C47E26259BA90002E742 /* SubscriptionAlerts.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SubscriptionAlerts.swift; sourceTree = ""; };
+ 5B90C48426259BF10002E742 /* APIService+Subscriptions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "APIService+Subscriptions.swift"; sourceTree = ""; };
+ 5B90C48A26259C120002E742 /* APIService+CoreData+Subscriptions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "APIService+CoreData+Subscriptions.swift"; sourceTree = ""; };
5D03938F2612D259007FE196 /* WebViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewController.swift; sourceTree = ""; };
5D0393952612D266007FE196 /* WebViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewModel.swift; sourceTree = ""; };
+ 5DA732CB2629CEF500A92342 /* UIView+Remove.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIView+Remove.swift"; sourceTree = ""; };
5DDDF1922617442700311060 /* Mastodon+Entity+Account.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Mastodon+Entity+Account.swift"; sourceTree = ""; };
5DDDF1982617447F00311060 /* Mastodon+Entity+Tag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Mastodon+Entity+Tag.swift"; sourceTree = ""; };
5DDDF1A82617489F00311060 /* Mastodon+Entity+History.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Mastodon+Entity+History.swift"; sourceTree = ""; };
@@ -907,6 +951,15 @@
path = CollectionViewCell;
sourceTree = "";
};
+ 2D35237F26256F470031AF25 /* TableViewCell */ = {
+ isa = PBXGroup;
+ children = (
+ 2D35238026256F690031AF25 /* NotificationTableViewCell.swift */,
+ 2D24E11C2626D8B100A59D4F /* NotificationStatusTableViewCell.swift */,
+ );
+ path = TableViewCell;
+ sourceTree = "";
+ };
2D364F7025E66D5B00204FDC /* ResendEmail */ = {
isa = PBXGroup;
children = (
@@ -1063,6 +1116,7 @@
DB1E346725F518E20079D7DF /* CategoryPickerSection.swift */,
2DE0FAC02615F04D00CDF649 /* RecommendHashTagSection.swift */,
2DE0FACD2615F7AD00CDF649 /* RecommendAccountSection.swift */,
+ 2D35237926256D920031AF25 /* NotificationSection.swift */,
2D198648261C0B8500F0B013 /* SearchResultSection.swift */,
DB66729525F9F91600D60309 /* ComposeStatusSection.swift */,
DB447680260B3ED600B66B82 /* CustomEmojiPickerSection.swift */,
@@ -1116,6 +1170,7 @@
children = (
2D7631B225C159F700929FB9 /* Item.swift */,
2D198642261BF09500F0B013 /* SearchResultItem.swift */,
+ 2D7867182625B77500211898 /* NotificationItem.swift */,
DB4481CB25EE2AFE00BEFB67 /* PollItem.swift */,
DB1E347725F519300079D7DF /* PickServerItem.swift */,
DB1FD45925F27898004CFCFC /* CategoryPickerItem.swift */,
@@ -1154,7 +1209,6 @@
isa = PBXGroup;
children = (
2DFAD5362617010500F9EE7C /* SearchingTableViewCell.swift */,
- 2D19864E261C372A00F0B013 /* SearchBottomLoader.swift */,
);
path = TableViewCell;
sourceTree = "";
@@ -1169,6 +1223,35 @@
name = Frameworks;
sourceTree = "";
};
+ 5B90C455262599800002E742 /* Settings */ = {
+ isa = PBXGroup;
+ children = (
+ 5B90C457262599800002E742 /* View */,
+ 5B90C456262599800002E742 /* SettingsViewModel.swift */,
+ 5B90C45D262599800002E742 /* SettingsViewController.swift */,
+ );
+ path = Settings;
+ sourceTree = "";
+ };
+ 5B90C457262599800002E742 /* View */ = {
+ isa = PBXGroup;
+ children = (
+ 5B90C458262599800002E742 /* Cell */,
+ 5B90C45C262599800002E742 /* SettingsSectionHeader.swift */,
+ );
+ path = View;
+ sourceTree = "";
+ };
+ 5B90C458262599800002E742 /* Cell */ = {
+ isa = PBXGroup;
+ children = (
+ 5B90C459262599800002E742 /* SettingsToggleTableViewCell.swift */,
+ 5B90C45A262599800002E742 /* SettingsAppearanceTableViewCell.swift */,
+ 5B90C45B262599800002E742 /* SettingsLinkTableViewCell.swift */,
+ );
+ path = Cell;
+ sourceTree = "";
+ };
5D03938E2612D200007FE196 /* Webview */ = {
isa = PBXGroup;
children = (
@@ -1346,6 +1429,7 @@
DB938F1426241FDF00E5B6C1 /* APIService+Thread.swift */,
DB49A62A25FF36C700B98345 /* APIService+CustomEmoji.swift */,
DB9A488326034BD7008B817C /* APIService+Status.swift */,
+ 2D61254C262547C200299647 /* APIService+Notification.swift */,
DB9A488F26035963008B817C /* APIService+Media.swift */,
2D34D9D026148D9E0081BFC0 /* APIService+Recommend.swift */,
2D34D9DA261494120081BFC0 /* APIService+Search.swift */,
@@ -1354,6 +1438,7 @@
DBAE3F932616E28B004B8251 /* APIService+Follow.swift */,
DBAE3F8D2616E0B1004B8251 /* APIService+Block.swift */,
DBAE3F9D2616E308004B8251 /* APIService+Mute.swift */,
+ 5B90C48426259BF10002E742 /* APIService+Subscriptions.swift */,
);
path = APIService;
sourceTree = "";
@@ -1365,6 +1450,7 @@
DB45FADC25CA6F6B005A8AC7 /* APIService+CoreData+MastodonUser.swift */,
DB45FAF825CA80A2005A8AC7 /* APIService+CoreData+MastodonAuthentication.swift */,
2D79E700261EA5550011E398 /* APIService+CoreData+Tag.swift */,
+ 5B90C48A26259C120002E742 /* APIService+CoreData+Subscriptions.swift */,
);
path = CoreData;
sourceTree = "";
@@ -1425,6 +1511,7 @@
5DDDF1982617447F00311060 /* Mastodon+Entity+Tag.swift */,
5DDDF1A82617489F00311060 /* Mastodon+Entity+History.swift */,
2D650FAA25ECDC9300851B58 /* Mastodon+Entity+Error+Detail.swift */,
+ 2DB72C8B262D764300CE6173 /* Mastodon+Entity+Notification+Type.swift */,
);
path = MastodonSDK;
sourceTree = "";
@@ -1521,6 +1608,7 @@
isa = PBXGroup;
children = (
DB89BA2625C110B4008580ED /* Status.swift */,
+ 2D6125462625436B00299647 /* Notification.swift */,
2D0B7A1C261D839600B44727 /* SearchHistory.swift */,
DB8AF52425C131D1002E6C99 /* MastodonUser.swift */,
DB8AF56725C13E2A002E6C99 /* HomeTimelineIndex.swift */,
@@ -1534,6 +1622,9 @@
DB4481AC25EE155900BEFB67 /* Poll.swift */,
DB4481B225EE16D000BEFB67 /* PollOption.swift */,
DBCC3B9A2615849F0045B23D /* PrivateNote.swift */,
+ 5B90C46D26259B2C0002E742 /* Setting.swift */,
+ 5B90C46C26259B2C0002E742 /* Subscription.swift */,
+ 5B90C47E26259BA90002E742 /* SubscriptionAlerts.swift */,
);
path = Entity;
sourceTree = "";
@@ -1585,6 +1676,7 @@
2D76316325C14BAC00929FB9 /* PublicTimeline */,
0F2021F5261325ED000C64BF /* HashtagTimeline */,
DB9D6BEE25E4F5370051B173 /* Search */,
+ 5B90C455262599800002E742 /* Settings */,
DB9D6BFD25E4F57B0051B173 /* Notification */,
DB9D6C0825E4F5A60051B173 /* Profile */,
DB789A1025F9F29B0071ACA0 /* Compose */,
@@ -1617,8 +1709,9 @@
5DF1056325F887CB00D6C0D4 /* AVPlayer.swift */,
DB47229625F9EFAD00DA7F53 /* NSManagedObjectContext.swift */,
2D32EAB925CB9B0500C9ED86 /* UIView.swift */,
- 2DFF41882614A4DC00F776A4 /* UIView+Constraint.swift */,
+ 5DA732CB2629CEF500A92342 /* UIView+Remove.swift */,
DB8AF55C25C138B7002E6C99 /* UIViewController.swift */,
+ 2D24E1222626ED9D00A59D4F /* UIView+Gesture.swift */,
2D3F9E0325DFA133004262D9 /* UITapGestureRecognizer.swift */,
2D84350425FF858100EECE90 /* UIScrollView.swift */,
DB9E0D6E25EE008500CFDD76 /* UIInterpolatingMotionEffect.swift */,
@@ -1691,6 +1784,11 @@
isa = PBXGroup;
children = (
DB9D6BF725E4F5690051B173 /* NotificationViewController.swift */,
+ 2D607AD726242FC500B70763 /* NotificationViewModel.swift */,
+ 2D084B8C26258EA3003AA3AF /* NotificationViewModel+diffable.swift */,
+ 2D084B9226259545003AA3AF /* NotificationViewModel+LoadLatestState.swift */,
+ 2D24E12C2626FD2E00A59D4F /* NotificationViewModel+LoadOldestState.swift */,
+ 2D35237F26256F470031AF25 /* TableViewCell */,
);
path = Notification;
sourceTree = "";
@@ -2262,6 +2360,7 @@
DB938F0F2624119800E5B6C1 /* ThreadViewModel+LoadThreadState.swift in Sources */,
5DDDF1932617442700311060 /* Mastodon+Entity+Account.swift in Sources */,
DBAE3F882615DDF4004B8251 /* UserProviderFacade.swift in Sources */,
+ 2D607AD826242FC500B70763 /* NotificationViewModel.swift in Sources */,
DBA0A10925FB3C2B0079C110 /* RoundedEdgesButton.swift in Sources */,
DBABE3EC25ECAC4B00879EE5 /* WelcomeIllustrationView.swift in Sources */,
DB71FD4625F8C6D200512AE1 /* StatusProvider+UITableViewDataSourcePrefetching.swift in Sources */,
@@ -2280,9 +2379,10 @@
DBE3CE07261D6A0E00430CC6 /* FavoriteViewModel+Diffable.swift in Sources */,
2D61335825C188A000CAE157 /* APIService+Persist+Status.swift in Sources */,
2D34D9DB261494120081BFC0 /* APIService+Search.swift in Sources */,
+ 5B90C48B26259C120002E742 /* APIService+CoreData+Subscriptions.swift in Sources */,
DBB5256E2612D5A1002F1F29 /* ProfileStatusDashboardView.swift in Sources */,
+ 2D24E1232626ED9D00A59D4F /* UIView+Gesture.swift in Sources */,
DB45FAE325CA7181005A8AC7 /* MastodonUser.swift in Sources */,
- 2D19864F261C372A00F0B013 /* SearchBottomLoader.swift in Sources */,
DB2FF510260B113300ADA9FE /* ComposeStatusPollExpiresOptionCollectionViewCell.swift in Sources */,
0F202213261351F5000C64BF /* APIService+HashtagTimeline.swift in Sources */,
DB0AC6FC25CD02E600D75117 /* APIService+Instance.swift in Sources */,
@@ -2294,10 +2394,12 @@
DB49A62B25FF36C700B98345 /* APIService+CustomEmoji.swift in Sources */,
DBCBED1D26132E1A00B49291 /* StatusFetchedResultsController.swift in Sources */,
2D79E701261EA5550011E398 /* APIService+CoreData+Tag.swift in Sources */,
+ 5B90C461262599800002E742 /* SettingsLinkTableViewCell.swift in Sources */,
DBE3CDEC261C6B2900430CC6 /* FavoriteViewController.swift in Sources */,
DB938EE62623F50700E5B6C1 /* ThreadViewController.swift in Sources */,
2D939AB525EDD8A90076FA61 /* String.swift in Sources */,
DB4481B925EE289600BEFB67 /* UITableView.swift in Sources */,
+ 2D35238126256F690031AF25 /* NotificationTableViewCell.swift in Sources */,
DBE3CDBB261C427900430CC6 /* TimelineHeaderTableViewCell.swift in Sources */,
0FAA101C25E10E760017CCDE /* UIFont.swift in Sources */,
2D38F1D525CD465300561493 /* HomeTimelineViewController.swift in Sources */,
@@ -2317,6 +2419,7 @@
DBD9149025DF6D8D00903DFD /* APIService+Onboarding.swift in Sources */,
DBAE3FAF26172FC0004B8251 /* RemoteProfileViewModel.swift in Sources */,
DBE3CE0D261D767100430CC6 /* FavoriteViewController+StatusProvider.swift in Sources */,
+ 2D084B9326259545003AA3AF /* NotificationViewModel+LoadLatestState.swift in Sources */,
DB98337F25C9452D00AD9700 /* APIService+APIError.swift in Sources */,
DB9E0D6F25EE008500CFDD76 /* UIInterpolatingMotionEffect.swift in Sources */,
DBB9759C262462E1004620BD /* ThreadMetaView.swift in Sources */,
@@ -2324,6 +2427,7 @@
5DDDF1A92617489F00311060 /* Mastodon+Entity+History.swift in Sources */,
DB59F11825EFA35B001F1DAB /* StripProgressView.swift in Sources */,
DB59F10425EF5EBC001F1DAB /* TableViewCellHeightCacheableContainer.swift in Sources */,
+ 5B90C463262599800002E742 /* SettingsViewController.swift in Sources */,
DB73B490261F030A002E9E9F /* SafariActivity.swift in Sources */,
DB9A48962603685D008B817C /* MastodonAttachmentService+UploadState.swift in Sources */,
2D198643261BF09500F0B013 /* SearchResultItem.swift in Sources */,
@@ -2331,6 +2435,7 @@
DBE0822425CD3F1E00FD6BBD /* MastodonRegisterViewModel.swift in Sources */,
2DF75B9B25D0E27500694EC8 /* StatusProviderFacade.swift in Sources */,
DB5086A525CC0B7000C2C187 /* AvatarBarButtonItem.swift in Sources */,
+ 5B90C45E262599800002E742 /* SettingsViewModel.swift in Sources */,
2D82B9FF25E7863200E36F0F /* OnboardingViewControllerAppearance.swift in Sources */,
5DF1054725F8870E00D6C0D4 /* VideoPlayerViewModel.swift in Sources */,
2DE0FAC12615F04D00CDF649 /* RecommendHashTagSection.swift in Sources */,
@@ -2343,6 +2448,7 @@
DB71FD3C25F8A1C500512AE1 /* APIService+Persist+PersistCache.swift in Sources */,
2DA6055125F74407006356F9 /* AudioContainerViewModel.swift in Sources */,
0FB3D2FE25E4CB6400AAD544 /* PickServerTitleCell.swift in Sources */,
+ 5DA732CC2629CEF500A92342 /* UIView+Remove.swift in Sources */,
2D38F1DF25CD46A400561493 /* HomeTimelineViewController+StatusProvider.swift in Sources */,
2D206B9225F60EA700143C56 /* UIControl.swift in Sources */,
2D46975E25C2A54100CF4AA9 /* NSLayoutConstraint.swift in Sources */,
@@ -2351,6 +2457,7 @@
DB7F48452620241000796008 /* ProfileHeaderViewModel.swift in Sources */,
2D3F9E0425DFA133004262D9 /* UITapGestureRecognizer.swift in Sources */,
5DDDF1992617447F00311060 /* Mastodon+Entity+Tag.swift in Sources */,
+ 5B90C45F262599800002E742 /* SettingsToggleTableViewCell.swift in Sources */,
2D694A7425F9EB4E0038ADDC /* ContentWarningOverlayView.swift in Sources */,
DBAE3F682615DD60004B8251 /* UserProvider.swift in Sources */,
DB9A486C26032AC1008B817C /* AttachmentContainerView+EmptyStateView.swift in Sources */,
@@ -2360,6 +2467,7 @@
0F20222D261457EE000C64BF /* HashtagTimelineViewModel+LoadOldestState.swift in Sources */,
DB4563BD25E11A24004DA0B9 /* KeyboardResponderService.swift in Sources */,
DB5086BE25CC0D9900C2C187 /* SplashPreference.swift in Sources */,
+ 5B90C462262599800002E742 /* SettingsSectionHeader.swift in Sources */,
DB44768B260B3F2100B66B82 /* CustomEmojiPickerItem.swift in Sources */,
2DF75BA125D0E29D00694EC8 /* StatusProvider+StatusTableViewCellDelegate.swift in Sources */,
5DF1056425F887CB00D6C0D4 /* AVPlayer.swift in Sources */,
@@ -2376,14 +2484,17 @@
DB789A1C25F9F76A0071ACA0 /* ComposeStatusContentCollectionViewCell.swift in Sources */,
DB1FD43625F26899004CFCFC /* MastodonPickServerViewModel+LoadIndexedServerState.swift in Sources */,
2D939AE825EE1CF80076FA61 /* MastodonRegisterViewController+Avatar.swift in Sources */,
+ 2D084B8D26258EA3003AA3AF /* NotificationViewModel+diffable.swift in Sources */,
DB1D186C25EF5BA7003F1F23 /* PollTableView.swift in Sources */,
2D5981A125E4A593000FB903 /* MastodonConfirmEmailViewModel.swift in Sources */,
DB87D4452609BE0500D12C0D /* ComposeStatusPollOptionCollectionViewCell.swift in Sources */,
DB8AF55025C13703002E6C99 /* MainTabBarController.swift in Sources */,
DB9D6BE925E4F5340051B173 /* SearchViewController.swift in Sources */,
2D38F1C625CD37F400561493 /* ContentOffsetAdjustableTimelineViewControllerDelegate.swift in Sources */,
+ 2D24E11D2626D8B100A59D4F /* NotificationStatusTableViewCell.swift in Sources */,
DB6C8C0F25F0A6AE00AAA452 /* Mastodon+Entity+Error.swift in Sources */,
DB1E346825F518E20079D7DF /* CategoryPickerSection.swift in Sources */,
+ 2D61254D262547C200299647 /* APIService+Notification.swift in Sources */,
DBB525642612C988002F1F29 /* MeProfileViewModel.swift in Sources */,
DBAE3F822615DDA3004B8251 /* ProfileViewController+UserProvider.swift in Sources */,
DB938EED2623F79B00E5B6C1 /* ThreadViewModel.swift in Sources */,
@@ -2415,8 +2526,11 @@
DB35FC252612FD7A006193C9 /* ProfileFieldView.swift in Sources */,
DB938F0326240EA300E5B6C1 /* CachedThreadViewModel.swift in Sources */,
2D650FAB25ECDC9300851B58 /* Mastodon+Entity+Error+Detail.swift in Sources */,
+ 2D24E12D2626FD2E00A59D4F /* NotificationViewModel+LoadOldestState.swift in Sources */,
+ 2DB72C8C262D764300CE6173 /* Mastodon+Entity+Notification+Type.swift in Sources */,
DB118A8C25E4BFB500FAB162 /* HighlightDimmableButton.swift in Sources */,
DBAE3FA92617106E004B8251 /* MastodonMetricFormatter.swift in Sources */,
+ 2D35237A26256D920031AF25 /* NotificationSection.swift in Sources */,
DB084B5725CBC56C00F898ED /* Status.swift in Sources */,
DB447691260B406600B66B82 /* CustomEmojiPickerItemCollectionViewCell.swift in Sources */,
DB9282B225F3222800823B15 /* PickServerEmptyStateView.swift in Sources */,
@@ -2429,14 +2543,16 @@
DB71FD3625F8A16C00512AE1 /* APIService+Persist+PersistMemo.swift in Sources */,
DBBE1B4525F3474B0081417A /* MastodonPickServerAppearance.swift in Sources */,
DB98338725C945ED00AD9700 /* Strings.swift in Sources */,
+ 2D7867192625B77500211898 /* NotificationItem.swift in Sources */,
DB45FAB625CA5485005A8AC7 /* UIAlertController.swift in Sources */,
DBE0821525CD382600FD6BBD /* MastodonRegisterViewController.swift in Sources */,
- 2DFF41892614A4DC00F776A4 /* UIView+Constraint.swift in Sources */,
2D5A3D0325CF8742002347D6 /* ControlContainableScrollViews.swift in Sources */,
DB98336B25C9420100AD9700 /* APIService+App.swift in Sources */,
DBA0A11325FB3FC10079C110 /* ComposeToolbarView.swift in Sources */,
2D32EADA25CBCC3300C9ED86 /* PublicTimelineViewModel+LoadMiddleState.swift in Sources */,
+ 5B90C48526259BF10002E742 /* APIService+Subscriptions.swift in Sources */,
0F20223926146553000C64BF /* Array+removeDuplicates.swift in Sources */,
+ 5B90C460262599800002E742 /* SettingsAppearanceTableViewCell.swift in Sources */,
DB8AF54425C13647002E6C99 /* SceneCoordinator.swift in Sources */,
5DF1058525F88AE500D6C0D4 /* NeedsDependency+AVPlayerViewControllerDelegate.swift in Sources */,
DB447697260B439000B66B82 /* CustomEmojiPickerHeaderCollectionReusableView.swift in Sources */,
@@ -2524,6 +2640,7 @@
2D927F1425C7EDD9004F19B8 /* Emoji.swift in Sources */,
2DF75BC725D1475D00694EC8 /* ManagedObjectContextObjectsDidChange.swift in Sources */,
DB89BA1225C1105C008580ED /* CoreDataStack.swift in Sources */,
+ 5B90C46F26259B2C0002E742 /* Setting.swift in Sources */,
DB89BA1C25C1107F008580ED /* NSManagedObjectContext.swift in Sources */,
DB9D6C2E25E504AC0051B173 /* Attachment.swift in Sources */,
2D927F0E25C7E9C9004F19B8 /* History.swift in Sources */,
@@ -2536,6 +2653,7 @@
2D152A9225C2980C009AA50C /* UIFont.swift in Sources */,
DB4481B325EE16D000BEFB67 /* PollOption.swift in Sources */,
DB89BA4425C1165F008580ED /* Managed.swift in Sources */,
+ 2D6125472625436B00299647 /* Notification.swift in Sources */,
DB89BA4325C1165F008580ED /* NetworkUpdatable.swift in Sources */,
DB8AF56825C13E2A002E6C99 /* HomeTimelineIndex.swift in Sources */,
DB45FAED25CA7A9A005A8AC7 /* MastodonAuthentication.swift in Sources */,
@@ -2544,6 +2662,8 @@
DB89BA1D25C1107F008580ED /* URL.swift in Sources */,
2D0B7A1D261D839600B44727 /* SearchHistory.swift in Sources */,
2D927F0825C7E9A8004F19B8 /* Tag.swift in Sources */,
+ 5B90C46E26259B2C0002E742 /* Subscription.swift in Sources */,
+ 5B90C47F26259BA90002E742 /* SubscriptionAlerts.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
diff --git a/Mastodon/Coordinator/SceneCoordinator.swift b/Mastodon/Coordinator/SceneCoordinator.swift
index 8874a69e..c2608fe8 100644
--- a/Mastodon/Coordinator/SceneCoordinator.swift
+++ b/Mastodon/Coordinator/SceneCoordinator.swift
@@ -68,6 +68,7 @@ extension SceneCoordinator {
#if DEBUG
case publicTimeline
+ case settings
#endif
var isOnboarding: Bool {
@@ -269,6 +270,10 @@ private extension SceneCoordinator {
let _viewController = PublicTimelineViewController()
_viewController.viewModel = PublicTimelineViewModel(context: appContext)
viewController = _viewController
+ case .settings:
+ let _viewController = SettingsViewController()
+ _viewController.viewModel = SettingsViewModel(context: appContext, coordinator: self)
+ viewController = _viewController
#endif
}
diff --git a/Mastodon/Diffiable/Item/NotificationItem.swift b/Mastodon/Diffiable/Item/NotificationItem.swift
new file mode 100644
index 00000000..ba0d0c14
--- /dev/null
+++ b/Mastodon/Diffiable/Item/NotificationItem.swift
@@ -0,0 +1,39 @@
+//
+// NotificationItem.swift
+// Mastodon
+//
+// Created by sxiaojian on 2021/4/13.
+//
+
+import CoreData
+import Foundation
+
+enum NotificationItem {
+ case notification(objectID: NSManagedObjectID)
+
+ case bottomLoader
+}
+
+extension NotificationItem: Equatable {
+ static func == (lhs: NotificationItem, rhs: NotificationItem) -> Bool {
+ switch (lhs, rhs) {
+ case (.notification(let idLeft), .notification(let idRight)):
+ return idLeft == idRight
+ case (.bottomLoader, .bottomLoader):
+ return true
+ default:
+ return false
+ }
+ }
+}
+
+extension NotificationItem: Hashable {
+ func hash(into hasher: inout Hasher) {
+ switch self {
+ case .notification(let id):
+ hasher.combine(id)
+ case .bottomLoader:
+ hasher.combine(String(describing: NotificationItem.bottomLoader.self))
+ }
+ }
+}
diff --git a/Mastodon/Diffiable/Section/NotificationSection.swift b/Mastodon/Diffiable/Section/NotificationSection.swift
new file mode 100644
index 00000000..5ccab431
--- /dev/null
+++ b/Mastodon/Diffiable/Section/NotificationSection.swift
@@ -0,0 +1,118 @@
+//
+// NotificationSection.swift
+// Mastodon
+//
+// Created by sxiaojian on 2021/4/13.
+//
+
+import Combine
+import CoreData
+import CoreDataStack
+import Foundation
+import MastodonSDK
+import UIKit
+
+enum NotificationSection: Equatable, Hashable {
+ case main
+}
+
+extension NotificationSection {
+ static func tableViewDiffableDataSource(
+ for tableView: UITableView,
+ timestampUpdatePublisher: AnyPublisher,
+ managedObjectContext: NSManagedObjectContext,
+ delegate: NotificationTableViewCellDelegate,
+ dependency: NeedsDependency,
+ requestUserID: String
+ ) -> UITableViewDiffableDataSource {
+ UITableViewDiffableDataSource(tableView: tableView) {
+ [weak delegate, weak dependency]
+ (tableView, indexPath, notificationItem) -> UITableViewCell? in
+ guard let dependency = dependency else { return nil }
+ switch notificationItem {
+ case .notification(let objectID):
+
+ let notification = managedObjectContext.object(with: objectID) as! MastodonNotification
+ guard let type = Mastodon.Entity.Notification.NotificationType(rawValue: notification.typeRaw) else {
+ assertionFailure()
+ return nil
+ }
+ let timeText = notification.createAt.shortTimeAgoSinceNow
+
+ let actionText = type.actionText
+ let actionImageName = type.actionImageName
+ let color = type.color
+
+ if let status = notification.status {
+ let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: NotificationStatusTableViewCell.self), for: indexPath) as! NotificationStatusTableViewCell
+ cell.delegate = delegate
+ let frame = CGRect(x: 0, y: 0, width: tableView.readableContentGuide.layoutFrame.width - NotificationStatusTableViewCell.statusPadding.left - NotificationStatusTableViewCell.statusPadding.right, height: tableView.readableContentGuide.layoutFrame.height)
+ StatusSection.configure(cell: cell,
+ dependency: dependency,
+ readableLayoutFrame: frame,
+ timestampUpdatePublisher: timestampUpdatePublisher,
+ status: status,
+ requestUserID: requestUserID,
+ statusItemAttribute: Item.StatusAttribute(isStatusTextSensitive: false, isStatusSensitive: false))
+ timestampUpdatePublisher
+ .sink { _ in
+ let timeText = notification.createAt.shortTimeAgoSinceNow
+ cell.actionLabel.text = actionText + " · " + timeText
+ }
+ .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
+ if let url = notification.account.avatarImageURL() {
+ cell.avatatImageView.af.setImage(
+ withURL: url,
+ placeholderImage: UIImage.placeholder(color: .systemFill),
+ imageTransition: .crossDissolve(0.2)
+ )
+ }
+ cell.avatatImageView.gesture().sink { [weak cell] _ in
+ cell?.delegate?.userAvatarDidPressed(notification: notification)
+ }
+ .store(in: &cell.disposeBag)
+ if let actionImage = UIImage(systemName: actionImageName, withConfiguration: UIImage.SymbolConfiguration(pointSize: 12, weight: .semibold))?.withRenderingMode(.alwaysTemplate) {
+ cell.actionImageView.image = actionImage
+ }
+ return cell
+
+ } else {
+ let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: NotificationTableViewCell.self), for: indexPath) as! NotificationTableViewCell
+ cell.delegate = delegate
+ timestampUpdatePublisher
+ .sink { _ in
+ let timeText = notification.createAt.shortTimeAgoSinceNow
+ cell.actionLabel.text = actionText + " · " + timeText
+ }
+ .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
+ if let url = notification.account.avatarImageURL() {
+ cell.avatatImageView.af.setImage(
+ withURL: url,
+ placeholderImage: UIImage.placeholder(color: .systemFill),
+ imageTransition: .crossDissolve(0.2)
+ )
+ }
+ cell.avatatImageView.gesture().sink { [weak cell] _ in
+ cell?.delegate?.userAvatarDidPressed(notification: notification)
+ }
+ .store(in: &cell.disposeBag)
+ if let actionImage = UIImage(systemName: actionImageName, withConfiguration: UIImage.SymbolConfiguration(pointSize: 12, weight: .semibold))?.withRenderingMode(.alwaysTemplate) {
+ cell.actionImageView.image = actionImage
+ }
+ return cell
+ }
+ case .bottomLoader:
+ let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self)) as! TimelineBottomLoaderTableViewCell
+ cell.startAnimating()
+ return cell
+ }
+ }
+ }
+}
+
diff --git a/Mastodon/Diffiable/Section/SearchResultSection.swift b/Mastodon/Diffiable/Section/SearchResultSection.swift
index 50c56160..1b9230ee 100644
--- a/Mastodon/Diffiable/Section/SearchResultSection.swift
+++ b/Mastodon/Diffiable/Section/SearchResultSection.swift
@@ -44,7 +44,7 @@ extension SearchResultSection {
cell.config(with: user)
return cell
case .bottomLoader:
- let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: SearchBottomLoader.self)) as! SearchBottomLoader
+ let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self)) as! TimelineBottomLoaderTableViewCell
cell.startAnimating()
return cell
}
diff --git a/Mastodon/Diffiable/Section/StatusSection.swift b/Mastodon/Diffiable/Section/StatusSection.swift
index a432bf06..1dd155d5 100644
--- a/Mastodon/Diffiable/Section/StatusSection.swift
+++ b/Mastodon/Diffiable/Section/StatusSection.swift
@@ -10,6 +10,12 @@ import CoreData
import CoreDataStack
import os.log
import UIKit
+import AVKit
+
+protocol StatusCell : DisposeBagCollectable {
+ var statusView: StatusView { get }
+ var pollCountdownSubscription: AnyCancellable? { get set }
+}
enum StatusSection: Equatable, Hashable {
case main
@@ -127,7 +133,7 @@ extension StatusSection {
extension StatusSection {
static func configure(
- cell: StatusTableViewCell,
+ cell: StatusCell,
dependency: NeedsDependency,
readableLayoutFrame: CGRect?,
timestampUpdatePublisher: AnyPublisher,
@@ -283,14 +289,27 @@ extension StatusSection {
if let videoAttachment = mediaAttachments.filter({ $0.type == .gifv || $0.type == .video }).first,
let videoPlayerViewModel = dependency.context.videoPlaybackService.dequeueVideoPlayerViewModel(for: videoAttachment)
{
- let parent = cell.delegate?.parent()
+ var parent: UIViewController?
+ var playerViewControllerDelegate: AVPlayerViewControllerDelegate? = nil
+ switch cell {
+ case is StatusTableViewCell:
+ let statusTableViewCell = cell as! StatusTableViewCell
+ parent = statusTableViewCell.delegate?.parent()
+ playerViewControllerDelegate = statusTableViewCell.delegate?.playerViewControllerDelegate
+ case is NotificationStatusTableViewCell:
+ let notificationTableViewCell = cell as! NotificationStatusTableViewCell
+ parent = notificationTableViewCell.delegate?.parent()
+ default:
+ parent = nil
+ assertionFailure("unknown cell")
+ }
let playerContainerView = cell.statusView.playerContainerView
let playerViewController = playerContainerView.setupPlayer(
aspectRatio: videoPlayerViewModel.videoSize,
maxSize: playerViewMaxSize,
parent: parent
)
- playerViewController.delegate = cell.delegate?.playerViewControllerDelegate
+ playerViewController.delegate = playerViewControllerDelegate
playerViewController.player = videoPlayerViewModel.player
playerViewController.showsPlaybackControls = videoPlayerViewModel.videoKind != .gif
playerContainerView.setMediaKind(kind: videoPlayerViewModel.videoKind)
@@ -376,7 +395,9 @@ extension StatusSection {
StatusSection.configureActionToolBar(cell: cell, status: status, requestUserID: requestUserID)
// separator line
- cell.separatorLine.isHidden = statusItemAttribute.isSeparatorLineHidden
+ if let statusTableViewCell = cell as? StatusTableViewCell {
+ statusTableViewCell.separatorLine.isHidden = statusItemAttribute.isSeparatorLineHidden
+ }
// set date
let createdAt = (status.reblog ?? status).createdAt
@@ -520,7 +541,7 @@ extension StatusSection {
static func configureHeader(
- cell: StatusTableViewCell,
+ cell: StatusCell,
status: Status
) {
if status.reblog != nil {
@@ -548,7 +569,7 @@ extension StatusSection {
}
static func configureActionToolBar(
- cell: StatusTableViewCell,
+ cell: StatusCell,
status: Status,
requestUserID: String
) {
@@ -579,7 +600,7 @@ extension StatusSection {
}
static func configurePoll(
- cell: StatusTableViewCell,
+ cell: StatusCell,
poll: Poll?,
requestUserID: String,
updateProgressAnimated: Bool,
diff --git a/Mastodon/Extension/MastodonSDK/Mastodon+Entity+Notification+Type.swift b/Mastodon/Extension/MastodonSDK/Mastodon+Entity+Notification+Type.swift
new file mode 100644
index 00000000..77a7b412
--- /dev/null
+++ b/Mastodon/Extension/MastodonSDK/Mastodon+Entity+Notification+Type.swift
@@ -0,0 +1,75 @@
+//
+// Mastodon+Entity+Notification+Type.swift
+// Mastodon
+//
+// Created by sxiaojian on 2021/4/19.
+//
+
+import Foundation
+import MastodonSDK
+import UIKit
+
+extension Mastodon.Entity.Notification.NotificationType {
+ public var color: UIColor {
+ get {
+ var color: UIColor
+ switch self {
+ case .follow:
+ color = Asset.Colors.brandBlue.color
+ case .favourite:
+ color = Asset.Colors.Notification.favourite.color
+ case .reblog:
+ color = Asset.Colors.Notification.reblog.color
+ case .mention:
+ color = Asset.Colors.Notification.mention.color
+ case .poll:
+ color = Asset.Colors.brandBlue.color
+ default:
+ color = .clear
+ }
+ return color
+ }
+ }
+
+ public var actionText: String {
+ get {
+ var actionText: String
+ switch self {
+ case .follow:
+ actionText = L10n.Scene.Notification.Action.follow
+ case .favourite:
+ actionText = L10n.Scene.Notification.Action.favourite
+ case .reblog:
+ actionText = L10n.Scene.Notification.Action.reblog
+ case .mention:
+ actionText = L10n.Scene.Notification.Action.mention
+ case .poll:
+ actionText = L10n.Scene.Notification.Action.poll
+ default:
+ actionText = ""
+ }
+ return actionText
+ }
+ }
+
+ public var actionImageName: String {
+ get {
+ var actionImageName: String
+ switch self {
+ case .follow:
+ actionImageName = "person.crop.circle.badge.checkmark"
+ case .favourite:
+ actionImageName = "star.fill"
+ case .reblog:
+ actionImageName = "arrow.2.squarepath"
+ case .mention:
+ actionImageName = "at"
+ case .poll:
+ actionImageName = "list.bullet"
+ default:
+ actionImageName = ""
+ }
+ return actionImageName
+ }
+ }
+}
diff --git a/Mastodon/Extension/UIButton.swift b/Mastodon/Extension/UIButton.swift
index 916ad222..31043157 100644
--- a/Mastodon/Extension/UIButton.swift
+++ b/Mastodon/Extension/UIButton.swift
@@ -43,3 +43,11 @@ extension UIButton {
}
}
+extension UIButton {
+ func setBackgroundColor(_ color: UIColor, for state: UIControl.State) {
+ self.setBackgroundImage(
+ UIImage.placeholder(color: color),
+ for: state
+ )
+ }
+}
diff --git a/Mastodon/Extension/UIView+Constraint.swift b/Mastodon/Extension/UIView+Constraint.swift
deleted file mode 100644
index baa923ad..00000000
--- a/Mastodon/Extension/UIView+Constraint.swift
+++ /dev/null
@@ -1,261 +0,0 @@
-//
-// UIView+Constraint.swift
-// Mastodon
-//
-// Created by sxiaojian on 2021/3/31.
-//
-
-import UIKit
-
-enum Dimension {
- case width
- case height
-
- var layoutAttribute: NSLayoutConstraint.Attribute {
- switch self {
- case .width:
- return .width
- case .height:
- return .height
- }
- }
-
-}
-
-extension UIView {
-
- func constrain(toSuperviewEdges: UIEdgeInsets?) {
- guard let view = superview else { assert(false, "Superview cannot be nil when adding contraints"); return}
- translatesAutoresizingMaskIntoConstraints = false
- NSLayoutConstraint.activate([
- NSLayoutConstraint(item: self,
- attribute: .leading,
- relatedBy: .equal,
- toItem: view,
- attribute: .leading,
- multiplier: 1.0,
- constant: toSuperviewEdges?.left ?? 0.0),
- NSLayoutConstraint(item: self,
- attribute: .top,
- relatedBy: .equal,
- toItem: view,
- attribute: .top,
- multiplier: 1.0,
- constant: toSuperviewEdges?.top ?? 0.0),
- NSLayoutConstraint(item: view,
- attribute: .trailing,
- relatedBy: .equal,
- toItem: self,
- attribute: .trailing,
- multiplier: 1.0,
- constant: toSuperviewEdges?.right ?? 0.0),
- NSLayoutConstraint(item: view,
- attribute: .bottom,
- relatedBy: .equal,
- toItem: self,
- attribute: .bottom,
- multiplier: 1.0,
- constant: toSuperviewEdges?.bottom ?? 0.0)
- ])
- }
-
- func constrain(_ constraints: [NSLayoutConstraint?]) {
- guard superview != nil else { assert(false, "Superview cannot be nil when adding contraints"); return }
- translatesAutoresizingMaskIntoConstraints = false
- NSLayoutConstraint.activate(constraints.compactMap { $0 })
- }
-
- func constraint(_ attribute: NSLayoutConstraint.Attribute, toView: UIView, constant: CGFloat?) -> NSLayoutConstraint? {
- guard superview != nil else { assert(false, "Superview cannot be nil when adding contraints"); return nil}
- translatesAutoresizingMaskIntoConstraints = false
- return NSLayoutConstraint(item: self, attribute: attribute, relatedBy: .equal, toItem: toView, attribute: attribute, multiplier: 1.0, constant: constant ?? 0.0)
- }
-
- func constraint(_ attribute: NSLayoutConstraint.Attribute, toView: UIView) -> NSLayoutConstraint? {
- guard superview != nil else { assert(false, "Superview cannot be nil when adding contraints"); return nil}
- translatesAutoresizingMaskIntoConstraints = false
- return NSLayoutConstraint(item: self, attribute: attribute, relatedBy: .equal, toItem: toView, attribute: attribute, multiplier: 1.0, constant: 0.0)
- }
-
- func constraint(_ dimension: Dimension, constant: CGFloat) -> NSLayoutConstraint? {
- guard superview != nil else { assert(false, "Superview cannot be nil when adding contraints"); return nil }
- translatesAutoresizingMaskIntoConstraints = false
- return NSLayoutConstraint(item: self,
- attribute: dimension.layoutAttribute,
- relatedBy: .equal,
- toItem: nil,
- attribute: .notAnAttribute,
- multiplier: 1.0,
- constant: constant)
- }
-
- func constrainTopCorners(sidePadding: CGFloat, topPadding: CGFloat, topLayoutGuide: UILayoutSupport) {
- guard let view = superview else { assert(false, "Superview cannot be nil when adding contraints"); return }
- translatesAutoresizingMaskIntoConstraints = false
- constrain([
- constraint(.leading, toView: view, constant: sidePadding),
- NSLayoutConstraint(item: self, attribute: .top, relatedBy: .equal, toItem: topLayoutGuide, attribute: .bottom, multiplier: 1.0, constant: topPadding),
- constraint(.trailing, toView: view, constant: -sidePadding)
- ])
- }
-
- func constrainTopCorners(sidePadding: CGFloat, topPadding: CGFloat) {
- guard let view = superview else { assert(false, "Superview cannot be nil when adding contraints"); return }
- translatesAutoresizingMaskIntoConstraints = false
- constrain([
- constraint(.leading, toView: view, constant: sidePadding),
- constraint(.top, toView: view, constant: topPadding),
- constraint(.trailing, toView: view, constant: -sidePadding)
- ])
- }
-
- func constrainTopCorners(height: CGFloat) {
- guard let view = superview else { assert(false, "Superview cannot be nil when adding contraints"); return }
- translatesAutoresizingMaskIntoConstraints = false
- constrain([
- constraint(.leading, toView: view),
- constraint(.top, toView: view),
- constraint(.trailing, toView: view),
- constraint(.height, constant: height)
- ])
- }
-
- func constrainBottomCorners(sidePadding: CGFloat, bottomPadding: CGFloat) {
- guard let view = superview else { assert(false, "Superview cannot be nil when adding contraints"); return }
- translatesAutoresizingMaskIntoConstraints = false
- constrain([
- constraint(.leading, toView: view, constant: sidePadding),
- constraint(.bottom, toView: view, constant: -bottomPadding),
- constraint(.trailing, toView: view, constant: -sidePadding)
- ])
- }
-
- func constrainBottomCorners(height: CGFloat) {
- guard let view = superview else { assert(false, "Superview cannot be nil when adding contraints"); return }
- translatesAutoresizingMaskIntoConstraints = false
- constrain([
- constraint(.leading, toView: view),
- constraint(.bottom, toView: view),
- constraint(.trailing, toView: view),
- constraint(.height, constant: height)
- ])
- }
-
- func constrainLeadingCorners() {
- guard let view = superview else { assert(false, "Superview cannot be nil when adding contraints"); return }
- translatesAutoresizingMaskIntoConstraints = false
- constrain([
- constraint(.top, toView: view),
- constraint(.leading, toView: view),
- constraint(.bottom, toView: view)
- ])
- }
-
- func constrainTrailingCorners() {
- guard let view = superview else { assert(false, "Superview cannot be nil when adding contraints"); return }
- translatesAutoresizingMaskIntoConstraints = false
- constrain([
- constraint(.top, toView: view),
- constraint(.trailing, toView: view),
- constraint(.bottom, toView: view)
- ])
- }
-
- func constrainToCenter() {
- guard let view = superview else { assert(false, "Superview cannot be nil when adding contraints"); return }
- translatesAutoresizingMaskIntoConstraints = false
- constrain([
- constraint(.centerX, toView: view),
- constraint(.centerY, toView: view)
- ])
- }
-
- func pin(toSize: CGSize) {
- guard superview != nil else { assert(false, "Superview cannot be nil when adding contraints"); return }
- translatesAutoresizingMaskIntoConstraints = false
- constrain([
- widthAnchor.constraint(equalToConstant: toSize.width),
- heightAnchor.constraint(equalToConstant: toSize.height)])
- }
-
- func pin(top: CGFloat?,left: CGFloat?,bottom: CGFloat?, right: CGFloat?) {
- guard let view = superview else { assert(false, "Superview cannot be nil when adding contraints"); return }
- translatesAutoresizingMaskIntoConstraints = false
- var constraints = [NSLayoutConstraint]()
- if let topConstant = top {
- constraints.append(topAnchor.constraint(equalTo: view.topAnchor, constant: topConstant))
- }
- if let leftConstant = left {
- constraints.append(leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: leftConstant))
- }
- if let bottomConstant = bottom {
- constraints.append(view.bottomAnchor.constraint(equalTo: bottomAnchor, constant: bottomConstant))
- }
- if let rightConstant = right {
- constraints.append(view.trailingAnchor.constraint(equalTo: trailingAnchor, constant: rightConstant))
- }
- constrain(constraints)
-
- }
- func pinTopLeft(padding: CGFloat) {
- guard let view = superview else { assert(false, "Superview cannot be nil when adding contraints"); return }
- translatesAutoresizingMaskIntoConstraints = false
- constrain([
- leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: padding),
- topAnchor.constraint(equalTo: view.topAnchor, constant: padding)])
- }
-
- func pinTopLeft(top: CGFloat, left: CGFloat) {
- guard let view = superview else { assert(false, "Superview cannot be nil when adding contraints"); return }
- translatesAutoresizingMaskIntoConstraints = false
- constrain([
- leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: left),
- topAnchor.constraint(equalTo: view.topAnchor, constant: top)])
- }
-
- func pinTopRight(padding: CGFloat) {
- guard let view = superview else { assert(false, "Superview cannot be nil when adding contraints"); return }
- translatesAutoresizingMaskIntoConstraints = false
- constrain([
- view.trailingAnchor.constraint(equalTo: trailingAnchor, constant: padding),
- topAnchor.constraint(equalTo: view.topAnchor, constant: padding)])
- }
-
- func pinTopRight(top: CGFloat, right: CGFloat) {
- guard let view = superview else { assert(false, "Superview cannot be nil when adding contraints"); return }
- translatesAutoresizingMaskIntoConstraints = false
- constrain([
- view.trailingAnchor.constraint(equalTo: trailingAnchor, constant: right),
- topAnchor.constraint(equalTo: view.topAnchor, constant: top)])
- }
-
- func pinTopLeft(toView: UIView, topPadding: CGFloat) {
- guard superview != nil else { assert(false, "Superview cannot be nil when adding contraints"); return }
- translatesAutoresizingMaskIntoConstraints = false
- constrain([
- leadingAnchor.constraint(equalTo: toView.leadingAnchor),
- topAnchor.constraint(equalTo: toView.bottomAnchor, constant: topPadding)])
- }
-
- /// Cross-fades between two views by animating their alpha then setting one or the other hidden.
- /// - parameters:
- /// - lhs: left view
- /// - rhs: right view
- /// - toRight: fade to the right view if true, fade to the left view if false
- /// - duration: animation duration
- ///
- static func crossfade(_ lhs: UIView, _ rhs: UIView, toRight: Bool, duration: TimeInterval) {
- lhs.alpha = toRight ? 1.0 : 0.0
- rhs.alpha = toRight ? 0.0 : 1.0
- lhs.isHidden = false
- rhs.isHidden = false
-
- UIView.animate(withDuration: duration, animations: {
- lhs.alpha = toRight ? 0.0 : 1.0
- rhs.alpha = toRight ? 1.0 : 0.0
- }, completion: { _ in
- lhs.isHidden = toRight
- rhs.isHidden = !toRight
- })
- }
-}
diff --git a/Mastodon/Extension/UIView+Gesture.swift b/Mastodon/Extension/UIView+Gesture.swift
new file mode 100644
index 00000000..a76843d8
--- /dev/null
+++ b/Mastodon/Extension/UIView+Gesture.swift
@@ -0,0 +1,93 @@
+//
+// UIView+Gesture.swift
+// Mastodon
+//
+// Created by sxiaojian on 2021/4/14.
+//
+
+import Combine
+import Foundation
+import UIKit
+
+struct GesturePublisher: Publisher {
+ typealias Output = GestureType
+ typealias Failure = Never
+ private let view: UIView
+ private let gestureType: GestureType
+ init(view: UIView, gestureType: GestureType) {
+ self.view = view
+ self.gestureType = gestureType
+ }
+
+ func receive(subscriber: S) where S: Subscriber,
+ GesturePublisher.Failure == S.Failure, GesturePublisher.Output
+ == S.Input
+ {
+ let subscription = GestureSubscription(
+ subscriber: subscriber,
+ view: view,
+ gestureType: gestureType
+ )
+ subscriber.receive(subscription: subscription)
+ }
+}
+
+enum GestureType {
+ case tap(UITapGestureRecognizer = .init())
+ case swipe(UISwipeGestureRecognizer = .init())
+ case longPress(UILongPressGestureRecognizer = .init())
+ case pan(UIPanGestureRecognizer = .init())
+ case pinch(UIPinchGestureRecognizer = .init())
+ case edge(UIScreenEdgePanGestureRecognizer = .init())
+ func get() -> UIGestureRecognizer {
+ switch self {
+ case let .tap(tapGesture):
+ return tapGesture
+ case let .swipe(swipeGesture):
+ return swipeGesture
+ case let .longPress(longPressGesture):
+ return longPressGesture
+ case let .pan(panGesture):
+ return panGesture
+ case let .pinch(pinchGesture):
+ return pinchGesture
+ case let .edge(edgePanGesture):
+ return edgePanGesture
+ }
+ }
+}
+
+class GestureSubscription: Subscription where S.Input == GestureType, S.Failure == Never {
+ private var subscriber: S?
+ private var gestureType: GestureType
+ private var view: UIView
+ init(subscriber: S, view: UIView, gestureType: GestureType) {
+ self.subscriber = subscriber
+ self.view = view
+ self.gestureType = gestureType
+ configureGesture(gestureType)
+ }
+
+ private func configureGesture(_ gestureType: GestureType) {
+ let gesture = gestureType.get()
+ gesture.addTarget(self, action: #selector(handler))
+ view.addGestureRecognizer(gesture)
+ }
+
+ func request(_ demand: Subscribers.Demand) {}
+ func cancel() {
+ subscriber = nil
+ }
+
+ @objc
+ private func handler() {
+ _ = subscriber?.receive(gestureType)
+ }
+}
+
+extension UIView {
+ func gesture(_ gestureType: GestureType = .tap()) -> GesturePublisher {
+ isUserInteractionEnabled = true
+ return GesturePublisher(view: self, gestureType: gestureType)
+ }
+}
diff --git a/Mastodon/Extension/UIView+Remove.swift b/Mastodon/Extension/UIView+Remove.swift
new file mode 100644
index 00000000..473b3c34
--- /dev/null
+++ b/Mastodon/Extension/UIView+Remove.swift
@@ -0,0 +1,18 @@
+//
+// UIView+Remove.swift
+// Mastodon
+//
+// Created by xiaojian sun on 2021/4/16.
+//
+
+import Foundation
+import UIKit
+
+extension UIView {
+ func removeFromStackView() {
+ if let stackView = self.superview as? UIStackView {
+ stackView.removeArrangedSubview(self)
+ }
+ self.removeFromSuperview()
+ }
+}
diff --git a/Mastodon/Generated/Assets.swift b/Mastodon/Generated/Assets.swift
index cd655e07..ce9e33e2 100644
--- a/Mastodon/Generated/Assets.swift
+++ b/Mastodon/Generated/Assets.swift
@@ -52,6 +52,7 @@ internal enum Asset {
internal static let tertiarySystemGroupedBackground = ColorAsset(name: "Colors/Background/tertiary.system.grouped.background")
}
internal enum Border {
+ internal static let notification = ColorAsset(name: "Colors/Border/notification")
internal static let searchCard = ColorAsset(name: "Colors/Border/searchCard")
}
internal enum Button {
@@ -69,6 +70,11 @@ internal enum Asset {
internal static let primary = ColorAsset(name: "Colors/Label/primary")
internal static let secondary = ColorAsset(name: "Colors/Label/secondary")
}
+ internal enum Notification {
+ internal static let favourite = ColorAsset(name: "Colors/Notification/favourite")
+ internal static let mention = ColorAsset(name: "Colors/Notification/mention")
+ internal static let reblog = ColorAsset(name: "Colors/Notification/reblog")
+ }
internal enum Shadow {
internal static let searchCard = ColorAsset(name: "Colors/Shadow/SearchCard")
}
@@ -80,6 +86,7 @@ internal enum Asset {
internal static let invalid = ColorAsset(name: "Colors/TextField/invalid")
internal static let valid = ColorAsset(name: "Colors/TextField/valid")
}
+ internal static let battleshipGrey = ColorAsset(name: "Colors/battleshipGrey")
internal static let brandBlue = ColorAsset(name: "Colors/brand.blue")
internal static let danger = ColorAsset(name: "Colors/danger")
internal static let disabled = ColorAsset(name: "Colors/disabled")
@@ -120,6 +127,11 @@ internal enum Asset {
internal static let mastodonLogoLarge = ImageAsset(name: "Scene/Welcome/mastodon.logo.large")
}
}
+ internal enum Settings {
+ internal static let appearanceAutomatic = ImageAsset(name: "Settings/appearance.automatic")
+ internal static let appearanceDark = ImageAsset(name: "Settings/appearance.dark")
+ internal static let appearanceLight = ImageAsset(name: "Settings/appearance.light")
+ }
}
// swiftlint:enable identifier_name line_length nesting type_body_length type_name
diff --git a/Mastodon/Generated/Strings.swift b/Mastodon/Generated/Strings.swift
index 5d486b5f..2fd52927 100644
--- a/Mastodon/Generated/Strings.swift
+++ b/Mastodon/Generated/Strings.swift
@@ -35,6 +35,14 @@ internal enum L10n {
/// Server Error
internal static let title = L10n.tr("Localizable", "Common.Alerts.ServerError.Title")
}
+ internal enum SignOut {
+ /// Sign Out
+ internal static let confirm = L10n.tr("Localizable", "Common.Alerts.SignOut.Confirm")
+ /// Are you sure you want to sign out?
+ internal static let message = L10n.tr("Localizable", "Common.Alerts.SignOut.Message")
+ /// Sign out
+ internal static let title = L10n.tr("Localizable", "Common.Alerts.SignOut.Title")
+ }
internal enum SignUpFailure {
/// Sign Up Failure
internal static let title = L10n.tr("Localizable", "Common.Alerts.SignUpFailure.Title")
@@ -353,6 +361,26 @@ internal enum L10n {
internal static let publishing = L10n.tr("Localizable", "Scene.HomeTimeline.NavigationBarState.Publishing")
}
}
+ internal enum Notification {
+ internal enum Action {
+ /// favorited your post
+ internal static let favourite = L10n.tr("Localizable", "Scene.Notification.Action.Favourite")
+ /// followed you
+ internal static let follow = L10n.tr("Localizable", "Scene.Notification.Action.Follow")
+ /// mentioned you
+ internal static let mention = L10n.tr("Localizable", "Scene.Notification.Action.Mention")
+ /// Your poll has ended
+ internal static let poll = L10n.tr("Localizable", "Scene.Notification.Action.Poll")
+ /// rebloged your post
+ internal static let reblog = L10n.tr("Localizable", "Scene.Notification.Action.Reblog")
+ }
+ internal enum Title {
+ /// Everything
+ internal static let everything = L10n.tr("Localizable", "Scene.Notification.Title.Everything")
+ /// Mentions
+ internal static let mentions = L10n.tr("Localizable", "Scene.Notification.Title.Mentions")
+ }
+ }
internal enum Profile {
/// %@ posts
internal static func subtitle(_ p1: Any) -> String {
@@ -595,6 +623,62 @@ internal enum L10n {
internal static let confirm = L10n.tr("Localizable", "Scene.ServerRules.Button.Confirm")
}
}
+ internal enum Settings {
+ /// Settings
+ internal static let title = L10n.tr("Localizable", "Scene.Settings.Title")
+ internal enum Section {
+ internal enum Appearance {
+ /// Automatic
+ internal static let automatic = L10n.tr("Localizable", "Scene.Settings.Section.Appearance.Automatic")
+ /// Always Dark
+ internal static let dark = L10n.tr("Localizable", "Scene.Settings.Section.Appearance.Dark")
+ /// Always Light
+ internal static let light = L10n.tr("Localizable", "Scene.Settings.Section.Appearance.Light")
+ /// Appearance
+ internal static let title = L10n.tr("Localizable", "Scene.Settings.Section.Appearance.Title")
+ }
+ internal enum Boringzone {
+ /// Privacy Policy
+ internal static let privacy = L10n.tr("Localizable", "Scene.Settings.Section.Boringzone.Privacy")
+ /// Terms of Service
+ internal static let terms = L10n.tr("Localizable", "Scene.Settings.Section.Boringzone.Terms")
+ /// The Boring zone
+ internal static let title = L10n.tr("Localizable", "Scene.Settings.Section.Boringzone.Title")
+ }
+ internal enum Notifications {
+ /// Reblogs my post
+ internal static let boosts = L10n.tr("Localizable", "Scene.Settings.Section.Notifications.Boosts")
+ /// Favorites my post
+ internal static let favorites = L10n.tr("Localizable", "Scene.Settings.Section.Notifications.Favorites")
+ /// Follows me
+ internal static let follows = L10n.tr("Localizable", "Scene.Settings.Section.Notifications.Follows")
+ /// Mentions me
+ internal static let mentions = L10n.tr("Localizable", "Scene.Settings.Section.Notifications.Mentions")
+ /// Notifications
+ internal static let title = L10n.tr("Localizable", "Scene.Settings.Section.Notifications.Title")
+ internal enum Trigger {
+ /// anyone
+ internal static let anyone = L10n.tr("Localizable", "Scene.Settings.Section.Notifications.Trigger.Anyone")
+ /// anyone I follow
+ internal static let follow = L10n.tr("Localizable", "Scene.Settings.Section.Notifications.Trigger.Follow")
+ /// a follower
+ internal static let follower = L10n.tr("Localizable", "Scene.Settings.Section.Notifications.Trigger.Follower")
+ /// no one
+ internal static let noone = L10n.tr("Localizable", "Scene.Settings.Section.Notifications.Trigger.Noone")
+ /// Notify me when
+ internal static let title = L10n.tr("Localizable", "Scene.Settings.Section.Notifications.Trigger.Title")
+ }
+ }
+ internal enum Spicyzone {
+ /// Clear Media Cache
+ internal static let clear = L10n.tr("Localizable", "Scene.Settings.Section.Spicyzone.Clear")
+ /// Sign Out
+ internal static let signout = L10n.tr("Localizable", "Scene.Settings.Section.Spicyzone.Signout")
+ /// The spicy zone
+ internal static let title = L10n.tr("Localizable", "Scene.Settings.Section.Spicyzone.Title")
+ }
+ }
+ }
internal enum Thread {
/// Post
internal static let backTitle = L10n.tr("Localizable", "Scene.Thread.BackTitle")
diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Border/notification.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/Border/notification.colorset/Contents.json
new file mode 100644
index 00000000..afc18df1
--- /dev/null
+++ b/Mastodon/Resources/Assets.xcassets/Colors/Border/notification.colorset/Contents.json
@@ -0,0 +1,38 @@
+{
+ "colors" : [
+ {
+ "color" : {
+ "color-space" : "srgb",
+ "components" : {
+ "alpha" : "1.000",
+ "blue" : "0xE8",
+ "green" : "0xE1",
+ "red" : "0xD9"
+ }
+ },
+ "idiom" : "universal"
+ },
+ {
+ "appearances" : [
+ {
+ "appearance" : "luminosity",
+ "value" : "dark"
+ }
+ ],
+ "color" : {
+ "color-space" : "srgb",
+ "components" : {
+ "alpha" : "1.000",
+ "blue" : "60",
+ "green" : "58",
+ "red" : "58"
+ }
+ },
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Label/highlight.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/Label/highlight.colorset/Contents.json
index 2e1ce5f3..d853a71a 100644
--- a/Mastodon/Resources/Assets.xcassets/Colors/Label/highlight.colorset/Contents.json
+++ b/Mastodon/Resources/Assets.xcassets/Colors/Label/highlight.colorset/Contents.json
@@ -5,9 +5,9 @@
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
- "blue" : "0.851",
- "green" : "0.565",
- "red" : "0.169"
+ "blue" : "217",
+ "green" : "144",
+ "red" : "43"
}
},
"idiom" : "universal"
diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Notification/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/Notification/Contents.json
new file mode 100644
index 00000000..6e965652
--- /dev/null
+++ b/Mastodon/Resources/Assets.xcassets/Colors/Notification/Contents.json
@@ -0,0 +1,9 @@
+{
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ },
+ "properties" : {
+ "provides-namespace" : true
+ }
+}
diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Notification/favourite.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/Notification/favourite.colorset/Contents.json
new file mode 100644
index 00000000..36de2027
--- /dev/null
+++ b/Mastodon/Resources/Assets.xcassets/Colors/Notification/favourite.colorset/Contents.json
@@ -0,0 +1,20 @@
+{
+ "colors" : [
+ {
+ "color" : {
+ "color-space" : "srgb",
+ "components" : {
+ "alpha" : "1.000",
+ "blue" : "0",
+ "green" : "204",
+ "red" : "255"
+ }
+ },
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Notification/mention.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/Notification/mention.colorset/Contents.json
new file mode 100644
index 00000000..9dff2f59
--- /dev/null
+++ b/Mastodon/Resources/Assets.xcassets/Colors/Notification/mention.colorset/Contents.json
@@ -0,0 +1,38 @@
+{
+ "colors" : [
+ {
+ "color" : {
+ "color-space" : "srgb",
+ "components" : {
+ "alpha" : "1.000",
+ "blue" : "222",
+ "green" : "82",
+ "red" : "175"
+ }
+ },
+ "idiom" : "universal"
+ },
+ {
+ "appearances" : [
+ {
+ "appearance" : "luminosity",
+ "value" : "dark"
+ }
+ ],
+ "color" : {
+ "color-space" : "srgb",
+ "components" : {
+ "alpha" : "1.000",
+ "blue" : "242",
+ "green" : "90",
+ "red" : "191"
+ }
+ },
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Notification/reblog.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/Notification/reblog.colorset/Contents.json
new file mode 100644
index 00000000..ec427cca
--- /dev/null
+++ b/Mastodon/Resources/Assets.xcassets/Colors/Notification/reblog.colorset/Contents.json
@@ -0,0 +1,38 @@
+{
+ "colors" : [
+ {
+ "color" : {
+ "color-space" : "srgb",
+ "components" : {
+ "alpha" : "1.000",
+ "blue" : "89",
+ "green" : "199",
+ "red" : "52"
+ }
+ },
+ "idiom" : "universal"
+ },
+ {
+ "appearances" : [
+ {
+ "appearance" : "luminosity",
+ "value" : "dark"
+ }
+ ],
+ "color" : {
+ "color-space" : "srgb",
+ "components" : {
+ "alpha" : "1.000",
+ "blue" : "75",
+ "green" : "215",
+ "red" : "20"
+ }
+ },
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Mastodon/Resources/Assets.xcassets/Colors/battleshipGrey.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/battleshipGrey.colorset/Contents.json
new file mode 100644
index 00000000..37df8107
--- /dev/null
+++ b/Mastodon/Resources/Assets.xcassets/Colors/battleshipGrey.colorset/Contents.json
@@ -0,0 +1,20 @@
+{
+ "colors" : [
+ {
+ "color" : {
+ "color-space" : "srgb",
+ "components" : {
+ "alpha" : "0.200",
+ "blue" : "0x80",
+ "green" : "0x78",
+ "red" : "0x78"
+ }
+ },
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Mastodon/Resources/Assets.xcassets/Settings/Contents.json b/Mastodon/Resources/Assets.xcassets/Settings/Contents.json
new file mode 100644
index 00000000..6e965652
--- /dev/null
+++ b/Mastodon/Resources/Assets.xcassets/Settings/Contents.json
@@ -0,0 +1,9 @@
+{
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ },
+ "properties" : {
+ "provides-namespace" : true
+ }
+}
diff --git a/Mastodon/Resources/Assets.xcassets/Settings/appearance.automatic.imageset/Contents.json b/Mastodon/Resources/Assets.xcassets/Settings/appearance.automatic.imageset/Contents.json
new file mode 100644
index 00000000..75da4a57
--- /dev/null
+++ b/Mastodon/Resources/Assets.xcassets/Settings/appearance.automatic.imageset/Contents.json
@@ -0,0 +1,12 @@
+{
+ "images" : [
+ {
+ "filename" : "iPhone 11 Pro _ X - 1.pdf",
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Mastodon/Resources/Assets.xcassets/Settings/appearance.automatic.imageset/iPhone 11 Pro _ X - 1.pdf b/Mastodon/Resources/Assets.xcassets/Settings/appearance.automatic.imageset/iPhone 11 Pro _ X - 1.pdf
new file mode 100644
index 00000000..868d8d8b
Binary files /dev/null and b/Mastodon/Resources/Assets.xcassets/Settings/appearance.automatic.imageset/iPhone 11 Pro _ X - 1.pdf differ
diff --git a/Mastodon/Resources/Assets.xcassets/Settings/appearance.dark.imageset/Contents.json b/Mastodon/Resources/Assets.xcassets/Settings/appearance.dark.imageset/Contents.json
new file mode 100644
index 00000000..6ca47e40
--- /dev/null
+++ b/Mastodon/Resources/Assets.xcassets/Settings/appearance.dark.imageset/Contents.json
@@ -0,0 +1,12 @@
+{
+ "images" : [
+ {
+ "filename" : "iPhone 11 Pro _ X - 1 (2).pdf",
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Mastodon/Resources/Assets.xcassets/Settings/appearance.dark.imageset/iPhone 11 Pro _ X - 1 (2).pdf b/Mastodon/Resources/Assets.xcassets/Settings/appearance.dark.imageset/iPhone 11 Pro _ X - 1 (2).pdf
new file mode 100644
index 00000000..a214d285
Binary files /dev/null and b/Mastodon/Resources/Assets.xcassets/Settings/appearance.dark.imageset/iPhone 11 Pro _ X - 1 (2).pdf differ
diff --git a/Mastodon/Resources/Assets.xcassets/Settings/appearance.light.imageset/Contents.json b/Mastodon/Resources/Assets.xcassets/Settings/appearance.light.imageset/Contents.json
new file mode 100644
index 00000000..86e635c3
--- /dev/null
+++ b/Mastodon/Resources/Assets.xcassets/Settings/appearance.light.imageset/Contents.json
@@ -0,0 +1,12 @@
+{
+ "images" : [
+ {
+ "filename" : "iPhone 11 Pro _ X - 1 (1).pdf",
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Mastodon/Resources/Assets.xcassets/Settings/appearance.light.imageset/iPhone 11 Pro _ X - 1 (1).pdf b/Mastodon/Resources/Assets.xcassets/Settings/appearance.light.imageset/iPhone 11 Pro _ X - 1 (1).pdf
new file mode 100644
index 00000000..2b8b869b
Binary files /dev/null and b/Mastodon/Resources/Assets.xcassets/Settings/appearance.light.imageset/iPhone 11 Pro _ X - 1 (1).pdf differ
diff --git a/Mastodon/Resources/en.lproj/Localizable.strings b/Mastodon/Resources/en.lproj/Localizable.strings
index 46275884..736ef10d 100644
--- a/Mastodon/Resources/en.lproj/Localizable.strings
+++ b/Mastodon/Resources/en.lproj/Localizable.strings
@@ -6,6 +6,9 @@
Please check your internet connection.";
"Common.Alerts.PublishPostFailure.Title" = "Publish Failure";
"Common.Alerts.ServerError.Title" = "Server Error";
+"Common.Alerts.SignOut.Confirm" = "Sign Out";
+"Common.Alerts.SignOut.Message" = "Are you sure you want to sign out?";
+"Common.Alerts.SignOut.Title" = "Sign out";
"Common.Alerts.SignUpFailure.Title" = "Sign Up Failure";
"Common.Alerts.VoteFailure.PollExpired" = "The poll has expired";
"Common.Alerts.VoteFailure.Title" = "Vote Failure";
@@ -118,6 +121,13 @@ tap the link to confirm your account.";
"Scene.HomeTimeline.NavigationBarState.Published" = "Published!";
"Scene.HomeTimeline.NavigationBarState.Publishing" = "Publishing post...";
"Scene.HomeTimeline.Title" = "Home";
+"Scene.Notification.Action.Favourite" = "favorited your post";
+"Scene.Notification.Action.Follow" = "followed you";
+"Scene.Notification.Action.Mention" = "mentioned you";
+"Scene.Notification.Action.Poll" = "Your poll has ended";
+"Scene.Notification.Action.Reblog" = "rebloged your post";
+"Scene.Notification.Title.Everything" = "Everything";
+"Scene.Notification.Title.Mentions" = "Mentions";
"Scene.Profile.Dashboard.Followers" = "followers";
"Scene.Profile.Dashboard.Following" = "following";
"Scene.Profile.Dashboard.Posts" = "posts";
@@ -190,6 +200,27 @@ any server.";
"Scene.ServerRules.Subtitle" = "These rules are set by the admins of %@.";
"Scene.ServerRules.TermsOfService" = "terms of service";
"Scene.ServerRules.Title" = "Some ground rules.";
+"Scene.Settings.Section.Appearance.Automatic" = "Automatic";
+"Scene.Settings.Section.Appearance.Dark" = "Always Dark";
+"Scene.Settings.Section.Appearance.Light" = "Always Light";
+"Scene.Settings.Section.Appearance.Title" = "Appearance";
+"Scene.Settings.Section.Boringzone.Privacy" = "Privacy Policy";
+"Scene.Settings.Section.Boringzone.Terms" = "Terms of Service";
+"Scene.Settings.Section.Boringzone.Title" = "The Boring zone";
+"Scene.Settings.Section.Notifications.Boosts" = "Reblogs my post";
+"Scene.Settings.Section.Notifications.Favorites" = "Favorites my post";
+"Scene.Settings.Section.Notifications.Follows" = "Follows me";
+"Scene.Settings.Section.Notifications.Mentions" = "Mentions me";
+"Scene.Settings.Section.Notifications.Title" = "Notifications";
+"Scene.Settings.Section.Notifications.Trigger.Anyone" = "anyone";
+"Scene.Settings.Section.Notifications.Trigger.Follow" = "anyone I follow";
+"Scene.Settings.Section.Notifications.Trigger.Follower" = "a follower";
+"Scene.Settings.Section.Notifications.Trigger.Noone" = "no one";
+"Scene.Settings.Section.Notifications.Trigger.Title" = "Notify me when";
+"Scene.Settings.Section.Spicyzone.Clear" = "Clear Media Cache";
+"Scene.Settings.Section.Spicyzone.Signout" = "Sign Out";
+"Scene.Settings.Section.Spicyzone.Title" = "The spicy zone";
+"Scene.Settings.Title" = "Settings";
"Scene.Thread.BackTitle" = "Post";
"Scene.Thread.Favorite.Multiple" = "%@ favorites";
"Scene.Thread.Favorite.Single" = "%@ favorite";
@@ -197,4 +228,4 @@ any server.";
"Scene.Thread.Reblog.Single" = "%@ reblog";
"Scene.Thread.Title" = "Post from %@";
"Scene.Welcome.Slogan" = "Social networking
-back in your hands.";
\ No newline at end of file
+back in your hands.";
diff --git a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+LoadOldestState.swift b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+LoadOldestState.swift
index e5c78f3d..13737364 100644
--- a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+LoadOldestState.swift
+++ b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+LoadOldestState.swift
@@ -123,9 +123,11 @@ extension HashtagTimelineViewModel.LoadOldestState {
assertionFailure()
return
}
- var snapshot = diffableDataSource.snapshot()
- snapshot.deleteItems([.bottomLoader])
- diffableDataSource.apply(snapshot)
+ DispatchQueue.main.async {
+ var snapshot = diffableDataSource.snapshot()
+ snapshot.deleteItems([.bottomLoader])
+ diffableDataSource.apply(snapshot)
+ }
}
}
}
diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift
index 401e4fc1..8bbf9436 100644
--- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift
+++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift
@@ -37,6 +37,10 @@ extension HomeTimelineViewController {
guard let self = self else { return }
self.showThreadAction(action)
},
+ UIAction(title: "Settings", image: UIImage(systemName: "gear"), attributes: []) { [weak self] action in
+ guard let self = self else { return }
+ self.showSettings(action)
+ },
UIAction(title: "Sign Out", image: UIImage(systemName: "escape"), attributes: .destructive) { [weak self] action in
guard let self = self else { return }
self.signOutAction(action)
@@ -323,5 +327,8 @@ extension HomeTimelineViewController {
coordinator.present(scene: .alertController(alertController: alertController), from: self, transition: .alertController(animated: true, completion: nil))
}
+ @objc private func showSettings(_ sender: UIAction) {
+ coordinator.present(scene: .settings, from: self, transition: .modal(animated: true, completion: nil))
+ }
}
#endif
diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+LoadOldestState.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+LoadOldestState.swift
index aaabd7a8..a74d03a5 100644
--- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+LoadOldestState.swift
+++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+LoadOldestState.swift
@@ -103,9 +103,11 @@ extension HomeTimelineViewModel.LoadOldestState {
assertionFailure()
return
}
- var snapshot = diffableDataSource.snapshot()
- snapshot.deleteItems([.bottomLoader])
- diffableDataSource.apply(snapshot)
+ DispatchQueue.main.async {
+ var snapshot = diffableDataSource.snapshot()
+ snapshot.deleteItems([.bottomLoader])
+ diffableDataSource.apply(snapshot)
+ }
}
}
}
diff --git a/Mastodon/Scene/Notification/NotificationViewController.swift b/Mastodon/Scene/Notification/NotificationViewController.swift
index f8b3ba81..ad9a7472 100644
--- a/Mastodon/Scene/Notification/NotificationViewController.swift
+++ b/Mastodon/Scene/Notification/NotificationViewController.swift
@@ -2,23 +2,227 @@
// NotificationViewController.swift
// Mastodon
//
-// Created by MainasuK Cirno on 2021-2-23.
+// Created by sxiaojian on 2021/4/12.
//
+import Combine
+import CoreData
+import CoreDataStack
+import GameplayKit
+import MastodonSDK
+import OSLog
import UIKit
final class NotificationViewController: UIViewController, NeedsDependency {
-
weak var context: AppContext! { willSet { precondition(!isViewLoaded) } }
weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } }
-
+
+ var disposeBag = Set()
+ private(set) lazy var viewModel = NotificationViewModel(context: context)
+
+ let segmentControl: UISegmentedControl = {
+ let control = UISegmentedControl(items: [L10n.Scene.Notification.Title.everything, L10n.Scene.Notification.Title.mentions])
+ control.selectedSegmentIndex = NotificationViewModel.NotificationSegment.EveryThing.rawValue
+ return control
+ }()
+
+ let tableView: UITableView = {
+ let tableView = ControlContainableTableView()
+ tableView.rowHeight = UITableView.automaticDimension
+ tableView.separatorStyle = .singleLine
+ tableView.separatorInset = UIEdgeInsets(top: 0, left: 0, bottom: 0, right: 0)
+ tableView.register(NotificationTableViewCell.self, forCellReuseIdentifier: String(describing: NotificationTableViewCell.self))
+ tableView.register(NotificationStatusTableViewCell.self, forCellReuseIdentifier: String(describing: NotificationStatusTableViewCell.self))
+ tableView.register(TimelineBottomLoaderTableViewCell.self, forCellReuseIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self))
+ tableView.tableFooterView = UIView()
+ tableView.estimatedRowHeight = UITableView.automaticDimension
+ return tableView
+ }()
+
+ let refreshControl = UIRefreshControl()
}
extension NotificationViewController {
-
override func viewDidLoad() {
super.viewDidLoad()
-
+ view.backgroundColor = Asset.Colors.Background.secondarySystemBackground.color
+ navigationItem.titleView = segmentControl
+ segmentControl.addTarget(self, action: #selector(NotificationViewController.segmentedControlValueChanged(_:)), for: .valueChanged)
+ tableView.translatesAutoresizingMaskIntoConstraints = false
+ view.addSubview(tableView)
+ NSLayoutConstraint.activate([
+ tableView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
+ tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
+ tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
+ tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
+ ])
+
+ tableView.refreshControl = refreshControl
+ refreshControl.addTarget(self, action: #selector(NotificationViewController.refreshControlValueChanged(_:)), for: .valueChanged)
+
+ tableView.delegate = self
+ viewModel.tableView = tableView
+ viewModel.contentOffsetAdjustableTimelineViewControllerDelegate = self
+ viewModel.setupDiffableDataSource(for: tableView, delegate: self, dependency: self)
+ viewModel.viewDidLoad.send()
+ // bind refresh control
+ viewModel.isFetchingLatestNotification
+ .receive(on: DispatchQueue.main)
+ .sink { [weak self] isFetching in
+ guard let self = self else { return }
+ if !isFetching {
+ UIView.animate(withDuration: 0.5) { [weak self] in
+ guard let self = self else { return }
+ self.refreshControl.endRefreshing()
+ }
+ }
+ }
+ .store(in: &disposeBag)
+ }
+
+ override func viewWillAppear(_ animated: Bool) {
+ super.viewWillAppear(animated)
+
+ // needs trigger manually after onboarding dismiss
+ setNeedsStatusBarAppearanceUpdate()
+ }
+
+ override func viewDidAppear(_ animated: Bool) {
+ super.viewDidAppear(animated)
+
+ DispatchQueue.main.async { [weak self] in
+ guard let self = self else { return }
+ if (self.viewModel.fetchedResultsController.fetchedObjects ?? []).count == 0 {
+ self.viewModel.loadLatestStateMachine.enter(NotificationViewModel.LoadLatestState.Loading.self)
+ }
+ }
+ }
+
+ override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
+ super.viewWillTransition(to: size, with: coordinator)
+
+ coordinator.animate { _ in
+ // do nothing
+ } completion: { _ in
+ self.tableView.reloadData()
+ }
}
-
+}
+
+extension NotificationViewController {
+ @objc private func segmentedControlValueChanged(_ sender: UISegmentedControl) {
+ os_log("%{public}s[%{public}ld], %{public}s: select at index: %ld", (#file as NSString).lastPathComponent, #line, #function, sender.selectedSegmentIndex)
+ guard let domain = viewModel.activeMastodonAuthenticationBox.value?.domain, let userID = viewModel.activeMastodonAuthenticationBox.value?.userID else {
+ return
+ }
+ if sender.selectedSegmentIndex == NotificationViewModel.NotificationSegment.EveryThing.rawValue {
+ viewModel.notificationPredicate.value = MastodonNotification.predicate(domain: domain, userID: userID)
+ } else {
+ viewModel.notificationPredicate.value = MastodonNotification.predicate(domain: domain, userID: userID, typeRaw: Mastodon.Entity.Notification.NotificationType.mention.rawValue)
+ }
+ viewModel.selectedIndex.value = NotificationViewModel.NotificationSegment(rawValue: sender.selectedSegmentIndex)!
+ }
+
+ @objc private func refreshControlValueChanged(_ sender: UIRefreshControl) {
+ guard viewModel.loadLatestStateMachine.enter(NotificationViewModel.LoadLatestState.Loading.self) else {
+ sender.endRefreshing()
+ return
+ }
+ }
+}
+
+extension NotificationViewController {
+ func cacheTableView(_ tableView: UITableView, didEndDisplaying cell: UITableViewCell, forRowAt indexPath: IndexPath) {
+ guard let diffableDataSource = viewModel.diffableDataSource else { return }
+ guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return }
+ let key = item.hashValue
+ let frame = cell.frame
+ viewModel.cellFrameCache.setObject(NSValue(cgRect: frame), forKey: NSNumber(value: key))
+ }
+
+ func handleTableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat {
+ guard let diffableDataSource = viewModel.diffableDataSource else { return UITableView.automaticDimension }
+ guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return UITableView.automaticDimension }
+ guard let frame = viewModel.cellFrameCache.object(forKey: NSNumber(value: item.hashValue))?.cgRectValue else {
+ if case .bottomLoader = item {
+ return TimelineLoaderTableViewCell.cellHeight
+ } else {
+ return UITableView.automaticDimension
+ }
+ }
+
+ return ceil(frame.height)
+ }
+}
+// MARK: - UITableViewDelegate
+
+extension NotificationViewController: UITableViewDelegate {
+
+ func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
+ tableView.deselectRow(at: indexPath, animated: true)
+ guard let diffableDataSource = viewModel.diffableDataSource else { return }
+ guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return }
+ switch item {
+ case .notification(let objectID):
+ let notification = context.managedObjectContext.object(with: objectID) as! MastodonNotification
+ if let status = notification.status {
+ let viewModel = ThreadViewModel(context: context, optionalStatus: status)
+ coordinator.present(scene: .thread(viewModel: viewModel), from: self, transition: .show)
+ } else {
+ let viewModel = ProfileViewModel(context: context, optionalMastodonUser: notification.account)
+ coordinator.present(scene: .profile(viewModel: viewModel), from: self, transition: .show)
+ }
+ default:
+ break
+ }
+ }
+
+ func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
+ guard let diffableDataSource = viewModel.diffableDataSource else { return }
+ guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return }
+ switch item {
+ case .bottomLoader:
+ if !tableView.isDragging, !tableView.isDecelerating {
+ viewModel.loadoldestStateMachine.enter(NotificationViewModel.LoadOldestState.Loading.self)
+ }
+ default:
+ break
+ }
+ }
+}
+
+// MARK: - ContentOffsetAdjustableTimelineViewControllerDelegate
+
+extension NotificationViewController: ContentOffsetAdjustableTimelineViewControllerDelegate {
+ func navigationBar() -> UINavigationBar? {
+ navigationController?.navigationBar
+ }
+}
+
+extension NotificationViewController: NotificationTableViewCellDelegate {
+ func userAvatarDidPressed(notification: MastodonNotification) {
+ let viewModel = ProfileViewModel(context: context, optionalMastodonUser: notification.account)
+ DispatchQueue.main.async {
+ self.coordinator.present(scene: .profile(viewModel: viewModel), from: self, transition: .show)
+ }
+ }
+
+ func parent() -> UIViewController {
+ self
+ }
+}
+
+// MARK: - UIScrollViewDelegate
+
+extension NotificationViewController {
+ func scrollViewDidScroll(_ scrollView: UIScrollView) {
+ handleScrollViewDidScroll(scrollView)
+ }
+}
+
+extension NotificationViewController: LoadMoreConfigurableTableViewContainer {
+ typealias BottomLoaderTableViewCell = TimelineBottomLoaderTableViewCell
+ typealias LoadingState = NotificationViewModel.LoadOldestState.Loading
+ var loadMoreConfigurableTableView: UITableView { tableView }
+ var loadMoreConfigurableStateMachine: GKStateMachine { viewModel.loadoldestStateMachine }
}
diff --git a/Mastodon/Scene/Notification/NotificationViewModel+LoadLatestState.swift b/Mastodon/Scene/Notification/NotificationViewModel+LoadLatestState.swift
new file mode 100644
index 00000000..0e6b0d62
--- /dev/null
+++ b/Mastodon/Scene/Notification/NotificationViewModel+LoadLatestState.swift
@@ -0,0 +1,95 @@
+//
+// NotificationViewModel+LoadLatestState.swift
+// Mastodon
+//
+// Created by sxiaojian on 2021/4/13.
+//
+
+import CoreData
+import CoreDataStack
+import Foundation
+import GameplayKit
+import MastodonSDK
+import os.log
+import func QuartzCore.CACurrentMediaTime
+
+extension NotificationViewModel {
+ class LoadLatestState: GKState {
+ weak var viewModel: NotificationViewModel?
+
+ init(viewModel: NotificationViewModel) {
+ self.viewModel = viewModel
+ }
+
+ override func didEnter(from previousState: GKState?) {
+ os_log("%{public}s[%{public}ld], %{public}s: enter %s, previous: %s", (#file as NSString).lastPathComponent, #line, #function, debugDescription, previousState.debugDescription)
+ viewModel?.loadLatestStateMachinePublisher.send(self)
+ }
+ }
+}
+
+extension NotificationViewModel.LoadLatestState {
+ class Initial: NotificationViewModel.LoadLatestState {
+ override func isValidNextState(_ stateClass: AnyClass) -> Bool {
+ stateClass == Loading.self
+ }
+ }
+
+ class Loading: NotificationViewModel.LoadLatestState {
+ override func isValidNextState(_ stateClass: AnyClass) -> Bool {
+ stateClass == Fail.self || stateClass == Idle.self
+ }
+
+ override func didEnter(from previousState: GKState?) {
+ super.didEnter(from: previousState)
+ guard let viewModel = viewModel, let stateMachine = stateMachine else { return }
+ guard let activeMastodonAuthenticationBox = viewModel.activeMastodonAuthenticationBox.value else {
+ // sign out when loading will enter here
+ stateMachine.enter(Fail.self)
+ return
+ }
+ let query = Mastodon.API.Notifications.Query(
+ maxID: nil,
+ sinceID: nil,
+ minID: nil,
+ limit: nil,
+ excludeTypes: [.followRequest],
+ accountID: nil
+ )
+ viewModel.context.apiService.allNotifications(
+ domain: activeMastodonAuthenticationBox.domain,
+ 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
+ }
+ }
+ .store(in: &viewModel.disposeBag)
+ }
+ }
+
+ class Fail: NotificationViewModel.LoadLatestState {
+ override func isValidNextState(_ stateClass: AnyClass) -> Bool {
+ stateClass == Loading.self || stateClass == Idle.self
+ }
+ }
+
+ class Idle: NotificationViewModel.LoadLatestState {
+ override func isValidNextState(_ stateClass: AnyClass) -> Bool {
+ stateClass == Loading.self
+ }
+ }
+}
diff --git a/Mastodon/Scene/Notification/NotificationViewModel+LoadOldestState.swift b/Mastodon/Scene/Notification/NotificationViewModel+LoadOldestState.swift
new file mode 100644
index 00000000..8075ce37
--- /dev/null
+++ b/Mastodon/Scene/Notification/NotificationViewModel+LoadOldestState.swift
@@ -0,0 +1,145 @@
+//
+// NotificationViewModel+LoadOldestState.swift
+// Mastodon
+//
+// Created by sxiaojian on 2021/4/14.
+//
+
+import CoreDataStack
+import Foundation
+import GameplayKit
+import MastodonSDK
+import os.log
+
+extension NotificationViewModel {
+ class LoadOldestState: GKState {
+ weak var viewModel: NotificationViewModel?
+
+ init(viewModel: NotificationViewModel) {
+ self.viewModel = viewModel
+ }
+
+ override func didEnter(from previousState: GKState?) {
+ os_log("%{public}s[%{public}ld], %{public}s: enter %s, previous: %s", (#file as NSString).lastPathComponent, #line, #function, debugDescription, previousState.debugDescription)
+ viewModel?.loadOldestStateMachinePublisher.send(self)
+ }
+ }
+}
+
+extension NotificationViewModel.LoadOldestState {
+ class Initial: NotificationViewModel.LoadOldestState {
+ override func isValidNextState(_ stateClass: AnyClass) -> Bool {
+ guard let viewModel = viewModel else { return false }
+ guard !(viewModel.fetchedResultsController.fetchedObjects ?? []).isEmpty else { return false }
+ return stateClass == Loading.self
+ }
+ }
+
+ class Loading: NotificationViewModel.LoadOldestState {
+ override func isValidNextState(_ stateClass: AnyClass) -> Bool {
+ stateClass == Fail.self || stateClass == Idle.self || stateClass == NoMore.self
+ }
+
+ override func didEnter(from previousState: GKState?) {
+ super.didEnter(from: previousState)
+ guard let viewModel = viewModel, let stateMachine = stateMachine else { return }
+ guard let activeMastodonAuthenticationBox = viewModel.activeMastodonAuthenticationBox.value else {
+ assertionFailure()
+ stateMachine.enter(Fail.self)
+ return
+ }
+ let notifications: [MastodonNotification]? = {
+ let request = MastodonNotification.sortedFetchRequest
+ request.predicate = MastodonNotification.predicate(domain: activeMastodonAuthenticationBox.domain, userID: activeMastodonAuthenticationBox.userID)
+ request.returnsObjectsAsFaults = false
+ do {
+ return try self.viewModel?.context.managedObjectContext.fetch(request)
+ } catch {
+ assertionFailure(error.localizedDescription)
+ return nil
+ }
+ }()
+
+ guard let last = notifications?.last else {
+ stateMachine.enter(Idle.self)
+ return
+ }
+
+ let maxID = last.id
+ let query = Mastodon.API.Notifications.Query(
+ maxID: maxID,
+ sinceID: nil,
+ minID: nil,
+ limit: nil,
+ excludeTypes: [.followRequest],
+ accountID: nil)
+ viewModel.context.apiService.allNotifications(
+ domain: activeMastodonAuthenticationBox.domain,
+ query: query,
+ mastodonAuthenticationBox: activeMastodonAuthenticationBox
+ )
+ .sink { completion in
+ switch completion {
+ case .failure(let error):
+ 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: { [weak viewModel] response in
+ guard let viewModel = viewModel else { return }
+ switch viewModel.selectedIndex.value {
+ case .EveryThing:
+ if response.value.isEmpty {
+ stateMachine.enter(NoMore.self)
+ } else {
+ stateMachine.enter(Idle.self)
+ }
+ case .Mentions:
+ viewModel.noMoreNotification.value = response.value.isEmpty
+ let list = response.value.filter { $0.type == Mastodon.Entity.Notification.NotificationType.mention }
+ if list.isEmpty {
+ stateMachine.enter(NoMore.self)
+ } else {
+ stateMachine.enter(Idle.self)
+ }
+ }
+ }
+ .store(in: &viewModel.disposeBag)
+ }
+ }
+
+ class Fail: NotificationViewModel.LoadOldestState {
+ override func isValidNextState(_ stateClass: AnyClass) -> Bool {
+ stateClass == Loading.self || stateClass == Idle.self
+ }
+ }
+
+ class Idle: NotificationViewModel.LoadOldestState {
+ override func isValidNextState(_ stateClass: AnyClass) -> Bool {
+ stateClass == Loading.self
+ }
+ }
+
+ class NoMore: NotificationViewModel.LoadOldestState {
+ override func isValidNextState(_ stateClass: AnyClass) -> Bool {
+ // reset state if needs
+ stateClass == Idle.self
+ }
+
+ override func didEnter(from previousState: GKState?) {
+ guard let viewModel = viewModel else { return }
+ guard let diffableDataSource = viewModel.diffableDataSource else {
+ assertionFailure()
+ return
+ }
+ DispatchQueue.main.async {
+ var snapshot = diffableDataSource.snapshot()
+ snapshot.deleteItems([.bottomLoader])
+ diffableDataSource.apply(snapshot)
+ }
+ }
+ }
+}
diff --git a/Mastodon/Scene/Notification/NotificationViewModel+diffable.swift b/Mastodon/Scene/Notification/NotificationViewModel+diffable.swift
new file mode 100644
index 00000000..5bd2d92d
--- /dev/null
+++ b/Mastodon/Scene/Notification/NotificationViewModel+diffable.swift
@@ -0,0 +1,127 @@
+//
+// NotificationViewModel+diffable.swift
+// Mastodon
+//
+// Created by sxiaojian on 2021/4/13.
+//
+
+import CoreData
+import CoreDataStack
+import os.log
+import UIKit
+
+extension NotificationViewModel {
+ func setupDiffableDataSource(
+ for tableView: UITableView,
+ delegate: NotificationTableViewCellDelegate,
+ dependency: NeedsDependency
+ ) {
+ let timestampUpdatePublisher = Timer.publish(every: 30.0, on: .main, in: .common)
+ .autoconnect()
+ .share()
+ .eraseToAnyPublisher()
+ guard let userid = activeMastodonAuthenticationBox.value?.userID else {
+ return
+ }
+ diffableDataSource = NotificationSection.tableViewDiffableDataSource(
+ for: tableView,
+ timestampUpdatePublisher: timestampUpdatePublisher,
+ managedObjectContext: context.managedObjectContext,
+ delegate: delegate,
+ dependency: dependency,
+ requestUserID: userid
+ )
+ }
+}
+
+extension NotificationViewModel: NSFetchedResultsControllerDelegate {
+ func controllerWillChangeContent(_ controller: NSFetchedResultsController) {
+ os_log("%{public}s[%{public}ld], %{public}s", (#file as NSString).lastPathComponent, #line, #function)
+ }
+
+ func controller(_ controller: NSFetchedResultsController, didChangeContentWith snapshot: NSDiffableDataSourceSnapshotReference) {
+ os_log("%{public}s[%{public}ld], %{public}s", (#file as NSString).lastPathComponent, #line, #function)
+
+ guard let tableView = self.tableView else { return }
+ guard let navigationBar = contentOffsetAdjustableTimelineViewControllerDelegate?.navigationBar() else { return }
+
+ guard let diffableDataSource = self.diffableDataSource else { return }
+
+ let predicate = fetchedResultsController.fetchRequest.predicate
+ let parentManagedObjectContext = fetchedResultsController.managedObjectContext
+ let managedObjectContext = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType)
+ managedObjectContext.parent = parentManagedObjectContext
+
+ managedObjectContext.perform {
+ let notifications: [MastodonNotification] = {
+ let request = MastodonNotification.sortedFetchRequest
+ request.returnsObjectsAsFaults = false
+ request.predicate = predicate
+ do {
+ return try managedObjectContext.fetch(request)
+ } catch {
+ assertionFailure(error.localizedDescription)
+ return []
+ }
+ }()
+
+ DispatchQueue.main.async {
+ let oldSnapshot = diffableDataSource.snapshot()
+ var newSnapshot = NSDiffableDataSourceSnapshot()
+ newSnapshot.appendSections([.main])
+ newSnapshot.appendItems(notifications.map { NotificationItem.notification(objectID: $0.objectID) }, toSection: .main)
+ if !notifications.isEmpty, self.noMoreNotification.value == false {
+ newSnapshot.appendItems([.bottomLoader], toSection: .main)
+ }
+ guard let difference = self.calculateReloadSnapshotDifference(navigationBar: navigationBar, tableView: tableView, oldSnapshot: oldSnapshot, newSnapshot: newSnapshot) else {
+ diffableDataSource.apply(newSnapshot, animatingDifferences: false)
+ self.isFetchingLatestNotification.value = false
+ tableView.reloadData()
+ return
+ }
+
+ diffableDataSource.apply(newSnapshot, animatingDifferences: false) {
+ tableView.scrollToRow(at: difference.targetIndexPath, at: .top, animated: false)
+ tableView.contentOffset.y = tableView.contentOffset.y - difference.offset
+ self.isFetchingLatestNotification.value = false
+ }
+ }
+ }
+ }
+
+ private struct Difference {
+ let item: T
+ let sourceIndexPath: IndexPath
+ let targetIndexPath: IndexPath
+ let offset: CGFloat
+ }
+
+ private func calculateReloadSnapshotDifference(
+ navigationBar: UINavigationBar,
+ tableView: UITableView,
+ oldSnapshot: NSDiffableDataSourceSnapshot,
+ newSnapshot: NSDiffableDataSourceSnapshot
+ ) -> Difference? {
+ guard oldSnapshot.numberOfItems != 0 else { return nil }
+
+ // old snapshot not empty. set source index path to first item if not match
+ let sourceIndexPath = UIViewController.topVisibleTableViewCellIndexPath(in: tableView, navigationBar: navigationBar) ?? IndexPath(row: 0, section: 0)
+
+ guard sourceIndexPath.row < oldSnapshot.itemIdentifiers(inSection: .main).count else { return nil }
+
+ if oldSnapshot.itemIdentifiers.elementsEqual(newSnapshot.itemIdentifiers) {
+ return nil
+ }
+ let timelineItem = oldSnapshot.itemIdentifiers(inSection: .main)[sourceIndexPath.row]
+ guard let itemIndex = newSnapshot.itemIdentifiers(inSection: .main).firstIndex(of: timelineItem) else { return nil }
+ let targetIndexPath = IndexPath(row: itemIndex, section: 0)
+
+ let offset = UIViewController.tableViewCellOriginOffsetToWindowTop(in: tableView, at: sourceIndexPath, navigationBar: navigationBar)
+ return Difference(
+ item: timelineItem,
+ sourceIndexPath: sourceIndexPath,
+ targetIndexPath: targetIndexPath,
+ offset: offset
+ )
+ }
+}
diff --git a/Mastodon/Scene/Notification/NotificationViewModel.swift b/Mastodon/Scene/Notification/NotificationViewModel.swift
new file mode 100644
index 00000000..e026af73
--- /dev/null
+++ b/Mastodon/Scene/Notification/NotificationViewModel.swift
@@ -0,0 +1,130 @@
+//
+// NotificationViewModel.swift
+// Mastodon
+//
+// Created by sxiaojian on 2021/4/12.
+//
+
+import Combine
+import CoreData
+import CoreDataStack
+import Foundation
+import GameplayKit
+import MastodonSDK
+import UIKit
+
+final class NotificationViewModel: NSObject {
+ var disposeBag = Set()
+
+ // input
+ let context: AppContext
+ weak var tableView: UITableView?
+ weak var contentOffsetAdjustableTimelineViewControllerDelegate: ContentOffsetAdjustableTimelineViewControllerDelegate?
+
+ let viewDidLoad = PassthroughSubject()
+ let selectedIndex = CurrentValueSubject(.EveryThing)
+ let noMoreNotification = CurrentValueSubject(false)
+
+ let activeMastodonAuthenticationBox: CurrentValueSubject
+ let fetchedResultsController: NSFetchedResultsController!
+ let notificationPredicate = CurrentValueSubject(nil)
+ let cellFrameCache = NSCache()
+
+ let isFetchingLatestNotification = CurrentValueSubject(false)
+
+ // output
+ var diffableDataSource: UITableViewDiffableDataSource!
+ // top loader
+ private(set) lazy var loadLatestStateMachine: GKStateMachine = {
+ // exclude timeline middle fetcher state
+ let stateMachine = GKStateMachine(states: [
+ LoadLatestState.Initial(viewModel: self),
+ LoadLatestState.Loading(viewModel: self),
+ LoadLatestState.Fail(viewModel: self),
+ LoadLatestState.Idle(viewModel: self),
+ ])
+ stateMachine.enter(LoadLatestState.Initial.self)
+ return stateMachine
+ }()
+
+ lazy var loadLatestStateMachinePublisher = CurrentValueSubject(nil)
+
+ // bottom loader
+ private(set) lazy var loadoldestStateMachine: GKStateMachine = {
+ // exclude timeline middle fetcher state
+ let stateMachine = GKStateMachine(states: [
+ LoadOldestState.Initial(viewModel: self),
+ LoadOldestState.Loading(viewModel: self),
+ LoadOldestState.Fail(viewModel: self),
+ LoadOldestState.Idle(viewModel: self),
+ LoadOldestState.NoMore(viewModel: self),
+ ])
+ stateMachine.enter(LoadOldestState.Initial.self)
+ return stateMachine
+ }()
+
+ lazy var loadOldestStateMachinePublisher = CurrentValueSubject(nil)
+
+ init(context: AppContext) {
+ self.context = context
+ self.activeMastodonAuthenticationBox = CurrentValueSubject(context.authenticationService.activeMastodonAuthenticationBox.value)
+ self.fetchedResultsController = {
+ let fetchRequest = MastodonNotification.sortedFetchRequest
+ fetchRequest.returnsObjectsAsFaults = false
+ fetchRequest.relationshipKeyPathsForPrefetching = [#keyPath(MastodonNotification.status), #keyPath(MastodonNotification.account)]
+ let controller = NSFetchedResultsController(
+ fetchRequest: fetchRequest,
+ managedObjectContext: context.managedObjectContext,
+ sectionNameKeyPath: nil,
+ cacheName: nil
+ )
+
+ return controller
+ }()
+
+ super.init()
+ fetchedResultsController.delegate = self
+ context.authenticationService.activeMastodonAuthenticationBox
+ .sink(receiveValue: { [weak self] box in
+ guard let self = self else { return }
+ self.activeMastodonAuthenticationBox.value = box
+ if let domain = box?.domain, let userID = box?.userID {
+ self.notificationPredicate.value = MastodonNotification.predicate(domain: domain, userID: userID)
+ }
+ })
+ .store(in: &disposeBag)
+
+ notificationPredicate
+ .compactMap { $0 }
+ .sink { [weak self] predicate in
+ guard let self = self else { return }
+ self.fetchedResultsController.fetchRequest.predicate = predicate
+ do {
+ self.diffableDataSource?.defaultRowAnimation = .fade
+ try self.fetchedResultsController.performFetch()
+ DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
+ guard let self = self else { return }
+ self.diffableDataSource?.defaultRowAnimation = .automatic
+ }
+ } catch {
+ assertionFailure(error.localizedDescription)
+ }
+ }
+ .store(in: &disposeBag)
+
+ viewDidLoad
+ .sink { [weak self] in
+
+ guard let domain = self?.activeMastodonAuthenticationBox.value?.domain, let userID = self?.activeMastodonAuthenticationBox.value?.userID else { return }
+ self?.notificationPredicate.value = MastodonNotification.predicate(domain: domain, userID: userID)
+ }
+ .store(in: &disposeBag)
+ }
+}
+
+extension NotificationViewModel {
+ enum NotificationSegment: Int {
+ case EveryThing
+ case Mentions
+ }
+}
diff --git a/Mastodon/Scene/Notification/TableViewCell/NotificationStatusTableViewCell.swift b/Mastodon/Scene/Notification/TableViewCell/NotificationStatusTableViewCell.swift
new file mode 100644
index 00000000..871adcae
--- /dev/null
+++ b/Mastodon/Scene/Notification/TableViewCell/NotificationStatusTableViewCell.swift
@@ -0,0 +1,209 @@
+//
+// NotificationStatusTableViewCell.swift
+// Mastodon
+//
+// Created by sxiaojian on 2021/4/14.
+//
+
+import Combine
+import Foundation
+import UIKit
+
+final class NotificationStatusTableViewCell: UITableViewCell, StatusCell {
+ static let actionImageBorderWidth: CGFloat = 2
+ static let statusPadding = UIEdgeInsets(top: 50, left: 73, bottom: 24, right: 24)
+ var disposeBag = Set()
+ var pollCountdownSubscription: AnyCancellable?
+ var delegate: NotificationTableViewCellDelegate?
+
+ let avatatImageView: UIImageView = {
+ let imageView = UIImageView()
+ imageView.layer.cornerRadius = 4
+ imageView.layer.cornerCurve = .continuous
+ imageView.clipsToBounds = true
+ return imageView
+ }()
+
+ let actionImageView: UIImageView = {
+ let imageView = UIImageView()
+ imageView.tintColor = Asset.Colors.Background.systemBackground.color
+ return imageView
+ }()
+
+ let actionImageBackground: UIView = {
+ let view = UIView()
+ view.layer.cornerRadius = (24 + NotificationStatusTableViewCell.actionImageBorderWidth) / 2
+ view.layer.cornerCurve = .continuous
+ view.clipsToBounds = true
+ view.layer.borderWidth = NotificationStatusTableViewCell.actionImageBorderWidth
+ view.layer.borderColor = Asset.Colors.Background.systemBackground.color.cgColor
+ view.tintColor = Asset.Colors.Background.systemBackground.color
+ return view
+ }()
+
+ let avatarContainer: UIView = {
+ let view = UIView()
+ return view
+ }()
+
+ let actionLabel: UILabel = {
+ let label = UILabel()
+ label.textColor = Asset.Colors.Label.secondary.color
+ label.font = UIFont.preferredFont(forTextStyle: .body)
+ label.lineBreakMode = .byTruncatingTail
+ return label
+ }()
+
+ let nameLabel: UILabel = {
+ let label = UILabel()
+ label.textColor = Asset.Colors.brandBlue.color
+ label.font = .systemFont(ofSize: 15, weight: .semibold)
+ label.lineBreakMode = .byTruncatingTail
+ return label
+ }()
+
+ let statusBorder: UIView = {
+ let view = UIView()
+ view.backgroundColor = .clear
+ view.layer.cornerRadius = 6
+ view.layer.borderWidth = 2
+ view.layer.cornerCurve = .continuous
+ view.layer.borderColor = Asset.Colors.Border.notification.color.cgColor
+ view.clipsToBounds = true
+ return view
+ }()
+
+ let statusView = StatusView()
+
+ override func prepareForReuse() {
+ super.prepareForReuse()
+ avatatImageView.af.cancelImageRequest()
+ statusView.isStatusTextSensitive = false
+ statusView.cleanUpContentWarning()
+ statusView.pollTableView.dataSource = nil
+ statusView.playerContainerView.reset()
+ statusView.playerContainerView.isHidden = true
+
+ disposeBag.removeAll()
+ }
+
+ override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
+ super.init(style: style, reuseIdentifier: reuseIdentifier)
+ configure()
+ }
+
+ required init?(coder: NSCoder) {
+ super.init(coder: coder)
+ configure()
+ }
+
+ override func layoutSubviews() {
+ super.layoutSubviews()
+ DispatchQueue.main.async {
+ self.statusView.drawContentWarningImageView()
+ }
+ }
+}
+
+extension NotificationStatusTableViewCell {
+ func configure() {
+ let containerStackView = UIStackView()
+ containerStackView.axis = .horizontal
+ containerStackView.alignment = .top
+ containerStackView.spacing = 4
+ containerStackView.layoutMargins = UIEdgeInsets(top: 14, left: 0, bottom: 12, right: 0)
+ containerStackView.isLayoutMarginsRelativeArrangement = true
+ containerStackView.translatesAutoresizingMaskIntoConstraints = false
+ contentView.addSubview(containerStackView)
+ NSLayoutConstraint.activate([
+ containerStackView.topAnchor.constraint(equalTo: contentView.topAnchor),
+ containerStackView.leadingAnchor.constraint(equalTo: contentView.readableContentGuide.leadingAnchor),
+ contentView.readableContentGuide.trailingAnchor.constraint(equalTo: containerStackView.trailingAnchor),
+ contentView.bottomAnchor.constraint(equalTo: containerStackView.bottomAnchor),
+ ])
+
+ containerStackView.addArrangedSubview(avatarContainer)
+ avatarContainer.translatesAutoresizingMaskIntoConstraints = false
+ NSLayoutConstraint.activate([
+ avatarContainer.heightAnchor.constraint(equalToConstant: 47).priority(.required - 1),
+ avatarContainer.widthAnchor.constraint(equalToConstant: 47).priority(.required - 1)
+ ])
+
+ avatarContainer.addSubview(avatatImageView)
+ avatatImageView.translatesAutoresizingMaskIntoConstraints = false
+ NSLayoutConstraint.activate([
+ avatatImageView.heightAnchor.constraint(equalToConstant: 35).priority(.required - 1),
+ avatatImageView.widthAnchor.constraint(equalToConstant: 35).priority(.required - 1),
+ avatatImageView.topAnchor.constraint(equalTo: avatarContainer.topAnchor),
+ avatatImageView.leadingAnchor.constraint(equalTo: avatarContainer.leadingAnchor)
+ ])
+
+ avatarContainer.addSubview(actionImageBackground)
+ actionImageBackground.translatesAutoresizingMaskIntoConstraints = false
+ NSLayoutConstraint.activate([
+ actionImageBackground.heightAnchor.constraint(equalToConstant: 24 + NotificationTableViewCell.actionImageBorderWidth).priority(.required - 1),
+ actionImageBackground.widthAnchor.constraint(equalToConstant: 24 + NotificationTableViewCell.actionImageBorderWidth).priority(.required - 1),
+ actionImageBackground.bottomAnchor.constraint(equalTo: avatarContainer.bottomAnchor),
+ actionImageBackground.trailingAnchor.constraint(equalTo: avatarContainer.trailingAnchor)
+ ])
+
+ avatarContainer.addSubview(actionImageView)
+ actionImageView.translatesAutoresizingMaskIntoConstraints = false
+ NSLayoutConstraint.activate([
+ actionImageView.centerXAnchor.constraint(equalTo: actionImageBackground.centerXAnchor),
+ actionImageView.centerYAnchor.constraint(equalTo: actionImageBackground.centerYAnchor)
+ ])
+
+
+ let actionStackView = UIStackView()
+ actionStackView.axis = .horizontal
+ actionStackView.distribution = .fill
+ actionStackView.spacing = 4
+ actionStackView.translatesAutoresizingMaskIntoConstraints = false
+
+ nameLabel.translatesAutoresizingMaskIntoConstraints = false
+ actionStackView.addArrangedSubview(nameLabel)
+ actionLabel.translatesAutoresizingMaskIntoConstraints = false
+ actionStackView.addArrangedSubview(actionLabel)
+ nameLabel.setContentCompressionResistancePriority(.required - 1, for: .vertical)
+ nameLabel.setContentHuggingPriority(.defaultHigh, for: .horizontal)
+ actionLabel.setContentHuggingPriority(.defaultLow, for: .horizontal)
+ let statusStackView = UIStackView()
+ statusStackView.axis = .vertical
+
+ statusStackView.distribution = .fill
+ statusStackView.spacing = 4
+ statusStackView.translatesAutoresizingMaskIntoConstraints = false
+ statusView.translatesAutoresizingMaskIntoConstraints = false
+ statusStackView.addArrangedSubview(actionStackView)
+
+ statusBorder.translatesAutoresizingMaskIntoConstraints = false
+ statusView.translatesAutoresizingMaskIntoConstraints = false
+ statusBorder.addSubview(statusView)
+ NSLayoutConstraint.activate([
+ statusView.topAnchor.constraint(equalTo: statusBorder.topAnchor, constant: 12),
+ statusView.leadingAnchor.constraint(equalTo: statusBorder.leadingAnchor, constant: 12),
+ statusBorder.bottomAnchor.constraint(equalTo: statusView.bottomAnchor, constant: 12),
+ statusBorder.trailingAnchor.constraint(equalTo: statusView.trailingAnchor, constant: 12),
+ ])
+
+
+ statusStackView.addArrangedSubview(statusBorder)
+
+ containerStackView.addArrangedSubview(statusStackView)
+
+ statusView.contentWarningBlurContentImageView.backgroundColor = Asset.Colors.Background.secondaryGroupedSystemBackground.color
+ statusView.isUserInteractionEnabled = false
+ // remove item don't display
+ statusView.actionToolbarContainer.removeFromStackView()
+ // it affect stackView's height,need remove
+ statusView.avatarView.removeFromStackView()
+ statusView.usernameLabel.removeFromStackView()
+ }
+
+ override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
+ super.traitCollectionDidChange(previousTraitCollection)
+ statusBorder.layer.borderColor = Asset.Colors.Border.notification.color.cgColor
+ actionImageBackground.layer.borderColor = Asset.Colors.Background.systemBackground.color.cgColor
+ }
+}
diff --git a/Mastodon/Scene/Notification/TableViewCell/NotificationTableViewCell.swift b/Mastodon/Scene/Notification/TableViewCell/NotificationTableViewCell.swift
new file mode 100644
index 00000000..60b43ac3
--- /dev/null
+++ b/Mastodon/Scene/Notification/TableViewCell/NotificationTableViewCell.swift
@@ -0,0 +1,155 @@
+//
+// NotificationTableViewCell.swift
+// Mastodon
+//
+// Created by sxiaojian on 2021/4/13.
+//
+
+import Combine
+import CoreDataStack
+import Foundation
+import UIKit
+
+protocol NotificationTableViewCellDelegate: AnyObject {
+ var context: AppContext! { get }
+
+ func parent() -> UIViewController
+
+ func userAvatarDidPressed(notification: MastodonNotification)
+}
+
+final class NotificationTableViewCell: UITableViewCell {
+ static let actionImageBorderWidth: CGFloat = 2
+
+ var disposeBag = Set()
+
+ var delegate: NotificationTableViewCellDelegate?
+
+ let avatatImageView: UIImageView = {
+ let imageView = UIImageView()
+ imageView.layer.cornerRadius = 4
+ imageView.layer.cornerCurve = .continuous
+ imageView.clipsToBounds = true
+ return imageView
+ }()
+
+ let actionImageView: UIImageView = {
+ let imageView = UIImageView()
+ imageView.tintColor = Asset.Colors.Background.systemBackground.color
+ return imageView
+ }()
+
+ let actionImageBackground: UIView = {
+ let view = UIView()
+ view.layer.cornerRadius = (24 + NotificationTableViewCell.actionImageBorderWidth) / 2
+ view.layer.cornerCurve = .continuous
+ view.clipsToBounds = true
+ view.layer.borderWidth = NotificationTableViewCell.actionImageBorderWidth
+ view.layer.borderColor = Asset.Colors.Background.systemBackground.color.cgColor
+ view.tintColor = Asset.Colors.Background.systemBackground.color
+ return view
+ }()
+
+ let avatarContainer: UIView = {
+ let view = UIView()
+ return view
+ }()
+
+ let actionLabel: UILabel = {
+ let label = UILabel()
+ label.textColor = Asset.Colors.Label.secondary.color
+ label.font = UIFont.preferredFont(forTextStyle: .body)
+ label.lineBreakMode = .byTruncatingTail
+ return label
+ }()
+
+ let nameLabel: UILabel = {
+ let label = UILabel()
+ label.textColor = Asset.Colors.brandBlue.color
+ label.font = .systemFont(ofSize: 15, weight: .semibold)
+ label.lineBreakMode = .byTruncatingTail
+ return label
+ }()
+
+ override func prepareForReuse() {
+ super.prepareForReuse()
+ avatatImageView.af.cancelImageRequest()
+ disposeBag.removeAll()
+ }
+
+ override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
+ super.init(style: style, reuseIdentifier: reuseIdentifier)
+ configure()
+ }
+
+ required init?(coder: NSCoder) {
+ super.init(coder: coder)
+ configure()
+ }
+}
+
+extension NotificationTableViewCell {
+ func configure() {
+
+ let containerStackView = UIStackView()
+ containerStackView.axis = .horizontal
+ containerStackView.alignment = .center
+ containerStackView.spacing = 4
+ containerStackView.layoutMargins = UIEdgeInsets(top: 14, left: 0, bottom: 12, right: 0)
+ containerStackView.isLayoutMarginsRelativeArrangement = true
+ containerStackView.translatesAutoresizingMaskIntoConstraints = false
+ contentView.addSubview(containerStackView)
+ NSLayoutConstraint.activate([
+ containerStackView.topAnchor.constraint(equalTo: contentView.topAnchor),
+ containerStackView.leadingAnchor.constraint(equalTo: contentView.readableContentGuide.leadingAnchor),
+ contentView.readableContentGuide.trailingAnchor.constraint(equalTo: containerStackView.trailingAnchor),
+ contentView.bottomAnchor.constraint(equalTo: containerStackView.bottomAnchor),
+ ])
+
+ containerStackView.addArrangedSubview(avatarContainer)
+ avatarContainer.translatesAutoresizingMaskIntoConstraints = false
+ NSLayoutConstraint.activate([
+ avatarContainer.heightAnchor.constraint(equalToConstant: 47).priority(.required - 1),
+ avatarContainer.widthAnchor.constraint(equalToConstant: 47).priority(.required - 1)
+ ])
+
+ avatarContainer.addSubview(avatatImageView)
+ avatatImageView.translatesAutoresizingMaskIntoConstraints = false
+ NSLayoutConstraint.activate([
+ avatatImageView.heightAnchor.constraint(equalToConstant: 35).priority(.required - 1),
+ avatatImageView.widthAnchor.constraint(equalToConstant: 35).priority(.required - 1),
+ avatatImageView.topAnchor.constraint(equalTo: avatarContainer.topAnchor),
+ avatatImageView.leadingAnchor.constraint(equalTo: avatarContainer.leadingAnchor)
+ ])
+
+ avatarContainer.addSubview(actionImageBackground)
+ actionImageBackground.translatesAutoresizingMaskIntoConstraints = false
+ NSLayoutConstraint.activate([
+ actionImageBackground.heightAnchor.constraint(equalToConstant: 24 + NotificationTableViewCell.actionImageBorderWidth).priority(.required - 1),
+ actionImageBackground.widthAnchor.constraint(equalToConstant: 24 + NotificationTableViewCell.actionImageBorderWidth).priority(.required - 1),
+ actionImageBackground.bottomAnchor.constraint(equalTo: avatarContainer.bottomAnchor),
+ actionImageBackground.trailingAnchor.constraint(equalTo: avatarContainer.trailingAnchor)
+ ])
+
+ avatarContainer.addSubview(actionImageView)
+ actionImageView.translatesAutoresizingMaskIntoConstraints = false
+ NSLayoutConstraint.activate([
+ actionImageView.centerXAnchor.constraint(equalTo: actionImageBackground.centerXAnchor),
+ actionImageView.centerYAnchor.constraint(equalTo: actionImageBackground.centerYAnchor)
+ ])
+
+ nameLabel.translatesAutoresizingMaskIntoConstraints = false
+ containerStackView.addArrangedSubview(nameLabel)
+ actionLabel.translatesAutoresizingMaskIntoConstraints = false
+ containerStackView.addArrangedSubview(actionLabel)
+ nameLabel.setContentCompressionResistancePriority(.required - 1, for: .vertical)
+ nameLabel.setContentHuggingPriority(.defaultHigh, for: .horizontal)
+ actionLabel.setContentHuggingPriority(.defaultLow, for: .horizontal)
+
+ }
+
+ override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
+ super.traitCollectionDidChange(previousTraitCollection)
+ actionImageBackground.layer.borderColor = Asset.Colors.Background.systemBackground.color.cgColor
+ }
+}
diff --git a/Mastodon/Scene/Search/CollectionViewCell/SearchRecommendAccountsCollectionViewCell.swift b/Mastodon/Scene/Search/CollectionViewCell/SearchRecommendAccountsCollectionViewCell.swift
index f4512467..9d6bbedc 100644
--- a/Mastodon/Scene/Search/CollectionViewCell/SearchRecommendAccountsCollectionViewCell.swift
+++ b/Mastodon/Scene/Search/CollectionViewCell/SearchRecommendAccountsCollectionViewCell.swift
@@ -62,6 +62,7 @@ class SearchRecommendAccountsCollectionViewCell: UICollectionViewCell {
let followButton: HighlightDimmableButton = {
let button = HighlightDimmableButton(type: .custom)
+ button.setInsets(forContentPadding: UIEdgeInsets(top: 0, left: 16, bottom: 0, right: 16), imageTitlePadding: 0)
button.setTitleColor(.white, for: .normal)
button.setTitle(L10n.Scene.Search.Recommend.Accounts.follow, for: .normal)
button.titleLabel?.font = .systemFont(ofSize: 14, weight: .semibold)
@@ -97,50 +98,70 @@ extension SearchRecommendAccountsCollectionViewCell {
headerImageView.layer.borderColor = Asset.Colors.Border.searchCard.color.cgColor
applyShadow(color: Asset.Colors.Shadow.searchCard.color, alpha: 0.1, x: 0, y: 3, blur: 12, spread: 0)
}
-
+ override open func layoutSubviews() {
+ super.layoutSubviews()
+ followButton.layer.cornerRadius = followButton.frame.height/2
+ }
private func configure() {
headerImageView.backgroundColor = Asset.Colors.brandBlue.color
layer.cornerRadius = 10
layer.cornerCurve = .continuous
clipsToBounds = false
applyShadow(color: Asset.Colors.Shadow.searchCard.color, alpha: 0.1, x: 0, y: 3, blur: 12, spread: 0)
+
+ headerImageView.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(headerImageView)
- headerImageView.pin(top: 16, left: 0, bottom: 0, right: 0)
+ NSLayoutConstraint.activate([
+ headerImageView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 16),
+ headerImageView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
+ headerImageView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
+ headerImageView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor)
+ ])
+ let containerStackView = UIStackView()
+ containerStackView.axis = .vertical
+ containerStackView.distribution = .fill
+ containerStackView.alignment = .center
+ containerStackView.spacing = 6
+ containerStackView.layoutMargins = UIEdgeInsets(top: 0, left: 16, bottom: 0, right: 16)
+ containerStackView.isLayoutMarginsRelativeArrangement = true
+ containerStackView.translatesAutoresizingMaskIntoConstraints = false
+
+ contentView.addSubview(containerStackView)
+ NSLayoutConstraint.activate([
+ containerStackView.topAnchor.constraint(equalTo: contentView.topAnchor),
+ containerStackView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
+ containerStackView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
+ ])
+
+ avatarImageView.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(avatarImageView)
- avatarImageView.pin(toSize: CGSize(width: 88, height: 88))
- avatarImageView.constrain([
- avatarImageView.constraint(.top, toView: contentView),
- avatarImageView.constraint(.centerX, toView: contentView)
+ NSLayoutConstraint.activate([
+ avatarImageView.widthAnchor.constraint(equalToConstant: 88),
+ avatarImageView.heightAnchor.constraint(equalToConstant: 88)
])
+ containerStackView.addArrangedSubview(avatarImageView)
+ containerStackView.setCustomSpacing(20, after: avatarImageView)
+ displayNameLabel.translatesAutoresizingMaskIntoConstraints = false
+ containerStackView.addArrangedSubview(displayNameLabel)
+ containerStackView.setCustomSpacing(0, after: displayNameLabel)
- contentView.addSubview(displayNameLabel)
- displayNameLabel.constrain([
- displayNameLabel.constraint(.top, toView: contentView, constant: 108),
- displayNameLabel.constraint(.leading, toView: contentView),
- displayNameLabel.constraint(.trailing, toView: contentView),
- displayNameLabel.constraint(.centerX, toView: contentView)
- ])
+ acctLabel.translatesAutoresizingMaskIntoConstraints = false
+ containerStackView.addArrangedSubview(acctLabel)
+ containerStackView.setCustomSpacing(7, after: acctLabel)
- contentView.addSubview(acctLabel)
- acctLabel.constrain([
- acctLabel.constraint(.top, toView: contentView, constant: 132),
- acctLabel.constraint(.leading, toView: contentView),
- acctLabel.constraint(.trailing, toView: contentView),
- acctLabel.constraint(.centerX, toView: contentView)
- ])
-
- contentView.addSubview(followButton)
- followButton.pin(toSize: CGSize(width: 76, height: 24))
- followButton.constrain([
- followButton.constraint(.top, toView: contentView, constant: 159),
- followButton.constraint(.centerX, toView: contentView)
+ followButton.translatesAutoresizingMaskIntoConstraints = false
+ containerStackView.addArrangedSubview(followButton)
+ NSLayoutConstraint.activate([
+ followButton.widthAnchor.constraint(greaterThanOrEqualToConstant: 76),
+ followButton.heightAnchor.constraint(greaterThanOrEqualToConstant: 24)
])
+ containerStackView.addArrangedSubview(followButton)
}
func config(with mastodonUser: MastodonUser) {
displayNameLabel.text = mastodonUser.displayName.isEmpty ? mastodonUser.username : mastodonUser.displayName
- acctLabel.text = mastodonUser.acct
+ acctLabel.text = "@" + mastodonUser.acct
avatarImageView.af.setImage(
withURL: URL(string: mastodonUser.avatar)!,
placeholderImage: UIImage.placeholder(color: .systemFill),
@@ -153,7 +174,13 @@ extension SearchRecommendAccountsCollectionViewCell {
) { [weak self] _ in
guard let self = self else { return }
self.headerImageView.addSubview(self.visualEffectView)
- self.visualEffectView.pin(top: 0, left: 0, bottom: 0, right: 0)
+ self.visualEffectView.translatesAutoresizingMaskIntoConstraints = false
+ NSLayoutConstraint.activate([
+ self.visualEffectView.topAnchor.constraint(equalTo: self.headerImageView.topAnchor),
+ self.visualEffectView.leadingAnchor.constraint(equalTo: self.headerImageView.leadingAnchor),
+ self.visualEffectView.trailingAnchor.constraint(equalTo: self.headerImageView.trailingAnchor),
+ self.visualEffectView.bottomAnchor.constraint(equalTo: self.headerImageView.bottomAnchor)
+ ])
}
delegate?.configFollowButton(with: mastodonUser, followButton: followButton)
followButton.publisher(for: .touchUpInside)
diff --git a/Mastodon/Scene/Search/CollectionViewCell/SearchRecommendTagsCollectionViewCell.swift b/Mastodon/Scene/Search/CollectionViewCell/SearchRecommendTagsCollectionViewCell.swift
index d00cb050..abcd9d08 100644
--- a/Mastodon/Scene/Search/CollectionViewCell/SearchRecommendTagsCollectionViewCell.swift
+++ b/Mastodon/Scene/Search/CollectionViewCell/SearchRecommendTagsCollectionViewCell.swift
@@ -12,7 +12,6 @@ import UIKit
class SearchRecommendTagsCollectionViewCell: UICollectionViewCell {
let backgroundImageView: UIImageView = {
let imageView = UIImageView()
- imageView.translatesAutoresizingMaskIntoConstraints = false
return imageView
}()
@@ -20,7 +19,6 @@ class SearchRecommendTagsCollectionViewCell: UICollectionViewCell {
let label = UILabel()
label.textColor = .white
label.font = .systemFont(ofSize: 20, weight: .semibold)
- label.translatesAutoresizingMaskIntoConstraints = false
label.lineBreakMode = .byTruncatingTail
return label
}()
@@ -29,7 +27,6 @@ class SearchRecommendTagsCollectionViewCell: UICollectionViewCell {
let label = UILabel()
label.textColor = .white
label.font = .preferredFont(forTextStyle: .body)
- label.translatesAutoresizingMaskIntoConstraints = false
return label
}()
@@ -38,7 +35,6 @@ class SearchRecommendTagsCollectionViewCell: UICollectionViewCell {
let image = UIImage(systemName: "flame.fill", withConfiguration: UIImage.SymbolConfiguration(pointSize: 20, weight: .semibold))!.withRenderingMode(.alwaysTemplate)
imageView.image = image
imageView.tintColor = .white
- imageView.translatesAutoresizingMaskIntoConstraints = false
return imageView
}()
@@ -74,17 +70,49 @@ extension SearchRecommendTagsCollectionViewCell {
layer.borderColor = Asset.Colors.Border.searchCard.color.cgColor
applyShadow(color: Asset.Colors.Shadow.searchCard.color, alpha: 0.1, x: 0, y: 3, blur: 12, spread: 0)
+ backgroundImageView.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(backgroundImageView)
- backgroundImageView.constrain(toSuperviewEdges: nil)
+ NSLayoutConstraint.activate([
+ backgroundImageView.topAnchor.constraint(equalTo: contentView.topAnchor),
+ backgroundImageView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
+ backgroundImageView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
+ backgroundImageView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor)
+ ])
- contentView.addSubview(hashtagTitleLabel)
- hashtagTitleLabel.pin(top: 16, left: 16, bottom: nil, right: 42)
- contentView.addSubview(peopleLabel)
- peopleLabel.pinTopLeft(top: 46, left: 16)
+ let containerStackView = UIStackView()
+ containerStackView.axis = .vertical
+ containerStackView.distribution = .fill
+ containerStackView.spacing = 6
+ containerStackView.layoutMargins = UIEdgeInsets(top: 16, left: 16, bottom: 0, right: 16)
+ containerStackView.isLayoutMarginsRelativeArrangement = true
+ containerStackView.translatesAutoresizingMaskIntoConstraints = false
+ contentView.addSubview(containerStackView)
+ NSLayoutConstraint.activate([
+ containerStackView.topAnchor.constraint(equalTo: contentView.topAnchor),
+ containerStackView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
+ containerStackView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor)
+ ])
- contentView.addSubview(flameIconView)
- flameIconView.pinTopRight(padding: 16)
+
+ let horizontalStackView = UIStackView()
+ horizontalStackView.axis = .horizontal
+ horizontalStackView.translatesAutoresizingMaskIntoConstraints = false
+ horizontalStackView.distribution = .fill
+
+ hashtagTitleLabel.translatesAutoresizingMaskIntoConstraints = false
+ hashtagTitleLabel.setContentHuggingPriority(.defaultLow - 1, for: .horizontal)
+ horizontalStackView.addArrangedSubview(hashtagTitleLabel)
+ horizontalStackView.setContentHuggingPriority(.required - 1, for: .vertical)
+
+ flameIconView.translatesAutoresizingMaskIntoConstraints = false
+ horizontalStackView.addArrangedSubview(flameIconView)
+
+
+ containerStackView.addArrangedSubview(horizontalStackView)
+ peopleLabel.translatesAutoresizingMaskIntoConstraints = false
+ peopleLabel.setContentHuggingPriority(.defaultLow - 1, for: .vertical)
+ containerStackView.addArrangedSubview(peopleLabel)
}
func config(with tag: Mastodon.Entity.Tag) {
diff --git a/Mastodon/Scene/Search/SearchViewController+Recommend.swift b/Mastodon/Scene/Search/SearchViewController+Recommend.swift
index e941fa84..f394f09f 100644
--- a/Mastodon/Scene/Search/SearchViewController+Recommend.swift
+++ b/Mastodon/Scene/Search/SearchViewController+Recommend.swift
@@ -23,8 +23,9 @@ extension SearchViewController {
hashtagCollectionView.register(SearchRecommendTagsCollectionViewCell.self, forCellWithReuseIdentifier: String(describing: SearchRecommendTagsCollectionViewCell.self))
hashtagCollectionView.delegate = self
+ hashtagCollectionView.translatesAutoresizingMaskIntoConstraints = false
stackView.addArrangedSubview(hashtagCollectionView)
- hashtagCollectionView.constrain([
+ NSLayoutConstraint.activate([
hashtagCollectionView.frameLayoutGuide.heightAnchor.constraint(equalToConstant: 130)
])
}
@@ -39,8 +40,9 @@ extension SearchViewController {
accountsCollectionView.register(SearchRecommendAccountsCollectionViewCell.self, forCellWithReuseIdentifier: String(describing: SearchRecommendAccountsCollectionViewCell.self))
accountsCollectionView.delegate = self
+ accountsCollectionView.translatesAutoresizingMaskIntoConstraints = false
stackView.addArrangedSubview(accountsCollectionView)
- accountsCollectionView.constrain([
+ NSLayoutConstraint.activate([
accountsCollectionView.frameLayoutGuide.heightAnchor.constraint(equalToConstant: 202)
])
}
diff --git a/Mastodon/Scene/Search/SearchViewController+Searching.swift b/Mastodon/Scene/Search/SearchViewController+Searching.swift
index 3eb9793a..0602ac20 100644
--- a/Mastodon/Scene/Search/SearchViewController+Searching.swift
+++ b/Mastodon/Scene/Search/SearchViewController+Searching.swift
@@ -15,16 +15,18 @@ import UIKit
extension SearchViewController {
func setupSearchingTableView() {
- searchingTableView.delegate = self
searchingTableView.register(SearchingTableViewCell.self, forCellReuseIdentifier: String(describing: SearchingTableViewCell.self))
- searchingTableView.register(SearchBottomLoader.self, forCellReuseIdentifier: String(describing: SearchBottomLoader.self))
+ searchingTableView.register(TimelineBottomLoaderTableViewCell.self, forCellReuseIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self))
+ searchingTableView.estimatedRowHeight = 66
+ searchingTableView.rowHeight = 66
view.addSubview(searchingTableView)
- searchingTableView.constrain([
- searchingTableView.frameLayoutGuide.topAnchor.constraint(equalTo: searchBar.bottomAnchor),
- searchingTableView.frameLayoutGuide.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor),
- searchingTableView.frameLayoutGuide.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor),
- searchingTableView.frameLayoutGuide.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor),
- searchingTableView.contentLayoutGuide.widthAnchor.constraint(equalTo: view.widthAnchor)
+ searchingTableView.delegate = self
+ searchingTableView.translatesAutoresizingMaskIntoConstraints = false
+ NSLayoutConstraint.activate([
+ searchingTableView.topAnchor.constraint(equalTo: searchBar.bottomAnchor),
+ searchingTableView.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor),
+ searchingTableView.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor),
+ searchingTableView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor),
])
searchingTableView.tableFooterView = UIView()
viewModel.isSearching
@@ -50,18 +52,23 @@ extension SearchViewController {
}
func setupSearchHeader() {
- searchHeader.addSubview(recentSearchesLabel)
- recentSearchesLabel.constrain([
- recentSearchesLabel.constraint(.leading, toView: searchHeader, constant: 16),
- recentSearchesLabel.constraint(.centerY, toView: searchHeader)
+ let containerStackView = UIStackView()
+ containerStackView.axis = .horizontal
+ containerStackView.distribution = .fill
+ containerStackView.translatesAutoresizingMaskIntoConstraints = false
+ containerStackView.layoutMargins = UIEdgeInsets(top: 0, left: 12, bottom: 0, right: 12)
+ containerStackView.isLayoutMarginsRelativeArrangement = true
+ searchHeader.addSubview(containerStackView)
+ NSLayoutConstraint.activate([
+ containerStackView.topAnchor.constraint(equalTo: searchHeader.topAnchor),
+ containerStackView.leadingAnchor.constraint(equalTo: searchHeader.leadingAnchor),
+ containerStackView.trailingAnchor.constraint(equalTo: searchHeader.trailingAnchor),
+ containerStackView.bottomAnchor.constraint(equalTo: searchHeader.bottomAnchor)
])
-
- searchHeader.addSubview(clearSearchHistoryButton)
- recentSearchesLabel.constrain([
- searchHeader.trailingAnchor.constraint(equalTo: clearSearchHistoryButton.trailingAnchor, constant: 16),
- clearSearchHistoryButton.constraint(.centerY, toView: searchHeader)
- ])
-
+ recentSearchesLabel.translatesAutoresizingMaskIntoConstraints = false
+ containerStackView.addArrangedSubview(recentSearchesLabel)
+ clearSearchHistoryButton.translatesAutoresizingMaskIntoConstraints = false
+ containerStackView.addArrangedSubview(clearSearchHistoryButton)
clearSearchHistoryButton.addTarget(self, action: #selector(SearchViewController.clearAction(_:)), for: .touchUpInside)
}
}
@@ -75,15 +82,9 @@ extension SearchViewController {
// MARK: - UITableViewDelegate
extension SearchViewController: UITableViewDelegate {
- func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat {
- 66
- }
-
- func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
- 66
- }
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
+ tableView.deselectRow(at: indexPath, animated: true)
guard let diffableDataSource = viewModel.searchResultDiffableDataSource else { return }
guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return }
viewModel.searchResultItemDidSelected(item: item, from: self)
diff --git a/Mastodon/Scene/Search/SearchViewController.swift b/Mastodon/Scene/Search/SearchViewController.swift
index 704e425a..770fb1da 100644
--- a/Mastodon/Scene/Search/SearchViewController.swift
+++ b/Mastodon/Scene/Search/SearchViewController.swift
@@ -82,7 +82,7 @@ final class SearchViewController: UIViewController, NeedsDependency {
// searching
let searchingTableView: UITableView = {
let tableView = UITableView()
- tableView.backgroundColor = Asset.Colors.Background.systemGroupedBackground.color
+ tableView.backgroundColor = Asset.Colors.Background.systemBackground.color
tableView.rowHeight = UITableView.automaticDimension
tableView.separatorStyle = .singleLine
tableView.separatorInset = UIEdgeInsets(top: 0, left: 0, bottom: 0, right: 0)
@@ -135,14 +135,16 @@ extension SearchViewController {
func setupSearchBar() {
searchBar.delegate = self
view.addSubview(searchBar)
- searchBar.constrain([
+ searchBar.translatesAutoresizingMaskIntoConstraints = false
+ NSLayoutConstraint.activate([
searchBar.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
searchBar.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor),
searchBar.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor),
])
- view.addSubview(statusBar)
- statusBar.constrain([
+ statusBar.translatesAutoresizingMaskIntoConstraints = false
+ view.addSubview(statusBar)
+ NSLayoutConstraint.activate([
statusBar.topAnchor.constraint(equalTo: view.topAnchor),
statusBar.leadingAnchor.constraint(equalTo: view.leadingAnchor),
statusBar.trailingAnchor.constraint(equalTo: view.trailingAnchor),
@@ -151,8 +153,9 @@ extension SearchViewController {
}
func setupScrollView() {
+ scrollView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(scrollView)
- scrollView.constrain([
+ NSLayoutConstraint.activate([
scrollView.frameLayoutGuide.topAnchor.constraint(equalTo: searchBar.bottomAnchor),
scrollView.frameLayoutGuide.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor),
scrollView.frameLayoutGuide.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor),
@@ -160,8 +163,9 @@ extension SearchViewController {
scrollView.contentLayoutGuide.widthAnchor.constraint(equalTo: view.widthAnchor),
])
+ stackView.translatesAutoresizingMaskIntoConstraints = false
scrollView.addSubview(stackView)
- stackView.constrain([
+ NSLayoutConstraint.activate([
stackView.topAnchor.constraint(equalTo: scrollView.contentLayoutGuide.topAnchor),
stackView.leadingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.leadingAnchor),
stackView.trailingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.trailingAnchor),
@@ -227,7 +231,7 @@ extension SearchViewController: UISearchBarDelegate {
}
extension SearchViewController: LoadMoreConfigurableTableViewContainer {
- typealias BottomLoaderTableViewCell = SearchBottomLoader
+ typealias BottomLoaderTableViewCell = TimelineBottomLoaderTableViewCell
typealias LoadingState = SearchViewModel.LoadOldestState.Loading
var loadMoreConfigurableTableView: UITableView { searchingTableView }
var loadMoreConfigurableStateMachine: GKStateMachine { viewModel.loadoldestStateMachine }
diff --git a/Mastodon/Scene/Search/SearchViewModel+LoadOldestState.swift b/Mastodon/Scene/Search/SearchViewModel+LoadOldestState.swift
index c76ab202..b486df77 100644
--- a/Mastodon/Scene/Search/SearchViewModel+LoadOldestState.swift
+++ b/Mastodon/Scene/Search/SearchViewModel+LoadOldestState.swift
@@ -136,9 +136,11 @@ extension SearchViewModel.LoadOldestState {
assertionFailure()
return
}
- var snapshot = diffableDataSource.snapshot()
- snapshot.deleteItems([.bottomLoader])
- diffableDataSource.apply(snapshot)
+ DispatchQueue.main.async {
+ var snapshot = diffableDataSource.snapshot()
+ snapshot.deleteItems([.bottomLoader])
+ diffableDataSource.apply(snapshot)
+ }
}
}
}
diff --git a/Mastodon/Scene/Search/SearchViewModel.swift b/Mastodon/Scene/Search/SearchViewModel.swift
index 1d87629b..27c322c8 100644
--- a/Mastodon/Scene/Search/SearchViewModel.swift
+++ b/Mastodon/Scene/Search/SearchViewModel.swift
@@ -237,10 +237,10 @@ final class SearchViewModel: NSObject {
.sink { completion in
switch completion {
case .failure(let error):
- os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: recommendHashTags request fail: %s", (#file as NSString).lastPathComponent, #line, #function, error.localizedDescription)
+ os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: recommendAccount request fail: %s", (#file as NSString).lastPathComponent, #line, #function, error.localizedDescription)
promise(.failure(error))
case .finished:
- os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: recommendHashTags request success", (#file as NSString).lastPathComponent, #line, #function)
+ os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: recommendAccount request success", (#file as NSString).lastPathComponent, #line, #function)
promise(.success(()))
}
} receiveValue: { [weak self] accounts in
diff --git a/Mastodon/Scene/Search/TableViewCell/SearchBottomLoader.swift b/Mastodon/Scene/Search/TableViewCell/SearchBottomLoader.swift
deleted file mode 100644
index 7ab18bb0..00000000
--- a/Mastodon/Scene/Search/TableViewCell/SearchBottomLoader.swift
+++ /dev/null
@@ -1,47 +0,0 @@
-//
-// SearchBottomLoader.swift
-// Mastodon
-//
-// Created by sxiaojian on 2021/4/6.
-//
-
-import Foundation
-import UIKit
-
-final class SearchBottomLoader: UITableViewCell {
- let activityIndicatorView: UIActivityIndicatorView = {
- let activityIndicatorView = UIActivityIndicatorView(style: .medium)
- activityIndicatorView.tintColor = Asset.Colors.Label.primary.color
- activityIndicatorView.hidesWhenStopped = true
- return activityIndicatorView
- }()
-
- override func prepareForReuse() {
- super.prepareForReuse()
- }
-
- override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
- super.init(style: style, reuseIdentifier: reuseIdentifier)
- _init()
- }
-
- required init?(coder: NSCoder) {
- super.init(coder: coder)
- _init()
- }
-
- func startAnimating() {
- activityIndicatorView.startAnimating()
- }
-
- func stopAnimating() {
- activityIndicatorView.stopAnimating()
- }
-
- func _init() {
- selectionStyle = .none
- backgroundColor = Asset.Colors.Background.systemGroupedBackground.color
- contentView.addSubview(activityIndicatorView)
- activityIndicatorView.constrainToCenter()
- }
-}
diff --git a/Mastodon/Scene/Search/TableViewCell/SearchingTableViewCell.swift b/Mastodon/Scene/Search/TableViewCell/SearchingTableViewCell.swift
index 5a258d8a..a3a7b58a 100644
--- a/Mastodon/Scene/Search/TableViewCell/SearchingTableViewCell.swift
+++ b/Mastodon/Scene/Search/TableViewCell/SearchingTableViewCell.swift
@@ -55,19 +55,40 @@ final class SearchingTableViewCell: UITableViewCell {
extension SearchingTableViewCell {
private func configure() {
backgroundColor = .clear
- selectionStyle = .none
- contentView.addSubview(_imageView)
- _imageView.pin(toSize: CGSize(width: 42, height: 42))
- _imageView.constrain([
- _imageView.constraint(.leading, toView: contentView, constant: 21),
- _imageView.constraint(.centerY, toView: contentView)
+
+ let containerStackView = UIStackView()
+ containerStackView.axis = .horizontal
+ containerStackView.distribution = .fill
+ containerStackView.spacing = 12
+ containerStackView.layoutMargins = UIEdgeInsets(top: 12, left: 21, bottom: 12, right: 12)
+ containerStackView.isLayoutMarginsRelativeArrangement = true
+ containerStackView.translatesAutoresizingMaskIntoConstraints = false
+ contentView.addSubview(containerStackView)
+ NSLayoutConstraint.activate([
+ containerStackView.topAnchor.constraint(equalTo: contentView.topAnchor),
+ containerStackView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
+ containerStackView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
+ containerStackView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor)
])
- contentView.addSubview(_titleLabel)
- _titleLabel.pin(top: 12, left: 75, bottom: nil, right: 0)
+ _imageView.translatesAutoresizingMaskIntoConstraints = false
+ containerStackView.addArrangedSubview(_imageView)
+ NSLayoutConstraint.activate([
+ _imageView.widthAnchor.constraint(equalToConstant: 42),
+ _imageView.heightAnchor.constraint(equalToConstant: 42),
+ ])
- contentView.addSubview(_subTitleLabel)
- _subTitleLabel.pin(top: 34, left: 75, bottom: nil, right: 0)
+ let textStackView = UIStackView()
+ textStackView.axis = .vertical
+ textStackView.distribution = .fill
+ textStackView.translatesAutoresizingMaskIntoConstraints = false
+ _titleLabel.translatesAutoresizingMaskIntoConstraints = false
+ textStackView.addArrangedSubview(_titleLabel)
+ _subTitleLabel.translatesAutoresizingMaskIntoConstraints = false
+ textStackView.addArrangedSubview(_subTitleLabel)
+ _subTitleLabel.setContentHuggingPriority(.defaultLow - 1, for: .vertical)
+
+ containerStackView.addArrangedSubview(textStackView)
}
func config(with account: Mastodon.Entity.Account) {
diff --git a/Mastodon/Scene/Search/View/SearchRecommendCollectionHeader.swift b/Mastodon/Scene/Search/View/SearchRecommendCollectionHeader.swift
index bc5bd766..3db8c280 100644
--- a/Mastodon/Scene/Search/View/SearchRecommendCollectionHeader.swift
+++ b/Mastodon/Scene/Search/View/SearchRecommendCollectionHeader.swift
@@ -47,14 +47,35 @@ extension SearchRecommendCollectionHeader {
private func configure() {
backgroundColor = .clear
translatesAutoresizingMaskIntoConstraints = false
- addSubview(titleLabel)
- titleLabel.pinTopLeft(top: 31, left: 16)
- addSubview(descriptionLabel)
- descriptionLabel.constrain(toSuperviewEdges: UIEdgeInsets(top: 60, left: 16, bottom: 16, right: 16))
+ let containerStackView = UIStackView()
+ containerStackView.axis = .vertical
+ containerStackView.layoutMargins = UIEdgeInsets(top: 31, left: 16, bottom: 16, right: 16)
+ containerStackView.isLayoutMarginsRelativeArrangement = true
+ containerStackView.translatesAutoresizingMaskIntoConstraints = false
+ addSubview(containerStackView)
+ NSLayoutConstraint.activate([
+ containerStackView.topAnchor.constraint(equalTo: topAnchor),
+ containerStackView.leadingAnchor.constraint(equalTo: leadingAnchor),
+ containerStackView.bottomAnchor.constraint(equalTo: bottomAnchor),
+ containerStackView.trailingAnchor.constraint(equalTo: trailingAnchor)
+ ])
- addSubview(seeAllButton)
- seeAllButton.pinTopRight(top: 26, right: 16)
+ let horizontalStackView = UIStackView()
+ horizontalStackView.axis = .horizontal
+ horizontalStackView.alignment = .center
+ horizontalStackView.translatesAutoresizingMaskIntoConstraints = false
+ horizontalStackView.distribution = .fill
+ titleLabel.translatesAutoresizingMaskIntoConstraints = false
+ titleLabel.setContentHuggingPriority(.defaultLow - 1, for: .horizontal)
+ horizontalStackView.addArrangedSubview(titleLabel)
+ seeAllButton.translatesAutoresizingMaskIntoConstraints = false
+ horizontalStackView.addArrangedSubview(seeAllButton)
+
+ containerStackView.addArrangedSubview(horizontalStackView)
+ descriptionLabel.translatesAutoresizingMaskIntoConstraints = false
+ containerStackView.addArrangedSubview(descriptionLabel)
+
}
}
diff --git a/Mastodon/Scene/Settings/SettingsViewController.swift b/Mastodon/Scene/Settings/SettingsViewController.swift
new file mode 100644
index 00000000..4615f92a
--- /dev/null
+++ b/Mastodon/Scene/Settings/SettingsViewController.swift
@@ -0,0 +1,488 @@
+//
+// SettingsViewController.swift
+// Mastodon
+//
+// Created by ihugo on 2021/4/7.
+//
+
+import os.log
+import UIKit
+import Combine
+import ActiveLabel
+import CoreData
+import CoreDataStack
+import AlamofireImage
+import Kingfisher
+
+// iTODO: when to ask permission to Use Notifications
+
+class SettingsViewController: UIViewController, NeedsDependency {
+
+ weak var context: AppContext! { willSet { precondition(!isViewLoaded) } }
+ weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } }
+
+ var viewModel: SettingsViewModel! { willSet { precondition(!isViewLoaded) } }
+ var disposeBag = Set()
+
+ var triggerMenu: UIMenu {
+ let anyone = L10n.Scene.Settings.Section.Notifications.Trigger.anyone
+ let follower = L10n.Scene.Settings.Section.Notifications.Trigger.follower
+ let follow = L10n.Scene.Settings.Section.Notifications.Trigger.follow
+ let noOne = L10n.Scene.Settings.Section.Notifications.Trigger.noone
+ let menu = UIMenu(
+ image: nil,
+ identifier: nil,
+ options: .displayInline,
+ children: [
+ UIAction(title: anyone, image: UIImage(systemName: "person.3"), attributes: []) { [weak self] action in
+ self?.updateTrigger(by: anyone)
+ },
+ UIAction(title: follower, image: UIImage(systemName: "person.crop.circle.badge.plus"), attributes: []) { [weak self] action in
+ self?.updateTrigger(by: follower)
+ },
+ UIAction(title: follow, image: UIImage(systemName: "person.crop.circle.badge.checkmark"), attributes: []) { [weak self] action in
+ self?.updateTrigger(by: follow)
+ },
+ UIAction(title: noOne, image: UIImage(systemName: "nosign"), attributes: []) { [weak self] action in
+ self?.updateTrigger(by: noOne)
+ },
+ ]
+ )
+ return menu
+ }
+
+ lazy var notifySectionHeader: UIView = {
+ let view = UIStackView()
+ view.translatesAutoresizingMaskIntoConstraints = false
+ view.isLayoutMarginsRelativeArrangement = true
+ view.layoutMargins = UIEdgeInsets(top: 15, left: 4, bottom: 5, right: 4)
+ view.axis = .horizontal
+ view.alignment = .fill
+ view.distribution = .equalSpacing
+ view.spacing = 4
+
+ let notifyLabel = UILabel()
+ notifyLabel.translatesAutoresizingMaskIntoConstraints = false
+ notifyLabel.font = UIFontMetrics(forTextStyle: .title3).scaledFont(for: UIFont.systemFont(ofSize: 20, weight: .semibold))
+ notifyLabel.textColor = Asset.Colors.Label.primary.color
+ notifyLabel.text = L10n.Scene.Settings.Section.Notifications.Trigger.title
+ view.addArrangedSubview(notifyLabel)
+ view.addArrangedSubview(whoButton)
+ return view
+ }()
+
+ lazy var whoButton: UIButton = {
+ let whoButton = UIButton(type: .roundedRect)
+ whoButton.menu = triggerMenu
+ whoButton.showsMenuAsPrimaryAction = true
+ whoButton.setBackgroundColor(Asset.Colors.battleshipGrey.color, for: .normal)
+ whoButton.setTitleColor(Asset.Colors.Label.primary.color, for: .normal)
+ if let setting = self.viewModel.setting.value, let trigger = setting.triggerBy {
+ whoButton.setTitle(trigger, for: .normal)
+ }
+ whoButton.titleLabel?.font = UIFontMetrics(forTextStyle: .title3).scaledFont(for: UIFont.systemFont(ofSize: 20, weight: .semibold))
+ whoButton.contentEdgeInsets = UIEdgeInsets(top: 5, left: 5, bottom: 5, right: 5)
+ whoButton.layer.cornerRadius = 10
+ whoButton.clipsToBounds = true
+ return whoButton
+ }()
+
+ lazy var tableView: UITableView = {
+ // init with a frame to fix a conflict ('UIView-Encapsulated-Layout-Width' UIStackView:0x7f8c2b6c0590.width == 0)
+ let tableView = UITableView(frame: CGRect(x: 0, y: 0, width: 320, height: 320), style: .grouped)
+ tableView.translatesAutoresizingMaskIntoConstraints = false
+ tableView.delegate = self
+ tableView.rowHeight = UITableView.automaticDimension
+ tableView.backgroundColor = Asset.Colors.Background.systemGroupedBackground.color
+
+ tableView.register(SettingsAppearanceTableViewCell.self, forCellReuseIdentifier: "SettingsAppearanceTableViewCell")
+ tableView.register(SettingsToggleTableViewCell.self, forCellReuseIdentifier: "SettingsToggleTableViewCell")
+ tableView.register(SettingsLinkTableViewCell.self, forCellReuseIdentifier: "SettingsLinkTableViewCell")
+ return tableView
+ }()
+
+ lazy var footerView: UIView = {
+ // init with a frame to fix a conflict ('UIView-Encapsulated-Layout-Height' UIStackView:0x7ffe41e47da0.height == 0)
+ let view = UIStackView(frame: CGRect(x: 0, y: 0, width: 320, height: 320))
+ view.isLayoutMarginsRelativeArrangement = true
+ view.layoutMargins = UIEdgeInsets(top: 20, left: 20, bottom: 20, right: 20)
+ view.axis = .vertical
+ view.alignment = .center
+
+ let label = ActiveLabel(style: .default)
+ label.textAlignment = .center
+ label.configure(content: "Mastodon is open source software. You can contribute or report issues on GitHub at tootsuite/mastodon (v3.3.0).")
+ label.delegate = self
+
+ view.addArrangedSubview(label)
+ return view
+ }()
+
+ override func viewDidLoad() {
+ super.viewDidLoad()
+
+ setupView()
+ bindViewModel()
+
+ viewModel.viewDidLoad.send()
+ }
+
+ override func viewDidLayoutSubviews() {
+ super.viewDidLayoutSubviews()
+ guard let footerView = self.tableView.tableFooterView else {
+ return
+ }
+
+ let width = self.tableView.bounds.size.width
+ let size = footerView.systemLayoutSizeFitting(CGSize(width: width, height: UIView.layoutFittingCompressedSize.height))
+ if footerView.frame.size.height != size.height {
+ footerView.frame.size.height = size.height
+ self.tableView.tableFooterView = footerView
+ }
+ }
+
+ // MAKR: - Private methods
+ private func bindViewModel() {
+ let input = SettingsViewModel.Input()
+ _ = viewModel.transform(input: input)
+ }
+
+ private func setupView() {
+ view.backgroundColor = Asset.Colors.Background.systemGroupedBackground.color
+ setupNavigation()
+ setupTableView()
+
+ view.addSubview(tableView)
+ NSLayoutConstraint.activate([
+ tableView.topAnchor.constraint(equalTo: view.topAnchor),
+ tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
+ tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
+ tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
+ ])
+ }
+
+ private func setupNavigation() {
+ navigationController?.navigationBar.prefersLargeTitles = true
+ navigationItem.rightBarButtonItem
+ = UIBarButtonItem(barButtonSystemItem: UIBarButtonItem.SystemItem.done,
+ target: self,
+ action: #selector(doneButtonDidClick))
+ navigationItem.title = L10n.Scene.Settings.title
+
+ let barAppearance = UINavigationBarAppearance()
+ barAppearance.configureWithDefaultBackground()
+ navigationItem.standardAppearance = barAppearance
+ navigationItem.compactAppearance = barAppearance
+ navigationItem.scrollEdgeAppearance = barAppearance
+ }
+
+ private func setupTableView() {
+ viewModel.dataSource = UITableViewDiffableDataSource(tableView: tableView, cellProvider: { [weak self] (tableView, indexPath, item) -> UITableViewCell? in
+ guard let self = self else { return nil }
+
+ switch item {
+ case .apperance(let item):
+ guard let cell = tableView.dequeueReusableCell(withIdentifier: "SettingsAppearanceTableViewCell") as? SettingsAppearanceTableViewCell else {
+ assertionFailure()
+ return nil
+ }
+ cell.update(with: item, delegate: self)
+ return cell
+ case .notification(let item):
+ guard let cell = tableView.dequeueReusableCell(withIdentifier: "SettingsToggleTableViewCell") as? SettingsToggleTableViewCell else {
+ assertionFailure()
+ return nil
+ }
+ cell.update(with: item, delegate: self)
+ return cell
+ case .boringZone(let item), .spicyZone(let item):
+ guard let cell = tableView.dequeueReusableCell(withIdentifier: "SettingsLinkTableViewCell") as? SettingsLinkTableViewCell else {
+ assertionFailure()
+ return nil
+ }
+ cell.update(with: item)
+ return cell
+ }
+ })
+
+ tableView.tableFooterView = footerView
+ }
+
+ func alertToSignout() {
+ let alertController = UIAlertController(
+ title: L10n.Common.Alerts.SignOut.title,
+ message: L10n.Common.Alerts.SignOut.message,
+ preferredStyle: .alert
+ )
+
+ let cancelAction = UIAlertAction(title: L10n.Common.Controls.Actions.cancel, style: .cancel, handler: nil)
+ let signOutAction = UIAlertAction(title: L10n.Common.Alerts.SignOut.confirm, style: .destructive) { [weak self] _ in
+ guard let self = self else { return }
+ self.signout()
+ }
+ alertController.addAction(cancelAction)
+ alertController.addAction(signOutAction)
+ self.coordinator.present(
+ scene: .alertController(alertController: alertController),
+ from: self,
+ transition: .alertController(animated: true, completion: nil)
+ )
+ }
+
+ func signout() {
+ guard let activeMastodonAuthenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else {
+ return
+ }
+
+ context.authenticationService.signOutMastodonUser(
+ domain: activeMastodonAuthenticationBox.domain,
+ userID: activeMastodonAuthenticationBox.userID
+ )
+ .receive(on: DispatchQueue.main)
+ .sink { [weak self] result in
+ guard let self = self else { return }
+ switch result {
+ case .failure(let error):
+ assertionFailure(error.localizedDescription)
+ case .success(let isSignOut):
+ os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: sign out %s", ((#file as NSString).lastPathComponent), #line, #function, isSignOut ? "success" : "fail")
+ guard isSignOut else { return }
+ self.coordinator.setup()
+ self.coordinator.setupOnboardingIfNeeds(animated: true)
+ }
+ }
+ .store(in: &disposeBag)
+ }
+
+ deinit {
+ os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s:", ((#file as NSString).lastPathComponent), #line, #function)
+ }
+
+ // Mark: - Actions
+ @objc func doneButtonDidClick() {
+ dismiss(animated: true, completion: nil)
+ }
+}
+
+extension SettingsViewController: UITableViewDelegate {
+ func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? {
+ let sections = viewModel.dataSource.snapshot().sectionIdentifiers
+ guard section < sections.count else { return nil }
+ let sectionData = sections[section]
+
+ if section == 1 {
+ let header = SettingsSectionHeader(
+ frame: CGRect(x: 0, y: 0, width: 375, height: 66),
+ customView: notifySectionHeader)
+ header.update(title: sectionData.title)
+
+ if let setting = self.viewModel.setting.value, let trigger = setting.triggerBy {
+ whoButton.setTitle(trigger, for: .normal)
+ } else {
+ let anyone = L10n.Scene.Settings.Section.Notifications.Trigger.anyone
+ whoButton.setTitle(anyone, for: .normal)
+ }
+ return header
+ } else {
+ let header = SettingsSectionHeader(frame: CGRect(x: 0, y: 0, width: 375, height: 66))
+ header.update(title: sectionData.title)
+ return header
+ }
+ }
+
+ // remove the gap of table's footer
+ func tableView(_ tableView: UITableView, viewForFooterInSection section: Int) -> UIView? {
+ return UIView()
+ }
+
+ // remove the gap of table's footer
+ func tableView(_ tableView: UITableView, heightForFooterInSection section: Int) -> CGFloat {
+ return 0
+ }
+
+ func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
+ let snapshot = self.viewModel.dataSource.snapshot()
+ let sectionIds = snapshot.sectionIdentifiers
+ guard indexPath.section < sectionIds.count else { return }
+ let sectionIdentifier = sectionIds[indexPath.section]
+ let items = snapshot.itemIdentifiers(inSection: sectionIdentifier)
+ guard indexPath.row < items.count else { return }
+ let item = items[indexPath.item]
+
+ switch item {
+ case .boringZone:
+ guard let url = viewModel.privacyURL else { break }
+ coordinator.present(
+ scene: .safari(url: url),
+ from: self,
+ transition: .safariPresent(animated: true, completion: nil)
+ )
+ case .spicyZone(let link):
+ // clear media cache
+ if link.title == L10n.Scene.Settings.Section.Spicyzone.clear {
+ // clean image cache for AlamofireImage
+ let diskBytes = ImageDownloader.defaultURLCache().currentDiskUsage
+ os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: diskBytes %d", ((#file as NSString).lastPathComponent), #line, #function, diskBytes)
+ os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: clean image cache", ((#file as NSString).lastPathComponent), #line, #function)
+ ImageDownloader.defaultURLCache().removeAllCachedResponses()
+ let cleanedDiskBytes = ImageDownloader.defaultURLCache().currentDiskUsage
+ os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: diskBytes %d", ((#file as NSString).lastPathComponent), #line, #function, cleanedDiskBytes)
+
+ // clean Kingfisher Cache
+ KingfisherManager.shared.cache.clearDiskCache()
+ }
+ // logout
+ if link.title == L10n.Scene.Settings.Section.Spicyzone.signout {
+ alertToSignout()
+ }
+ default:
+ break
+ }
+ }
+}
+
+// Update setting into core data
+extension SettingsViewController {
+ func updateTrigger(by who: String) {
+ guard self.viewModel.triggerBy != who else { return }
+ guard let setting = self.viewModel.setting.value else { return }
+
+ setting.update(triggerBy: who)
+ // trigger to call `subscription` API with POST method
+ // confirm the local data is correct even if request failed
+ // The asynchronous execution is to solve the problem of dropped frames for animations.
+ DispatchQueue.main.async { [weak self] in
+ self?.viewModel.setting.value = setting
+ }
+ }
+
+ func updateAlert(title: String?, isOn: Bool) {
+ guard let title = title else { return }
+ guard let settings = self.viewModel.setting.value else { return }
+ guard let triggerBy = settings.triggerBy else { return }
+
+ if let alerts = settings.subscription?.first(where: { (s) -> Bool in
+ return s.type == settings.triggerBy
+ })?.alert {
+ var alertValues = [Bool?]()
+ alertValues.append(alerts.favourite?.boolValue)
+ alertValues.append(alerts.follow?.boolValue)
+ alertValues.append(alerts.reblog?.boolValue)
+ alertValues.append(alerts.mention?.boolValue)
+
+ // need to update `alerts` to make update API with correct parameter
+ switch title {
+ case L10n.Scene.Settings.Section.Notifications.favorites:
+ alertValues[0] = isOn
+ alerts.favourite = NSNumber(booleanLiteral: isOn)
+ case L10n.Scene.Settings.Section.Notifications.follows:
+ alertValues[1] = isOn
+ alerts.follow = NSNumber(booleanLiteral: isOn)
+ case L10n.Scene.Settings.Section.Notifications.boosts:
+ alertValues[2] = isOn
+ alerts.reblog = NSNumber(booleanLiteral: isOn)
+ case L10n.Scene.Settings.Section.Notifications.mentions:
+ alertValues[3] = isOn
+ alerts.mention = NSNumber(booleanLiteral: isOn)
+ default: break
+ }
+ self.viewModel.updateSubscriptionSubject.send((triggerBy: triggerBy, values: alertValues))
+ } else if let alertValues = self.viewModel.notificationDefaultValue[triggerBy] {
+ self.viewModel.updateSubscriptionSubject.send((triggerBy: triggerBy, values: alertValues))
+ }
+ }
+}
+
+extension SettingsViewController: SettingsAppearanceTableViewCellDelegate {
+ func settingsAppearanceCell(_ view: SettingsAppearanceTableViewCell, didSelect: SettingsItem.AppearanceMode) {
+ guard let setting = self.viewModel.setting.value else { return }
+
+ context.managedObjectContext.performChanges {
+ setting.update(appearance: didSelect.rawValue)
+ }
+ .sink { (_) in
+ // change light / dark mode
+ var overrideUserInterfaceStyle: UIUserInterfaceStyle!
+ switch didSelect {
+ case .automatic:
+ overrideUserInterfaceStyle = .unspecified
+ case .light:
+ overrideUserInterfaceStyle = .light
+ case .dark:
+ overrideUserInterfaceStyle = .dark
+ }
+ view.window?.overrideUserInterfaceStyle = overrideUserInterfaceStyle
+ }.store(in: &disposeBag)
+ }
+}
+
+extension SettingsViewController: SettingsToggleCellDelegate {
+ func settingsToggleCell(_ cell: SettingsToggleTableViewCell, didChangeStatus: Bool) {
+ updateAlert(title: cell.data?.title, isOn: didChangeStatus)
+ }
+}
+
+extension SettingsViewController: ActiveLabelDelegate {
+ func activeLabel(_ activeLabel: ActiveLabel, didSelectActiveEntity entity: ActiveEntity) {
+ coordinator.present(
+ scene: .safari(url: URL(string: "https://github.com/tootsuite/mastodon")!),
+ from: self,
+ transition: .safariPresent(animated: true, completion: nil)
+ )
+ }
+}
+
+extension SettingsViewController {
+ static func updateOverrideUserInterfaceStyle(window: UIWindow?) {
+ guard let box = AppContext.shared.authenticationService.activeMastodonAuthenticationBox.value else {
+ return
+ }
+
+ guard let setting: Setting? = {
+ let domain = box.domain
+ let request = Setting.sortedFetchRequest
+ request.predicate = Setting.predicate(domain: domain, userID: box.userID)
+ request.fetchLimit = 1
+ request.returnsObjectsAsFaults = false
+ do {
+ return try AppContext.shared.managedObjectContext.fetch(request).first
+ } catch {
+ assertionFailure(error.localizedDescription)
+ return nil
+ }
+ }() else { return }
+
+ guard let didSelect = SettingsItem.AppearanceMode(rawValue: setting?.appearance ?? "") else {
+ return
+ }
+
+ var overrideUserInterfaceStyle: UIUserInterfaceStyle!
+ switch didSelect {
+ case .automatic:
+ overrideUserInterfaceStyle = .unspecified
+ case .light:
+ overrideUserInterfaceStyle = .light
+ case .dark:
+ overrideUserInterfaceStyle = .dark
+ }
+ window?.overrideUserInterfaceStyle = overrideUserInterfaceStyle
+ }
+}
+
+#if canImport(SwiftUI) && DEBUG
+import SwiftUI
+
+struct SettingsViewController_Previews: PreviewProvider {
+
+ static var previews: some View {
+ Group {
+ UIViewControllerPreview { () -> UIViewController in
+ return SettingsViewController()
+ }
+ .previewLayout(.fixed(width: 390, height: 844))
+ }
+ }
+
+}
+
+#endif
diff --git a/Mastodon/Scene/Settings/SettingsViewModel.swift b/Mastodon/Scene/Settings/SettingsViewModel.swift
new file mode 100644
index 00000000..470617ae
--- /dev/null
+++ b/Mastodon/Scene/Settings/SettingsViewModel.swift
@@ -0,0 +1,393 @@
+//
+// SettingsViewModel.swift
+// Mastodon
+//
+// Created by ihugo on 2021/4/7.
+//
+
+import Combine
+import CoreData
+import CoreDataStack
+import Foundation
+import MastodonSDK
+import UIKit
+import os.log
+
+class SettingsViewModel: NSObject, NeedsDependency {
+ // confirm set only once
+ weak var context: AppContext! { willSet { precondition(context == nil) } }
+ weak var coordinator: SceneCoordinator! { willSet { precondition(coordinator == nil) } }
+
+ var dataSource: UITableViewDiffableDataSource!
+ var disposeBag = Set()
+ var updateDisposeBag = Set()
+ var createDisposeBag = Set()
+
+ let viewDidLoad = PassthroughSubject()
+ lazy var fetchResultsController: NSFetchedResultsController = {
+ let fetchRequest = Setting.sortedFetchRequest
+ if let box =
+ self.context.authenticationService.activeMastodonAuthenticationBox.value {
+ let domain = box.domain
+ fetchRequest.predicate = Setting.predicate(domain: domain, userID: box.userID)
+ }
+
+ fetchRequest.fetchLimit = 1
+ fetchRequest.returnsObjectsAsFaults = false
+ let controller = NSFetchedResultsController(
+ fetchRequest: fetchRequest,
+ managedObjectContext: context.managedObjectContext,
+ sectionNameKeyPath: nil,
+ cacheName: nil
+ )
+ controller.delegate = self
+ return controller
+ }()
+ let setting = CurrentValueSubject(nil)
+
+ /// create a subscription when:
+ /// - does not has one
+ /// - does not find subscription for selected trigger when change trigger
+ let createSubscriptionSubject = PassthroughSubject<(triggerBy: String, values: [Bool?]), Never>()
+
+ /// update a subscription when:
+ /// - change switch for specified alerts
+ let updateSubscriptionSubject = PassthroughSubject<(triggerBy: String, values: [Bool?]), Never>()
+
+ lazy var notificationDefaultValue: [String: [Bool?]] = {
+ let followerSwitchItems: [Bool?] = [true, nil, true, true]
+ let anyoneSwitchItems: [Bool?] = [true, true, true, true]
+ let noOneSwitchItems: [Bool?] = [nil, nil, nil, nil]
+ let followSwitchItems: [Bool?] = [true, true, true, true]
+
+ let anyone = L10n.Scene.Settings.Section.Notifications.Trigger.anyone
+ let follower = L10n.Scene.Settings.Section.Notifications.Trigger.follower
+ let follow = L10n.Scene.Settings.Section.Notifications.Trigger.follow
+ let noOne = L10n.Scene.Settings.Section.Notifications.Trigger.noone
+ return [anyone: anyoneSwitchItems,
+ follower: followerSwitchItems,
+ follow: followSwitchItems,
+ noOne: noOneSwitchItems]
+ }()
+
+ lazy var privacyURL: URL? = {
+ guard let box = AppContext.shared.authenticationService.activeMastodonAuthenticationBox.value else {
+ return nil
+ }
+
+ return Mastodon.API.privacyURL(domain: box.domain)
+ }()
+
+ /// to store who trigger the notification.
+ var triggerBy: String?
+
+ struct Input {
+ }
+
+ struct Output {
+ }
+
+ init(context: AppContext, coordinator: SceneCoordinator) {
+ self.context = context
+ self.coordinator = coordinator
+
+ super.init()
+ }
+
+ func transform(input: Input?) -> Output? {
+ typealias SubscriptionResponse = Mastodon.Response.Content
+ createSubscriptionSubject
+ .debounce(for: .milliseconds(300), scheduler: DispatchQueue.main)
+ .sink { _ in
+ } receiveValue: { [weak self] (arg) in
+ let (triggerBy, values) = arg
+ guard let self = self else {
+ return
+ }
+ guard let activeMastodonAuthenticationBox =
+ self.context.authenticationService.activeMastodonAuthenticationBox.value else {
+ return
+ }
+ guard values.count >= 4 else {
+ return
+ }
+
+ self.createDisposeBag.removeAll()
+ typealias Query = Mastodon.API.Subscriptions.CreateSubscriptionQuery
+ let domain = activeMastodonAuthenticationBox.domain
+ let query = Query(
+ // FIXME: to replace the correct endpoint, p256dh, auth
+ endpoint: "http://www.google.com",
+ p256dh: "BLQELIDm-6b9Bl07YrEuXJ4BL_YBVQ0dvt9NQGGJxIQidJWHPNa9YrouvcQ9d7_MqzvGS9Alz60SZNCG3qfpk=",
+ auth: "4vQK-SvRAN5eo-8ASlrwA==",
+ favourite: values[0],
+ follow: values[1],
+ reblog: values[2],
+ mention: values[3],
+ poll: nil
+ )
+ self.context.apiService.changeSubscription(
+ domain: domain,
+ mastodonAuthenticationBox: activeMastodonAuthenticationBox,
+ query: query,
+ triggerBy: triggerBy,
+ userID: activeMastodonAuthenticationBox.userID
+ )
+ .sink { (_) in
+ } receiveValue: { (_) in
+ }
+ .store(in: &self.createDisposeBag)
+ }
+ .store(in: &disposeBag)
+
+ updateSubscriptionSubject
+ .debounce(for: .milliseconds(300), scheduler: DispatchQueue.main)
+ .sink { _ in
+ } receiveValue: { [weak self] (arg) in
+ let (triggerBy, values) = arg
+ guard let self = self else {
+ return
+ }
+ guard let activeMastodonAuthenticationBox =
+ self.context.authenticationService.activeMastodonAuthenticationBox.value else {
+ return
+ }
+ guard values.count >= 4 else {
+ return
+ }
+
+ self.updateDisposeBag.removeAll()
+ typealias Query = Mastodon.API.Subscriptions.UpdateSubscriptionQuery
+ let domain = activeMastodonAuthenticationBox.domain
+ let query = Query(
+ favourite: values[0],
+ follow: values[1],
+ reblog: values[2],
+ mention: values[3],
+ poll: nil)
+ self.context.apiService.updateSubscription(
+ domain: domain,
+ mastodonAuthenticationBox: activeMastodonAuthenticationBox,
+ query: query,
+ triggerBy: triggerBy,
+ userID: activeMastodonAuthenticationBox.userID
+ )
+ .sink { (_) in
+ } receiveValue: { (_) in
+ }
+ .store(in: &self.updateDisposeBag)
+ }
+ .store(in: &disposeBag)
+
+ // build data for table view
+ buildDataSource()
+
+ // request subsription data for updating or initialization
+ requestSubscription()
+ return nil
+ }
+
+ // MARK: - Private methods
+ fileprivate func processDataSource(_ settings: Setting?) {
+ var snapshot = NSDiffableDataSourceSnapshot()
+
+ // appearance
+ let appearnceMode = SettingsItem.AppearanceMode(rawValue: settings?.appearance ?? "") ?? .automatic
+ let appearanceItem = SettingsItem.apperance(item: appearnceMode)
+ let appearance = SettingsSection.apperance(title: L10n.Scene.Settings.Section.Appearance.title, selectedMode:appearanceItem)
+ snapshot.appendSections([appearance])
+ snapshot.appendItems([appearanceItem])
+
+ // notifications
+ var switches: [Bool?]?
+ if let alerts = settings?.subscription?.first(where: { (s) -> Bool in
+ return s.type == settings?.triggerBy
+ })?.alert {
+ var items = [Bool?]()
+ items.append(alerts.favourite?.boolValue)
+ items.append(alerts.follow?.boolValue)
+ items.append(alerts.reblog?.boolValue)
+ items.append(alerts.mention?.boolValue)
+ switches = items
+ } else if let triggerBy = settings?.triggerBy,
+ let values = self.notificationDefaultValue[triggerBy] {
+ switches = values
+ } else {
+ // fallback a default value
+ let anyone = L10n.Scene.Settings.Section.Notifications.Trigger.anyone
+ switches = self.notificationDefaultValue[anyone]
+ }
+
+ let notifications = [L10n.Scene.Settings.Section.Notifications.favorites,
+ L10n.Scene.Settings.Section.Notifications.follows,
+ L10n.Scene.Settings.Section.Notifications.boosts,
+ L10n.Scene.Settings.Section.Notifications.mentions,]
+ var notificationItems = [SettingsItem]()
+ for (i, noti) in notifications.enumerated() {
+ var value: Bool? = nil
+ if let switches = switches, i < switches.count {
+ value = switches[i]
+ }
+
+ let item = SettingsItem.notification(item: SettingsItem.NotificationSwitch(title: noti, isOn: value == true, enable: value != nil))
+ notificationItems.append(item)
+ }
+ let notificationSection = SettingsSection.notifications(title: L10n.Scene.Settings.Section.Notifications.title, items: notificationItems)
+ snapshot.appendSections([notificationSection])
+ snapshot.appendItems(notificationItems)
+
+ // boring zone
+ let boringLinks = [L10n.Scene.Settings.Section.Boringzone.terms,
+ L10n.Scene.Settings.Section.Boringzone.privacy]
+ var boringLinkItems = [SettingsItem]()
+ for l in boringLinks {
+ let item = SettingsItem.boringZone(item: SettingsItem.Link(title: l, color: .systemBlue))
+ boringLinkItems.append(item)
+ }
+ let boringSection = SettingsSection.boringZone(title: L10n.Scene.Settings.Section.Boringzone.title, items: boringLinkItems)
+ snapshot.appendSections([boringSection])
+ snapshot.appendItems(boringLinkItems)
+
+ // spicy zone
+ let spicyLinks = [L10n.Scene.Settings.Section.Spicyzone.clear,
+ L10n.Scene.Settings.Section.Spicyzone.signout]
+ var spicyLinkItems = [SettingsItem]()
+ for l in spicyLinks {
+ let item = SettingsItem.spicyZone(item: SettingsItem.Link(title: l, color: .systemRed))
+ spicyLinkItems.append(item)
+ }
+ let spicySection = SettingsSection.spicyZone(title: L10n.Scene.Settings.Section.Spicyzone.title, items: spicyLinkItems)
+ snapshot.appendSections([spicySection])
+ snapshot.appendItems(spicyLinkItems)
+
+ self.dataSource.apply(snapshot, animatingDifferences: false)
+ }
+
+ private func buildDataSource() {
+ setting.sink { [weak self] (settings) in
+ guard let self = self else { return }
+ self.processDataSource(settings)
+ }
+ .store(in: &disposeBag)
+ }
+
+ private func requestSubscription() {
+ setting.sink { [weak self] (settings) in
+ guard let self = self else { return }
+ guard settings != nil else { return }
+ guard self.triggerBy != settings?.triggerBy else { return }
+ self.triggerBy = settings?.triggerBy
+
+ var switches: [Bool?]?
+ var who: String?
+ if let alerts = settings?.subscription?.first(where: { (s) -> Bool in
+ return s.type == settings?.triggerBy
+ })?.alert {
+ var items = [Bool?]()
+ items.append(alerts.favourite?.boolValue)
+ items.append(alerts.follow?.boolValue)
+ items.append(alerts.reblog?.boolValue)
+ items.append(alerts.mention?.boolValue)
+ switches = items
+ who = settings?.triggerBy
+ } else if let triggerBy = settings?.triggerBy,
+ let values = self.notificationDefaultValue[triggerBy] {
+ switches = values
+ who = triggerBy
+ } else {
+ // fallback a default value
+ let anyone = L10n.Scene.Settings.Section.Notifications.Trigger.anyone
+ switches = self.notificationDefaultValue[anyone]
+ who = anyone
+ }
+
+ // should create a subscription whenever change trigger
+ if let values = switches, let triggerBy = who {
+ self.createSubscriptionSubject.send((triggerBy: triggerBy, values: values))
+ }
+ }
+ .store(in: &disposeBag)
+
+ guard let activeMastodonAuthenticationBox = self.context.authenticationService.activeMastodonAuthenticationBox.value else {
+ return
+ }
+ let domain = activeMastodonAuthenticationBox.domain
+ let userId = activeMastodonAuthenticationBox.userID
+
+ do {
+ try fetchResultsController.performFetch()
+ if nil == fetchResultsController.fetchedObjects?.first {
+ let anyone = L10n.Scene.Settings.Section.Notifications.Trigger.anyone
+ setting.value = self.context.apiService.createSettingIfNeed(domain: domain,
+ userId: userId,
+ triggerBy: anyone)
+ } else {
+ setting.value = fetchResultsController.fetchedObjects?.first
+ }
+ } catch {
+ assertionFailure(error.localizedDescription)
+ }
+ }
+
+ deinit {
+ os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s:", ((#file as NSString).lastPathComponent), #line, #function)
+ }
+}
+
+// MARK: - NSFetchedResultsControllerDelegate
+extension SettingsViewModel: NSFetchedResultsControllerDelegate {
+
+ func controllerWillChangeContent(_ controller: NSFetchedResultsController) {
+ os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
+ }
+
+ func controllerDidChangeContent(_ controller: NSFetchedResultsController) {
+ guard controller === fetchResultsController else {
+ return
+ }
+
+ setting.value = fetchResultsController.fetchedObjects?.first
+ }
+
+}
+
+enum SettingsSection: Hashable {
+ case apperance(title: String, selectedMode: SettingsItem)
+ case notifications(title: String, items: [SettingsItem])
+ case boringZone(title: String, items: [SettingsItem])
+ case spicyZone(title: String, items: [SettingsItem])
+
+ var title: String {
+ switch self {
+ case .apperance(let title, _),
+ .notifications(let title, _),
+ .boringZone(let title, _),
+ .spicyZone(let title, _):
+ return title
+ }
+ }
+}
+
+enum SettingsItem: Hashable {
+ enum AppearanceMode: String {
+ case automatic
+ case light
+ case dark
+ }
+
+ struct NotificationSwitch: Hashable {
+ let title: String
+ let isOn: Bool
+ let enable: Bool
+ }
+
+ struct Link: Hashable {
+ let title: String
+ let color: UIColor
+ }
+
+ case apperance(item: AppearanceMode)
+ case notification(item: NotificationSwitch)
+ case boringZone(item: Link)
+ case spicyZone(item: Link)
+}
diff --git a/Mastodon/Scene/Settings/View/Cell/SettingsAppearanceTableViewCell.swift b/Mastodon/Scene/Settings/View/Cell/SettingsAppearanceTableViewCell.swift
new file mode 100644
index 00000000..a477661e
--- /dev/null
+++ b/Mastodon/Scene/Settings/View/Cell/SettingsAppearanceTableViewCell.swift
@@ -0,0 +1,205 @@
+//
+// SettingsAppearanceTableViewCell.swift
+// Mastodon
+//
+// Created by ihugo on 2021/4/8.
+//
+
+import UIKit
+
+protocol SettingsAppearanceTableViewCellDelegate: class {
+ func settingsAppearanceCell(_ view: SettingsAppearanceTableViewCell, didSelect: SettingsItem.AppearanceMode)
+}
+
+class AppearanceView: UIView {
+ lazy var imageView: UIImageView = {
+ let view = UIImageView()
+ return view
+ }()
+ lazy var titleLabel: UILabel = {
+ let label = UILabel()
+ label.font = .systemFont(ofSize: 12, weight: .regular)
+ label.textColor = Asset.Colors.Label.primary.color
+ label.textAlignment = .center
+ return label
+ }()
+ lazy var checkBox: UIButton = {
+ let button = UIButton()
+ button.isUserInteractionEnabled = false
+ button.setImage(UIImage(systemName: "circle"), for: .normal)
+ button.setImage(UIImage(systemName: "checkmark.circle.fill"), for: .selected)
+ button.imageView?.preferredSymbolConfiguration = UIImage.SymbolConfiguration(textStyle: .body)
+ button.imageView?.tintColor = Asset.Colors.Label.secondary.color
+ button.imageView?.contentMode = .scaleAspectFill
+ return button
+ }()
+ lazy var stackView: UIStackView = {
+ let view = UIStackView()
+ view.axis = .vertical
+ view.spacing = 10
+ view.distribution = .equalSpacing
+ return view
+ }()
+
+ var selected: Bool = false {
+ didSet {
+ checkBox.isSelected = selected
+ if selected {
+ checkBox.imageView?.tintColor = Asset.Colors.Label.highlight.color
+ } else {
+ checkBox.imageView?.tintColor = Asset.Colors.Label.secondary.color
+ }
+ }
+ }
+
+ // MARK: - Methods
+ init(image: UIImage?, title: String) {
+ super.init(frame: .zero)
+ setupUI()
+
+ imageView.image = image
+ titleLabel.text = title
+ }
+
+ required init?(coder: NSCoder) {
+ fatalError("init(coder:) has not been implemented")
+ }
+
+ // MARK: - Private methods
+ private func setupUI() {
+ stackView.addArrangedSubview(imageView)
+ stackView.addArrangedSubview(titleLabel)
+ stackView.addArrangedSubview(checkBox)
+
+ addSubview(stackView)
+ translatesAutoresizingMaskIntoConstraints = false
+ stackView.translatesAutoresizingMaskIntoConstraints = false
+ NSLayoutConstraint.activate([
+ stackView.topAnchor.constraint(equalTo: self.topAnchor),
+ stackView.leadingAnchor.constraint(equalTo: self.leadingAnchor),
+ stackView.bottomAnchor.constraint(equalTo: self.bottomAnchor),
+ stackView.trailingAnchor.constraint(equalTo: self.trailingAnchor),
+ imageView.heightAnchor.constraint(equalTo: imageView.widthAnchor, multiplier: 218.0 / 100.0),
+ ])
+ }
+}
+
+class SettingsAppearanceTableViewCell: UITableViewCell {
+ weak var delegate: SettingsAppearanceTableViewCellDelegate?
+ var appearance: SettingsItem.AppearanceMode = .automatic
+
+ lazy var stackView: UIStackView = {
+ let view = UIStackView()
+ view.isLayoutMarginsRelativeArrangement = true
+ view.layoutMargins = UIEdgeInsets(top: 0, left: 20, bottom: 0, right: 20)
+ view.axis = .horizontal
+ view.distribution = .fillEqually
+ view.spacing = 18
+ view.translatesAutoresizingMaskIntoConstraints = false
+ return view
+ }()
+
+ let automatic = AppearanceView(image: Asset.Settings.appearanceAutomatic.image,
+ title: L10n.Scene.Settings.Section.Appearance.automatic)
+ let light = AppearanceView(image: Asset.Settings.appearanceLight.image,
+ title: L10n.Scene.Settings.Section.Appearance.light)
+ let dark = AppearanceView(image: Asset.Settings.appearanceDark.image,
+ title: L10n.Scene.Settings.Section.Appearance.dark)
+
+ lazy var automaticTap: UITapGestureRecognizer = {
+ let tapGestureRecognizer = UITapGestureRecognizer.singleTapGestureRecognizer
+ tapGestureRecognizer.addTarget(self, action: #selector(appearanceDidTap(sender:)))
+ return tapGestureRecognizer
+ }()
+
+ lazy var lightTap: UITapGestureRecognizer = {
+ let tapGestureRecognizer = UITapGestureRecognizer.singleTapGestureRecognizer
+ tapGestureRecognizer.addTarget(self, action: #selector(appearanceDidTap(sender:)))
+ return tapGestureRecognizer
+ }()
+
+ lazy var darkTap: UITapGestureRecognizer = {
+ let tapGestureRecognizer = UITapGestureRecognizer.singleTapGestureRecognizer
+ tapGestureRecognizer.addTarget(self, action: #selector(appearanceDidTap(sender:)))
+ return tapGestureRecognizer
+ }()
+
+ // MARK: - Methods
+ override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
+ super.init(style: style, reuseIdentifier: reuseIdentifier)
+ setupUI()
+ }
+
+ required init?(coder: NSCoder) {
+ fatalError("init(coder:) has not been implemented")
+ }
+
+ override func layoutSubviews() {
+ super.layoutSubviews()
+
+ // remove seperator line in section of group tableview
+ for subview in self.subviews {
+ if subview != self.contentView && subview.frame.width == self.frame.width {
+ subview.removeFromSuperview()
+ }
+ }
+ }
+
+ func update(with data: SettingsItem.AppearanceMode, delegate: SettingsAppearanceTableViewCellDelegate?) {
+ appearance = data
+ self.delegate = delegate
+
+ automatic.selected = false
+ light.selected = false
+ dark.selected = false
+
+ switch data {
+ case .automatic:
+ automatic.selected = true
+ case .light:
+ light.selected = true
+ case .dark:
+ dark.selected = true
+ }
+ }
+
+ // MARK: Private methods
+ private func setupUI() {
+ backgroundColor = Asset.Colors.Background.systemGroupedBackground.color
+ selectionStyle = .none
+ contentView.addSubview(stackView)
+
+ stackView.addArrangedSubview(automatic)
+ stackView.addArrangedSubview(light)
+ stackView.addArrangedSubview(dark)
+
+ automatic.addGestureRecognizer(automaticTap)
+ light.addGestureRecognizer(lightTap)
+ dark.addGestureRecognizer(darkTap)
+
+ NSLayoutConstraint.activate([
+ stackView.topAnchor.constraint(equalTo: contentView.topAnchor),
+ stackView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
+ stackView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor),
+ stackView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
+ ])
+ }
+
+ // MARK: - Actions
+ @objc func appearanceDidTap(sender: UIGestureRecognizer) {
+ if sender == automaticTap {
+ appearance = .automatic
+ }
+
+ if sender == lightTap {
+ appearance = .light
+ }
+
+ if sender == darkTap {
+ appearance = .dark
+ }
+
+ guard let delegate = self.delegate else { return }
+ delegate.settingsAppearanceCell(self, didSelect: appearance)
+ }
+}
diff --git a/Mastodon/Scene/Settings/View/Cell/SettingsLinkTableViewCell.swift b/Mastodon/Scene/Settings/View/Cell/SettingsLinkTableViewCell.swift
new file mode 100644
index 00000000..b5d0306d
--- /dev/null
+++ b/Mastodon/Scene/Settings/View/Cell/SettingsLinkTableViewCell.swift
@@ -0,0 +1,31 @@
+//
+// SettingsLinkTableViewCell.swift
+// Mastodon
+//
+// Created by ihugo on 2021/4/8.
+//
+
+import UIKit
+
+class SettingsLinkTableViewCell: UITableViewCell {
+ override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
+ super.init(style: style, reuseIdentifier: reuseIdentifier)
+
+ selectionStyle = .none
+ }
+
+ required init?(coder: NSCoder) {
+ fatalError("init(coder:) has not been implemented")
+ }
+
+ override func setHighlighted(_ highlighted: Bool, animated: Bool) {
+ super.setHighlighted(highlighted, animated: animated)
+ textLabel?.alpha = highlighted ? 0.6 : 1.0
+ }
+
+ // MARK: - Methods
+ func update(with data: SettingsItem.Link) {
+ textLabel?.text = data.title
+ textLabel?.textColor = data.color
+ }
+}
diff --git a/Mastodon/Scene/Settings/View/Cell/SettingsToggleTableViewCell.swift b/Mastodon/Scene/Settings/View/Cell/SettingsToggleTableViewCell.swift
new file mode 100644
index 00000000..b35b2b50
--- /dev/null
+++ b/Mastodon/Scene/Settings/View/Cell/SettingsToggleTableViewCell.swift
@@ -0,0 +1,63 @@
+//
+// SettingsToggleTableViewCell.swift
+// Mastodon
+//
+// Created by ihugo on 2021/4/8.
+//
+
+import UIKit
+
+protocol SettingsToggleCellDelegate: class {
+ func settingsToggleCell(_ cell: SettingsToggleTableViewCell, didChangeStatus: Bool)
+}
+
+class SettingsToggleTableViewCell: UITableViewCell {
+ lazy var switchButton: UISwitch = {
+ let view = UISwitch(frame:.zero)
+ return view
+ }()
+
+ var data: SettingsItem.NotificationSwitch?
+ weak var delegate: SettingsToggleCellDelegate?
+
+ // MARK: - Methods
+ override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
+ super.init(style: .default, reuseIdentifier: reuseIdentifier)
+ setupUI()
+ }
+
+ required init?(coder: NSCoder) {
+ fatalError("init(coder:) has not been implemented")
+ }
+
+ func update(with data: SettingsItem.NotificationSwitch, delegate: SettingsToggleCellDelegate?) {
+ self.delegate = delegate
+ self.data = data
+ textLabel?.text = data.title
+ switchButton.isOn = data.isOn
+ setup(enable: data.enable)
+ }
+
+ // MARK: Actions
+ @objc func valueDidChange(sender: UISwitch) {
+ guard let delegate = delegate else { return }
+ delegate.settingsToggleCell(self, didChangeStatus: sender.isOn)
+ }
+
+ // MARK: Private methods
+ private func setupUI() {
+ selectionStyle = .none
+ accessoryView = switchButton
+
+ switchButton.addTarget(self, action: #selector(valueDidChange(sender:)), for: .valueChanged)
+ }
+
+ private func setup(enable: Bool) {
+ if enable {
+ textLabel?.textColor = Asset.Colors.Label.primary.color
+ } else {
+ textLabel?.textColor = Asset.Colors.Label.secondary.color
+ }
+ switchButton.isEnabled = enable
+ }
+}
diff --git a/Mastodon/Scene/Settings/View/SettingsSectionHeader.swift b/Mastodon/Scene/Settings/View/SettingsSectionHeader.swift
new file mode 100644
index 00000000..ccd7fd87
--- /dev/null
+++ b/Mastodon/Scene/Settings/View/SettingsSectionHeader.swift
@@ -0,0 +1,64 @@
+//
+// SettingsSectionHeader.swift
+// Mastodon
+//
+// Created by ihugo on 2021/4/8.
+//
+
+import UIKit
+
+struct GroupedTableViewConstraints {
+ static let topMargin: CGFloat = 40
+ static let bottomMargin: CGFloat = 10
+}
+
+/// section header which supports add a custom view blelow the title
+class SettingsSectionHeader: UIView {
+ lazy var titleLabel: UILabel = {
+ let label = UILabel()
+ label.translatesAutoresizingMaskIntoConstraints = false
+ label.font = .systemFont(ofSize: 13, weight: .regular)
+ label.textColor = Asset.Colors.Label.secondary.color
+ return label
+ }()
+
+ lazy var stackView: UIStackView = {
+ let view = UIStackView()
+ view.translatesAutoresizingMaskIntoConstraints = false
+ view.isLayoutMarginsRelativeArrangement = true
+ view.layoutMargins = UIEdgeInsets(
+ top: GroupedTableViewConstraints.topMargin,
+ left: 0,
+ bottom: GroupedTableViewConstraints.bottomMargin,
+ right: 0
+ )
+ view.axis = .vertical
+ return view
+ }()
+
+ init(frame: CGRect, customView: UIView? = nil) {
+ super.init(frame: frame)
+
+ backgroundColor = Asset.Colors.Background.systemGroupedBackground.color
+ stackView.addArrangedSubview(titleLabel)
+ if let view = customView {
+ stackView.addArrangedSubview(view)
+ }
+
+ addSubview(stackView)
+ NSLayoutConstraint.activate([
+ stackView.leadingAnchor.constraint(equalTo: self.readableContentGuide.leadingAnchor),
+ stackView.trailingAnchor.constraint(lessThanOrEqualTo: self.readableContentGuide.trailingAnchor),
+ stackView.bottomAnchor.constraint(equalTo: self.bottomAnchor),
+ stackView.topAnchor.constraint(equalTo: self.topAnchor),
+ ])
+ }
+
+ required init?(coder: NSCoder) {
+ fatalError("init(coder:) has not been implemented")
+ }
+
+ func update(title: String?) {
+ titleLabel.text = title?.uppercased()
+ }
+}
diff --git a/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift b/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift
index 7184b767..d546fea6 100644
--- a/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift
+++ b/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift
@@ -47,7 +47,7 @@ extension StatusTableViewCellDelegate {
}
}
-final class StatusTableViewCell: UITableViewCell {
+final class StatusTableViewCell: UITableViewCell, StatusCell {
static let bottomPaddingHeight: CGFloat = 10
diff --git a/Mastodon/Scene/Share/ViewModel/AudioContainerViewModel.swift b/Mastodon/Scene/Share/ViewModel/AudioContainerViewModel.swift
index 56bf0cbc..2bc6db22 100644
--- a/Mastodon/Scene/Share/ViewModel/AudioContainerViewModel.swift
+++ b/Mastodon/Scene/Share/ViewModel/AudioContainerViewModel.swift
@@ -11,7 +11,7 @@ import UIKit
class AudioContainerViewModel {
static func configure(
- cell: StatusTableViewCell,
+ cell: StatusCell,
audioAttachment: Attachment,
audioService: AudioPlaybackService
) {
@@ -51,7 +51,7 @@ class AudioContainerViewModel {
}
static func observePlayer(
- cell: StatusTableViewCell,
+ cell: StatusCell,
audioAttachment: Attachment,
audioService: AudioPlaybackService
) {
diff --git a/Mastodon/Service/APIService/APIService+Notification.swift b/Mastodon/Service/APIService/APIService+Notification.swift
new file mode 100644
index 00000000..ee8f5186
--- /dev/null
+++ b/Mastodon/Service/APIService/APIService+Notification.swift
@@ -0,0 +1,67 @@
+//
+// APIService+Notification.swift
+// Mastodon
+//
+// Created by sxiaojian on 2021/4/13.
+//
+
+import Combine
+import CoreData
+import CoreDataStack
+import Foundation
+import MastodonSDK
+import OSLog
+
+extension APIService {
+ func allNotifications(
+ domain: String,
+ query: Mastodon.API.Notifications.Query,
+ mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox
+ ) -> AnyPublisher, Error> {
+ let authorization = mastodonAuthenticationBox.userAuthorization
+ let userID = mastodonAuthenticationBox.userID
+ return Mastodon.API.Notifications.getNotifications(
+ session: session,
+ domain: domain,
+ query: query,
+ authorization: authorization
+ )
+ .flatMap { response -> AnyPublisher, Error> in
+ let log = OSLog.api
+ return self.backgroundManagedObjectContext.performChanges {
+ 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?
+ if let statusEntity = notification.status {
+ let (statusInCoreData, _, _) = APIService.CoreData.createOrMergeStatus(
+ into: self.backgroundManagedObjectContext,
+ for: nil,
+ domain: domain,
+ entity: statusEntity,
+ statusCache: nil,
+ userCache: nil,
+ networkDate: Date(),
+ log: log
+ )
+ status = statusInCoreData
+ }
+ // use constrain to avoid repeated save
+ let property = MastodonNotification.Property(id: notification.id, typeRaw: notification.type.rawValue, account: mastodonUser, status: status, createdAt: notification.createdAt)
+ let notification = MastodonNotification.insert(into: self.backgroundManagedObjectContext, domain: domain, userID: userID, networkDate: response.networkDate, property: property)
+ os_log(.info, log: log, "%{public}s[%{public}ld], %{public}s: fetch mastodon user [%s](%s)", (#file as NSString).lastPathComponent, #line, #function, notification.typeRaw, notification.account.username)
+ }
+ }
+ .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()
+ }
+}
diff --git a/Mastodon/Service/APIService/APIService+Subscriptions.swift b/Mastodon/Service/APIService/APIService+Subscriptions.swift
new file mode 100644
index 00000000..337ab26d
--- /dev/null
+++ b/Mastodon/Service/APIService/APIService+Subscriptions.swift
@@ -0,0 +1,163 @@
+//
+// APIService+Settings.swift
+// Mastodon
+//
+// Created by ihugo on 2021/4/9.
+//
+
+import Combine
+import CoreData
+import CoreDataStack
+import Foundation
+import MastodonSDK
+
+extension APIService {
+
+ func subscription(
+ domain: String,
+ userID: String,
+ mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox
+ ) -> AnyPublisher, Error> {
+ let authorization = mastodonAuthenticationBox.userAuthorization
+
+ let findSettings: Setting? = {
+ let request = Setting.sortedFetchRequest
+ request.predicate = Setting.predicate(domain: domain, userID: userID)
+ request.fetchLimit = 1
+ request.returnsObjectsAsFaults = false
+ do {
+ return try self.backgroundManagedObjectContext.fetch(request).first
+ } catch {
+ assertionFailure(error.localizedDescription)
+ return nil
+ }
+ }()
+ let triggerBy = findSettings?.triggerBy ?? "anyone"
+ let setting = self.createSettingIfNeed(
+ domain: domain,
+ userId: userID,
+ triggerBy: triggerBy
+ )
+ return Mastodon.API.Subscriptions.subscription(
+ session: session,
+ domain: domain,
+ authorization: authorization
+ )
+ .flatMap { response -> AnyPublisher, Error> in
+ return self.backgroundManagedObjectContext.performChanges {
+ _ = APIService.CoreData.createOrMergeSubscription(
+ into: self.backgroundManagedObjectContext,
+ entity: response.value,
+ domain: domain,
+ triggerBy: triggerBy,
+ setting: setting)
+ }
+ .setFailureType(to: Error.self)
+ .map { _ in return response }
+ .eraseToAnyPublisher()
+ }.eraseToAnyPublisher()
+ }
+
+ func changeSubscription(
+ domain: String,
+ mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox,
+ query: Mastodon.API.Subscriptions.CreateSubscriptionQuery,
+ triggerBy: String,
+ userID: String
+ ) -> AnyPublisher, Error> {
+ let authorization = mastodonAuthenticationBox.userAuthorization
+
+ let setting = self.createSettingIfNeed(domain: domain,
+ userId: userID,
+ triggerBy: triggerBy)
+ return Mastodon.API.Subscriptions.createSubscription(
+ session: session,
+ domain: domain,
+ authorization: authorization,
+ query: query
+ )
+ .flatMap { response -> AnyPublisher, Error> in
+ return self.backgroundManagedObjectContext.performChanges {
+ _ = APIService.CoreData.createOrMergeSubscription(
+ into: self.backgroundManagedObjectContext,
+ entity: response.value,
+ domain: domain,
+ triggerBy: triggerBy,
+ setting: setting
+ )
+ }
+ .setFailureType(to: Error.self)
+ .map { _ in return response }
+ .eraseToAnyPublisher()
+ }.eraseToAnyPublisher()
+ }
+
+ func updateSubscription(
+ domain: String,
+ mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox,
+ query: Mastodon.API.Subscriptions.UpdateSubscriptionQuery,
+ triggerBy: String,
+ userID: String
+ ) -> AnyPublisher, Error> {
+ let authorization = mastodonAuthenticationBox.userAuthorization
+
+ let setting = self.createSettingIfNeed(domain: domain,
+ userId: userID,
+ triggerBy: triggerBy)
+
+ return Mastodon.API.Subscriptions.updateSubscription(
+ session: session,
+ domain: domain,
+ authorization: authorization,
+ query: query
+ )
+ .flatMap { response -> AnyPublisher, Error> in
+ return self.backgroundManagedObjectContext.performChanges {
+ _ = APIService.CoreData.createOrMergeSubscription(
+ into: self.backgroundManagedObjectContext,
+ entity: response.value,
+ domain: domain,
+ triggerBy: triggerBy,
+ setting: setting
+ )
+ }
+ .setFailureType(to: Error.self)
+ .map { _ in return response }
+ .eraseToAnyPublisher()
+ }.eraseToAnyPublisher()
+ }
+
+ func createSettingIfNeed(domain: String, userId: String, triggerBy: String) -> Setting {
+ // create setting entity if possible
+ let oldSetting: Setting? = {
+ let request = Setting.sortedFetchRequest
+ request.predicate = Setting.predicate(domain: domain, userID: userId)
+ request.fetchLimit = 1
+ request.returnsObjectsAsFaults = false
+ do {
+ return try backgroundManagedObjectContext.fetch(request).first
+ } catch {
+ assertionFailure(error.localizedDescription)
+ return nil
+ }
+ }()
+ var setting: Setting!
+ if let oldSetting = oldSetting {
+ setting = oldSetting
+ } else {
+ let property = Setting.Property(
+ appearance: "automatic",
+ triggerBy: triggerBy,
+ domain: domain,
+ userID: userId)
+ (setting, _) = APIService.CoreData.createOrMergeSetting(
+ into: backgroundManagedObjectContext,
+ domain: domain,
+ userID: userId,
+ property: property
+ )
+ }
+ return setting
+ }
+}
+
diff --git a/Mastodon/Service/APIService/CoreData/APIService+CoreData+Subscriptions.swift b/Mastodon/Service/APIService/CoreData/APIService+CoreData+Subscriptions.swift
new file mode 100644
index 00000000..f5a4022e
--- /dev/null
+++ b/Mastodon/Service/APIService/CoreData/APIService+CoreData+Subscriptions.swift
@@ -0,0 +1,108 @@
+//
+// APIService+CoreData+Notification.swift
+// Mastodon
+//
+// Created by ihugo on 2021/4/11.
+//
+
+import os.log
+import Foundation
+import CoreData
+import CoreDataStack
+import MastodonSDK
+
+extension APIService.CoreData {
+
+ static func createOrMergeSetting(
+ into managedObjectContext: NSManagedObjectContext,
+ domain: String,
+ userID: String,
+ property: Setting.Property
+ ) -> (Subscription: Setting, isCreated: Bool) {
+ let oldSetting: Setting? = {
+ let request = Setting.sortedFetchRequest
+ request.predicate = Setting.predicate(domain: property.domain, userID: userID)
+ request.fetchLimit = 1
+ request.returnsObjectsAsFaults = false
+ do {
+ return try managedObjectContext.fetch(request).first
+ } catch {
+ assertionFailure(error.localizedDescription)
+ return nil
+ }
+ }()
+
+ if let oldSetting = oldSetting {
+ return (oldSetting, false)
+ } else {
+ let setting = Setting.insert(
+ into: managedObjectContext,
+ property: property)
+ return (setting, true)
+ }
+ }
+
+ static func createOrMergeSubscription(
+ into managedObjectContext: NSManagedObjectContext,
+ entity: Mastodon.Entity.Subscription,
+ domain: String,
+ triggerBy: String,
+ setting: Setting
+ ) -> (Subscription: Subscription, isCreated: Bool) {
+ let oldSubscription: Subscription? = {
+ let request = Subscription.sortedFetchRequest
+ request.predicate = Subscription.predicate(type: triggerBy)
+ request.fetchLimit = 1
+ request.returnsObjectsAsFaults = false
+ do {
+ return try managedObjectContext.fetch(request).first
+ } catch {
+ assertionFailure(error.localizedDescription)
+ return nil
+ }
+ }()
+
+ let property = Subscription.Property(
+ endpoint: entity.endpoint,
+ id: entity.id,
+ serverKey: entity.serverKey,
+ type: triggerBy
+ )
+ let alertEntity = entity.alerts
+ let alert = SubscriptionAlerts.Property(
+ favourite: alertEntity.favouriteNumber,
+ follow: alertEntity.followNumber,
+ mention: alertEntity.mentionNumber,
+ poll: alertEntity.pollNumber,
+ reblog: alertEntity.reblogNumber
+ )
+ if let oldSubscription = oldSubscription {
+ oldSubscription.updateIfNeed(property: property)
+ if nil == oldSubscription.alert {
+ oldSubscription.alert = SubscriptionAlerts.insert(
+ into: managedObjectContext,
+ property: alert
+ )
+ } else {
+ oldSubscription.alert?.updateIfNeed(property: alert)
+ }
+
+ if oldSubscription.alert?.hasChanges == true || oldSubscription.hasChanges {
+ // don't expand subscription if add existed subscription
+ //setting.mutableSetValue(forKey: #keyPath(Setting.subscription)).add(oldSubscription)
+ oldSubscription.didUpdate(at: Date())
+ }
+ return (oldSubscription, false)
+ } else {
+ let subscription = Subscription.insert(
+ into: managedObjectContext,
+ property: property
+ )
+ subscription.alert = SubscriptionAlerts.insert(
+ into: managedObjectContext,
+ property: alert)
+ setting.mutableSetValue(forKey: #keyPath(Setting.subscription)).add(subscription)
+ return (subscription, true)
+ }
+ }
+}
diff --git a/Mastodon/Supporting Files/SceneDelegate.swift b/Mastodon/Supporting Files/SceneDelegate.swift
index e13395cc..0f5e2bd5 100644
--- a/Mastodon/Supporting Files/SceneDelegate.swift
+++ b/Mastodon/Supporting Files/SceneDelegate.swift
@@ -27,6 +27,9 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
sceneCoordinator.setup()
sceneCoordinator.setupOnboardingIfNeeds(animated: false)
window.makeKeyAndVisible()
+
+ // update `overrideUserInterfaceStyle` with current setting
+ SettingsViewController.updateOverrideUserInterfaceStyle(window: window)
}
func sceneDidDisconnect(_ scene: UIScene) {
diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Notifications.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Notifications.swift
index cdee8292..b0ab13ed 100644
--- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Notifications.swift
+++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Notifications.swift
@@ -1,18 +1,19 @@
//
// File.swift
-//
+//
//
// Created by BradGao on 2021/4/1.
//
-import Foundation
import Combine
+import Foundation
extension Mastodon.API.Notifications {
- static func notificationsEndpointURL(domain: String) -> URL {
- Mastodon.API.endpointV2URL(domain: domain).appendingPathComponent("notifications")
+ internal static func notificationsEndpointURL(domain: String) -> URL {
+ Mastodon.API.endpointURL(domain: domain).appendingPathComponent("notifications")
}
- static func getNotificationEndpointURL(domain: String, notificationID: String) -> URL {
+
+ internal static func getNotificationEndpointURL(domain: String, notificationID: String) -> URL {
notificationsEndpointURL(domain: domain).appendingPathComponent(notificationID)
}
@@ -27,15 +28,15 @@ extension Mastodon.API.Notifications {
/// - Parameters:
/// - session: `URLSession`
/// - domain: Mastodon instance domain. e.g. "example.com"
- /// - query: `GetAllNotificationsQuery` with query parameters
+ /// - query: `NotificationsQuery` with query parameters
/// - authorization: User token
/// - Returns: `AnyPublisher` contains `Token` nested in the response
- public static func getAll(
+ public static func getNotifications(
session: URLSession,
domain: String,
- query: GetAllNotificationsQuery,
- authorization: Mastodon.API.OAuth.Authorization?
- ) -> AnyPublisher, Error> {
+ query: Mastodon.API.Notifications.Query,
+ authorization: Mastodon.API.OAuth.Authorization
+ ) -> AnyPublisher, Error> {
let request = Mastodon.API.get(
url: notificationsEndpointURL(domain: domain),
query: query,
@@ -63,12 +64,12 @@ extension Mastodon.API.Notifications {
/// - notificationID: ID of the notification.
/// - authorization: User token
/// - Returns: `AnyPublisher` contains `Token` nested in the response
- public static func get(
+ public static func getNotification(
session: URLSession,
domain: String,
notificationID: String,
- authorization: Mastodon.API.OAuth.Authorization?
- ) -> AnyPublisher, Error> {
+ authorization: Mastodon.API.OAuth.Authorization
+ ) -> AnyPublisher, Error> {
let request = Mastodon.API.get(
url: getNotificationEndpointURL(domain: domain, notificationID: notificationID),
query: nil,
@@ -81,13 +82,15 @@ extension Mastodon.API.Notifications {
}
.eraseToAnyPublisher()
}
-
- public struct GetAllNotificationsQuery: Codable, PagedQueryType, GetQuery {
+}
+
+extension Mastodon.API.Notifications {
+ public struct Query: PagedQueryType, GetQuery {
public let maxID: Mastodon.Entity.Status.ID?
public let sinceID: Mastodon.Entity.Status.ID?
public let minID: Mastodon.Entity.Status.ID?
public let limit: Int?
- public let excludeTypes: [String]?
+ public let excludeTypes: [Mastodon.Entity.Notification.NotificationType]?
public let accountID: String?
public init(
@@ -95,7 +98,7 @@ extension Mastodon.API.Notifications {
sinceID: Mastodon.Entity.Status.ID? = nil,
minID: Mastodon.Entity.Status.ID? = nil,
limit: Int? = nil,
- excludeTypes: [String]? = nil,
+ excludeTypes: [Mastodon.Entity.Notification.NotificationType]? = nil,
accountID: String? = nil
) {
self.maxID = maxID
@@ -114,7 +117,7 @@ extension Mastodon.API.Notifications {
limit.flatMap { items.append(URLQueryItem(name: "limit", value: String($0))) }
if let excludeTypes = excludeTypes {
excludeTypes.forEach {
- items.append(URLQueryItem(name: "exclude_types[]", value: $0))
+ items.append(URLQueryItem(name: "exclude_types[]", value: $0.rawValue))
}
}
accountID.flatMap { items.append(URLQueryItem(name: "account_id", value: $0)) }
diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Push.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Push.swift
new file mode 100644
index 00000000..df916849
--- /dev/null
+++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Push.swift
@@ -0,0 +1,226 @@
+//
+// File.swift
+//
+//
+// Created by ihugo on 2021/4/9.
+//
+
+import Foundation
+import Combine
+
+extension Mastodon.API.Subscriptions {
+
+ static func pushEndpointURL(domain: String) -> URL {
+ return Mastodon.API.endpointURL(domain: domain).appendingPathComponent("push/subscription")
+ }
+
+ /// Get current subscription
+ ///
+ /// Using this endpoint to get current subscription
+ ///
+ /// - Since: 2.4.0
+ /// - Version: 3.3.0
+ /// # Last Update
+ /// 2021/4/9
+ /// # Reference
+ /// [Document](https://docs.joinmastodon.org/methods/notifications/push/)
+ /// - Parameters:
+ /// - session: `URLSession`
+ /// - domain: Mastodon instance domain. e.g. "example.com"
+ /// - authorization: User token. Could be nil if status is public
+ /// - Returns: `AnyPublisher` contains `Subscription` nested in the response
+ public static func subscription(
+ session: URLSession,
+ domain: String,
+ authorization: Mastodon.API.OAuth.Authorization
+ ) -> AnyPublisher, Error> {
+ let request = Mastodon.API.get(
+ url: pushEndpointURL(domain: domain),
+ query: nil,
+ authorization: authorization
+ )
+ return session.dataTaskPublisher(for: request)
+ .tryMap { data, response in
+ let value = try Mastodon.API.decode(type: Mastodon.Entity.Subscription.self, from: data, response: response)
+ return Mastodon.Response.Content(value: value, response: response)
+ }
+ .eraseToAnyPublisher()
+ }
+
+ /// Subscribe to push notifications
+ ///
+ /// Add a Web Push API subscription to receive notifications. Each access token can have one push subscription. If you create a new subscription, the old subscription is deleted.
+ ///
+ /// - Since: 2.4.0
+ /// - Version: 3.3.0
+ /// # Last Update
+ /// 2021/4/9
+ /// # Reference
+ /// [Document](https://docs.joinmastodon.org/methods/notifications/push/)
+ /// - Parameters:
+ /// - session: `URLSession`
+ /// - domain: Mastodon instance domain. e.g. "example.com"
+ /// - authorization: User token. Could be nil if status is public
+ /// - Returns: `AnyPublisher` contains `Subscription` nested in the response
+ public static func createSubscription(
+ session: URLSession,
+ domain: String,
+ authorization: Mastodon.API.OAuth.Authorization,
+ query: CreateSubscriptionQuery
+ ) -> AnyPublisher, Error> {
+ let request = Mastodon.API.post(
+ url: pushEndpointURL(domain: domain),
+ query: query,
+ authorization: authorization
+ )
+ return session.dataTaskPublisher(for: request)
+ .tryMap { data, response in
+ let value = try Mastodon.API.decode(type: Mastodon.Entity.Subscription.self, from: data, response: response)
+ return Mastodon.Response.Content(value: value, response: response)
+ }
+ .eraseToAnyPublisher()
+ }
+
+ /// Change types of notifications
+ ///
+ /// Updates the current push subscription. Only the data part can be updated. To change fundamentals, a new subscription must be created instead.
+ ///
+ /// - Since: 2.4.0
+ /// - Version: 3.3.0
+ /// # Last Update
+ /// 2021/4/9
+ /// # Reference
+ /// [Document](https://docs.joinmastodon.org/methods/notifications/push/)
+ /// - Parameters:
+ /// - session: `URLSession`
+ /// - domain: Mastodon instance domain. e.g. "example.com"
+ /// - authorization: User token. Could be nil if status is public
+ /// - Returns: `AnyPublisher` contains `Subscription` nested in the response
+ public static func updateSubscription(
+ session: URLSession,
+ domain: String,
+ authorization: Mastodon.API.OAuth.Authorization,
+ query: UpdateSubscriptionQuery
+ ) -> AnyPublisher, Error> {
+ let request = Mastodon.API.put(
+ url: pushEndpointURL(domain: domain),
+ query: query,
+ authorization: authorization
+ )
+ return session.dataTaskPublisher(for: request)
+ .tryMap { data, response in
+ let value = try Mastodon.API.decode(type: Mastodon.Entity.Subscription.self, from: data, response: response)
+ return Mastodon.Response.Content(value: value, response: response)
+ }
+ .eraseToAnyPublisher()
+ }
+}
+
+extension Mastodon.API.Subscriptions {
+ public struct CreateSubscriptionQuery: Codable, PostQuery {
+ let endpoint: String
+ let p256dh: String
+ let auth: String
+ let favourite: Bool?
+ let follow: Bool?
+ let reblog: Bool?
+ let mention: Bool?
+ let poll: Bool?
+
+ var queryItems: [URLQueryItem]? {
+ var items = [URLQueryItem]()
+
+ items.append(URLQueryItem(name: "subscription[endpoint]", value: endpoint))
+ items.append(URLQueryItem(name: "subscription[keys][p256dh]", value: p256dh))
+ items.append(URLQueryItem(name: "subscription[keys][auth]", value: auth))
+
+ if let followValue = follow?.queryItemValue {
+ let followItem = URLQueryItem(name: "data[alerts][follow]", value: followValue)
+ items.append(followItem)
+ }
+
+ if let favouriteValue = favourite?.queryItemValue {
+ let favouriteItem = URLQueryItem(name: "data[alerts][favourite]", value: favouriteValue)
+ items.append(favouriteItem)
+ }
+
+ if let reblogValue = reblog?.queryItemValue {
+ let reblogItem = URLQueryItem(name: "data[alerts][reblog]", value: reblogValue)
+ items.append(reblogItem)
+ }
+
+ if let mentionValue = mention?.queryItemValue {
+ let mentionItem = URLQueryItem(name: "data[alerts][mention]", value: mentionValue)
+ items.append(mentionItem)
+ }
+ return items
+ }
+
+ public init(
+ endpoint: String,
+ p256dh: String,
+ auth: String,
+ favourite: Bool?,
+ follow: Bool?,
+ reblog: Bool?,
+ mention: Bool?,
+ poll: Bool?
+ ) {
+ self.endpoint = endpoint
+ self.p256dh = p256dh
+ self.auth = auth
+ self.favourite = favourite
+ self.follow = follow
+ self.reblog = reblog
+ self.mention = mention
+ self.poll = poll
+ }
+ }
+
+ public struct UpdateSubscriptionQuery: Codable, PutQuery {
+ let favourite: Bool?
+ let follow: Bool?
+ let reblog: Bool?
+ let mention: Bool?
+ let poll: Bool?
+
+ var queryItems: [URLQueryItem]? {
+ var items = [URLQueryItem]()
+
+ if let followValue = follow?.queryItemValue {
+ let followItem = URLQueryItem(name: "data[alerts][follow]", value: followValue)
+ items.append(followItem)
+ }
+
+ if let favouriteValue = favourite?.queryItemValue {
+ let favouriteItem = URLQueryItem(name: "data[alerts][favourite]", value: favouriteValue)
+ items.append(favouriteItem)
+ }
+
+ if let reblogValue = reblog?.queryItemValue {
+ let reblogItem = URLQueryItem(name: "data[alerts][reblog]", value: reblogValue)
+ items.append(reblogItem)
+ }
+
+ if let mentionValue = mention?.queryItemValue {
+ let mentionItem = URLQueryItem(name: "data[alerts][mention]", value: mentionValue)
+ items.append(mentionItem)
+ }
+ return items
+ }
+
+ public init(
+ favourite: Bool?,
+ follow: Bool?,
+ reblog: Bool?,
+ mention: Bool?,
+ poll: Bool?
+ ) {
+ self.favourite = favourite
+ self.follow = follow
+ self.reblog = reblog
+ self.mention = mention
+ self.poll = poll
+ }
+ }
+}
diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift
index 2fdb9b34..1a4496ed 100644
--- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift
+++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift
@@ -115,6 +115,7 @@ extension Mastodon.API {
public enum Trends { }
public enum Suggestions { }
public enum Notifications { }
+ public enum Subscriptions { }
}
extension Mastodon.API {
diff --git a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Notification.swift b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Notification.swift
index 413c89bd..0cdcc2e7 100644
--- a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Notification.swift
+++ b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Notification.swift
@@ -37,6 +37,7 @@ extension Mastodon.Entity {
}
extension Mastodon.Entity.Notification {
+ public typealias NotificationType = Type
public enum `Type`: RawRepresentable, Codable {
case follow
case followRequest
diff --git a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Subscription.swift b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Subscription.swift
new file mode 100644
index 00000000..3ae5718e
--- /dev/null
+++ b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Subscription.swift
@@ -0,0 +1,77 @@
+//
+// File.swift
+//
+//
+// Created by ihugo on 2021/4/9.
+//
+
+import Foundation
+
+
+extension Mastodon.Entity {
+ /// Subscription
+ ///
+ /// - Since: 2.4.0
+ /// - Version: 3.3.0
+ /// # Last Update
+ /// 2021/4/9
+ /// # Reference
+ /// [Document](https://docs.joinmastodon.org/entities/pushsubscription/)
+ public struct Subscription: Codable {
+ // Base
+ public let id: String
+ public let endpoint: String
+ public let alerts: Alerts
+ public let serverKey: String
+
+ enum CodingKeys: String, CodingKey {
+ case id
+ case endpoint
+ case serverKey = "server_key"
+ case alerts
+ }
+
+ public struct Alerts: Codable {
+ public let follow: Bool?
+ public let favourite: Bool?
+ public let reblog: Bool?
+ public let mention: Bool?
+ public let poll: Bool?
+
+ public var followNumber: NSNumber? {
+ guard let value = follow else { return nil }
+ return NSNumber(booleanLiteral: value)
+ }
+ public var favouriteNumber: NSNumber? {
+ guard let value = favourite else { return nil }
+ return NSNumber(booleanLiteral: value)
+ }
+ public var reblogNumber: NSNumber? {
+ guard let value = reblog else { return nil }
+ return NSNumber(booleanLiteral: value)
+ }
+ public var mentionNumber: NSNumber? {
+ guard let value = mention else { return nil }
+ return NSNumber(booleanLiteral: value)
+ }
+ public var pollNumber: NSNumber? {
+ guard let value = poll else { return nil }
+ return NSNumber(booleanLiteral: value)
+ }
+ }
+
+ public init(from decoder: Decoder) throws {
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+
+ var id = try? container.decode(String.self, forKey: .id)
+ if nil == id, let numId = try? container.decode(Int.self, forKey: .id) {
+ id = String(numId)
+ }
+ self.id = id ?? ""
+
+ endpoint = try container.decode(String.self, forKey: .endpoint)
+ alerts = try container.decode(Alerts.self, forKey: .alerts)
+ serverKey = try container.decode(String.self, forKey: .serverKey)
+ }
+ }
+}