diff --git a/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents b/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents
index 5ed4021a7..2569da5e6 100644
--- a/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents
+++ b/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents
@@ -65,6 +65,21 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -208,6 +223,7 @@
+
@@ -217,4 +233,4 @@
-
+
\ No newline at end of file
diff --git a/CoreDataStack/Entity/Notification.swift b/CoreDataStack/Entity/Notification.swift
new file mode 100644
index 000000000..f19f68988
--- /dev/null
+++ b/CoreDataStack/Entity/Notification.swift
@@ -0,0 +1,110 @@
+//
+// 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 domain: String
+ @NSManaged public private(set) var createAt: Date
+ @NSManaged public private(set) var updatedAt: Date
+ @NSManaged public private(set) var type: String
+ @NSManaged public private(set) var account: MastodonUser
+ @NSManaged public private(set) var status: Status?
+
+}
+
+extension MastodonNotification {
+ public override func awakeFromInsert() {
+ super.awakeFromInsert()
+ setPrimitiveValue(UUID(), forKey: #keyPath(MastodonNotification.identifier))
+ }
+
+ public override func willSave() {
+ super.willSave()
+ setPrimitiveValue(Date(), forKey: #keyPath(MastodonNotification.updatedAt))
+ }
+
+}
+
+public extension MastodonNotification {
+ @discardableResult
+ static func insert(
+ into context: NSManagedObjectContext,
+ domain: String,
+ property: Property
+ ) -> MastodonNotification {
+ let notification: MastodonNotification = context.insertObject()
+ notification.id = property.id
+ notification.createAt = property.createdAt
+ notification.updatedAt = property.createdAt
+ notification.type = property.type
+ notification.account = property.account
+ notification.status = property.status
+ notification.domain = domain
+ return notification
+ }
+}
+
+public extension MastodonNotification {
+ struct Property {
+ public init(id: String,
+ type: String,
+ account: MastodonUser,
+ status: Status?,
+ createdAt: Date) {
+ self.id = id
+ self.type = type
+ self.account = account
+ self.status = status
+ self.createdAt = createdAt
+ }
+
+ public let id: String
+ public let type: 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(type: String) -> NSPredicate {
+ return NSPredicate(format: "%K == %@", #keyPath(MastodonNotification.type), type)
+ }
+
+ public static func predicate(domain: String, type: String) -> NSPredicate {
+ return NSCompoundPredicate(andPredicateWithSubpredicates: [
+ MastodonNotification.predicate(domain: domain),
+ MastodonNotification.predicate(type: type)
+ ])
+ }
+
+ static func predicate(types: [String]) -> NSPredicate {
+ return NSPredicate(format: "%K IN %@", #keyPath(MastodonNotification.type), types)
+ }
+
+ public static func predicate(domain: String, types: [String]) -> NSPredicate {
+ return NSCompoundPredicate(andPredicateWithSubpredicates: [
+ MastodonNotification.predicate(domain: domain),
+ MastodonNotification.predicate(types: types)
+ ])
+ }
+}
+
+extension MastodonNotification: Managed {
+ public static var defaultSortDescriptors: [NSSortDescriptor] {
+ return [NSSortDescriptor(keyPath: \MastodonNotification.updatedAt, ascending: false)]
+ }
+}
diff --git a/Localization/app.json b/Localization/app.json
index 120458f74..33f1abc2d 100644
--- a/Localization/app.json
+++ b/Localization/app.json
@@ -321,6 +321,19 @@
},
"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"
+ }
}
}
}
diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj
index b52139898..b2258c1df 100644
--- a/Mastodon.xcodeproj/project.pbxproj
+++ b/Mastodon.xcodeproj/project.pbxproj
@@ -30,6 +30,8 @@
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 */; };
@@ -49,6 +51,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 +80,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 +98,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 */; };
@@ -409,6 +417,8 @@
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 = ""; };
@@ -428,6 +438,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 = ""; };
@@ -453,6 +465,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 = ""; };
@@ -467,6 +482,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 = ""; };
@@ -878,6 +894,14 @@
path = CollectionViewCell;
sourceTree = "";
};
+ 2D35237F26256F470031AF25 /* TableViewCell */ = {
+ isa = PBXGroup;
+ children = (
+ 2D35238026256F690031AF25 /* NotificationTableViewCell.swift */,
+ );
+ path = TableViewCell;
+ sourceTree = "";
+ };
2D364F7025E66D5B00204FDC /* ResendEmail */ = {
isa = PBXGroup;
children = (
@@ -1032,6 +1056,7 @@
DB1E346725F518E20079D7DF /* CategoryPickerSection.swift */,
2DE0FAC02615F04D00CDF649 /* RecommendHashTagSection.swift */,
2DE0FACD2615F7AD00CDF649 /* RecommendAccountSection.swift */,
+ 2D35237926256D920031AF25 /* NotificationSection.swift */,
2D198648261C0B8500F0B013 /* SearchResultSection.swift */,
DB66729525F9F91600D60309 /* ComposeStatusSection.swift */,
DB447680260B3ED600B66B82 /* CustomEmojiPickerSection.swift */,
@@ -1083,6 +1108,7 @@
children = (
2D7631B225C159F700929FB9 /* Item.swift */,
2D198642261BF09500F0B013 /* SearchResultItem.swift */,
+ 2D7867182625B77500211898 /* NotificationItem.swift */,
DB4481CB25EE2AFE00BEFB67 /* PollItem.swift */,
DB1E347725F519300079D7DF /* PickServerItem.swift */,
DB1FD45925F27898004CFCFC /* CategoryPickerItem.swift */,
@@ -1312,6 +1338,7 @@
DB71FD5125F8CCAA00512AE1 /* APIService+Status.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 */,
@@ -1487,6 +1514,7 @@
isa = PBXGroup;
children = (
DB89BA2625C110B4008580ED /* Status.swift */,
+ 2D6125462625436B00299647 /* Notification.swift */,
2D0B7A1C261D839600B44727 /* SearchHistory.swift */,
DB8AF52425C131D1002E6C99 /* MastodonUser.swift */,
DB8AF56725C13E2A002E6C99 /* HomeTimelineIndex.swift */,
@@ -1642,6 +1670,10 @@
isa = PBXGroup;
children = (
DB9D6BF725E4F5690051B173 /* NotificationViewController.swift */,
+ 2D607AD726242FC500B70763 /* NotificationViewModel.swift */,
+ 2D084B8C26258EA3003AA3AF /* NotificationViewModel+diffable.swift */,
+ 2D084B9226259545003AA3AF /* NotificationViewModel+LoadLatestState.swift */,
+ 2D35237F26256F470031AF25 /* TableViewCell */,
);
path = Notification;
sourceTree = "";
@@ -2210,6 +2242,7 @@
2D8434F525FF465D00EECE90 /* HomeTimelineNavigationBarTitleViewModel.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 */,
@@ -2244,6 +2277,7 @@
DBE3CDEC261C6B2900430CC6 /* FavoriteViewController.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 */,
@@ -2263,6 +2297,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 */,
2DF123A725C3B0210020F248 /* ActiveLabel.swift in Sources */,
@@ -2321,6 +2356,7 @@
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 */,
@@ -2329,6 +2365,7 @@
2D38F1C625CD37F400561493 /* ContentOffsetAdjustableTimelineViewControllerDelegate.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 */,
DBC7A67C260DFADE00E57475 /* StatusPublishService.swift in Sources */,
@@ -2358,6 +2395,7 @@
2D650FAB25ECDC9300851B58 /* Mastodon+Entity+Error+Detail.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 */,
@@ -2370,6 +2408,7 @@
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 */,
@@ -2472,6 +2511,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 */,
diff --git a/Mastodon/Diffiable/Item/NotificationItem.swift b/Mastodon/Diffiable/Item/NotificationItem.swift
new file mode 100644
index 000000000..e4a53d2b1
--- /dev/null
+++ b/Mastodon/Diffiable/Item/NotificationItem.swift
@@ -0,0 +1,40 @@
+//
+// NotificationItem.swift
+// Mastodon
+//
+// Created by sxiaojian on 2021/4/13.
+//
+
+import Foundation
+import CoreData
+
+enum NotificationItem {
+
+ case notification(ObjectID: NSManagedObjectID)
+
+ case bottomLoader
+}
+
+extension NotificationItem: Equatable {
+ static func == (lhs: NotificationItem, rhs: NotificationItem) -> Bool {
+ switch (lhs, rhs) {
+ case (.bottomLoader, .bottomLoader):
+ return true
+ case (.notification(let idLeft),.notification(let idRight)):
+ return idLeft == idRight
+ 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 000000000..d697d3cef
--- /dev/null
+++ b/Mastodon/Diffiable/Section/NotificationSection.swift
@@ -0,0 +1,75 @@
+//
+// NotificationSection.swift
+// Mastodon
+//
+// Created by sxiaojian on 2021/4/13.
+//
+
+import CoreData
+import CoreDataStack
+import Foundation
+import MastodonSDK
+import UIKit
+import Combine
+
+enum NotificationSection: Equatable, Hashable {
+ case main
+}
+
+extension NotificationSection {
+ static func tableViewDiffableDataSource(
+ for tableView: UITableView,
+ timestampUpdatePublisher: AnyPublisher,
+ managedObjectContext: NSManagedObjectContext
+ ) -> UITableViewDiffableDataSource {
+
+ return UITableViewDiffableDataSource(tableView: tableView) { (tableView, indexPath, notificationItem) -> UITableViewCell? in
+ switch notificationItem {
+ case .notification(let objectID):
+ let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: NotificationTableViewCell.self), for: indexPath) as! NotificationTableViewCell
+ let notification = managedObjectContext.object(with: objectID) as! MastodonNotification
+ let type = Mastodon.Entity.Notification.NotificationType(rawValue: notification.type)
+
+ var actionText: String
+ var actionImageName: String
+ switch type {
+ case .follow:
+ actionText = L10n.Scene.Notification.Action.follow
+ actionImageName = "person.crop.circle.badge.checkmark"
+ case .favourite:
+ actionText = L10n.Scene.Notification.Action.favourite
+ actionImageName = "star.fill"
+ case .reblog:
+ actionText = L10n.Scene.Notification.Action.reblog
+ actionImageName = "arrow.2.squarepath"
+ case .mention:
+ actionText = L10n.Scene.Notification.Action.mention
+ actionImageName = "at"
+ case .poll:
+ actionText = L10n.Scene.Notification.Action.poll
+ actionImageName = "list.bullet"
+ default:
+ actionText = ""
+ actionImageName = ""
+ }
+
+ timestampUpdatePublisher
+ .sink { _ in
+ let timeText = notification.createAt.shortTimeAgoSinceNow
+ cell.actionLabel.text = actionText + " ยท " + timeText
+ }
+ .store(in: &cell.disposeBag)
+ cell.nameLabel.text = notification.account.displayName
+
+ 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: SearchBottomLoader.self)) as! SearchBottomLoader
+ cell.startAnimating()
+ return cell
+ }
+ }
+ }
+}
diff --git a/Mastodon/Generated/Strings.swift b/Mastodon/Generated/Strings.swift
index 14b993881..a94afa130 100644
--- a/Mastodon/Generated/Strings.swift
+++ b/Mastodon/Generated/Strings.swift
@@ -339,6 +339,26 @@ internal enum L10n {
internal static let publishing = L10n.tr("Localizable", "Scene.HomeTimeline.NavigationBarState.Publishing")
}
}
+ internal enum Notification {
+ internal enum Action {
+ /// favorited your toot
+ 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")
+ /// boosted your toot
+ 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 {
diff --git a/Mastodon/Resources/en.lproj/Localizable.strings b/Mastodon/Resources/en.lproj/Localizable.strings
index 40000befa..e4b10c0cf 100644
--- a/Mastodon/Resources/en.lproj/Localizable.strings
+++ b/Mastodon/Resources/en.lproj/Localizable.strings
@@ -114,6 +114,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 toot";
+"Scene.Notification.Action.Follow" = "followed you";
+"Scene.Notification.Action.Mention" = "mentioned you";
+"Scene.Notification.Action.Poll" = "Your poll has ended";
+"Scene.Notification.Action.Reblog" = "boosted your toot";
+"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";
diff --git a/Mastodon/Scene/Notification/NotificationViewController.swift b/Mastodon/Scene/Notification/NotificationViewController.swift
index f8b3ba815..51a94e89e 100644
--- a/Mastodon/Scene/Notification/NotificationViewController.swift
+++ b/Mastodon/Scene/Notification/NotificationViewController.swift
@@ -2,23 +2,147 @@
// NotificationViewController.swift
// Mastodon
//
-// Created by MainasuK Cirno on 2021-2-23.
+// Created by sxiaojian on 2021/4/12.
//
import UIKit
+import Combine
+import OSLog
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, coordinator: coordinator)
+
+ let segmentControl: UISegmentedControl = {
+ let control = UISegmentedControl(items: [L10n.Scene.Notification.Title.everything,L10n.Scene.Notification.Title.mentions])
+ control.selectedSegmentIndex = 0
+ control.addTarget(self, action: #selector(NotificationViewController.segmentedControlValueChanged(_:)), for: .touchUpInside)
+ return control
+ }()
+
+ let tableView: UITableView = {
+ let tableView = ControlContainableTableView()
+ tableView.rowHeight = UITableView.automaticDimension
+ tableView.separatorStyle = .none
+ tableView.backgroundColor = .clear
+ tableView.register(NotificationTableViewCell.self, forCellReuseIdentifier: String(describing: NotificationTableViewCell.self))
+ return tableView
+ }()
+
+ let refreshControl = UIRefreshControl()
+
}
extension NotificationViewController {
override func viewDidLoad() {
super.viewDidLoad()
+ view.backgroundColor = Asset.Colors.Background.searchResult.color
+ navigationItem.titleView = segmentControl
+ view.addSubview(tableView)
+ tableView.constrain([
+ 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)
+
+ // 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)
+ }
+
+ @objc private func refreshControlValueChanged(_ sender: UIRefreshControl) {
+ guard viewModel.loadLatestStateMachine.enter(NotificationViewModel.LoadLatestState.Loading.self) else {
+ sender.endRefreshing()
+ return
+ }
+ }
+}
+
+// MARK: - UITableViewDelegate
+extension NotificationViewController: UITableViewDelegate {
+
+ func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat {
+ return 68
+ }
+}
+
+// MARK: - ContentOffsetAdjustableTimelineViewControllerDelegate
+extension NotificationViewController: ContentOffsetAdjustableTimelineViewControllerDelegate {
+ func navigationBar() -> UINavigationBar? {
+ return navigationController?.navigationBar
+ }
+}
+
+//// MARK: - UIScrollViewDelegate
+//extension NotificationViewController {
+// func scrollViewDidScroll(_ scrollView: UIScrollView) {
+// handleScrollViewDidScroll(scrollView)
+// }
+//}
+//
+//extension NotificationViewController: LoadMoreConfigurableTableViewContainer {
+// typealias BottomLoaderTableViewCell = SearchBottomLoader
+// typealias LoadingState = NotificationViewController.LoadOldestState.Loading
+// var loadMoreConfigurableTableView: UITableView { return tableView }
+// var loadMoreConfigurableStateMachine: GKStateMachine { return viewModel.loadoldestStateMachine }
+//}
diff --git a/Mastodon/Scene/Notification/NotificationViewModel+LoadLatestState.swift b/Mastodon/Scene/Notification/NotificationViewModel+LoadLatestState.swift
new file mode 100644
index 000000000..364085c89
--- /dev/null
+++ b/Mastodon/Scene/Notification/NotificationViewModel+LoadLatestState.swift
@@ -0,0 +1,96 @@
+//
+// NotificationViewModel+LoadLatestState.swift
+// Mastodon
+//
+// Created by sxiaojian on 2021/4/13.
+//
+
+import os.log
+import func QuartzCore.CACurrentMediaTime
+import Foundation
+import CoreData
+import CoreDataStack
+import GameplayKit
+import MastodonSDK
+
+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, self.debugDescription, previousState.debugDescription)
+ viewModel?.loadLatestStateMachinePublisher.send(self)
+ }
+ }
+}
+
+extension NotificationViewModel.LoadLatestState {
+ class Initial: NotificationViewModel.LoadLatestState {
+ override func isValidNextState(_ stateClass: AnyClass) -> Bool {
+ return stateClass == Loading.self
+ }
+ }
+
+ class Loading: NotificationViewModel.LoadLatestState {
+ override func isValidNextState(_ stateClass: AnyClass) -> Bool {
+ return 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.context.authenticationService.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: Mastodon.API.Notifications.allExcludeTypes(),
+ 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 {
+ return stateClass == Loading.self || stateClass == Idle.self
+ }
+ }
+
+ class Idle: NotificationViewModel.LoadLatestState {
+ override func isValidNextState(_ stateClass: AnyClass) -> Bool {
+ return stateClass == Loading.self
+ }
+ }
+
+}
diff --git a/Mastodon/Scene/Notification/NotificationViewModel+diffable.swift b/Mastodon/Scene/Notification/NotificationViewModel+diffable.swift
new file mode 100644
index 000000000..c68096c86
--- /dev/null
+++ b/Mastodon/Scene/Notification/NotificationViewModel+diffable.swift
@@ -0,0 +1,118 @@
+//
+// NotificationViewModel+diffable.swift
+// Mastodon
+//
+// Created by sxiaojian on 2021/4/13.
+//
+
+import os.log
+import UIKit
+import CoreData
+import CoreDataStack
+
+extension NotificationViewModel {
+
+ func setupDiffableDataSource(
+ for tableView: UITableView
+ ) {
+ let timestampUpdatePublisher = Timer.publish(every: 30.0, on: .main, in: .common)
+ .autoconnect()
+ .share()
+ .eraseToAnyPublisher()
+
+ diffableDataSource = NotificationSection.tableViewDiffableDataSource(
+ for: tableView,
+ timestampUpdatePublisher: timestampUpdatePublisher,
+ managedObjectContext: context.managedObjectContext
+ )
+ }
+
+}
+
+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 = self.contentOffsetAdjustableTimelineViewControllerDelegate?.navigationBar() else { return }
+
+ guard let diffableDataSource = self.diffableDataSource else { return }
+ let oldSnapshot = diffableDataSource.snapshot()
+
+ 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 []
+ }
+ }()
+
+ var newSnapshot = NSDiffableDataSourceSnapshot()
+ newSnapshot.appendSections([.main])
+ newSnapshot.appendItems(notifications.map({NotificationItem.notification(ObjectID: $0.objectID)}), toSection: .main)
+ newSnapshot.appendItems([.bottomLoader], toSection: .main)
+
+ DispatchQueue.main.async {
+ guard let difference = self.calculateReloadSnapshotDifference(navigationBar: navigationBar, tableView: tableView, oldSnapshot: oldSnapshot, newSnapshot: newSnapshot) else {
+ diffableDataSource.apply(newSnapshot)
+ self.isFetchingLatestNotification.value = false
+ 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 }
+
+ 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 000000000..4736785f6
--- /dev/null
+++ b/Mastodon/Scene/Notification/NotificationViewModel.swift
@@ -0,0 +1,87 @@
+//
+// NotificationViewModel.swift
+// Mastodon
+//
+// Created by sxiaojian on 2021/4/12.
+//
+
+import Foundation
+import Combine
+import UIKit
+import CoreData
+import CoreDataStack
+import GameplayKit
+
+final class NotificationViewModel: NSObject {
+
+ var disposeBag = Set()
+
+ // input
+ let context: AppContext
+ weak var coordinator: SceneCoordinator!
+ weak var tableView: UITableView!
+ weak var contentOffsetAdjustableTimelineViewControllerDelegate: ContentOffsetAdjustableTimelineViewControllerDelegate?
+
+
+ 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)
+
+ init(context: AppContext,coordinator: SceneCoordinator) {
+ self.coordinator = coordinator
+ 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()
+ self.fetchedResultsController.delegate = self
+ context.authenticationService.activeMastodonAuthenticationBox
+ .assign(to: \.value, on: activeMastodonAuthenticationBox)
+ .store(in: &disposeBag)
+
+ notificationPredicate
+ .compactMap{ $0 }
+ .sink { [weak self] predicate in
+ guard let self = self else { return }
+ self.fetchedResultsController.fetchRequest.predicate = predicate
+ do {
+ try self.fetchedResultsController.performFetch()
+ } catch {
+ assertionFailure(error.localizedDescription)
+ }
+ }
+ .store(in: &disposeBag)
+ }
+}
diff --git a/Mastodon/Scene/Notification/TableViewCell/NotificationTableViewCell.swift b/Mastodon/Scene/Notification/TableViewCell/NotificationTableViewCell.swift
new file mode 100644
index 000000000..8a1b35721
--- /dev/null
+++ b/Mastodon/Scene/Notification/TableViewCell/NotificationTableViewCell.swift
@@ -0,0 +1,109 @@
+//
+// NotificationTableViewCell.swift
+// Mastodon
+//
+// Created by sxiaojian on 2021/4/13.
+//
+
+import Foundation
+import UIKit
+import Combine
+
+
+final class NotificationTableViewCell: UITableViewCell {
+
+ static let actionImageBorderWidth: CGFloat = 3
+
+ var disposeBag = Set()
+
+ 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.layer.cornerRadius = 4
+ imageView.layer.cornerCurve = .continuous
+ imageView.clipsToBounds = true
+ imageView.layer.borderWidth = NotificationTableViewCell.actionImageBorderWidth
+ imageView.layer.borderColor = Asset.Colors.Background.searchResult.color.cgColor
+ imageView.tintColor = Asset.Colors.Background.searchResult.color
+ return imageView
+ }()
+
+ 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
+ }()
+
+ var nameLabelTop: NSLayoutConstraint!
+
+ override func prepareForReuse() {
+ super.prepareForReuse()
+ avatatImageView.af.cancelImageRequest()
+
+ }
+
+ 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() {
+ contentView.addSubview(avatatImageView)
+ avatatImageView.pin(toSize: CGSize(width: 35, height: 35))
+ avatatImageView.pin(top: 12, left: 12, bottom: nil, right: nil)
+
+ contentView.addSubview(actionImageView)
+ actionImageView.pin(toSize: CGSize(width: 24, height: 24))
+ actionImageView.pin(top: 33, left: 33, bottom: nil, right: nil)
+
+ nameLabelTop = nameLabel.topAnchor.constraint(equalTo: contentView.topAnchor)
+ nameLabel.constrain([
+ nameLabelTop,
+ nameLabel.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 61)
+ ])
+
+ actionLabel.constrain([
+ actionLabel.leadingAnchor.constraint(equalTo: nameLabel.trailingAnchor, constant: 4),
+ actionLabel.topAnchor.constraint(equalTo: nameLabel.topAnchor),
+ contentView.trailingAnchor.constraint(equalTo: actionLabel.trailingAnchor, constant: 4)
+ ])
+ }
+
+ public func nameLabelLayoutIn(center: Bool) {
+ if center {
+ nameLabelTop.constant = 24
+ } else {
+ nameLabelTop.constant = 12
+ }
+ }
+ override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
+ super.traitCollectionDidChange(previousTraitCollection)
+
+ self.actionImageView.layer.borderColor = Asset.Colors.Background.searchResult.color.cgColor
+ }
+}
diff --git a/Mastodon/Service/APIService/APIService+Notification.swift b/Mastodon/Service/APIService/APIService+Notification.swift
new file mode 100644
index 000000000..745a04fa0
--- /dev/null
+++ b/Mastodon/Service/APIService/APIService+Notification.swift
@@ -0,0 +1,65 @@
+//
+// APIService+Notification.swift
+// Mastodon
+//
+// Created by sxiaojian on 2021/4/13.
+//
+
+import Foundation
+import Combine
+import CoreData
+import CoreDataStack
+import MastodonSDK
+import OSLog
+
+extension APIService {
+ func allNotifications(
+ domain: String,
+ query: Mastodon.API.Notifications.Query,
+ mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox
+ ) -> AnyPublisher, Error> {
+ let authorization = mastodonAuthenticationBox.userAuthorization
+ 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,isCreated) = APIService.CoreData.createOrMergeMastodonUser(into: self.backgroundManagedObjectContext, for: nil, in: domain, entity: notification.account, userCache: nil, networkDate: Date(), log: log)
+ let flag = isCreated ? "+" : "-"
+ os_log(.info, log: log, "%{public}s[%{public}ld], %{public}s: fetch mastodon user [%s](%s)%s", (#file as NSString).lastPathComponent, #line, #function, flag, mastodonUser.id, mastodonUser.username)
+ 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
+ _ = MastodonNotification.insert(into: self.backgroundManagedObjectContext, domain: domain, property: MastodonNotification.Property(id: notification.id, type: notification.type.rawValue, account: mastodonUser, status: status, createdAt: Date()))
+
+ }
+ }
+ .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/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Notifications.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Notifications.swift
index cdee82926..b7fd0fb46 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")
+public extension Mastodon.API.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(
+ 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(
+ 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,
@@ -82,12 +83,22 @@ extension Mastodon.API.Notifications {
.eraseToAnyPublisher()
}
- public struct GetAllNotificationsQuery: Codable, PagedQueryType, GetQuery {
+ static func allExcludeTypes() -> [Mastodon.Entity.Notification.NotificationType] {
+ [.follow]
+ }
+
+ static func mentionsExcludeTypes() -> [Mastodon.Entity.Notification.NotificationType] {
+ [.follow, .followRequest, .favourite, .reblog, .poll]
+ }
+}
+
+public extension Mastodon.API.Notifications {
+ struct Query: Codable, 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 +106,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 +125,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/Entity/Mastodon+Entity+Notification.swift b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Notification.swift
index 413c89bd3..0cdcc2e7c 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