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