From 9001289801aef99492c7d7c84281480dd9a075b0 Mon Sep 17 00:00:00 2001 From: CMK Date: Sun, 25 Apr 2021 12:48:29 +0800 Subject: [PATCH 01/19] feat: add push notification --- Mastodon.xcodeproj/project.pbxproj | 241 +++++++++++++++++- .../xcschemes/xcschememanagement.plist | 13 +- .../xcshareddata/swiftpm/Package.resolved | 9 + .../Extension/Array+removeDuplicates.swift | 23 -- Mastodon/Extension/Array.swift | 99 +++++++ Mastodon/Mastodon.entitlements | 2 + .../Settings/SettingsViewController.swift | 14 +- .../Scene/Settings/SettingsViewModel.swift | 101 ++++---- .../APIService/APIService+Subscriptions.swift | 17 +- Mastodon/Service/NotificationService.swift | 241 ++++++++++++++++++ Mastodon/State/AppContext.swift | 8 +- Mastodon/Supporting Files/AppDelegate.swift | 16 +- Mastodon/Supporting Files/AppSecret.swift | 51 ++++ .../MastodonSDK/API/Mastodon+API+Push.swift | 149 +++++------ .../Sources/MastodonSDK/Extension/Data.swift | 9 + NotificationService/Extension/String.swift | 31 +++ NotificationService/Info.plist | 31 +++ .../NotificationService+Decrypt.swift | 96 +++++++ NotificationService/NotificationService.swift | 103 ++++++++ Podfile | 16 ++ Podfile.lock | 9 +- README.md | 1 + 22 files changed, 1094 insertions(+), 186 deletions(-) delete mode 100644 Mastodon/Extension/Array+removeDuplicates.swift create mode 100644 Mastodon/Extension/Array.swift create mode 100644 Mastodon/Service/NotificationService.swift create mode 100644 Mastodon/Supporting Files/AppSecret.swift create mode 100644 NotificationService/Extension/String.swift create mode 100644 NotificationService/Info.plist create mode 100644 NotificationService/NotificationService+Decrypt.swift create mode 100644 NotificationService/NotificationService.swift diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index 0f8236e62..30202e558 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -16,7 +16,7 @@ 0F202227261411BB000C64BF /* HashtagTimelineViewController+StatusProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F202226261411BA000C64BF /* HashtagTimelineViewController+StatusProvider.swift */; }; 0F20222D261457EE000C64BF /* HashtagTimelineViewModel+LoadOldestState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F20222C261457EE000C64BF /* HashtagTimelineViewModel+LoadOldestState.swift */; }; 0F20223326145E51000C64BF /* HashtagTimelineViewModel+LoadMiddleState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F20223226145E51000C64BF /* HashtagTimelineViewModel+LoadMiddleState.swift */; }; - 0F20223926146553000C64BF /* Array+removeDuplicates.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F20223826146553000C64BF /* Array+removeDuplicates.swift */; }; + 0F20223926146553000C64BF /* Array.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F20223826146553000C64BF /* Array.swift */; }; 0FAA0FDF25E0B57E0017CCDE /* WelcomeViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FAA0FDE25E0B57E0017CCDE /* WelcomeViewController.swift */; }; 0FAA101225E105390017CCDE /* PrimaryActionButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FAA101125E105390017CCDE /* PrimaryActionButton.swift */; }; 0FAA101C25E10E760017CCDE /* UIFont.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FAA101B25E10E760017CCDE /* UIFont.swift */; }; @@ -160,7 +160,9 @@ 5DFC35DF262068D20045711D /* SearchViewController+Follow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DFC35DE262068D20045711D /* SearchViewController+Follow.swift */; }; 5E0DEC05797A7E6933788DDB /* Pods_MastodonTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 452147B2903DF38070FE56A2 /* Pods_MastodonTests.framework */; }; 5E44BF88AD33646E64727BCF /* Pods_MastodonTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CD92E0F10BDE4FE7C4B999F2 /* Pods_MastodonTests.framework */; }; + 7D603B3DC600BB75956FAD7D /* Pods_Mastodon_NotificationService.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 79D7EB88A6A8B34DFDFC96FC /* Pods_Mastodon_NotificationService.framework */; }; 87FFDA5D898A5C42ADCB35E7 /* Pods_Mastodon.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A4ABE34829701A4496C5BB64 /* Pods_Mastodon.framework */; }; + DB00CA972632DDB600A54956 /* CommonOSLog in Frameworks */ = {isa = PBXBuildFile; productRef = DB00CA962632DDB600A54956 /* CommonOSLog */; }; DB0140A125C40C0600F9F3CF /* MastodonPinBasedAuthenticationViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0140A025C40C0600F9F3CF /* MastodonPinBasedAuthenticationViewController.swift */; }; DB0140A825C40C1500F9F3CF /* MastodonPinBasedAuthenticationViewModelNavigationDelegateShim.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0140A725C40C1500F9F3CF /* MastodonPinBasedAuthenticationViewModelNavigationDelegateShim.swift */; }; DB0140AE25C40C7300F9F3CF /* MastodonPinBasedAuthenticationViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0140AD25C40C7300F9F3CF /* MastodonPinBasedAuthenticationViewModel.swift */; }; @@ -173,6 +175,7 @@ DB118A8225E4B6E600FAB162 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = DB118A8125E4B6E600FAB162 /* Preview Assets.xcassets */; }; DB118A8C25E4BFB500FAB162 /* HighlightDimmableButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB118A8B25E4BFB500FAB162 /* HighlightDimmableButton.swift */; }; DB1D186C25EF5BA7003F1F23 /* PollTableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB1D186B25EF5BA7003F1F23 /* PollTableView.swift */; }; + DB1E05E1263180F500201847 /* AppSecret.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB1E05E0263180F500201847 /* AppSecret.swift */; }; DB1E346825F518E20079D7DF /* CategoryPickerSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB1E346725F518E20079D7DF /* CategoryPickerSection.swift */; }; DB1E347825F519300079D7DF /* PickServerItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB1E347725F519300079D7DF /* PickServerItem.swift */; }; DB1FD43625F26899004CFCFC /* MastodonPickServerViewModel+LoadIndexedServerState.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB1FD43525F26899004CFCFC /* MastodonPickServerViewModel+LoadIndexedServerState.swift */; }; @@ -220,6 +223,7 @@ DB482A3F261331E8008AE74C /* UserTimelineViewModel+State.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB482A3E261331E8008AE74C /* UserTimelineViewModel+State.swift */; }; DB482A45261335BA008AE74C /* UserTimelineViewController+StatusProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB482A44261335BA008AE74C /* UserTimelineViewController+StatusProvider.swift */; }; DB482A4B261340A7008AE74C /* APIService+UserTimeline.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB482A4A261340A7008AE74C /* APIService+UserTimeline.swift */; }; + DB4924E226312AB200E9DB22 /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB4924E126312AB200E9DB22 /* NotificationService.swift */; }; DB49A61425FF2C5600B98345 /* EmojiService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB49A61325FF2C5600B98345 /* EmojiService.swift */; }; DB49A61F25FF32AA00B98345 /* EmojiService+CustomEmojiViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB49A61E25FF32AA00B98345 /* EmojiService+CustomEmojiViewModel.swift */; }; DB49A62525FF334C00B98345 /* EmojiService+CustomEmojiViewModel+LoadState.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB49A62425FF334C00B98345 /* EmojiService+CustomEmojiViewModel+LoadState.swift */; }; @@ -246,6 +250,10 @@ DB6B35182601FA3400DC1E11 /* MastodonAttachmentService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6B35172601FA3400DC1E11 /* MastodonAttachmentService.swift */; }; DB6B351E2601FAEE00DC1E11 /* ComposeStatusAttachmentCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6B351D2601FAEE00DC1E11 /* ComposeStatusAttachmentCollectionViewCell.swift */; }; DB6C8C0F25F0A6AE00AAA452 /* Mastodon+Entity+Error.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6C8C0E25F0A6AE00AAA452 /* Mastodon+Entity+Error.swift */; }; + DB6D9F232635195E008423CD /* String.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6D9F222635195E008423CD /* String.swift */; }; + DB6D9F3526351B7A008423CD /* NotificationService+Decrypt.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6D9F3426351B7A008423CD /* NotificationService+Decrypt.swift */; }; + DB6D9F3B26352019008423CD /* AppSecret.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB1E05E0263180F500201847 /* AppSecret.swift */; }; + DB6D9F42263527CE008423CD /* AlamofireImage in Frameworks */ = {isa = PBXBuildFile; productRef = DB6D9F41263527CE008423CD /* AlamofireImage */; }; DB71FD2C25F86A5100512AE1 /* AvatarStackContainerButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB71FD2B25F86A5100512AE1 /* AvatarStackContainerButton.swift */; }; DB71FD3625F8A16C00512AE1 /* APIService+Persist+PersistMemo.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB71FD3525F8A16C00512AE1 /* APIService+Persist+PersistMemo.swift */; }; DB71FD3C25F8A1C500512AE1 /* APIService+Persist+PersistCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB71FD3B25F8A1C500512AE1 /* APIService+Persist+PersistCache.swift */; }; @@ -367,6 +375,10 @@ DBE3CE13261D7D4200430CC6 /* StatusTableViewControllerAspect.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBE3CE12261D7D4200430CC6 /* StatusTableViewControllerAspect.swift */; }; DBE64A8B260C49D200E6359A /* TwitterTextEditor in Frameworks */ = {isa = PBXBuildFile; productRef = DBE64A8A260C49D200E6359A /* TwitterTextEditor */; }; DBE64A8C260C49D200E6359A /* TwitterTextEditor in Embed Frameworks */ = {isa = PBXBuildFile; productRef = DBE64A8A260C49D200E6359A /* TwitterTextEditor */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; }; + DBF8AE16263293E400C9C23C /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBF8AE15263293E400C9C23C /* NotificationService.swift */; }; + DBF8AE1A263293E400C9C23C /* NotificationService.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = DBF8AE13263293E400C9C23C /* NotificationService.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + DBF8AE862632992800C9C23C /* Base85 in Frameworks */ = {isa = PBXBuildFile; productRef = DBF8AE852632992800C9C23C /* Base85 */; }; + DBF96326262EC0A6001D8D25 /* AuthenticationServices.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DBF96325262EC0A6001D8D25 /* AuthenticationServices.framework */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -405,6 +417,13 @@ remoteGlobalIDString = DB89B9ED25C10FD0008580ED; remoteInfo = CoreDataStack; }; + DBF8AE18263293E400C9C23C /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = DB427DCA25BAA00100D1B89D /* Project object */; + proxyType = 1; + remoteGlobalIDString = DBF8AE12263293E400C9C23C; + remoteInfo = NotificationService; + }; /* End PBXContainerItemProxy section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -420,6 +439,17 @@ name = "Embed Frameworks"; runOnlyForDeploymentPostprocessing = 0; }; + DBF8AE1B263293E400C9C23C /* Embed App Extensions */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 13; + files = ( + DBF8AE1A263293E400C9C23C /* NotificationService.appex in Embed App Extensions */, + ); + name = "Embed App Extensions"; + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ @@ -432,7 +462,7 @@ 0F202226261411BA000C64BF /* HashtagTimelineViewController+StatusProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HashtagTimelineViewController+StatusProvider.swift"; sourceTree = ""; }; 0F20222C261457EE000C64BF /* HashtagTimelineViewModel+LoadOldestState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HashtagTimelineViewModel+LoadOldestState.swift"; sourceTree = ""; }; 0F20223226145E51000C64BF /* HashtagTimelineViewModel+LoadMiddleState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HashtagTimelineViewModel+LoadMiddleState.swift"; sourceTree = ""; }; - 0F20223826146553000C64BF /* Array+removeDuplicates.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Array+removeDuplicates.swift"; sourceTree = ""; }; + 0F20223826146553000C64BF /* Array.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Array.swift; sourceTree = ""; }; 0FAA0FDE25E0B57E0017CCDE /* WelcomeViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WelcomeViewController.swift; sourceTree = ""; }; 0FAA101125E105390017CCDE /* PrimaryActionButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrimaryActionButton.swift; sourceTree = ""; }; 0FAA101B25E10E760017CCDE /* UIFont.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIFont.swift; sourceTree = ""; }; @@ -574,8 +604,11 @@ 5DF1058425F88AE500D6C0D4 /* NeedsDependency+AVPlayerViewControllerDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NeedsDependency+AVPlayerViewControllerDelegate.swift"; sourceTree = ""; }; 5DFC35DE262068D20045711D /* SearchViewController+Follow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SearchViewController+Follow.swift"; sourceTree = ""; }; 75E3471C898DDD9631729B6E /* Pods-Mastodon.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Mastodon.release.xcconfig"; path = "Target Support Files/Pods-Mastodon/Pods-Mastodon.release.xcconfig"; sourceTree = ""; }; + 79D7EB88A6A8B34DFDFC96FC /* Pods_Mastodon_NotificationService.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Mastodon_NotificationService.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 8ED8C4B1F1BA2DCFF2926BB1 /* Pods-Mastodon-NotificationService.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Mastodon-NotificationService.debug.xcconfig"; path = "Target Support Files/Pods-Mastodon-NotificationService/Pods-Mastodon-NotificationService.debug.xcconfig"; sourceTree = ""; }; 9780A4C98FFC65B32B50D1C0 /* Pods-MastodonTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-MastodonTests.release.xcconfig"; path = "Target Support Files/Pods-MastodonTests/Pods-MastodonTests.release.xcconfig"; sourceTree = ""; }; A4ABE34829701A4496C5BB64 /* Pods_Mastodon.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Mastodon.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + B44342AC2B6585F8295F1DDF /* Pods-Mastodon-NotificationService.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Mastodon-NotificationService.release.xcconfig"; path = "Target Support Files/Pods-Mastodon-NotificationService/Pods-Mastodon-NotificationService.release.xcconfig"; sourceTree = ""; }; BB482D32A7B9825BF5327C4F /* Pods-Mastodon-MastodonUITests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Mastodon-MastodonUITests.release.xcconfig"; path = "Target Support Files/Pods-Mastodon-MastodonUITests/Pods-Mastodon-MastodonUITests.release.xcconfig"; sourceTree = ""; }; CD92E0F10BDE4FE7C4B999F2 /* Pods_MastodonTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_MastodonTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; DB0140A025C40C0600F9F3CF /* MastodonPinBasedAuthenticationViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonPinBasedAuthenticationViewController.swift; sourceTree = ""; }; @@ -589,6 +622,7 @@ DB118A8125E4B6E600FAB162 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; DB118A8B25E4BFB500FAB162 /* HighlightDimmableButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HighlightDimmableButton.swift; sourceTree = ""; }; DB1D186B25EF5BA7003F1F23 /* PollTableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollTableView.swift; sourceTree = ""; }; + DB1E05E0263180F500201847 /* AppSecret.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSecret.swift; sourceTree = ""; }; DB1E346725F518E20079D7DF /* CategoryPickerSection.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CategoryPickerSection.swift; sourceTree = ""; }; DB1E347725F519300079D7DF /* PickServerItem.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PickServerItem.swift; sourceTree = ""; }; DB1FD43525F26899004CFCFC /* MastodonPickServerViewModel+LoadIndexedServerState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MastodonPickServerViewModel+LoadIndexedServerState.swift"; sourceTree = ""; }; @@ -643,6 +677,7 @@ DB482A3E261331E8008AE74C /* UserTimelineViewModel+State.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UserTimelineViewModel+State.swift"; sourceTree = ""; }; DB482A44261335BA008AE74C /* UserTimelineViewController+StatusProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UserTimelineViewController+StatusProvider.swift"; sourceTree = ""; }; DB482A4A261340A7008AE74C /* APIService+UserTimeline.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+UserTimeline.swift"; sourceTree = ""; }; + DB4924E126312AB200E9DB22 /* NotificationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationService.swift; sourceTree = ""; }; DB49A61325FF2C5600B98345 /* EmojiService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiService.swift; sourceTree = ""; }; DB49A61E25FF32AA00B98345 /* EmojiService+CustomEmojiViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "EmojiService+CustomEmojiViewModel.swift"; sourceTree = ""; }; DB49A62425FF334C00B98345 /* EmojiService+CustomEmojiViewModel+LoadState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "EmojiService+CustomEmojiViewModel+LoadState.swift"; sourceTree = ""; }; @@ -668,6 +703,8 @@ DB6B35172601FA3400DC1E11 /* MastodonAttachmentService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonAttachmentService.swift; sourceTree = ""; }; DB6B351D2601FAEE00DC1E11 /* ComposeStatusAttachmentCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeStatusAttachmentCollectionViewCell.swift; sourceTree = ""; }; DB6C8C0E25F0A6AE00AAA452 /* Mastodon+Entity+Error.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Mastodon+Entity+Error.swift"; sourceTree = ""; }; + DB6D9F222635195E008423CD /* String.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = String.swift; sourceTree = ""; }; + DB6D9F3426351B7A008423CD /* NotificationService+Decrypt.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NotificationService+Decrypt.swift"; sourceTree = ""; }; DB71FD2B25F86A5100512AE1 /* AvatarStackContainerButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AvatarStackContainerButton.swift; sourceTree = ""; }; DB71FD3525F8A16C00512AE1 /* APIService+Persist+PersistMemo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Persist+PersistMemo.swift"; sourceTree = ""; }; DB71FD3B25F8A1C500512AE1 /* APIService+Persist+PersistCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Persist+PersistCache.swift"; sourceTree = ""; }; @@ -790,6 +827,10 @@ DBE3CE12261D7D4200430CC6 /* StatusTableViewControllerAspect.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusTableViewControllerAspect.swift; sourceTree = ""; }; DBF53F5F25C14E88008AAC7B /* Mastodon.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; name = Mastodon.xctestplan; path = Mastodon/Mastodon.xctestplan; sourceTree = ""; }; DBF53F6025C14E9D008AAC7B /* MastodonSDK.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = MastodonSDK.xctestplan; sourceTree = ""; }; + DBF8AE13263293E400C9C23C /* NotificationService.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = NotificationService.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + DBF8AE15263293E400C9C23C /* NotificationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationService.swift; sourceTree = ""; }; + DBF8AE17263293E400C9C23C /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + DBF96325262EC0A6001D8D25 /* AuthenticationServices.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = AuthenticationServices.framework; path = System/Library/Frameworks/AuthenticationServices.framework; sourceTree = SDKROOT; }; EC6E707B68A67DB08EC288FA /* Pods-MastodonTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-MastodonTests.debug.xcconfig"; path = "Target Support Files/Pods-MastodonTests/Pods-MastodonTests.debug.xcconfig"; sourceTree = ""; }; /* End PBXFileReference section */ @@ -806,6 +847,7 @@ DBB525082611EAC0002F1F29 /* Tabman in Frameworks */, 5D526FE225BE9AC400460CB9 /* MastodonSDK in Frameworks */, DB5086B825CC0D6400C2C187 /* Kingfisher in Frameworks */, + DBF96326262EC0A6001D8D25 /* AuthenticationServices.framework in Frameworks */, 2D61336925C18A4F00CAE157 /* AlamofireNetworkActivityIndicator in Frameworks */, DB3D0FF325BAA61700EAA174 /* AlamofireImage in Frameworks */, DBE64A8B260C49D200E6359A /* TwitterTextEditor in Frameworks */, @@ -846,6 +888,17 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + DBF8AE10263293E400C9C23C /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + DB00CA972632DDB600A54956 /* CommonOSLog in Frameworks */, + DB6D9F42263527CE008423CD /* AlamofireImage in Frameworks */, + DBF8AE862632992800C9C23C /* Base85 in Frameworks */, + 7D603B3DC600BB75956FAD7D /* Pods_Mastodon_NotificationService.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -924,6 +977,8 @@ BB482D32A7B9825BF5327C4F /* Pods-Mastodon-MastodonUITests.release.xcconfig */, EC6E707B68A67DB08EC288FA /* Pods-MastodonTests.debug.xcconfig */, 9780A4C98FFC65B32B50D1C0 /* Pods-MastodonTests.release.xcconfig */, + 8ED8C4B1F1BA2DCFF2926BB1 /* Pods-Mastodon-NotificationService.debug.xcconfig */, + B44342AC2B6585F8295F1DDF /* Pods-Mastodon-NotificationService.release.xcconfig */, ); path = Pods; sourceTree = ""; @@ -1053,6 +1108,7 @@ 5DF1054025F886D400D6C0D4 /* ViedeoPlaybackService.swift */, DB71FD4B25F8C80E00512AE1 /* StatusPrefetchingService.swift */, DBC7A67B260DFADE00E57475 /* StatusPublishService.swift */, + DB4924E126312AB200E9DB22 /* NotificationService.swift */, ); path = Service; sourceTree = ""; @@ -1216,9 +1272,11 @@ 3FE14AD363ED19AE7FF210A6 /* Frameworks */ = { isa = PBXGroup; children = ( + DBF96325262EC0A6001D8D25 /* AuthenticationServices.framework */, A4ABE34829701A4496C5BB64 /* Pods_Mastodon.framework */, 3C030226D3C73DCC23D67452 /* Pods_Mastodon_MastodonUITests.framework */, 452147B2903DF38070FE56A2 /* Pods_MastodonTests.framework */, + 79D7EB88A6A8B34DFDFC96FC /* Pods_Mastodon_NotificationService.framework */, ); name = Frameworks; sourceTree = ""; @@ -1318,6 +1376,7 @@ children = ( DB427DD525BAA00100D1B89D /* AppDelegate.swift */, DB427DD725BAA00100D1B89D /* SceneDelegate.swift */, + DB1E05E0263180F500201847 /* AppSecret.swift */, DB427DDB25BAA00100D1B89D /* Main.storyboard */, DB427DE025BAA00100D1B89D /* LaunchScreen.storyboard */, DB68A05C25E9055900CFDF14 /* Settings.bundle */, @@ -1347,6 +1406,7 @@ DB427DF625BAA00100D1B89D /* MastodonUITests */, DB89B9EF25C10FD0008580ED /* CoreDataStack */, DB89B9FC25C10FD0008580ED /* CoreDataStackTests */, + DBF8AE14263293E400C9C23C /* NotificationService */, DB427DD325BAA00100D1B89D /* Products */, 1EBA4F56E920856A3FC84ACB /* Pods */, 3FE14AD363ED19AE7FF210A6 /* Frameworks */, @@ -1362,6 +1422,7 @@ DB427DF325BAA00100D1B89D /* MastodonUITests.xctest */, DB89B9EE25C10FD0008580ED /* CoreDataStack.framework */, DB89B9F625C10FD0008580ED /* CoreDataStackTests.xctest */, + DBF8AE13263293E400C9C23C /* NotificationService.appex */, ); name = Products; sourceTree = ""; @@ -1516,6 +1577,14 @@ path = MastodonSDK; sourceTree = ""; }; + DB6D9F2926351961008423CD /* Extension */ = { + isa = PBXGroup; + children = ( + DB6D9F222635195E008423CD /* String.swift */, + ); + path = Extension; + sourceTree = ""; + }; DB72602125E36A2500235243 /* ServerRules */ = { isa = PBXGroup; children = ( @@ -1715,7 +1784,7 @@ 2D3F9E0325DFA133004262D9 /* UITapGestureRecognizer.swift */, 2D84350425FF858100EECE90 /* UIScrollView.swift */, DB9E0D6E25EE008500CFDD76 /* UIInterpolatingMotionEffect.swift */, - 0F20223826146553000C64BF /* Array+removeDuplicates.swift */, + 0F20223826146553000C64BF /* Array.swift */, DBCC3B2F261440A50045B23D /* UITabBarController.swift */, DBCC3B35261440BA0045B23D /* UINavigationController.swift */, ); @@ -1951,6 +2020,17 @@ path = Favorite; sourceTree = ""; }; + DBF8AE14263293E400C9C23C /* NotificationService */ = { + isa = PBXGroup; + children = ( + DBF8AE15263293E400C9C23C /* NotificationService.swift */, + DB6D9F3426351B7A008423CD /* NotificationService+Decrypt.swift */, + DB6D9F2926351961008423CD /* Extension */, + DBF8AE17263293E400C9C23C /* Info.plist */, + ); + path = NotificationService; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXHeadersBuildPhase section */ @@ -1976,11 +2056,13 @@ DB427DD025BAA00100D1B89D /* Resources */, 5532CB85BBE168B25B20720B /* [CP] Embed Pods Frameworks */, DB89BA0825C10FD0008580ED /* Embed Frameworks */, + DBF8AE1B263293E400C9C23C /* Embed App Extensions */, ); buildRules = ( ); dependencies = ( DB89BA0225C10FD0008580ED /* PBXTargetDependency */, + DBF8AE19263293E400C9C23C /* PBXTargetDependency */, ); name = Mastodon; packageProductDependencies = ( @@ -2076,6 +2158,29 @@ productReference = DB89B9F625C10FD0008580ED /* CoreDataStackTests.xctest */; productType = "com.apple.product-type.bundle.unit-test"; }; + DBF8AE12263293E400C9C23C /* NotificationService */ = { + isa = PBXNativeTarget; + buildConfigurationList = DBF8AE1E263293E400C9C23C /* Build configuration list for PBXNativeTarget "NotificationService" */; + buildPhases = ( + 0DC740704503CA6BED56F5C8 /* [CP] Check Pods Manifest.lock */, + DBF8AE0F263293E400C9C23C /* Sources */, + DBF8AE10263293E400C9C23C /* Frameworks */, + DBF8AE11263293E400C9C23C /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = NotificationService; + packageProductDependencies = ( + DBF8AE852632992800C9C23C /* Base85 */, + DB00CA962632DDB600A54956 /* CommonOSLog */, + DB6D9F41263527CE008423CD /* AlamofireImage */, + ); + productName = NotificationService; + productReference = DBF8AE13263293E400C9C23C /* NotificationService.appex */; + productType = "com.apple.product-type.app-extension"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -2105,6 +2210,9 @@ CreatedOnToolsVersion = 12.4; TestTargetID = DB427DD125BAA00100D1B89D; }; + DBF8AE12263293E400C9C23C = { + CreatedOnToolsVersion = 12.4; + }; }; }; buildConfigurationList = DB427DCD25BAA00100D1B89D /* Build configuration list for PBXProject "Mastodon" */; @@ -2127,6 +2235,7 @@ DB9A487C2603456B008B817C /* XCRemoteSwiftPackageReference "UITextView-Placeholder" */, DBE64A89260C49D200E6359A /* XCRemoteSwiftPackageReference "TwitterTextEditor" */, DBB525062611EAC0002F1F29 /* XCRemoteSwiftPackageReference "Tabman" */, + DBF8AE842632992700C9C23C /* XCRemoteSwiftPackageReference "Base85" */, ); productRefGroup = DB427DD325BAA00100D1B89D /* Products */; projectDirPath = ""; @@ -2137,6 +2246,7 @@ DB427DF225BAA00100D1B89D /* MastodonUITests */, DB89B9ED25C10FD0008580ED /* CoreDataStack */, DB89B9F525C10FD0008580ED /* CoreDataStackTests */, + DBF8AE12263293E400C9C23C /* NotificationService */, ); }; /* End PBXProject section */ @@ -2184,9 +2294,38 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + DBF8AE11263293E400C9C23C /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ + 0DC740704503CA6BED56F5C8 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-Mastodon-NotificationService-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; 5532CB85BBE168B25B20720B /* [CP] Embed Pods Frameworks */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; @@ -2551,7 +2690,7 @@ DBA0A11325FB3FC10079C110 /* ComposeToolbarView.swift in Sources */, 2D32EADA25CBCC3300C9ED86 /* PublicTimelineViewModel+LoadMiddleState.swift in Sources */, 5B90C48526259BF10002E742 /* APIService+Subscriptions.swift in Sources */, - 0F20223926146553000C64BF /* Array+removeDuplicates.swift in Sources */, + 0F20223926146553000C64BF /* Array.swift in Sources */, 5B90C460262599800002E742 /* SettingsAppearanceTableViewCell.swift in Sources */, DB8AF54425C13647002E6C99 /* SceneCoordinator.swift in Sources */, 5DF1058525F88AE500D6C0D4 /* NeedsDependency+AVPlayerViewControllerDelegate.swift in Sources */, @@ -2594,6 +2733,7 @@ DBB525302611EBF3002F1F29 /* ProfilePagingViewModel.swift in Sources */, 2D5A3D6225CFD9CB002347D6 /* HomeTimelineViewController+DebugAction.swift in Sources */, DB49A62525FF334C00B98345 /* EmojiService+CustomEmojiViewModel+LoadState.swift in Sources */, + DB4924E226312AB200E9DB22 /* NotificationService.swift in Sources */, DBB525412611ED54002F1F29 /* ProfileHeaderViewController.swift in Sources */, DB9D6BFF25E4F5940051B173 /* ProfileViewController.swift in Sources */, 0FB3D30825E524C600AAD544 /* PickServerCategoriesCell.swift in Sources */, @@ -2609,6 +2749,7 @@ 2D6DE40026141DF600A63F6A /* SearchViewModel.swift in Sources */, DB51D172262832380062B7A1 /* BlurHashDecode.swift in Sources */, DBCC3B89261454BA0045B23D /* CGImage.swift in Sources */, + DB1E05E1263180F500201847 /* AppSecret.swift in Sources */, DBCCC71E25F73297007E1AB6 /* APIService+Reblog.swift in Sources */, DB789A2B25F9F7AB0071ACA0 /* ComposeRepliedToStatusContentCollectionViewCell.swift in Sources */, DBE3CE13261D7D4200430CC6 /* StatusTableViewControllerAspect.swift in Sources */, @@ -2675,6 +2816,17 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + DBF8AE0F263293E400C9C23C /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + DB6D9F232635195E008423CD /* String.swift in Sources */, + DB6D9F3B26352019008423CD /* AppSecret.swift in Sources */, + DB6D9F3526351B7A008423CD /* NotificationService+Decrypt.swift in Sources */, + DBF8AE16263293E400C9C23C /* NotificationService.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ @@ -2703,6 +2855,11 @@ target = DB89B9ED25C10FD0008580ED /* CoreDataStack */; targetProxy = DB89BA0125C10FD0008580ED /* PBXContainerItemProxy */; }; + DBF8AE19263293E400C9C23C /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = DBF8AE12263293E400C9C23C /* NotificationService */; + targetProxy = DBF8AE18263293E400C9C23C /* PBXContainerItemProxy */; + }; /* End PBXTargetDependency section */ /* Begin PBXVariantGroup section */ @@ -2861,6 +3018,7 @@ isa = XCBuildConfiguration; baseConfigurationReference = 2E1F6A67FDF9771D3E064FDC /* Pods-Mastodon.debug.xcconfig */; buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CLANG_ENABLE_MODULES = YES; @@ -2888,6 +3046,7 @@ isa = XCBuildConfiguration; baseConfigurationReference = 75E3471C898DDD9631729B6E /* Pods-Mastodon.release.xcconfig */; buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CLANG_ENABLE_MODULES = YES; @@ -3089,6 +3248,48 @@ }; name = Release; }; + DBF8AE1C263293E400C9C23C /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 8ED8C4B1F1BA2DCFF2926BB1 /* Pods-Mastodon-NotificationService.debug.xcconfig */; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = 7LFDZ96332; + INFOPLIST_FILE = NotificationService/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 14.4; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = org.joinmastodon.Mastodon.NotificationService; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + DBF8AE1D263293E400C9C23C /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = B44342AC2B6585F8295F1DDF /* Pods-Mastodon-NotificationService.release.xcconfig */; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = 7LFDZ96332; + INFOPLIST_FILE = NotificationService/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 14.4; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = org.joinmastodon.Mastodon.NotificationService; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -3146,6 +3347,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + DBF8AE1E263293E400C9C23C /* Build configuration list for PBXNativeTarget "NotificationService" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + DBF8AE1C263293E400C9C23C /* Debug */, + DBF8AE1D263293E400C9C23C /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ @@ -3229,6 +3439,14 @@ kind = branch; }; }; + DBF8AE842632992700C9C23C /* XCRemoteSwiftPackageReference "Base85" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/MainasuK/Base85.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 1.0.1; + }; + }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ @@ -3256,6 +3474,11 @@ isa = XCSwiftPackageProductDependency; productName = MastodonSDK; }; + DB00CA962632DDB600A54956 /* CommonOSLog */ = { + isa = XCSwiftPackageProductDependency; + package = DB0140BB25C40D7500F9F3CF /* XCRemoteSwiftPackageReference "CommonOSLog" */; + productName = CommonOSLog; + }; DB0140BC25C40D7500F9F3CF /* CommonOSLog */ = { isa = XCSwiftPackageProductDependency; package = DB0140BB25C40D7500F9F3CF /* XCRemoteSwiftPackageReference "CommonOSLog" */; @@ -3271,6 +3494,11 @@ package = DB5086B625CC0D6400C2C187 /* XCRemoteSwiftPackageReference "Kingfisher" */; productName = Kingfisher; }; + DB6D9F41263527CE008423CD /* AlamofireImage */ = { + isa = XCSwiftPackageProductDependency; + package = DB3D0FF125BAA61700EAA174 /* XCRemoteSwiftPackageReference "AlamofireImage" */; + productName = AlamofireImage; + }; DB9A487D2603456B008B817C /* UITextView+Placeholder */ = { isa = XCSwiftPackageProductDependency; package = DB9A487C2603456B008B817C /* XCRemoteSwiftPackageReference "UITextView-Placeholder" */; @@ -3286,6 +3514,11 @@ package = DBE64A89260C49D200E6359A /* XCRemoteSwiftPackageReference "TwitterTextEditor" */; productName = TwitterTextEditor; }; + DBF8AE852632992800C9C23C /* Base85 */ = { + isa = XCSwiftPackageProductDependency; + package = DBF8AE842632992700C9C23C /* XCRemoteSwiftPackageReference "Base85" */; + productName = Base85; + }; /* End XCSwiftPackageProductDependency section */ /* Begin XCVersionGroup section */ diff --git a/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist b/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist index 18c8840d8..41711ac91 100644 --- a/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist +++ b/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist @@ -7,22 +7,27 @@ CoreDataStack.xcscheme_^#shared#^_ orderHint - 13 + 14 Mastodon - RTL.xcscheme_^#shared#^_ orderHint - 9 + 2 Mastodon - Release.xcscheme_^#shared#^_ orderHint - 2 + 0 Mastodon.xcscheme_^#shared#^_ orderHint - 7 + 1 + + NotificationService.xcscheme_^#shared#^_ + + orderHint + 17 SuppressBuildableAutocreation diff --git a/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved index 741947371..a8c0df347 100644 --- a/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -37,6 +37,15 @@ "version": "3.1.0" } }, + { + "package": "Base85", + "repositoryURL": "https://github.com/MainasuK/Base85.git", + "state": { + "branch": null, + "revision": "626be96816618689627f806b5c875b5adb6346ef", + "version": "1.0.1" + } + }, { "package": "CommonOSLog", "repositoryURL": "https://github.com/MainasuK/CommonOSLog", diff --git a/Mastodon/Extension/Array+removeDuplicates.swift b/Mastodon/Extension/Array+removeDuplicates.swift deleted file mode 100644 index c3a4b0384..000000000 --- a/Mastodon/Extension/Array+removeDuplicates.swift +++ /dev/null @@ -1,23 +0,0 @@ -// -// Array+removeDuplicates.swift -// Mastodon -// -// Created by BradGao on 2021/3/31. -// - -import Foundation - -/// https://www.hackingwithswift.com/example-code/language/how-to-remove-duplicate-items-from-an-array -extension Array where Element: Hashable { - func removingDuplicates() -> [Element] { - var addedDict = [Element: Bool]() - - return filter { - addedDict.updateValue(true, forKey: $0) == nil - } - } - - mutating func removeDuplicates() { - self = self.removingDuplicates() - } -} diff --git a/Mastodon/Extension/Array.swift b/Mastodon/Extension/Array.swift new file mode 100644 index 000000000..42f8594d1 --- /dev/null +++ b/Mastodon/Extension/Array.swift @@ -0,0 +1,99 @@ +// +// Array.swift +// Mastodon +// +// Created by BradGao on 2021/3/31. +// + +import Foundation + +/// https://www.hackingwithswift.com/example-code/language/how-to-remove-duplicate-items-from-an-array +extension Array where Element: Hashable { + func removingDuplicates() -> [Element] { + var addedDict = [Element: Bool]() + + return filter { + addedDict.updateValue(true, forKey: $0) == nil + } + } + + mutating func removeDuplicates() { + self = self.removingDuplicates() + } +} + +// +// CryptoSwift +// +// Copyright (C) 2014-2017 Marcin Krzyżanowski +// This software is provided 'as-is', without any express or implied warranty. +// +// In no event will the authors be held liable for any damages arising from the use of this software. +// +// Permission is granted to anyone to use this software for any purpose,including commercial applications, and to alter it and redistribute it freely, subject to the following restrictions: +// +// - The origin of this software must not be misrepresented; you must not claim that you wrote the original software. If you use this software in a product, an acknowledgment in the product documentation is required. +// - Altered source versions must be plainly marked as such, and must not be misrepresented as being the original software. +// - This notice may not be removed or altered from any source or binary distribution. +// + +extension Array { + init(reserveCapacity: Int) { + self = Array() + self.reserveCapacity(reserveCapacity) + } + + var slice: ArraySlice { + self[self.startIndex ..< self.endIndex] + } +} + +extension Array where Element == UInt8 { + public init(hex: String) { + self.init(reserveCapacity: hex.unicodeScalars.lazy.underestimatedCount) + var buffer: UInt8? + var skip = hex.hasPrefix("0x") ? 2 : 0 + for char in hex.unicodeScalars.lazy { + guard skip == 0 else { + skip -= 1 + continue + } + guard char.value >= 48 && char.value <= 102 else { + removeAll() + return + } + let v: UInt8 + let c: UInt8 = UInt8(char.value) + switch c { + case let c where c <= 57: + v = c - 48 + case let c where c >= 65 && c <= 70: + v = c - 55 + case let c where c >= 97: + v = c - 87 + default: + removeAll() + return + } + if let b = buffer { + append(b << 4 | v) + buffer = nil + } else { + buffer = v + } + } + if let b = buffer { + append(b) + } + } + + public func toHexString() -> String { + `lazy`.reduce(into: "") { + var s = String($1, radix: 16) + if s.count == 1 { + s = "0" + s + } + $0 += s + } + } +} diff --git a/Mastodon/Mastodon.entitlements b/Mastodon/Mastodon.entitlements index d334a5e6d..8917adbf4 100644 --- a/Mastodon/Mastodon.entitlements +++ b/Mastodon/Mastodon.entitlements @@ -2,6 +2,8 @@ + aps-environment + development com.apple.security.application-groups group.org.joinmastodon.mastodon-temp diff --git a/Mastodon/Scene/Settings/SettingsViewController.swift b/Mastodon/Scene/Settings/SettingsViewController.swift index 4615f92ab..b9ded67d0 100644 --- a/Mastodon/Scene/Settings/SettingsViewController.swift +++ b/Mastodon/Scene/Settings/SettingsViewController.swift @@ -55,7 +55,7 @@ class SettingsViewController: UIViewController, NeedsDependency { let view = UIStackView() view.translatesAutoresizingMaskIntoConstraints = false view.isLayoutMarginsRelativeArrangement = true - view.layoutMargins = UIEdgeInsets(top: 15, left: 4, bottom: 5, right: 4) + //view.layoutMargins = UIEdgeInsets(top: 15, left: 4, bottom: 5, right: 4) view.axis = .horizontal view.alignment = .fill view.distribution = .equalSpacing @@ -270,8 +270,9 @@ extension SettingsViewController: UITableViewDelegate { guard section < sections.count else { return nil } let sectionData = sections[section] + let header: SettingsSectionHeader if section == 1 { - let header = SettingsSectionHeader( + header = SettingsSectionHeader( frame: CGRect(x: 0, y: 0, width: 375, height: 66), customView: notifySectionHeader) header.update(title: sectionData.title) @@ -282,16 +283,19 @@ extension SettingsViewController: UITableViewDelegate { let anyone = L10n.Scene.Settings.Section.Notifications.Trigger.anyone whoButton.setTitle(anyone, for: .normal) } - return header } else { - let header = SettingsSectionHeader(frame: CGRect(x: 0, y: 0, width: 375, height: 66)) + header = SettingsSectionHeader(frame: CGRect(x: 0, y: 0, width: 375, height: 66)) header.update(title: sectionData.title) - return header } + + header.preservesSuperviewLayoutMargins = true + + return header } // remove the gap of table's footer func tableView(_ tableView: UITableView, viewForFooterInSection section: Int) -> UIView? { + return UIView() } diff --git a/Mastodon/Scene/Settings/SettingsViewModel.swift b/Mastodon/Scene/Settings/SettingsViewModel.swift index 470617aeb..f7ee4c71b 100644 --- a/Mastodon/Scene/Settings/SettingsViewModel.swift +++ b/Mastodon/Scene/Settings/SettingsViewModel.swift @@ -96,49 +96,49 @@ class SettingsViewModel: NSObject, NeedsDependency { func transform(input: Input?) -> Output? { typealias SubscriptionResponse = Mastodon.Response.Content - createSubscriptionSubject - .debounce(for: .milliseconds(300), scheduler: DispatchQueue.main) - .sink { _ in - } receiveValue: { [weak self] (arg) in - let (triggerBy, values) = arg - guard let self = self else { - return - } - guard let activeMastodonAuthenticationBox = - self.context.authenticationService.activeMastodonAuthenticationBox.value else { - return - } - guard values.count >= 4 else { - return - } - - self.createDisposeBag.removeAll() - typealias Query = Mastodon.API.Subscriptions.CreateSubscriptionQuery - let domain = activeMastodonAuthenticationBox.domain - let query = Query( - // FIXME: to replace the correct endpoint, p256dh, auth - endpoint: "http://www.google.com", - p256dh: "BLQELIDm-6b9Bl07YrEuXJ4BL_YBVQ0dvt9NQGGJxIQidJWHPNa9YrouvcQ9d7_MqzvGS9Alz60SZNCG3qfpk=", - auth: "4vQK-SvRAN5eo-8ASlrwA==", - favourite: values[0], - follow: values[1], - reblog: values[2], - mention: values[3], - poll: nil - ) - self.context.apiService.changeSubscription( - domain: domain, - mastodonAuthenticationBox: activeMastodonAuthenticationBox, - query: query, - triggerBy: triggerBy, - userID: activeMastodonAuthenticationBox.userID - ) - .sink { (_) in - } receiveValue: { (_) in - } - .store(in: &self.createDisposeBag) - } - .store(in: &disposeBag) +// createSubscriptionSubject +// .debounce(for: .milliseconds(300), scheduler: DispatchQueue.main) +// .sink { _ in +// } receiveValue: { [weak self] (arg) in +// let (triggerBy, values) = arg +// guard let self = self else { +// return +// } +// guard let activeMastodonAuthenticationBox = +// self.context.authenticationService.activeMastodonAuthenticationBox.value else { +// return +// } +// guard values.count >= 4 else { +// return +// } +// +// self.createDisposeBag.removeAll() +// typealias Query = Mastodon.API.Subscriptions.CreateSubscriptionQuery +// let domain = activeMastodonAuthenticationBox.domain +// let query = Query( +// // FIXME: to replace the correct endpoint, p256dh, auth +// endpoint: "http://www.google.com", +// p256dh: "BLQELIDm-6b9Bl07YrEuXJ4BL_YBVQ0dvt9NQGGJxIQidJWHPNa9YrouvcQ9d7_MqzvGS9Alz60SZNCG3qfpk=", +// auth: "4vQK-SvRAN5eo-8ASlrwA==", +// favourite: values[0], +// follow: values[1], +// reblog: values[2], +// mention: values[3], +// poll: nil +// ) +// self.context.apiService.changeSubscription( +// domain: domain, +// mastodonAuthenticationBox: activeMastodonAuthenticationBox, +// query: query, +// triggerBy: triggerBy, +// userID: activeMastodonAuthenticationBox.userID +// ) +// .sink { (_) in +// } receiveValue: { (_) in +// } +// .store(in: &self.createDisposeBag) +// } +// .store(in: &disposeBag) updateSubscriptionSubject .debounce(for: .milliseconds(300), scheduler: DispatchQueue.main) @@ -160,11 +160,16 @@ class SettingsViewModel: NSObject, NeedsDependency { typealias Query = Mastodon.API.Subscriptions.UpdateSubscriptionQuery let domain = activeMastodonAuthenticationBox.domain let query = Query( - favourite: values[0], - follow: values[1], - reblog: values[2], - mention: values[3], - poll: nil) + data: Mastodon.API.Subscriptions.QueryData( + alerts: Mastodon.API.Subscriptions.QueryData.Alerts( + favourite: values[0], + follow: values[1], + reblog: values[2], + mention: values[3], + poll: nil + ) + ) + ) self.context.apiService.updateSubscription( domain: domain, mastodonAuthenticationBox: activeMastodonAuthenticationBox, diff --git a/Mastodon/Service/APIService/APIService+Subscriptions.swift b/Mastodon/Service/APIService/APIService+Subscriptions.swift index 337ab26d2..5260452ce 100644 --- a/Mastodon/Service/APIService/APIService+Subscriptions.swift +++ b/Mastodon/Service/APIService/APIService+Subscriptions.swift @@ -14,11 +14,11 @@ import MastodonSDK extension APIService { func subscription( - domain: String, - userID: String, mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox ) -> AnyPublisher, Error> { let authorization = mastodonAuthenticationBox.userAuthorization + let domain = mastodonAuthenticationBox.domain + let userID = mastodonAuthenticationBox.userID let findSettings: Setting? = { let request = Setting.sortedFetchRequest @@ -58,18 +58,21 @@ extension APIService { }.eraseToAnyPublisher() } - func changeSubscription( - domain: String, + func createSubscription( mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox, query: Mastodon.API.Subscriptions.CreateSubscriptionQuery, triggerBy: String, userID: String ) -> AnyPublisher, Error> { let authorization = mastodonAuthenticationBox.userAuthorization + let domain = mastodonAuthenticationBox.domain + let userID = mastodonAuthenticationBox.userID - let setting = self.createSettingIfNeed(domain: domain, - userId: userID, - triggerBy: triggerBy) + let setting = self.createSettingIfNeed( + domain: domain, + userId: userID, + triggerBy: triggerBy + ) return Mastodon.API.Subscriptions.createSubscription( session: session, domain: domain, diff --git a/Mastodon/Service/NotificationService.swift b/Mastodon/Service/NotificationService.swift new file mode 100644 index 000000000..098fdff62 --- /dev/null +++ b/Mastodon/Service/NotificationService.swift @@ -0,0 +1,241 @@ +// +// NotificationService.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-22. +// + +import os.log +import UIKit +import Combine +import CoreData +import CoreDataStack +import MastodonSDK + +final class NotificationService { + + var disposeBag = Set() + + let workingQueue = DispatchQueue(label: "org.joinmastodon.Mastodon.NotificationService.working-queue") + + // input + weak var apiService: APIService? + weak var authenticationService: AuthenticationService? + let isNotificationPermissionGranted = CurrentValueSubject(false) + let deviceToken = CurrentValueSubject(nil) + let mastodonAuthenticationBoxes = CurrentValueSubject<[AuthenticationService.MastodonAuthenticationBox], Never>([]) + + // output + /// [Token: UserID] + let notificationSubscriptionDict: [String: NotificationSubscription] = [:] + + init( + apiService: APIService, + authenticationService: AuthenticationService + ) { + self.apiService = apiService + self.authenticationService = authenticationService + + authenticationService.mastodonAuthentications + .handleEvents(receiveOutput: { [weak self] mastodonAuthentications in + guard let self = self else { return } + + // request permission when sign-in + guard !mastodonAuthentications.isEmpty else { return } + self.requestNotificationPermission() + }) + .map { authentications -> [AuthenticationService.MastodonAuthenticationBox] in + return authentications.compactMap { authentication -> AuthenticationService.MastodonAuthenticationBox? in + return AuthenticationService.MastodonAuthenticationBox( + domain: authentication.domain, + userID: authentication.userID, + appAuthorization: Mastodon.API.OAuth.Authorization(accessToken: authentication.appAccessToken), + userAuthorization: Mastodon.API.OAuth.Authorization(accessToken: authentication.userAccessToken) + ) + } + } + .assign(to: \.value, on: mastodonAuthenticationBoxes) + .store(in: &disposeBag) + + deviceToken + .receive(on: DispatchQueue.main) + .sink { [weak self] deviceToken in + guard let self = self else { return } + guard let deviceToken = deviceToken else { return } + let token = [UInt8](deviceToken).toHexString() + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: deviceToken: %s", ((#file as NSString).lastPathComponent), #line, #function, token) + } + .store(in: &disposeBag) + + Publishers.CombineLatest3( + isNotificationPermissionGranted, + deviceToken, + mastodonAuthenticationBoxes + ) + .sink { [weak self] isNotificationPermissionGranted, deviceToken, mastodonAuthenticationBoxes in + guard let self = self else { return } + guard isNotificationPermissionGranted else { return } + guard let deviceToken = deviceToken else { return } + self.registerNotificationSubscriptions( + deviceToken: [UInt8](deviceToken).toHexString(), + mastodonAuthenticationBoxes: mastodonAuthenticationBoxes + ) + } + .store(in: &disposeBag) + } + +} + +extension NotificationService { + private func requestNotificationPermission() { + let center = UNUserNotificationCenter.current() + center.requestAuthorization(options: [.alert, .sound, .badge]) { [weak self] granted, error in + guard let self = self else { return } + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: request notification permission: %s", ((#file as NSString).lastPathComponent), #line, #function, granted ? "granted" : "fail") + + self.isNotificationPermissionGranted.value = granted + + if let _ = error { + // Handle the error here. + } + + // Enable or disable features based on the authorization. + } + } + + private func registerNotificationSubscriptions( + deviceToken: String, + mastodonAuthenticationBoxes: [AuthenticationService.MastodonAuthenticationBox] + ) { + for mastodonAuthenticationBox in mastodonAuthenticationBoxes { + guard let notificationSubscription = dequeueNotificationSubscription(mastodonAuthenticationBox: mastodonAuthenticationBox) else { continue } + let token = NotificationSubscription.SubscribeToken( + deviceToken: deviceToken, + authenticationBox: mastodonAuthenticationBox + ) + guard let subscription = subscribe( + notificationSubscription: notificationSubscription, + token: token + ) else { continue } + + subscription + .sink { completion in + // handle error + } receiveValue: { response in + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: did create subscription %s with userToken %s", ((#file as NSString).lastPathComponent), #line, #function, response.value.id, mastodonAuthenticationBox.userAuthorization.accessToken) + // do nothing + } + .store(in: &self.disposeBag) + } + } + + private func dequeueNotificationSubscription(mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox) -> NotificationSubscription? { + var _notificationSubscription: NotificationSubscription? + workingQueue.sync { + let domain = mastodonAuthenticationBox.domain + let userID = mastodonAuthenticationBox.userID + let key = [domain, userID].joined(separator: "@") + + if let notificationSubscription = notificationSubscriptionDict[key] { + _notificationSubscription = notificationSubscription + } else { + let notificationSubscription = NotificationSubscription(domain: domain, userID: userID) + _notificationSubscription = notificationSubscription + } + } + return _notificationSubscription + } + + private func subscribe( + notificationSubscription: NotificationSubscription, + token: NotificationSubscription.SubscribeToken + ) -> AnyPublisher, Error>? { + guard let apiService = self.apiService else { return nil } + + if let oldToken = notificationSubscription.token { + guard oldToken != token else { return nil } + } + notificationSubscription.token = token + + let appSecret = AppSecret.default + let endpoint = appSecret.notificationEndpoint + "/" + token.deviceToken + let p256dh = appSecret.uncompressionNotificationPublicKeyData + let auth = appSecret.notificationAuth + + let query = Mastodon.API.Subscriptions.CreateSubscriptionQuery( + subscription: Mastodon.API.Subscriptions.QuerySubscription( + endpoint: endpoint, + keys: Mastodon.API.Subscriptions.QuerySubscription.Keys( + p256dh: p256dh, + auth: auth + ) + ), + data: Mastodon.API.Subscriptions.QueryData( + alerts: Mastodon.API.Subscriptions.QueryData.Alerts( + favourite: true, + follow: true, + reblog: true, + mention: true, + poll: true + ) + ) + ) + + return apiService.createSubscription( + mastodonAuthenticationBox: token.authenticationBox, + query: query, + triggerBy: "anyone", + userID: token.authenticationBox.userID + ) + } + + static func createRandomAuthBytes() -> Data { + let byteCount = 16 + var bytes = Data(count: byteCount) + _ = bytes.withUnsafeMutableBytes { SecRandomCopyBytes(kSecRandomDefault, byteCount, $0.baseAddress!) } + return bytes + } +} + +extension NotificationService { + final class NotificationSubscription { + + var disposeBag = Set() + + // input + let domain: String + let userID: Mastodon.Entity.Account.ID + + var token: SubscribeToken? + + init(domain: String, userID: Mastodon.Entity.Account.ID) { + self.domain = domain + self.userID = userID + } + + struct SubscribeToken: Equatable { + + let deviceToken: String + let authenticationBox: AuthenticationService.MastodonAuthenticationBox + // TODO: set other parameter + + init( + deviceToken: String, + authenticationBox: AuthenticationService.MastodonAuthenticationBox + ) { + self.deviceToken = deviceToken + self.authenticationBox = authenticationBox + } + + static func == ( + lhs: NotificationService.NotificationSubscription.SubscribeToken, + rhs: NotificationService.NotificationSubscription.SubscribeToken + ) -> Bool { + return lhs.deviceToken == rhs.deviceToken && + lhs.authenticationBox.domain == rhs.authenticationBox.domain && + lhs.authenticationBox.userID == rhs.authenticationBox.userID && + lhs.authenticationBox.userAuthorization.accessToken == rhs.authenticationBox.userAuthorization.accessToken + } + } + } +} diff --git a/Mastodon/State/AppContext.swift b/Mastodon/State/AppContext.swift index 903cb7693..1ed2c283f 100644 --- a/Mastodon/State/AppContext.swift +++ b/Mastodon/State/AppContext.swift @@ -28,6 +28,7 @@ class AppContext: ObservableObject { let videoPlaybackService = VideoPlaybackService() let statusPrefetchingService: StatusPrefetchingService let statusPublishService = StatusPublishService() + let notificationService: NotificationService let documentStore: DocumentStore private var documentStoreSubscription: AnyCancellable! @@ -45,11 +46,12 @@ class AppContext: ObservableObject { let _apiService = APIService(backgroundManagedObjectContext: _backgroundManagedObjectContext) apiService = _apiService - authenticationService = AuthenticationService( + let _authenticationService = AuthenticationService( managedObjectContext: _managedObjectContext, backgroundManagedObjectContext: _backgroundManagedObjectContext, apiService: _apiService ) + authenticationService = _authenticationService emojiService = EmojiService( apiService: apiService @@ -57,6 +59,10 @@ class AppContext: ObservableObject { statusPrefetchingService = StatusPrefetchingService( apiService: _apiService ) + notificationService = NotificationService( + apiService: _apiService, + authenticationService: _authenticationService + ) documentStore = DocumentStore() documentStoreSubscription = documentStore.objectWillChange diff --git a/Mastodon/Supporting Files/AppDelegate.swift b/Mastodon/Supporting Files/AppDelegate.swift index 72ee1334d..fd4c23004 100644 --- a/Mastodon/Supporting Files/AppDelegate.swift +++ b/Mastodon/Supporting Files/AppDelegate.swift @@ -5,6 +5,7 @@ // Created by MainasuK Cirno on 2021/1/22. // +import os.log import UIKit @main @@ -18,6 +19,9 @@ class AppDelegate: UIResponder, UIApplicationDelegate { UserDefaults.standard.setValue(UIApplication.appVersion(), forKey: "Mastodon.appVersion") UserDefaults.standard.setValue(UIApplication.appBuild(), forKey: "Mastodon.appBundle") +// UNUserNotificationCenter.current().delegate = self + application.registerForRemoteNotifications() + return true } @@ -38,13 +42,23 @@ class AppDelegate: UIResponder, UIApplicationDelegate { } - extension AppDelegate { func application(_ application: UIApplication, supportedInterfaceOrientationsFor window: UIWindow?) -> UIInterfaceOrientationMask { return UIDevice.current.userInterfaceIdiom == .phone ? .portrait : .all } } +extension AppDelegate { + + func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) { + appContext.notificationService.deviceToken.value = deviceToken + } +} + +// MARK: - UNUserNotificationCenterDelegate +extension AppDelegate: UNUserNotificationCenterDelegate { + +} extension AppContext { static var shared: AppContext { diff --git a/Mastodon/Supporting Files/AppSecret.swift b/Mastodon/Supporting Files/AppSecret.swift new file mode 100644 index 000000000..0a30553a7 --- /dev/null +++ b/Mastodon/Supporting Files/AppSecret.swift @@ -0,0 +1,51 @@ +// +// AppSecret.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-22. +// + +import Foundation +import CryptoKit +import Keys + +final class AppSecret { + + let notificationEndpoint: String + + let notificationPrivateKey: P256.KeyAgreement.PrivateKey! + let notificationPublicKey: P256.KeyAgreement.PublicKey! + let notificationAuth: Data + + static let `default`: AppSecret = { + return AppSecret() + }() + + init() { + let keys = MastodonKeys() + + #if DEBUG + self.notificationEndpoint = keys.notification_endpoint_debug + let nonce = keys.notification_key_nonce_debug + let auth = keys.notification_key_auth_debug + #else + self.notificationEndpoint = keys.notification_endpoint + let nonce = keys.notification_key_nonce + let auth = keys.notification_key_auth + #endif + + notificationPrivateKey = try! P256.KeyAgreement.PrivateKey(rawRepresentation: Data(base64Encoded: nonce)!) + notificationPublicKey = notificationPrivateKey!.publicKey + notificationAuth = Data(base64Encoded: auth)! + } + + var uncompressionNotificationPublicKeyData: Data { + var data = notificationPublicKey.rawRepresentation + if data.count == 64 { + let prefix: [UInt8] = [0x04] + data = Data(prefix) + data + } + return data + } + +} diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Push.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Push.swift index df9168499..f78c2c6c6 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Push.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Push.swift @@ -117,110 +117,75 @@ extension Mastodon.API.Subscriptions { } extension Mastodon.API.Subscriptions { - public struct CreateSubscriptionQuery: Codable, PostQuery { + + public struct QuerySubscription: Codable { let endpoint: String - let p256dh: String - let auth: String - let favourite: Bool? - let follow: Bool? - let reblog: Bool? - let mention: Bool? - let poll: Bool? - - var queryItems: [URLQueryItem]? { - var items = [URLQueryItem]() - - items.append(URLQueryItem(name: "subscription[endpoint]", value: endpoint)) - items.append(URLQueryItem(name: "subscription[keys][p256dh]", value: p256dh)) - items.append(URLQueryItem(name: "subscription[keys][auth]", value: auth)) - - if let followValue = follow?.queryItemValue { - let followItem = URLQueryItem(name: "data[alerts][follow]", value: followValue) - items.append(followItem) - } - - if let favouriteValue = favourite?.queryItemValue { - let favouriteItem = URLQueryItem(name: "data[alerts][favourite]", value: favouriteValue) - items.append(favouriteItem) - } - - if let reblogValue = reblog?.queryItemValue { - let reblogItem = URLQueryItem(name: "data[alerts][reblog]", value: reblogValue) - items.append(reblogItem) - } - - if let mentionValue = mention?.queryItemValue { - let mentionItem = URLQueryItem(name: "data[alerts][mention]", value: mentionValue) - items.append(mentionItem) - } - return items - } + let keys: Keys public init( endpoint: String, - p256dh: String, - auth: String, - favourite: Bool?, - follow: Bool?, - reblog: Bool?, - mention: Bool?, - poll: Bool? + keys: Keys ) { self.endpoint = endpoint - self.p256dh = p256dh - self.auth = auth - self.favourite = favourite - self.follow = follow - self.reblog = reblog - self.mention = mention - self.poll = poll + self.keys = keys + } + + public struct Keys: Codable { + let p256dh: String + let auth: String + + public init(p256dh: Data, auth: Data) { + self.p256dh = p256dh.base64UrlEncodedString() + self.auth = auth.base64UrlEncodedString() + } + } + } + + public struct QueryData: Codable { + let alerts: Alerts + + public init(alerts: Mastodon.API.Subscriptions.QueryData.Alerts) { + self.alerts = alerts + } + + public struct Alerts: Codable { + let favourite: Bool? + let follow: Bool? + let reblog: Bool? + let mention: Bool? + let poll: Bool? + + public init(favourite: Bool?, follow: Bool?, reblog: Bool?, mention: Bool?, poll: Bool?) { + self.favourite = favourite + self.follow = follow + self.reblog = reblog + self.mention = mention + self.poll = poll + } + } + } + + public struct CreateSubscriptionQuery: Codable, PostQuery { + let subscription: QuerySubscription + let data: QueryData + + public init( + subscription: Mastodon.API.Subscriptions.QuerySubscription, + data: Mastodon.API.Subscriptions.QueryData + ) { + self.subscription = subscription + self.data = data } } public struct UpdateSubscriptionQuery: Codable, PutQuery { - let favourite: Bool? - let follow: Bool? - let reblog: Bool? - let mention: Bool? - let poll: Bool? - var queryItems: [URLQueryItem]? { - var items = [URLQueryItem]() - - if let followValue = follow?.queryItemValue { - let followItem = URLQueryItem(name: "data[alerts][follow]", value: followValue) - items.append(followItem) - } - - if let favouriteValue = favourite?.queryItemValue { - let favouriteItem = URLQueryItem(name: "data[alerts][favourite]", value: favouriteValue) - items.append(favouriteItem) - } - - if let reblogValue = reblog?.queryItemValue { - let reblogItem = URLQueryItem(name: "data[alerts][reblog]", value: reblogValue) - items.append(reblogItem) - } - - if let mentionValue = mention?.queryItemValue { - let mentionItem = URLQueryItem(name: "data[alerts][mention]", value: mentionValue) - items.append(mentionItem) - } - return items + let data: QueryData + + public init(data: Mastodon.API.Subscriptions.QueryData) { + self.data = data } - public init( - favourite: Bool?, - follow: Bool?, - reblog: Bool?, - mention: Bool?, - poll: Bool? - ) { - self.favourite = favourite - self.follow = follow - self.reblog = reblog - self.mention = mention - self.poll = poll - } + var queryItems: [URLQueryItem]? { nil } } } diff --git a/MastodonSDK/Sources/MastodonSDK/Extension/Data.swift b/MastodonSDK/Sources/MastodonSDK/Extension/Data.swift index 43354394d..48e442b9d 100644 --- a/MastodonSDK/Sources/MastodonSDK/Extension/Data.swift +++ b/MastodonSDK/Sources/MastodonSDK/Extension/Data.swift @@ -35,3 +35,12 @@ extension Data { } } + +extension Data { + func base64UrlEncodedString() -> String { + return base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + } +} diff --git a/NotificationService/Extension/String.swift b/NotificationService/Extension/String.swift new file mode 100644 index 000000000..edb162428 --- /dev/null +++ b/NotificationService/Extension/String.swift @@ -0,0 +1,31 @@ +// +// String.swift +// NotificationService +// +// Created by MainasuK Cirno on 2021-4-25. +// + +import Foundation + +extension String { + static func normalize(base64String: String) -> String { + let base64 = base64String + .replacingOccurrences(of: "-", with: "+") + .replacingOccurrences(of: "_", with: "/") + .padding() + return base64 + } + + private func padding() -> String { + let remainder = self.count % 4 + if remainder > 0 { + return self.padding( + toLength: self.count + 4 - remainder, + withPad: "=", + startingAt: 0 + ) + } + return self + } +} + diff --git a/NotificationService/Info.plist b/NotificationService/Info.plist new file mode 100644 index 000000000..7db9ec9cb --- /dev/null +++ b/NotificationService/Info.plist @@ -0,0 +1,31 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + NotificationService + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + NSExtension + + NSExtensionPointIdentifier + com.apple.usernotifications.service + NSExtensionPrincipalClass + $(PRODUCT_MODULE_NAME).NotificationService + + + diff --git a/NotificationService/NotificationService+Decrypt.swift b/NotificationService/NotificationService+Decrypt.swift new file mode 100644 index 000000000..065863fda --- /dev/null +++ b/NotificationService/NotificationService+Decrypt.swift @@ -0,0 +1,96 @@ +// +// NotificationService+Decrypt.swift +// NotificationService +// +// Created by MainasuK Cirno on 2021-4-25. +// + +import os.log +import Foundation +import CryptoKit + +extension NotificationService { + + static func decrypt(payload: Data, salt: Data, auth: Data, privateKey: P256.KeyAgreement.PrivateKey, publicKey: P256.KeyAgreement.PublicKey) -> Data? { + guard let sharedSecret = try? privateKey.sharedSecretFromKeyAgreement(with: publicKey) else { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: failed to craete shared secret", ((#file as NSString).lastPathComponent), #line, #function) + return nil + } + + let keyMaterial = sharedSecret.hkdfDerivedSymmetricKey(using: SHA256.self, salt: auth, sharedInfo: Data("Content-Encoding: auth\0".utf8), outputByteCount: 32) + + let keyInfo = info(type: "aesgcm", clientPublicKey: privateKey.publicKey.x963Representation, serverPublicKey: publicKey.x963Representation) + let key = HKDF.deriveKey(inputKeyMaterial: keyMaterial, salt: salt, info: keyInfo, outputByteCount: 16) + + let nonceInfo = info(type: "nonce", clientPublicKey: privateKey.publicKey.x963Representation, serverPublicKey: publicKey.x963Representation) + let nonce = HKDF.deriveKey(inputKeyMaterial: keyMaterial, salt: salt, info: nonceInfo, outputByteCount: 12) + + let nonceData = nonce.withUnsafeBytes(Array.init) + + guard let sealedBox = try? AES.GCM.SealedBox(combined: nonceData + payload) else { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: failed to create sealedBox", ((#file as NSString).lastPathComponent), #line, #function) + return nil + } + + guard let plaintext = try? AES.GCM.open(sealedBox, using: key) else { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: failed to open sealedBox", ((#file as NSString).lastPathComponent), #line, #function) + return nil + } + + let paddingLength = Int(plaintext[0]) * 256 + Int(plaintext[1]) + guard plaintext.count >= 2 + paddingLength else { + print("1") + fatalError() + } + let unpadded = plaintext.suffix(from: paddingLength + 2) + + return Data(unpadded) + } + + static private func info(type: String, clientPublicKey: Data, serverPublicKey: Data) -> Data { + var info = Data() + + info.append("Content-Encoding: ".data(using: .utf8)!) + info.append(type.data(using: .utf8)!) + info.append(0) + info.append("P-256".data(using: .utf8)!) + info.append(0) + info.append(0) + info.append(65) + info.append(clientPublicKey) + info.append(0) + info.append(65) + info.append(serverPublicKey) + + return info + } +} + +extension NotificationService { + struct MastodonNotification: Codable { + + private let _accessToken: String + var accessToken: String { + return String.normalize(base64String: _accessToken) + } + + let notificationID: Int + let notificationType: String + + let preferredLocale: String? + let icon: String? + let title: String + let body: String + + enum CodingKeys: String, CodingKey { + case _accessToken = "access_token" + case notificationID = "notification_id" + case notificationType = "notification_type" + case preferredLocale = "preferred_locale" + case icon + case title + case body + } + + } +} diff --git a/NotificationService/NotificationService.swift b/NotificationService/NotificationService.swift new file mode 100644 index 000000000..911866f17 --- /dev/null +++ b/NotificationService/NotificationService.swift @@ -0,0 +1,103 @@ +// +// NotificationService.swift +// NotificationService +// +// Created by MainasuK Cirno on 2021-4-23. +// + +import UserNotifications +import CommonOSLog +import CryptoKit +import AlamofireImage +import Base85 +import Keys + +class NotificationService: UNNotificationServiceExtension { + + var contentHandler: ((UNNotificationContent) -> Void)? + var bestAttemptContent: UNMutableNotificationContent? + + override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) { + self.contentHandler = contentHandler + bestAttemptContent = (request.content.mutableCopy() as? UNMutableNotificationContent) + + if let bestAttemptContent = bestAttemptContent { + // Modify the notification content here... + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + + let privateKey = AppSecret.default.notificationPrivateKey! + + guard let encodedPayload = bestAttemptContent.userInfo["p"] as? String, + let payload = Data(base85Encoded: encodedPayload, options: [], encoding: .z85) else { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: invalid payload", ((#file as NSString).lastPathComponent), #line, #function) + contentHandler(bestAttemptContent) + return + } + + guard let encodedPublicKey = bestAttemptContent.userInfo["k"] as? String, + let publicKey = NotificationService.publicKey(encodedPublicKey: encodedPublicKey) else { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: invalid public key", ((#file as NSString).lastPathComponent), #line, #function) + contentHandler(bestAttemptContent) + return + } + + guard let encodedSalt = bestAttemptContent.userInfo["s"] as? String, + let salt = Data(base85Encoded: encodedSalt, options: [], encoding: .z85) else { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: invalid salt", ((#file as NSString).lastPathComponent), #line, #function) + contentHandler(bestAttemptContent) + return + } + + let auth = AppSecret.default.notificationAuth + guard let plaintextData = NotificationService.decrypt(payload: payload, salt: salt, auth: auth, privateKey: privateKey, publicKey: publicKey), + let notification = try? JSONDecoder().decode(MastodonNotification.self, from: plaintextData) else { + contentHandler(bestAttemptContent) + return + } + + bestAttemptContent.title = notification.title + bestAttemptContent.subtitle = "" + bestAttemptContent.body = notification.body + + if let urlString = notification.icon, let url = URL(string: urlString) { + let temporaryDirectoryURL = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("notification-attachments") + try? FileManager.default.createDirectory(at: temporaryDirectoryURL, withIntermediateDirectories: true, attributes: nil) + let filename = url.lastPathComponent + let fileURL = temporaryDirectoryURL.appendingPathComponent(filename) + + ImageDownloader.default.download(URLRequest(url: url), completion: { [weak self] response in + guard let _ = self else { return } + switch response.result { + case .failure(let error): + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: download image %s fail: %s", ((#file as NSString).lastPathComponent), #line, #function, url.debugDescription, error.localizedDescription) + case .success(let image): + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: download image %s success", ((#file as NSString).lastPathComponent), #line, #function, url.debugDescription) + try? image.pngData()?.write(to: fileURL) + if let attachment = try? UNNotificationAttachment(identifier: filename, url: fileURL, options: nil) { + bestAttemptContent.attachments = [attachment] + } + } + contentHandler(bestAttemptContent) + }) + } else { + contentHandler(bestAttemptContent) + } + } + } + + override func serviceExtensionTimeWillExpire() { + // Called just before the extension will be terminated by the system. + // Use this as an opportunity to deliver your "best attempt" at modified content, otherwise the original push payload will be used. + if let contentHandler = contentHandler, let bestAttemptContent = bestAttemptContent { + contentHandler(bestAttemptContent) + } + } + +} + +extension NotificationService { + static func publicKey(encodedPublicKey: String) -> P256.KeyAgreement.PublicKey? { + guard let publicKeyData = Data(base85Encoded: encodedPublicKey, options: [], encoding: .z85) else { return nil } + return try? P256.KeyAgreement.PublicKey(x963Representation: publicKeyData) + } +} diff --git a/Podfile b/Podfile index d4bec65d4..bb929277e 100644 --- a/Podfile +++ b/Podfile @@ -23,4 +23,20 @@ target 'Mastodon' do # Pods for testing end + target 'NotificationService' do + + end + end + +plugin 'cocoapods-keys', { + :project => "Mastodon", + :keys => [ + "notification_endpoint", + "notification_endpoint_debug", + "notification_key_nonce", + "notification_key_nonce_debug", + "notification_key_auth", + "notification_key_auth_debug" + ] +} \ No newline at end of file diff --git a/Podfile.lock b/Podfile.lock index 4f553c4e3..d34a2ada5 100644 --- a/Podfile.lock +++ b/Podfile.lock @@ -1,12 +1,14 @@ PODS: - DateToolsSwift (5.0.0) - Kanna (5.2.4) + - Keys (1.0.1) - SwiftGen (6.4.0) - "UITextField+Shake (1.2.1)" DEPENDENCIES: - DateToolsSwift (~> 5.0.0) - Kanna (~> 5.2.2) + - Keys (from `Pods/CocoaPodsKeys`) - SwiftGen (~> 6.4.0) - "UITextField+Shake (~> 1.2)" @@ -17,12 +19,17 @@ SPEC REPOS: - SwiftGen - "UITextField+Shake" +EXTERNAL SOURCES: + Keys: + :path: Pods/CocoaPodsKeys + SPEC CHECKSUMS: DateToolsSwift: 4207ada6ad615d8dc076323d27037c94916dbfa6 Kanna: b9d00d7c11428308c7f95e1f1f84b8205f567a8f + Keys: a576f4c9c1c641ca913a959a9c62ed3f215a8de9 SwiftGen: 67860cc7c3cfc2ed25b9b74cfd55495fc89f9108 "UITextField+Shake": 298ac5a0f239d731bdab999b19b628c956ca0ac3 -PODFILE CHECKSUM: 30e8e3a555251a512e7b5e91183747152f126e7a +PODFILE CHECKSUM: b99204f58cb11d471cfad7269bbf0abb853dc953 COCOAPODS: 1.10.1 diff --git a/README.md b/README.md index 23957fa16..d3cddb071 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,7 @@ arch -x86_64 pod install - [AlamofireNetworkActivityIndicator](https://github.com/Alamofire/AlamofireNetworkActivityIndicator) - [Alamofire](https://github.com/Alamofire/Alamofire) - [CommonOSLog](https://github.com/mainasuk/CommonOSLog) +- [CryptoSwift](https://github.com/krzyzanowskim/CryptoSwift) - [DateToolSwift](https://github.com/MatthewYork/DateTools) - [Kanna](https://github.com/tid-kijyun/Kanna) - [Kingfisher](https://github.com/onevcat/Kingfisher) From cbd598739e291ab1d852407ec1bedbebe10f9f08 Mon Sep 17 00:00:00 2001 From: CMK Date: Mon, 26 Apr 2021 16:57:50 +0800 Subject: [PATCH 02/19] feat: make push notification trigger update when change setting --- .../CoreData.xcdatamodel/contents | 47 +- CoreDataStack/CoreDataStack.swift | 2 +- CoreDataStack/Entity/Setting.swift | 55 +- CoreDataStack/Entity/Subscription.swift | 76 ++- CoreDataStack/Entity/SubscriptionAlerts.swift | 168 ++++--- Mastodon.xcodeproj/project.pbxproj | 58 ++- .../xcschemes/xcschememanagement.plist | 4 +- Mastodon/Coordinator/SceneCoordinator.swift | 12 +- .../SettingFetchedResultController.swift | 64 +++ Mastodon/Diffiable/Item/SettingsItem.swift | 67 +++ .../Diffiable/Section/SettingsSection.swift | 24 + .../Extension/CoreDataStack/Setting.swift | 24 + .../CoreDataStack/Subscription.swift | 20 + .../CoreDataStack/SubscriptionAlerts.swift | 28 ++ .../Mastodon+API+Subscriptions+Policy.swift | 20 + Mastodon/Extension/UserDefaults.swift | 31 ++ .../Preference/AppearancePreference.swift | 20 + ...meTimelineViewController+DebugAction.swift | 4 +- .../HomeTimelineViewController.swift | 14 +- .../Scene/Profile/ProfileViewController.swift | 4 +- .../Settings/SettingsViewController.swift | 298 +++++------ .../Scene/Settings/SettingsViewModel.swift | 473 +++++------------- .../SettingsAppearanceTableViewCell.swift | 17 +- .../View/Cell/SettingsLinkTableViewCell.swift | 14 +- .../Cell/SettingsToggleTableViewCell.swift | 56 ++- .../View/Content/TimelineHeaderView.swift | 2 + .../APIService/APIService+Subscriptions.swift | 153 ++---- .../APIService+CoreData+Setting.swift | 61 +++ .../APIService+CoreData+Subscriptions.swift | 101 +--- Mastodon/Service/AuthenticationService.swift | 55 +- Mastodon/Service/NotificationService.swift | 184 ++----- Mastodon/Service/SettingService.swift | 162 ++++++ Mastodon/State/AppContext.swift | 11 +- Mastodon/Supporting Files/AppSharedName.swift | 12 + Mastodon/Supporting Files/SceneDelegate.swift | 10 +- .../MastodonSDK/API/Mastodon+API+Push.swift | 79 ++- .../MastodonSDK/API/Mastodon+API.swift | 8 + .../Entity/Mastodon+Entity+Subscription.swift | 35 +- .../Sources/MastodonSDK/Query/Query.swift | 2 + 39 files changed, 1356 insertions(+), 1119 deletions(-) create mode 100644 Mastodon/Diffiable/FetchedResultsController/SettingFetchedResultController.swift create mode 100644 Mastodon/Diffiable/Item/SettingsItem.swift create mode 100644 Mastodon/Diffiable/Section/SettingsSection.swift create mode 100644 Mastodon/Extension/CoreDataStack/Setting.swift create mode 100644 Mastodon/Extension/CoreDataStack/Subscription.swift create mode 100644 Mastodon/Extension/CoreDataStack/SubscriptionAlerts.swift create mode 100644 Mastodon/Extension/MastodonSDK/Mastodon+API+Subscriptions+Policy.swift create mode 100644 Mastodon/Extension/UserDefaults.swift create mode 100644 Mastodon/Preference/AppearancePreference.swift create mode 100644 Mastodon/Service/APIService/CoreData/APIService+CoreData+Setting.swift create mode 100644 Mastodon/Service/SettingService.swift create mode 100644 Mastodon/Supporting Files/AppSharedName.swift diff --git a/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents b/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents index 0d0170282..69c30e990 100644 --- a/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents +++ b/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents @@ -172,14 +172,12 @@ - - - - - - - - + + + + + + @@ -221,24 +219,27 @@ - + + + - - - - + + + + - - - - - + + + + + + - + @@ -263,10 +264,10 @@ - + - - + + - + \ No newline at end of file diff --git a/CoreDataStack/CoreDataStack.swift b/CoreDataStack/CoreDataStack.swift index 1d13ee5ee..766bcf4de 100644 --- a/CoreDataStack/CoreDataStack.swift +++ b/CoreDataStack/CoreDataStack.swift @@ -18,7 +18,7 @@ public final class CoreDataStack { } public convenience init(databaseName: String = "shared") { - let storeURL = URL.storeURL(for: "group.org.joinmastodon.mastodon-temp", databaseName: databaseName) + let storeURL = URL.storeURL(for: AppSharedName.groupID, databaseName: databaseName) let storeDescription = NSPersistentStoreDescription(url: storeURL) self.init(persistentStoreDescriptions: [storeDescription]) } diff --git a/CoreDataStack/Entity/Setting.swift b/CoreDataStack/Entity/Setting.swift index 671f9bab3..6fac8c351 100644 --- a/CoreDataStack/Entity/Setting.swift +++ b/CoreDataStack/Entity/Setting.swift @@ -9,66 +9,61 @@ import CoreData import Foundation public final class Setting: NSManagedObject { - @NSManaged public var appearance: String? - @NSManaged public var triggerBy: String? - @NSManaged public var domain: String? - @NSManaged public var userID: String? + + @NSManaged public var appearanceRaw: String + @NSManaged public var domain: String + @NSManaged public var userID: String @NSManaged public private(set) var createdAt: Date @NSManaged public private(set) var updatedAt: Date - // relationships - @NSManaged public var subscription: Set? + // one-to-many relationships + @NSManaged public var subscriptions: Set? } -public extension Setting { - override func awakeFromInsert() { - super.awakeFromInsert() - setPrimitiveValue(Date(), forKey: #keyPath(Setting.createdAt)) - } +extension Setting { - func didUpdate(at networkDate: Date) { - self.updatedAt = networkDate + public override func awakeFromInsert() { + super.awakeFromInsert() + let now = Date() + setPrimitiveValue(now, forKey: #keyPath(Setting.createdAt)) + setPrimitiveValue(now, forKey: #keyPath(Setting.updatedAt)) } @discardableResult - static func insert( + public static func insert( into context: NSManagedObjectContext, property: Property ) -> Setting { let setting: Setting = context.insertObject() - setting.appearance = property.appearance - setting.triggerBy = property.triggerBy + setting.appearanceRaw = property.appearanceRaw setting.domain = property.domain setting.userID = property.userID return setting } - func update(appearance: String?) { - guard appearance != self.appearance else { return } - self.appearance = appearance + public func update(appearanceRaw: String) { + guard appearanceRaw != self.appearanceRaw else { return } + self.appearanceRaw = appearanceRaw didUpdate(at: Date()) } - func update(triggerBy: String?) { - guard triggerBy != self.triggerBy else { return } - self.triggerBy = triggerBy - didUpdate(at: Date()) + public func didUpdate(at networkDate: Date) { + self.updatedAt = networkDate } + } -public extension Setting { - struct Property { - public let appearance: String - public let triggerBy: String +extension Setting { + public struct Property { public let domain: String public let userID: String + public let appearanceRaw: String - public init(appearance: String, triggerBy: String, domain: String, userID: String) { - self.appearance = appearance - self.triggerBy = triggerBy + public init(domain: String, userID: String, appearanceRaw: String) { self.domain = domain self.userID = userID + self.appearanceRaw = appearanceRaw } } } diff --git a/CoreDataStack/Entity/Subscription.swift b/CoreDataStack/Entity/Subscription.swift index 8ced945d9..6cb1902a1 100644 --- a/CoreDataStack/Entity/Subscription.swift +++ b/CoreDataStack/Entity/Subscription.swift @@ -10,30 +10,35 @@ import Foundation import CoreData public final class Subscription: NSManagedObject { - @NSManaged public var id: String - @NSManaged public var endpoint: String - @NSManaged public var serverKey: String - /// four types: - /// - anyone - /// - a follower - /// - anyone I follow - /// - no one - @NSManaged public var type: String + @NSManaged public var id: String? + @NSManaged public var endpoint: String? + @NSManaged public var policyRaw: String + @NSManaged public var serverKey: String? + @NSManaged public var userToken: String? @NSManaged public private(set) var createdAt: Date @NSManaged public private(set) var updatedAt: Date + @NSManaged public private(set) var activedAt: Date + + // MARK: one-to-one relationships + @NSManaged public var alert: SubscriptionAlerts - // MARK: - relationships - @NSManaged public var alert: SubscriptionAlerts? - // MARK: holder + // MARK: many-to-one relationships @NSManaged public var setting: Setting? } public extension Subscription { override func awakeFromInsert() { super.awakeFromInsert() - setPrimitiveValue(Date(), forKey: #keyPath(Subscription.createdAt)) + let now = Date() + setPrimitiveValue(now, forKey: #keyPath(Subscription.createdAt)) + setPrimitiveValue(now, forKey: #keyPath(Subscription.updatedAt)) + setPrimitiveValue(now, forKey: #keyPath(Subscription.activedAt)) + } + + func update(activedAt: Date) { + self.activedAt = activedAt } func didUpdate(at networkDate: Date) { @@ -43,45 +48,22 @@ public extension Subscription { @discardableResult static func insert( into context: NSManagedObjectContext, - property: Property + property: Property, + setting: Setting ) -> Subscription { - let setting: Subscription = context.insertObject() - setting.id = property.id - setting.endpoint = property.endpoint - setting.serverKey = property.serverKey - setting.type = property.type - - return setting + let subscription: Subscription = context.insertObject() + subscription.policyRaw = property.policyRaw + subscription.setting = setting + return subscription } } public extension Subscription { struct Property { - public let endpoint: String - public let id: String - public let serverKey: String - public let type: String + public let policyRaw: String - public init(endpoint: String, id: String, serverKey: String, type: String) { - self.endpoint = endpoint - self.id = id - self.serverKey = serverKey - self.type = type - } - } - - func updateIfNeed(property: Property) { - if self.endpoint != property.endpoint { - self.endpoint = property.endpoint - } - if self.id != property.id { - self.id = property.id - } - if self.serverKey != property.serverKey { - self.serverKey = property.serverKey - } - if self.type != property.type { - self.type = property.type + public init(policyRaw: String) { + self.policyRaw = policyRaw } } } @@ -94,8 +76,8 @@ extension Subscription: Managed { extension Subscription { - public static func predicate(type: String) -> NSPredicate { - return NSPredicate(format: "%K == %@", #keyPath(Subscription.type), type) + public static func predicate(policyRaw: String) -> NSPredicate { + return NSPredicate(format: "%K == %@", #keyPath(Subscription.policyRaw), policyRaw) } } diff --git a/CoreDataStack/Entity/SubscriptionAlerts.swift b/CoreDataStack/Entity/SubscriptionAlerts.swift index f5abf4955..613d1caf7 100644 --- a/CoreDataStack/Entity/SubscriptionAlerts.swift +++ b/CoreDataStack/Entity/SubscriptionAlerts.swift @@ -10,117 +10,165 @@ import Foundation import CoreData public final class SubscriptionAlerts: NSManagedObject { - @NSManaged public var follow: NSNumber? - @NSManaged public var favourite: NSNumber? - @NSManaged public var reblog: NSNumber? - @NSManaged public var mention: NSNumber? - @NSManaged public var poll: NSNumber? + @NSManaged public var favouriteRaw: NSNumber? + @NSManaged public var followRaw: NSNumber? + @NSManaged public var followRequestRaw: NSNumber? + @NSManaged public var mentionRaw: NSNumber? + @NSManaged public var pollRaw: NSNumber? + @NSManaged public var reblogRaw: NSNumber? @NSManaged public private(set) var createdAt: Date @NSManaged public private(set) var updatedAt: Date - // MARK: - relationships - @NSManaged public var subscription: Subscription? + // MARK: one-to-one relationships + @NSManaged public var subscription: Subscription } -public extension SubscriptionAlerts { - override func awakeFromInsert() { - super.awakeFromInsert() - setPrimitiveValue(Date(), forKey: #keyPath(SubscriptionAlerts.createdAt)) - } +extension SubscriptionAlerts { - func didUpdate(at networkDate: Date) { - self.updatedAt = networkDate + public override func awakeFromInsert() { + super.awakeFromInsert() + let now = Date() + setPrimitiveValue(now, forKey: #keyPath(SubscriptionAlerts.createdAt)) + setPrimitiveValue(now, forKey: #keyPath(SubscriptionAlerts.updatedAt)) } @discardableResult - static func insert( + public static func insert( into context: NSManagedObjectContext, - property: Property + property: Property, + subscription: Subscription ) -> SubscriptionAlerts { let alerts: SubscriptionAlerts = context.insertObject() - alerts.favourite = property.favourite - alerts.follow = property.follow - alerts.mention = property.mention - alerts.poll = property.poll - alerts.reblog = property.reblog + + alerts.favouriteRaw = property.favouriteRaw + alerts.followRaw = property.followRaw + alerts.followRequestRaw = property.followRequestRaw + alerts.mentionRaw = property.mentionRaw + alerts.pollRaw = property.pollRaw + alerts.reblogRaw = property.reblogRaw + + alerts.subscription = subscription + return alerts } - func update(favourite: NSNumber?) { + public func update(favourite: Bool?) { guard self.favourite != favourite else { return } self.favourite = favourite didUpdate(at: Date()) } - func update(follow: NSNumber?) { + public func update(follow: Bool?) { guard self.follow != follow else { return } self.follow = follow didUpdate(at: Date()) } - func update(mention: NSNumber?) { + public func update(followRequest: Bool?) { + guard self.followRequest != followRequest else { return } + self.followRequest = followRequest + + didUpdate(at: Date()) + } + + public func update(mention: Bool?) { guard self.mention != mention else { return } self.mention = mention didUpdate(at: Date()) } - func update(poll: NSNumber?) { + public func update(poll: Bool?) { guard self.poll != poll else { return } self.poll = poll didUpdate(at: Date()) } - func update(reblog: NSNumber?) { + public func update(reblog: Bool?) { guard self.reblog != reblog else { return } self.reblog = reblog didUpdate(at: Date()) } + + public func didUpdate(at networkDate: Date) { + self.updatedAt = networkDate + } + } -public extension SubscriptionAlerts { - struct Property { - public let favourite: NSNumber? - public let follow: NSNumber? - public let mention: NSNumber? - public let poll: NSNumber? - public let reblog: NSNumber? +extension SubscriptionAlerts { + + private func boolean(from number: NSNumber?) -> Bool? { + return number.flatMap { $0.intValue == 1 } + } + + private func number(from boolean: Bool?) -> NSNumber? { + return boolean.flatMap { NSNumber(integerLiteral: $0 ? 1 : 0) } + } + + public var favourite: Bool? { + get { boolean(from: favouriteRaw) } + set { favouriteRaw = number(from: newValue) } + } + + public var follow: Bool? { + get { boolean(from: followRaw) } + set { followRaw = number(from: newValue) } + } + + public var followRequest: Bool? { + get { boolean(from: followRequestRaw) } + set { followRequestRaw = number(from: newValue) } + } + + public var mention: Bool? { + get { boolean(from: mentionRaw) } + set { mentionRaw = number(from: newValue) } + } + + public var poll: Bool? { + get { boolean(from: pollRaw) } + set { pollRaw = number(from: newValue) } + } + + public var reblog: Bool? { + get { boolean(from: reblogRaw) } + set { reblogRaw = number(from: newValue) } + } + +} - public init(favourite: NSNumber?, follow: NSNumber?, mention: NSNumber?, poll: NSNumber?, reblog: NSNumber?) { - self.favourite = favourite - self.follow = follow - self.mention = mention - self.poll = poll - self.reblog = reblog +extension SubscriptionAlerts { + public struct Property { + public let favouriteRaw: NSNumber? + public let followRaw: NSNumber? + public let followRequestRaw: NSNumber? + public let mentionRaw: NSNumber? + public let pollRaw: NSNumber? + public let reblogRaw: NSNumber? + + public init( + favourite: Bool?, + follow: Bool?, + followRequest: Bool?, + mention: Bool?, + poll: Bool?, + reblog: Bool? + ) { + self.favouriteRaw = favourite.flatMap { NSNumber(value: $0 ? 1 : 0) } + self.followRaw = follow.flatMap { NSNumber(value: $0 ? 1 : 0) } + self.followRequestRaw = followRequest.flatMap { NSNumber(value: $0 ? 1 : 0) } + self.mentionRaw = mention.flatMap { NSNumber(value: $0 ? 1 : 0) } + self.pollRaw = poll.flatMap { NSNumber(value: $0 ? 1 : 0) } + self.reblogRaw = reblog.flatMap { NSNumber(value: $0 ? 1 : 0) } } } - func updateIfNeed(property: Property) { - if self.follow != property.follow { - self.follow = property.follow - } - - if self.favourite != property.favourite { - self.favourite = property.favourite - } - - if self.reblog != property.reblog { - self.reblog = property.reblog - } - - if self.mention != property.mention { - self.mention = property.mention - } - - if self.poll != property.poll { - self.poll = property.poll - } - } } extension SubscriptionAlerts: Managed { diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index 30202e558..b1bc63e6d 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -138,7 +138,6 @@ 5B90C460262599800002E742 /* SettingsAppearanceTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B90C45A262599800002E742 /* SettingsAppearanceTableViewCell.swift */; }; 5B90C461262599800002E742 /* SettingsLinkTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B90C45B262599800002E742 /* SettingsLinkTableViewCell.swift */; }; 5B90C462262599800002E742 /* SettingsSectionHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B90C45C262599800002E742 /* SettingsSectionHeader.swift */; }; - 5B90C463262599800002E742 /* SettingsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B90C45D262599800002E742 /* SettingsViewController.swift */; }; 5B90C46E26259B2C0002E742 /* Subscription.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B90C46C26259B2C0002E742 /* Subscription.swift */; }; 5B90C46F26259B2C0002E742 /* Setting.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B90C46D26259B2C0002E742 /* Setting.swift */; }; 5B90C47F26259BA90002E742 /* SubscriptionAlerts.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B90C47E26259BA90002E742 /* SubscriptionAlerts.swift */; }; @@ -250,10 +249,24 @@ DB6B35182601FA3400DC1E11 /* MastodonAttachmentService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6B35172601FA3400DC1E11 /* MastodonAttachmentService.swift */; }; DB6B351E2601FAEE00DC1E11 /* ComposeStatusAttachmentCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6B351D2601FAEE00DC1E11 /* ComposeStatusAttachmentCollectionViewCell.swift */; }; DB6C8C0F25F0A6AE00AAA452 /* Mastodon+Entity+Error.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6C8C0E25F0A6AE00AAA452 /* Mastodon+Entity+Error.swift */; }; + DB6D1B24263684C600ACB481 /* UserDefaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6D1B23263684C600ACB481 /* UserDefaults.swift */; }; + DB6D1B2B2636852000ACB481 /* AppSharedName.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6D1B2A2636852000ACB481 /* AppSharedName.swift */; }; + DB6D1B312636853100ACB481 /* AppSharedName.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6D1B2A2636852000ACB481 /* AppSharedName.swift */; }; + DB6D1B3D2636857500ACB481 /* AppearancePreference.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6D1B3C2636857500ACB481 /* AppearancePreference.swift */; }; + DB6D1B44263691CF00ACB481 /* Mastodon+API+Subscriptions+Policy.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6D1B43263691CF00ACB481 /* Mastodon+API+Subscriptions+Policy.swift */; }; DB6D9F232635195E008423CD /* String.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6D9F222635195E008423CD /* String.swift */; }; DB6D9F3526351B7A008423CD /* NotificationService+Decrypt.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6D9F3426351B7A008423CD /* NotificationService+Decrypt.swift */; }; DB6D9F3B26352019008423CD /* AppSecret.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB1E05E0263180F500201847 /* AppSecret.swift */; }; DB6D9F42263527CE008423CD /* AlamofireImage in Frameworks */ = {isa = PBXBuildFile; productRef = DB6D9F41263527CE008423CD /* AlamofireImage */; }; + DB6D9F4926353FD7008423CD /* Subscription.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6D9F4826353FD6008423CD /* Subscription.swift */; }; + DB6D9F502635761F008423CD /* SubscriptionAlerts.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6D9F4F2635761F008423CD /* SubscriptionAlerts.swift */; }; + DB6D9F57263577D2008423CD /* APIService+CoreData+Setting.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6D9F56263577D2008423CD /* APIService+CoreData+Setting.swift */; }; + DB6D9F6326357848008423CD /* SettingService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6D9F6226357848008423CD /* SettingService.swift */; }; + DB6D9F6F2635807F008423CD /* Setting.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6D9F6E2635807F008423CD /* Setting.swift */; }; + DB6D9F76263587C7008423CD /* SettingFetchedResultController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6D9F75263587C7008423CD /* SettingFetchedResultController.swift */; }; + DB6D9F7D26358ED4008423CD /* SettingsSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6D9F7C26358ED4008423CD /* SettingsSection.swift */; }; + DB6D9F8426358EEC008423CD /* SettingsItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6D9F8326358EEC008423CD /* SettingsItem.swift */; }; + DB6D9F9726367249008423CD /* SettingsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6D9F9626367249008423CD /* SettingsViewController.swift */; }; DB71FD2C25F86A5100512AE1 /* AvatarStackContainerButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB71FD2B25F86A5100512AE1 /* AvatarStackContainerButton.swift */; }; DB71FD3625F8A16C00512AE1 /* APIService+Persist+PersistMemo.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB71FD3525F8A16C00512AE1 /* APIService+Persist+PersistMemo.swift */; }; DB71FD3C25F8A1C500512AE1 /* APIService+Persist+PersistCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB71FD3B25F8A1C500512AE1 /* APIService+Persist+PersistCache.swift */; }; @@ -584,7 +597,6 @@ 5B90C45A262599800002E742 /* SettingsAppearanceTableViewCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SettingsAppearanceTableViewCell.swift; sourceTree = ""; }; 5B90C45B262599800002E742 /* SettingsLinkTableViewCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SettingsLinkTableViewCell.swift; sourceTree = ""; }; 5B90C45C262599800002E742 /* SettingsSectionHeader.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SettingsSectionHeader.swift; sourceTree = ""; }; - 5B90C45D262599800002E742 /* SettingsViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SettingsViewController.swift; sourceTree = ""; }; 5B90C46C26259B2C0002E742 /* Subscription.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Subscription.swift; sourceTree = ""; }; 5B90C46D26259B2C0002E742 /* Setting.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Setting.swift; sourceTree = ""; }; 5B90C47E26259BA90002E742 /* SubscriptionAlerts.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SubscriptionAlerts.swift; sourceTree = ""; }; @@ -703,8 +715,21 @@ DB6B35172601FA3400DC1E11 /* MastodonAttachmentService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonAttachmentService.swift; sourceTree = ""; }; DB6B351D2601FAEE00DC1E11 /* ComposeStatusAttachmentCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeStatusAttachmentCollectionViewCell.swift; sourceTree = ""; }; DB6C8C0E25F0A6AE00AAA452 /* Mastodon+Entity+Error.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Mastodon+Entity+Error.swift"; sourceTree = ""; }; + DB6D1B23263684C600ACB481 /* UserDefaults.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserDefaults.swift; sourceTree = ""; }; + DB6D1B2A2636852000ACB481 /* AppSharedName.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSharedName.swift; sourceTree = ""; }; + DB6D1B3C2636857500ACB481 /* AppearancePreference.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppearancePreference.swift; sourceTree = ""; }; + DB6D1B43263691CF00ACB481 /* Mastodon+API+Subscriptions+Policy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Mastodon+API+Subscriptions+Policy.swift"; sourceTree = ""; }; DB6D9F222635195E008423CD /* String.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = String.swift; sourceTree = ""; }; DB6D9F3426351B7A008423CD /* NotificationService+Decrypt.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NotificationService+Decrypt.swift"; sourceTree = ""; }; + DB6D9F4826353FD6008423CD /* Subscription.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Subscription.swift; sourceTree = ""; }; + DB6D9F4F2635761F008423CD /* SubscriptionAlerts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubscriptionAlerts.swift; sourceTree = ""; }; + DB6D9F56263577D2008423CD /* APIService+CoreData+Setting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+CoreData+Setting.swift"; sourceTree = ""; }; + DB6D9F6226357848008423CD /* SettingService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingService.swift; sourceTree = ""; }; + DB6D9F6E2635807F008423CD /* Setting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Setting.swift; sourceTree = ""; }; + DB6D9F75263587C7008423CD /* SettingFetchedResultController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingFetchedResultController.swift; sourceTree = ""; }; + DB6D9F7C26358ED4008423CD /* SettingsSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsSection.swift; sourceTree = ""; }; + DB6D9F8326358EEC008423CD /* SettingsItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsItem.swift; sourceTree = ""; }; + DB6D9F9626367249008423CD /* SettingsViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SettingsViewController.swift; sourceTree = ""; }; DB71FD2B25F86A5100512AE1 /* AvatarStackContainerButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AvatarStackContainerButton.swift; sourceTree = ""; }; DB71FD3525F8A16C00512AE1 /* APIService+Persist+PersistMemo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Persist+PersistMemo.swift"; sourceTree = ""; }; DB71FD3B25F8A1C500512AE1 /* APIService+Persist+PersistCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Persist+PersistCache.swift"; sourceTree = ""; }; @@ -1109,6 +1134,7 @@ DB71FD4B25F8C80E00512AE1 /* StatusPrefetchingService.swift */, DBC7A67B260DFADE00E57475 /* StatusPublishService.swift */, DB4924E126312AB200E9DB22 /* NotificationService.swift */, + DB6D9F6226357848008423CD /* SettingService.swift */, ); path = Service; sourceTree = ""; @@ -1176,6 +1202,7 @@ 2D198648261C0B8500F0B013 /* SearchResultSection.swift */, DB66729525F9F91600D60309 /* ComposeStatusSection.swift */, DB447680260B3ED600B66B82 /* CustomEmojiPickerSection.swift */, + DB6D9F7C26358ED4008423CD /* SettingsSection.swift */, ); path = Section; sourceTree = ""; @@ -1232,6 +1259,7 @@ DB1FD45925F27898004CFCFC /* CategoryPickerItem.swift */, DB66729B25F9F91F00D60309 /* ComposeStatusItem.swift */, DB44768A260B3F2100B66B82 /* CustomEmojiPickerItem.swift */, + DB6D9F8326358EEC008423CD /* SettingsItem.swift */, ); path = Item; sourceTree = ""; @@ -1285,8 +1313,8 @@ isa = PBXGroup; children = ( 5B90C457262599800002E742 /* View */, + DB6D9F9626367249008423CD /* SettingsViewController.swift */, 5B90C456262599800002E742 /* SettingsViewModel.swift */, - 5B90C45D262599800002E742 /* SettingsViewController.swift */, ); path = Settings; sourceTree = ""; @@ -1350,6 +1378,9 @@ DB084B5625CBC56C00F898ED /* Status.swift */, DB45FAE225CA7181005A8AC7 /* MastodonUser.swift */, DB9D6C3725E508BE0051B173 /* Attachment.swift */, + DB6D9F6E2635807F008423CD /* Setting.swift */, + DB6D9F4826353FD6008423CD /* Subscription.swift */, + DB6D9F4F2635761F008423CD /* SubscriptionAlerts.swift */, ); path = CoreDataStack; sourceTree = ""; @@ -1377,6 +1408,7 @@ DB427DD525BAA00100D1B89D /* AppDelegate.swift */, DB427DD725BAA00100D1B89D /* SceneDelegate.swift */, DB1E05E0263180F500201847 /* AppSecret.swift */, + DB6D1B2A2636852000ACB481 /* AppSharedName.swift */, DB427DDB25BAA00100D1B89D /* Main.storyboard */, DB427DE025BAA00100D1B89D /* LaunchScreen.storyboard */, DB68A05C25E9055900CFDF14 /* Settings.bundle */, @@ -1511,6 +1543,7 @@ DB45FADC25CA6F6B005A8AC7 /* APIService+CoreData+MastodonUser.swift */, DB45FAF825CA80A2005A8AC7 /* APIService+CoreData+MastodonAuthentication.swift */, 2D79E700261EA5550011E398 /* APIService+CoreData+Tag.swift */, + DB6D9F56263577D2008423CD /* APIService+CoreData+Setting.swift */, 5B90C48A26259C120002E742 /* APIService+CoreData+Subscriptions.swift */, ); path = CoreData; @@ -1530,6 +1563,7 @@ isa = PBXGroup; children = ( DB5086BD25CC0D9900C2C187 /* SplashPreference.swift */, + DB6D1B3C2636857500ACB481 /* AppearancePreference.swift */, ); path = Preference; sourceTree = ""; @@ -1573,6 +1607,7 @@ 5DDDF1A82617489F00311060 /* Mastodon+Entity+History.swift */, 2D650FAA25ECDC9300851B58 /* Mastodon+Entity+Error+Detail.swift */, 2DB72C8B262D764300CE6173 /* Mastodon+Entity+Notification+Type.swift */, + DB6D1B43263691CF00ACB481 /* Mastodon+API+Subscriptions+Policy.swift */, ); path = MastodonSDK; sourceTree = ""; @@ -1787,6 +1822,7 @@ 0F20223826146553000C64BF /* Array.swift */, DBCC3B2F261440A50045B23D /* UITabBarController.swift */, DBCC3B35261440BA0045B23D /* UINavigationController.swift */, + DB6D1B23263684C600ACB481 /* UserDefaults.swift */, ); path = Extension; sourceTree = ""; @@ -1994,6 +2030,7 @@ isa = PBXGroup; children = ( DBCBED1C26132E1A00B49291 /* StatusFetchedResultsController.swift */, + DB6D9F75263587C7008423CD /* SettingFetchedResultController.swift */, ); path = FetchedResultsController; sourceTree = ""; @@ -2555,6 +2592,7 @@ DB55D33025FB630A0002F825 /* TwitterTextEditor+String.swift in Sources */, 2D38F1EB25CD477000561493 /* HomeTimelineViewModel+LoadLatestState.swift in Sources */, 0F202201261326E6000C64BF /* HashtagTimelineViewModel.swift in Sources */, + DB6D9F9726367249008423CD /* SettingsViewController.swift in Sources */, DBD9149025DF6D8D00903DFD /* APIService+Onboarding.swift in Sources */, DBAE3FAF26172FC0004B8251 /* RemoteProfileViewModel.swift in Sources */, DBE3CE0D261D767100430CC6 /* FavoriteViewController+StatusProvider.swift in Sources */, @@ -2566,8 +2604,8 @@ 5DDDF1A92617489F00311060 /* Mastodon+Entity+History.swift in Sources */, DB59F11825EFA35B001F1DAB /* StripProgressView.swift in Sources */, DB59F10425EF5EBC001F1DAB /* TableViewCellHeightCacheableContainer.swift in Sources */, - 5B90C463262599800002E742 /* SettingsViewController.swift in Sources */, DB73B490261F030A002E9E9F /* SafariActivity.swift in Sources */, + DB6D1B44263691CF00ACB481 /* Mastodon+API+Subscriptions+Policy.swift in Sources */, DB9A48962603685D008B817C /* MastodonAttachmentService+UploadState.swift in Sources */, 2D198643261BF09500F0B013 /* SearchResultItem.swift in Sources */, DB66728C25F9F8DC00D60309 /* ComposeViewModel+Diffable.swift in Sources */, @@ -2599,6 +2637,7 @@ 5B90C45F262599800002E742 /* SettingsToggleTableViewCell.swift in Sources */, 2D694A7425F9EB4E0038ADDC /* ContentWarningOverlayView.swift in Sources */, DBAE3F682615DD60004B8251 /* UserProvider.swift in Sources */, + DB6D9F76263587C7008423CD /* SettingFetchedResultController.swift in Sources */, DB9A486C26032AC1008B817C /* AttachmentContainerView+EmptyStateView.swift in Sources */, 5D0393902612D259007FE196 /* WebViewController.swift in Sources */, DB4481CC25EE2AFE00BEFB67 /* PollItem.swift in Sources */, @@ -2624,9 +2663,11 @@ DB1FD43625F26899004CFCFC /* MastodonPickServerViewModel+LoadIndexedServerState.swift in Sources */, 2D939AE825EE1CF80076FA61 /* MastodonRegisterViewController+Avatar.swift in Sources */, 2D084B8D26258EA3003AA3AF /* NotificationViewModel+diffable.swift in Sources */, + DB6D1B24263684C600ACB481 /* UserDefaults.swift in Sources */, DB1D186C25EF5BA7003F1F23 /* PollTableView.swift in Sources */, 2D5981A125E4A593000FB903 /* MastodonConfirmEmailViewModel.swift in Sources */, DB87D4452609BE0500D12C0D /* ComposeStatusPollOptionCollectionViewCell.swift in Sources */, + DB6D9F7D26358ED4008423CD /* SettingsSection.swift in Sources */, DB8AF55025C13703002E6C99 /* MainTabBarController.swift in Sources */, DB9D6BE925E4F5340051B173 /* SearchViewController.swift in Sources */, 2D38F1C625CD37F400561493 /* ContentOffsetAdjustableTimelineViewControllerDelegate.swift in Sources */, @@ -2645,6 +2686,7 @@ DB2F073525E8ECF000957B2D /* AuthenticationViewModel.swift in Sources */, DB68A04A25E9027700CFDF14 /* AdaptiveStatusBarStyleNavigationController.swift in Sources */, 0FB3D33825E6401400AAD544 /* PickServerCell.swift in Sources */, + DB6D9F8426358EEC008423CD /* SettingsItem.swift in Sources */, 2D364F7825E66D8300204FDC /* MastodonResendEmailViewModel.swift in Sources */, DB8AF54525C13647002E6C99 /* NeedsDependency.swift in Sources */, DB9D6BF825E4F5690051B173 /* NotificationViewController.swift in Sources */, @@ -2664,6 +2706,7 @@ 2D76319F25C1521200929FB9 /* StatusSection.swift in Sources */, DB35FC252612FD7A006193C9 /* ProfileFieldView.swift in Sources */, DB938F0326240EA300E5B6C1 /* CachedThreadViewModel.swift in Sources */, + DB6D9F6326357848008423CD /* SettingService.swift in Sources */, 2D650FAB25ECDC9300851B58 /* Mastodon+Entity+Error+Detail.swift in Sources */, 2D24E12D2626FD2E00A59D4F /* NotificationViewModel+LoadOldestState.swift in Sources */, 2DB72C8C262D764300CE6173 /* Mastodon+Entity+Notification+Type.swift in Sources */, @@ -2678,11 +2721,13 @@ DB2B3AE925E38850007045F9 /* UIViewPreview.swift in Sources */, DBAE3F9E2616E308004B8251 /* APIService+Mute.swift in Sources */, DB427DD625BAA00100D1B89D /* AppDelegate.swift in Sources */, + DB6D9F57263577D2008423CD /* APIService+CoreData+Setting.swift in Sources */, DB9D6C0E25E4F9780051B173 /* MosaicImageViewContainer.swift in Sources */, DB71FD3625F8A16C00512AE1 /* APIService+Persist+PersistMemo.swift in Sources */, DBBE1B4525F3474B0081417A /* MastodonPickServerAppearance.swift in Sources */, DB98338725C945ED00AD9700 /* Strings.swift in Sources */, 2D7867192625B77500211898 /* NotificationItem.swift in Sources */, + DB6D1B2B2636852000ACB481 /* AppSharedName.swift in Sources */, DB45FAB625CA5485005A8AC7 /* UIAlertController.swift in Sources */, DBE0821525CD382600FD6BBD /* MastodonRegisterViewController.swift in Sources */, 2D5A3D0325CF8742002347D6 /* ControlContainableScrollViews.swift in Sources */, @@ -2701,6 +2746,7 @@ 2D76317D25C14DF500929FB9 /* PublicTimelineViewController+StatusProvider.swift in Sources */, 0F20223326145E51000C64BF /* HashtagTimelineViewModel+LoadMiddleState.swift in Sources */, 2D206B8025F5F45E00143C56 /* UIImage.swift in Sources */, + DB6D9F502635761F008423CD /* SubscriptionAlerts.swift in Sources */, 0F20220726134DA4000C64BF /* HashtagTimelineViewModel+Diffable.swift in Sources */, 2D5A3D2825CF8BC9002347D6 /* HomeTimelineViewModel+Diffable.swift in Sources */, DB98339C25C96DE600AD9700 /* APIService+Account.swift in Sources */, @@ -2734,17 +2780,20 @@ 2D5A3D6225CFD9CB002347D6 /* HomeTimelineViewController+DebugAction.swift in Sources */, DB49A62525FF334C00B98345 /* EmojiService+CustomEmojiViewModel+LoadState.swift in Sources */, DB4924E226312AB200E9DB22 /* NotificationService.swift in Sources */, + DB6D9F6F2635807F008423CD /* Setting.swift in Sources */, DBB525412611ED54002F1F29 /* ProfileHeaderViewController.swift in Sources */, DB9D6BFF25E4F5940051B173 /* ProfileViewController.swift in Sources */, 0FB3D30825E524C600AAD544 /* PickServerCategoriesCell.swift in Sources */, DB789A1225F9F2CC0071ACA0 /* ComposeViewModel.swift in Sources */, DBB525362611ECEB002F1F29 /* UserTimelineViewController.swift in Sources */, + DB6D1B3D2636857500ACB481 /* AppearancePreference.swift in Sources */, DB938F3326243D6200E5B6C1 /* TimelineTopLoaderTableViewCell.swift in Sources */, 2D38F1FE25CD481700561493 /* StatusProvider.swift in Sources */, DB938F25262438D600E5B6C1 /* ThreadViewController+StatusProvider.swift in Sources */, DB66729C25F9F91F00D60309 /* ComposeStatusItem.swift in Sources */, 0FB3D31E25E534C700AAD544 /* PickServerCategoryCollectionViewCell.swift in Sources */, 5DF1057925F88A1D00D6C0D4 /* PlayerContainerView.swift in Sources */, + DB6D9F4926353FD7008423CD /* Subscription.swift in Sources */, DB45FB0F25CA87D0005A8AC7 /* AuthenticationService.swift in Sources */, 2D6DE40026141DF600A63F6A /* SearchViewModel.swift in Sources */, DB51D172262832380062B7A1 /* BlurHashDecode.swift in Sources */, @@ -2794,6 +2843,7 @@ 2D152A9225C2980C009AA50C /* UIFont.swift in Sources */, DB4481B325EE16D000BEFB67 /* PollOption.swift in Sources */, DB89BA4425C1165F008580ED /* Managed.swift in Sources */, + DB6D1B312636853100ACB481 /* AppSharedName.swift in Sources */, 2D6125472625436B00299647 /* Notification.swift in Sources */, DB89BA4325C1165F008580ED /* NetworkUpdatable.swift in Sources */, DB8AF56825C13E2A002E6C99 /* HomeTimelineIndex.swift in Sources */, diff --git a/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist b/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist index 41711ac91..3e5f0c5d3 100644 --- a/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist +++ b/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist @@ -7,7 +7,7 @@ CoreDataStack.xcscheme_^#shared#^_ orderHint - 14 + 13 Mastodon - RTL.xcscheme_^#shared#^_ @@ -27,7 +27,7 @@ NotificationService.xcscheme_^#shared#^_ orderHint - 17 + 14 SuppressBuildableAutocreation diff --git a/Mastodon/Coordinator/SceneCoordinator.swift b/Mastodon/Coordinator/SceneCoordinator.swift index c2608fe83..9770aa9a4 100644 --- a/Mastodon/Coordinator/SceneCoordinator.swift +++ b/Mastodon/Coordinator/SceneCoordinator.swift @@ -61,6 +61,9 @@ extension SceneCoordinator { case profile(viewModel: ProfileViewModel) case favorite(viewModel: FavoriteViewModel) + // setting + case settings(viewModel: SettingsViewModel) + // misc case safari(url: URL) case alertController(alertController: UIAlertController) @@ -68,7 +71,6 @@ extension SceneCoordinator { #if DEBUG case publicTimeline - case settings #endif var isOnboarding: Bool { @@ -246,6 +248,10 @@ private extension SceneCoordinator { let _viewController = FavoriteViewController() _viewController.viewModel = viewModel viewController = _viewController + case .settings(let viewModel): + let _viewController = SettingsViewController() + _viewController.viewModel = viewModel + viewController = _viewController case .safari(let url): guard let scheme = url.scheme?.lowercased(), scheme == "http" || scheme == "https" else { @@ -270,10 +276,6 @@ private extension SceneCoordinator { let _viewController = PublicTimelineViewController() _viewController.viewModel = PublicTimelineViewModel(context: appContext) viewController = _viewController - case .settings: - let _viewController = SettingsViewController() - _viewController.viewModel = SettingsViewModel(context: appContext, coordinator: self) - viewController = _viewController #endif } diff --git a/Mastodon/Diffiable/FetchedResultsController/SettingFetchedResultController.swift b/Mastodon/Diffiable/FetchedResultsController/SettingFetchedResultController.swift new file mode 100644 index 000000000..52eafc6b6 --- /dev/null +++ b/Mastodon/Diffiable/FetchedResultsController/SettingFetchedResultController.swift @@ -0,0 +1,64 @@ +// +// SettingFetchedResultController.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-25. +// + +import os.log +import UIKit +import Combine +import CoreData +import CoreDataStack +import MastodonSDK + +final class SettingFetchedResultController: NSObject { + + var disposeBag = Set() + + let fetchedResultsController: NSFetchedResultsController + + // input + + // output + let settings = CurrentValueSubject<[Setting], Never>([]) + + init(managedObjectContext: NSManagedObjectContext, additionalPredicate: NSPredicate?) { + self.fetchedResultsController = { + let fetchRequest = Setting.sortedFetchRequest + fetchRequest.returnsObjectsAsFaults = false + if let additionalPredicate = additionalPredicate { + fetchRequest.predicate = additionalPredicate + } + fetchRequest.fetchBatchSize = 20 + let controller = NSFetchedResultsController( + fetchRequest: fetchRequest, + managedObjectContext: managedObjectContext, + sectionNameKeyPath: nil, + cacheName: nil + ) + + return controller + }() + super.init() + + fetchedResultsController.delegate = self + + do { + try self.fetchedResultsController.performFetch() + } catch { + assertionFailure(error.localizedDescription) + } + } + +} + +// MARK: - NSFetchedResultsControllerDelegate +extension SettingFetchedResultController: NSFetchedResultsControllerDelegate { + func controller(_ controller: NSFetchedResultsController, didChangeContentWith snapshot: NSDiffableDataSourceSnapshotReference) { + os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + + let objects = fetchedResultsController.fetchedObjects ?? [] + self.settings.value = objects + } +} diff --git a/Mastodon/Diffiable/Item/SettingsItem.swift b/Mastodon/Diffiable/Item/SettingsItem.swift new file mode 100644 index 000000000..8aabdc741 --- /dev/null +++ b/Mastodon/Diffiable/Item/SettingsItem.swift @@ -0,0 +1,67 @@ +// +// SettingsItem.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-25. +// + +import UIKit +import CoreData + +enum SettingsItem: Hashable { + case apperance(settingObjectID: NSManagedObjectID) + case notification(settingObjectID: NSManagedObjectID, switchMode: NotificationSwitchMode) + case boringZone(item: Link) + case spicyZone(item: Link) +} + +extension SettingsItem { + + enum AppearanceMode: String { + case automatic + case light + case dark + } + + enum NotificationSwitchMode: CaseIterable { + case favorite + case follow + case reblog + case mention + + var title: String { + switch self { + case .favorite: return L10n.Scene.Settings.Section.Notifications.favorites + case .follow: return L10n.Scene.Settings.Section.Notifications.follows + case .reblog: return L10n.Scene.Settings.Section.Notifications.boosts + case .mention: return L10n.Scene.Settings.Section.Notifications.mentions + } + } + } + + enum Link: CaseIterable { + case termsOfService + case privacyPolicy + case clearMediaCache + case signOut + + var title: String { + switch self { + case .termsOfService: return L10n.Scene.Settings.Section.Boringzone.terms + case .privacyPolicy: return L10n.Scene.Settings.Section.Boringzone.privacy + case .clearMediaCache: return L10n.Scene.Settings.Section.Spicyzone.clear + case .signOut: return L10n.Scene.Settings.Section.Spicyzone.signout + } + } + + var textColor: UIColor { + switch self { + case .termsOfService: return .systemBlue + case .privacyPolicy: return .systemBlue + case .clearMediaCache: return .systemRed + case .signOut: return .systemRed + } + } + } + +} diff --git a/Mastodon/Diffiable/Section/SettingsSection.swift b/Mastodon/Diffiable/Section/SettingsSection.swift new file mode 100644 index 000000000..7ec78a2ed --- /dev/null +++ b/Mastodon/Diffiable/Section/SettingsSection.swift @@ -0,0 +1,24 @@ +// +// SettingsSection.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-25. +// + +import Foundation + +enum SettingsSection: Hashable { + case apperance + case notifications + case boringZone + case spicyZone + + var title: String { + switch self { + case .apperance: return L10n.Scene.Settings.Section.Appearance.title + case .notifications: return L10n.Scene.Settings.Section.Notifications.title + case .boringZone: return L10n.Scene.Settings.Section.Boringzone.title + case .spicyZone: return L10n.Scene.Settings.Section.Spicyzone.title + } + } +} diff --git a/Mastodon/Extension/CoreDataStack/Setting.swift b/Mastodon/Extension/CoreDataStack/Setting.swift new file mode 100644 index 000000000..b995b80e3 --- /dev/null +++ b/Mastodon/Extension/CoreDataStack/Setting.swift @@ -0,0 +1,24 @@ +// +// Setting.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-25. +// + +import Foundation +import CoreDataStack +import MastodonSDK + +extension Setting { + + var appearance: SettingsItem.AppearanceMode { + return SettingsItem.AppearanceMode(rawValue: appearanceRaw) ?? .automatic + } + + var activeSubscription: Subscription? { + return (subscriptions ?? Set()) + .sorted(by: { $0.activedAt > $1.activedAt }) + .first + } + +} diff --git a/Mastodon/Extension/CoreDataStack/Subscription.swift b/Mastodon/Extension/CoreDataStack/Subscription.swift new file mode 100644 index 000000000..8253264a0 --- /dev/null +++ b/Mastodon/Extension/CoreDataStack/Subscription.swift @@ -0,0 +1,20 @@ +// +// Subscription.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-25. +// + +import Foundation +import CoreDataStack +import MastodonSDK + +typealias NotificationSubscription = Subscription + +extension Subscription { + + var policy: Mastodon.API.Subscriptions.Policy { + return Mastodon.API.Subscriptions.Policy(rawValue: policyRaw) ?? .all + } + +} diff --git a/Mastodon/Extension/CoreDataStack/SubscriptionAlerts.swift b/Mastodon/Extension/CoreDataStack/SubscriptionAlerts.swift new file mode 100644 index 000000000..edf2df0c9 --- /dev/null +++ b/Mastodon/Extension/CoreDataStack/SubscriptionAlerts.swift @@ -0,0 +1,28 @@ +// +// SubscriptionAlerts.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-25. +// + + +import Foundation +import CoreDataStack +import MastodonSDK + +extension SubscriptionAlerts.Property { + + init(policy: Mastodon.API.Subscriptions.Policy) { + switch policy { + case .all: + self.init(favourite: true, follow: true, followRequest: true, mention: true, poll: true, reblog: true) + case .follower: + self.init(favourite: true, follow: nil, followRequest: nil, mention: true, poll: true, reblog: true) + case .followed: + self.init(favourite: true, follow: true, followRequest: true, mention: true, poll: true, reblog: true) + case .none, ._other: + self.init(favourite: nil, follow: nil, followRequest: nil, mention: nil, poll: nil, reblog: nil) + } + } + +} diff --git a/Mastodon/Extension/MastodonSDK/Mastodon+API+Subscriptions+Policy.swift b/Mastodon/Extension/MastodonSDK/Mastodon+API+Subscriptions+Policy.swift new file mode 100644 index 000000000..24bbfdace --- /dev/null +++ b/Mastodon/Extension/MastodonSDK/Mastodon+API+Subscriptions+Policy.swift @@ -0,0 +1,20 @@ +// +// Mastodon+API+Subscriptions+Policy.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-26. +// + +import Foundation +import MastodonSDK + +extension Mastodon.API.Subscriptions.Policy { + var title: String { + switch self { + case .all: return L10n.Scene.Settings.Section.Notifications.Trigger.anyone + case .follower: return L10n.Scene.Settings.Section.Notifications.Trigger.follower + case .followed: return L10n.Scene.Settings.Section.Notifications.Trigger.follow + case .none, ._other: return L10n.Scene.Settings.Section.Notifications.Trigger.noone + } + } +} diff --git a/Mastodon/Extension/UserDefaults.swift b/Mastodon/Extension/UserDefaults.swift new file mode 100644 index 000000000..5e067bbe9 --- /dev/null +++ b/Mastodon/Extension/UserDefaults.swift @@ -0,0 +1,31 @@ +// +// UserDefaults.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-26. +// + +import Foundation + +extension UserDefaults { + static let shared = UserDefaults(suiteName: AppSharedName.groupID)! +} + +extension UserDefaults { + + subscript(key: String) -> T? { + get { + if let rawValue = value(forKey: key) as? T.RawValue { + return T(rawValue: rawValue) + } + return nil + } + set { set(newValue?.rawValue, forKey: key) } + } + + subscript(key: String) -> T? { + get { return value(forKey: key) as? T } + set { set(newValue, forKey: key) } + } + +} diff --git a/Mastodon/Preference/AppearancePreference.swift b/Mastodon/Preference/AppearancePreference.swift new file mode 100644 index 000000000..8f2818c39 --- /dev/null +++ b/Mastodon/Preference/AppearancePreference.swift @@ -0,0 +1,20 @@ +// +// AppearancePreference.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-26. +// + +import UIKit + +extension UserDefaults { + + @objc dynamic var customUserInterfaceStyle: UIUserInterfaceStyle { + get { + register(defaults: [#function: UIUserInterfaceStyle.unspecified.rawValue]) + return UIUserInterfaceStyle(rawValue: integer(forKey: #function)) ?? .unspecified + } + set { UserDefaults.shared[#function] = newValue.rawValue } + } + +} diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift index 8bbf9436e..63f76152f 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift @@ -328,7 +328,9 @@ extension HomeTimelineViewController { } @objc private func showSettings(_ sender: UIAction) { - coordinator.present(scene: .settings, from: self, transition: .modal(animated: true, completion: nil)) + guard let currentSetting = context.settingService.currentSetting.value else { return } + let settingsViewModel = SettingsViewModel(context: context, setting: currentSetting) + coordinator.present(scene: .settings(viewModel: settingsViewModel), from: self, transition: .modal(animated: true, completion: nil)) } } #endif diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift index 53909b2df..93e632f77 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift @@ -88,14 +88,8 @@ extension HomeTimelineViewController { // long press to trigger debug menu settingBarButtonItem.menu = debugMenu #else - // settingBarButtonItem.target = self - // settingBarButtonItem.action = #selector(HomeTimelineViewController.settingBarButtonItemPressed(_:)) - settingBarButtonItem.menu = UIMenu(title: "Settings", image: nil, identifier: nil, options: .displayInline, children: [ - UIAction(title: "Sign Out", image: UIImage(systemName: "escape"), attributes: .destructive) { [weak self] action in - guard let self = self else { return } - self.signOutAction(action) - } - ]) + settingBarButtonItem.target = self + settingBarButtonItem.action = #selector(HomeTimelineViewController.settingBarButtonItemPressed(_:)) #endif navigationItem.rightBarButtonItem = composeBarButtonItem @@ -220,7 +214,9 @@ extension HomeTimelineViewController { @objc private func settingBarButtonItemPressed(_ sender: UIBarButtonItem) { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) - + guard let setting = context.settingService.currentSetting.value else { return } + let settingsViewModel = SettingsViewModel(context: context, setting: setting) + coordinator.present(scene: .settings(viewModel: settingsViewModel), from: self, transition: .modal(animated: true, completion: nil)) } @objc private func composeBarButtonItemPressed(_ sender: UIBarButtonItem) { diff --git a/Mastodon/Scene/Profile/ProfileViewController.swift b/Mastodon/Scene/Profile/ProfileViewController.swift index 8fc915a0a..e4be1eb1f 100644 --- a/Mastodon/Scene/Profile/ProfileViewController.swift +++ b/Mastodon/Scene/Profile/ProfileViewController.swift @@ -517,7 +517,9 @@ extension ProfileViewController { @objc private func settingBarButtonItemPressed(_ sender: UIBarButtonItem) { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) - + guard let setting = context.settingService.currentSetting.value else { return } + let settingsViewModel = SettingsViewModel(context: context, setting: setting) + coordinator.present(scene: .settings(viewModel: settingsViewModel), from: self, transition: .modal(animated: true, completion: nil)) } @objc private func shareBarButtonItemPressed(_ sender: UIBarButtonItem) { diff --git a/Mastodon/Scene/Settings/SettingsViewController.swift b/Mastodon/Scene/Settings/SettingsViewController.swift index b9ded67d0..aeed943eb 100644 --- a/Mastodon/Scene/Settings/SettingsViewController.swift +++ b/Mastodon/Scene/Settings/SettingsViewController.swift @@ -11,11 +11,10 @@ import Combine import ActiveLabel import CoreData import CoreDataStack +import MastodonSDK import AlamofireImage import Kingfisher -// iTODO: when to ask permission to Use Notifications - class SettingsViewController: UIViewController, NeedsDependency { weak var context: AppContext! { willSet { precondition(!isViewLoaded) } } @@ -23,6 +22,7 @@ class SettingsViewController: UIViewController, NeedsDependency { var viewModel: SettingsViewModel! { willSet { precondition(!isViewLoaded) } } var disposeBag = Set() + var notificationPolicySubscription: AnyCancellable? var triggerMenu: UIMenu { let anyone = L10n.Scene.Settings.Section.Notifications.Trigger.anyone @@ -35,23 +35,23 @@ class SettingsViewController: UIViewController, NeedsDependency { options: .displayInline, children: [ UIAction(title: anyone, image: UIImage(systemName: "person.3"), attributes: []) { [weak self] action in - self?.updateTrigger(by: anyone) + self?.updateTrigger(policy: .all) }, UIAction(title: follower, image: UIImage(systemName: "person.crop.circle.badge.plus"), attributes: []) { [weak self] action in - self?.updateTrigger(by: follower) + self?.updateTrigger(policy: .follower) }, UIAction(title: follow, image: UIImage(systemName: "person.crop.circle.badge.checkmark"), attributes: []) { [weak self] action in - self?.updateTrigger(by: follow) + self?.updateTrigger(policy: .followed) }, UIAction(title: noOne, image: UIImage(systemName: "nosign"), attributes: []) { [weak self] action in - self?.updateTrigger(by: noOne) + self?.updateTrigger(policy: .none) }, ] ) return menu } - lazy var notifySectionHeader: UIView = { + private(set) lazy var notifySectionHeader: UIView = { let view = UIStackView() view.translatesAutoresizingMaskIntoConstraints = false view.isLayoutMarginsRelativeArrangement = true @@ -71,15 +71,12 @@ class SettingsViewController: UIViewController, NeedsDependency { return view }() - lazy var whoButton: UIButton = { + private(set) lazy var whoButton: UIButton = { let whoButton = UIButton(type: .roundedRect) whoButton.menu = triggerMenu whoButton.showsMenuAsPrimaryAction = true whoButton.setBackgroundColor(Asset.Colors.battleshipGrey.color, for: .normal) whoButton.setTitleColor(Asset.Colors.Label.primary.color, for: .normal) - if let setting = self.viewModel.setting.value, let trigger = setting.triggerBy { - whoButton.setTitle(trigger, for: .normal) - } whoButton.titleLabel?.font = UIFontMetrics(forTextStyle: .title3).scaledFont(for: UIFont.systemFont(ofSize: 20, weight: .semibold)) whoButton.contentEdgeInsets = UIEdgeInsets(top: 5, left: 5, bottom: 5, right: 5) whoButton.layer.cornerRadius = 10 @@ -87,7 +84,7 @@ class SettingsViewController: UIViewController, NeedsDependency { return whoButton }() - lazy var tableView: UITableView = { + private(set) lazy var tableView: UITableView = { // init with a frame to fix a conflict ('UIView-Encapsulated-Layout-Width' UIStackView:0x7f8c2b6c0590.width == 0) let tableView = UITableView(frame: CGRect(x: 0, y: 0, width: 320, height: 320), style: .grouped) tableView.translatesAutoresizingMaskIntoConstraints = false @@ -95,13 +92,13 @@ class SettingsViewController: UIViewController, NeedsDependency { tableView.rowHeight = UITableView.automaticDimension tableView.backgroundColor = Asset.Colors.Background.systemGroupedBackground.color - tableView.register(SettingsAppearanceTableViewCell.self, forCellReuseIdentifier: "SettingsAppearanceTableViewCell") - tableView.register(SettingsToggleTableViewCell.self, forCellReuseIdentifier: "SettingsToggleTableViewCell") - tableView.register(SettingsLinkTableViewCell.self, forCellReuseIdentifier: "SettingsLinkTableViewCell") + tableView.register(SettingsAppearanceTableViewCell.self, forCellReuseIdentifier: String(describing: SettingsAppearanceTableViewCell.self)) + tableView.register(SettingsToggleTableViewCell.self, forCellReuseIdentifier: String(describing: SettingsToggleTableViewCell.self)) + tableView.register(SettingsLinkTableViewCell.self, forCellReuseIdentifier: String(describing: SettingsLinkTableViewCell.self)) return tableView }() - lazy var footerView: UIView = { + lazy var tableFooterView: UIView = { // init with a frame to fix a conflict ('UIView-Encapsulated-Layout-Height' UIStackView:0x7ffe41e47da0.height == 0) let view = UIStackView(frame: CGRect(x: 0, y: 0, width: 320, height: 320)) view.isLayoutMarginsRelativeArrangement = true @@ -143,14 +140,30 @@ class SettingsViewController: UIViewController, NeedsDependency { // MAKR: - Private methods private func bindViewModel() { - let input = SettingsViewModel.Input() - _ = viewModel.transform(input: input) + self.whoButton.setTitle(viewModel.setting.value.activeSubscription?.policy.title, for: .normal) + viewModel.setting + .sink { [weak self] setting in + guard let self = self else { return } + self.notificationPolicySubscription = ManagedObjectObserver.observe(object: setting) + .sink { _ in + // do nothing + } receiveValue: { [weak self] change in + guard let self = self else { return } + guard case let .update(object) = change.changeType, + let setting = object as? Setting else { return } + if let activeSubscription = setting.activeSubscription { + self.whoButton.setTitle(activeSubscription.policy.title, for: .normal) + } else { + assertionFailure() + } + } + } + .store(in: &disposeBag) } private func setupView() { view.backgroundColor = Asset.Colors.Background.systemGroupedBackground.color setupNavigation() - setupTableView() view.addSubview(tableView) NSLayoutConstraint.activate([ @@ -159,6 +172,7 @@ class SettingsViewController: UIViewController, NeedsDependency { tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor), tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor), ]) + setupTableView() } private func setupNavigation() { @@ -177,35 +191,12 @@ class SettingsViewController: UIViewController, NeedsDependency { } private func setupTableView() { - viewModel.dataSource = UITableViewDiffableDataSource(tableView: tableView, cellProvider: { [weak self] (tableView, indexPath, item) -> UITableViewCell? in - guard let self = self else { return nil } - - switch item { - case .apperance(let item): - guard let cell = tableView.dequeueReusableCell(withIdentifier: "SettingsAppearanceTableViewCell") as? SettingsAppearanceTableViewCell else { - assertionFailure() - return nil - } - cell.update(with: item, delegate: self) - return cell - case .notification(let item): - guard let cell = tableView.dequeueReusableCell(withIdentifier: "SettingsToggleTableViewCell") as? SettingsToggleTableViewCell else { - assertionFailure() - return nil - } - cell.update(with: item, delegate: self) - return cell - case .boringZone(let item), .spicyZone(let item): - guard let cell = tableView.dequeueReusableCell(withIdentifier: "SettingsLinkTableViewCell") as? SettingsLinkTableViewCell else { - assertionFailure() - return nil - } - cell.update(with: item) - return cell - } - }) - - tableView.tableFooterView = footerView + viewModel.setupDiffableDataSource( + for: tableView, + settingsAppearanceTableViewCellDelegate: self, + settingsToggleCellDelegate: self + ) + tableView.tableFooterView = tableFooterView } func alertToSignout() { @@ -218,7 +209,7 @@ class SettingsViewController: UIViewController, NeedsDependency { let cancelAction = UIAlertAction(title: L10n.Common.Controls.Actions.cancel, style: .cancel, handler: nil) let signOutAction = UIAlertAction(title: L10n.Common.Alerts.SignOut.confirm, style: .destructive) { [weak self] _ in guard let self = self else { return } - self.signout() + self.signOut() } alertController.addAction(cancelAction) alertController.addAction(signOutAction) @@ -229,7 +220,7 @@ class SettingsViewController: UIViewController, NeedsDependency { ) } - func signout() { + func signOut() { guard let activeMastodonAuthenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { return } @@ -258,8 +249,11 @@ class SettingsViewController: UIViewController, NeedsDependency { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s:", ((#file as NSString).lastPathComponent), #line, #function) } - // Mark: - Actions - @objc func doneButtonDidClick() { +} + +// Mark: - Actions +extension SettingsViewController { + @objc private func doneButtonDidClick() { dismiss(animated: true, completion: nil) } } @@ -268,51 +262,39 @@ extension SettingsViewController: UITableViewDelegate { func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? { let sections = viewModel.dataSource.snapshot().sectionIdentifiers guard section < sections.count else { return nil } - let sectionData = sections[section] + + let sectionIdentifier = sections[section] let header: SettingsSectionHeader - if section == 1 { + switch sectionIdentifier { + case .notifications: header = SettingsSectionHeader( frame: CGRect(x: 0, y: 0, width: 375, height: 66), customView: notifySectionHeader) - header.update(title: sectionData.title) - - if let setting = self.viewModel.setting.value, let trigger = setting.triggerBy { - whoButton.setTitle(trigger, for: .normal) - } else { - let anyone = L10n.Scene.Settings.Section.Notifications.Trigger.anyone - whoButton.setTitle(anyone, for: .normal) - } - } else { + header.update(title: sectionIdentifier.title) + default: header = SettingsSectionHeader(frame: CGRect(x: 0, y: 0, width: 375, height: 66)) - header.update(title: sectionData.title) + header.update(title: sectionIdentifier.title) } - header.preservesSuperviewLayoutMargins = true - + return header } - + // remove the gap of table's footer func tableView(_ tableView: UITableView, viewForFooterInSection section: Int) -> UIView? { - return UIView() } - + // remove the gap of table's footer func tableView(_ tableView: UITableView, heightForFooterInSection section: Int) -> CGFloat { - return 0 + return CGFloat.leastNonzeroMagnitude } - + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - let snapshot = self.viewModel.dataSource.snapshot() - let sectionIds = snapshot.sectionIdentifiers - guard indexPath.section < sectionIds.count else { return } - let sectionIdentifier = sectionIds[indexPath.section] - let items = snapshot.itemIdentifiers(inSection: sectionIdentifier) - guard indexPath.row < items.count else { return } - let item = items[indexPath.item] - + guard let dataSource = viewModel.dataSource else { return } + let item = dataSource.itemIdentifier(for: indexPath) + switch item { case .boringZone: guard let url = viewModel.privacyURL else { break } @@ -331,7 +313,7 @@ extension SettingsViewController: UITableViewDelegate { ImageDownloader.defaultURLCache().removeAllCachedResponses() let cleanedDiskBytes = ImageDownloader.defaultURLCache().currentDiskUsage os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: diskBytes %d", ((#file as NSString).lastPathComponent), #line, #function, cleanedDiskBytes) - + // clean Kingfisher Cache KingfisherManager.shared.cache.clearDiskCache() } @@ -347,82 +329,77 @@ extension SettingsViewController: UITableViewDelegate { // Update setting into core data extension SettingsViewController { - func updateTrigger(by who: String) { - guard self.viewModel.triggerBy != who else { return } - guard let setting = self.viewModel.setting.value else { return } + func updateTrigger(policy: Mastodon.API.Subscriptions.Policy) { + let objectID = self.viewModel.setting.value.objectID + let managedObjectContext = context.backgroundManagedObjectContext - setting.update(triggerBy: who) - // trigger to call `subscription` API with POST method - // confirm the local data is correct even if request failed - // The asynchronous execution is to solve the problem of dropped frames for animations. - DispatchQueue.main.async { [weak self] in - self?.viewModel.setting.value = setting + managedObjectContext.performChanges { + let setting = managedObjectContext.object(with: objectID) as! Setting + let (subscription, _) = APIService.CoreData.createOrFetchSubscription( + into: managedObjectContext, + setting: setting, + policy: policy + ) + let now = Date() + subscription.update(activedAt: now) + setting.didUpdate(at: now) } - } - - func updateAlert(title: String?, isOn: Bool) { - guard let title = title else { return } - guard let settings = self.viewModel.setting.value else { return } - guard let triggerBy = settings.triggerBy else { return } - - if let alerts = settings.subscription?.first(where: { (s) -> Bool in - return s.type == settings.triggerBy - })?.alert { - var alertValues = [Bool?]() - alertValues.append(alerts.favourite?.boolValue) - alertValues.append(alerts.follow?.boolValue) - alertValues.append(alerts.reblog?.boolValue) - alertValues.append(alerts.mention?.boolValue) - - // need to update `alerts` to make update API with correct parameter - switch title { - case L10n.Scene.Settings.Section.Notifications.favorites: - alertValues[0] = isOn - alerts.favourite = NSNumber(booleanLiteral: isOn) - case L10n.Scene.Settings.Section.Notifications.follows: - alertValues[1] = isOn - alerts.follow = NSNumber(booleanLiteral: isOn) - case L10n.Scene.Settings.Section.Notifications.boosts: - alertValues[2] = isOn - alerts.reblog = NSNumber(booleanLiteral: isOn) - case L10n.Scene.Settings.Section.Notifications.mentions: - alertValues[3] = isOn - alerts.mention = NSNumber(booleanLiteral: isOn) - default: break - } - self.viewModel.updateSubscriptionSubject.send((triggerBy: triggerBy, values: alertValues)) - } else if let alertValues = self.viewModel.notificationDefaultValue[triggerBy] { - self.viewModel.updateSubscriptionSubject.send((triggerBy: triggerBy, values: alertValues)) + .sink { _ in + // do nothing + } receiveValue: { _ in + // do nohting } + .store(in: &disposeBag) } } +// MARK: - SettingsAppearanceTableViewCellDelegate extension SettingsViewController: SettingsAppearanceTableViewCellDelegate { - func settingsAppearanceCell(_ view: SettingsAppearanceTableViewCell, didSelect: SettingsItem.AppearanceMode) { - guard let setting = self.viewModel.setting.value else { return } - + func settingsAppearanceCell(_ cell: SettingsAppearanceTableViewCell, didSelectAppearanceMode appearanceMode: SettingsItem.AppearanceMode) { + guard let dataSource = viewModel.dataSource else { return } + guard let indexPath = tableView.indexPath(for: cell) else { return } + let item = dataSource.itemIdentifier(for: indexPath) + guard case let .apperance(settingObjectID) = item else { return } + context.managedObjectContext.performChanges { - setting.update(appearance: didSelect.rawValue) + let setting = self.context.managedObjectContext.object(with: settingObjectID) as! Setting + setting.update(appearanceRaw: appearanceMode.rawValue) } - .sink { (_) in - // change light / dark mode - var overrideUserInterfaceStyle: UIUserInterfaceStyle! - switch didSelect { - case .automatic: - overrideUserInterfaceStyle = .unspecified - case .light: - overrideUserInterfaceStyle = .light - case .dark: - overrideUserInterfaceStyle = .dark - } - view.window?.overrideUserInterfaceStyle = overrideUserInterfaceStyle + .sink { _ in + // do nothing }.store(in: &disposeBag) } } extension SettingsViewController: SettingsToggleCellDelegate { - func settingsToggleCell(_ cell: SettingsToggleTableViewCell, didChangeStatus: Bool) { - updateAlert(title: cell.data?.title, isOn: didChangeStatus) + func settingsToggleCell(_ cell: SettingsToggleTableViewCell, switchValueDidChange switch: UISwitch) { + guard let dataSource = viewModel.dataSource else { return } + guard let indexPath = tableView.indexPath(for: cell) else { return } + let item = dataSource.itemIdentifier(for: indexPath) + switch item { + case .notification(let settingObjectID, let switchMode): + let isOn = `switch`.isOn + let managedObjectContext = context.backgroundManagedObjectContext + managedObjectContext.performChanges { + let setting = managedObjectContext.object(with: settingObjectID) as! Setting + guard let subscription = setting.activeSubscription else { return } + let alert = subscription.alert + switch switchMode { + case .favorite: alert.update(favourite: isOn) + case .follow: alert.update(follow: isOn) + case .reblog: alert.update(reblog: isOn) + case .mention: alert.update(mention: isOn) + } + // trigger setting update + alert.subscription.setting?.didUpdate(at: Date()) + } + .sink { _ in + // do nothing + } + .store(in: &disposeBag) + default: + break + } } } @@ -436,43 +413,6 @@ extension SettingsViewController: ActiveLabelDelegate { } } -extension SettingsViewController { - static func updateOverrideUserInterfaceStyle(window: UIWindow?) { - guard let box = AppContext.shared.authenticationService.activeMastodonAuthenticationBox.value else { - return - } - - guard let setting: Setting? = { - let domain = box.domain - let request = Setting.sortedFetchRequest - request.predicate = Setting.predicate(domain: domain, userID: box.userID) - request.fetchLimit = 1 - request.returnsObjectsAsFaults = false - do { - return try AppContext.shared.managedObjectContext.fetch(request).first - } catch { - assertionFailure(error.localizedDescription) - return nil - } - }() else { return } - - guard let didSelect = SettingsItem.AppearanceMode(rawValue: setting?.appearance ?? "") else { - return - } - - var overrideUserInterfaceStyle: UIUserInterfaceStyle! - switch didSelect { - case .automatic: - overrideUserInterfaceStyle = .unspecified - case .light: - overrideUserInterfaceStyle = .light - case .dark: - overrideUserInterfaceStyle = .dark - } - window?.overrideUserInterfaceStyle = overrideUserInterfaceStyle - } -} - #if canImport(SwiftUI) && DEBUG import SwiftUI diff --git a/Mastodon/Scene/Settings/SettingsViewModel.swift b/Mastodon/Scene/Settings/SettingsViewModel.swift index f7ee4c71b..c168b5611 100644 --- a/Mastodon/Scene/Settings/SettingsViewModel.swift +++ b/Mastodon/Scene/Settings/SettingsViewModel.swift @@ -13,38 +13,21 @@ import MastodonSDK import UIKit import os.log -class SettingsViewModel: NSObject, NeedsDependency { - // confirm set only once - weak var context: AppContext! { willSet { precondition(context == nil) } } - weak var coordinator: SceneCoordinator! { willSet { precondition(coordinator == nil) } } +class SettingsViewModel { - var dataSource: UITableViewDiffableDataSource! var disposeBag = Set() + + let context: AppContext + + // input + let setting: CurrentValueSubject var updateDisposeBag = Set() var createDisposeBag = Set() let viewDidLoad = PassthroughSubject() - lazy var fetchResultsController: NSFetchedResultsController = { - let fetchRequest = Setting.sortedFetchRequest - if let box = - self.context.authenticationService.activeMastodonAuthenticationBox.value { - let domain = box.domain - fetchRequest.predicate = Setting.predicate(domain: domain, userID: box.userID) - } - - fetchRequest.fetchLimit = 1 - fetchRequest.returnsObjectsAsFaults = false - let controller = NSFetchedResultsController( - fetchRequest: fetchRequest, - managedObjectContext: context.managedObjectContext, - sectionNameKeyPath: nil, - cacheName: nil - ) - controller.delegate = self - return controller - }() - let setting = CurrentValueSubject(nil) + // output + var dataSource: UITableViewDiffableDataSource! /// create a subscription when: /// - does not has one /// - does not find subscription for selected trigger when change trigger @@ -54,22 +37,6 @@ class SettingsViewModel: NSObject, NeedsDependency { /// - change switch for specified alerts let updateSubscriptionSubject = PassthroughSubject<(triggerBy: String, values: [Bool?]), Never>() - lazy var notificationDefaultValue: [String: [Bool?]] = { - let followerSwitchItems: [Bool?] = [true, nil, true, true] - let anyoneSwitchItems: [Bool?] = [true, true, true, true] - let noOneSwitchItems: [Bool?] = [nil, nil, nil, nil] - let followSwitchItems: [Bool?] = [true, true, true, true] - - let anyone = L10n.Scene.Settings.Section.Notifications.Trigger.anyone - let follower = L10n.Scene.Settings.Section.Notifications.Trigger.follower - let follow = L10n.Scene.Settings.Section.Notifications.Trigger.follow - let noOne = L10n.Scene.Settings.Section.Notifications.Trigger.noone - return [anyone: anyoneSwitchItems, - follower: followerSwitchItems, - follow: followSwitchItems, - noOne: noOneSwitchItems] - }() - lazy var privacyURL: URL? = { guard let box = AppContext.shared.authenticationService.activeMastodonAuthenticationBox.value else { return nil @@ -78,321 +45,151 @@ class SettingsViewModel: NSObject, NeedsDependency { return Mastodon.API.privacyURL(domain: box.domain) }() - /// to store who trigger the notification. - var triggerBy: String? - - struct Input { - } - - struct Output { - } - - init(context: AppContext, coordinator: SceneCoordinator) { + init(context: AppContext, setting: Setting) { self.context = context - self.coordinator = coordinator + self.setting = CurrentValueSubject(setting) - super.init() - } - - func transform(input: Input?) -> Output? { - typealias SubscriptionResponse = Mastodon.Response.Content -// createSubscriptionSubject -// .debounce(for: .milliseconds(300), scheduler: DispatchQueue.main) -// .sink { _ in -// } receiveValue: { [weak self] (arg) in -// let (triggerBy, values) = arg -// guard let self = self else { -// return -// } -// guard let activeMastodonAuthenticationBox = -// self.context.authenticationService.activeMastodonAuthenticationBox.value else { -// return -// } -// guard values.count >= 4 else { -// return -// } -// -// self.createDisposeBag.removeAll() -// typealias Query = Mastodon.API.Subscriptions.CreateSubscriptionQuery -// let domain = activeMastodonAuthenticationBox.domain -// let query = Query( -// // FIXME: to replace the correct endpoint, p256dh, auth -// endpoint: "http://www.google.com", -// p256dh: "BLQELIDm-6b9Bl07YrEuXJ4BL_YBVQ0dvt9NQGGJxIQidJWHPNa9YrouvcQ9d7_MqzvGS9Alz60SZNCG3qfpk=", -// auth: "4vQK-SvRAN5eo-8ASlrwA==", -// favourite: values[0], -// follow: values[1], -// reblog: values[2], -// mention: values[3], -// poll: nil -// ) -// self.context.apiService.changeSubscription( -// domain: domain, -// mastodonAuthenticationBox: activeMastodonAuthenticationBox, -// query: query, -// triggerBy: triggerBy, -// userID: activeMastodonAuthenticationBox.userID -// ) -// .sink { (_) in -// } receiveValue: { (_) in -// } -// .store(in: &self.createDisposeBag) -// } -// .store(in: &disposeBag) - - updateSubscriptionSubject - .debounce(for: .milliseconds(300), scheduler: DispatchQueue.main) - .sink { _ in - } receiveValue: { [weak self] (arg) in - let (triggerBy, values) = arg - guard let self = self else { - return - } - guard let activeMastodonAuthenticationBox = - self.context.authenticationService.activeMastodonAuthenticationBox.value else { - return - } - guard values.count >= 4 else { - return - } - - self.updateDisposeBag.removeAll() - typealias Query = Mastodon.API.Subscriptions.UpdateSubscriptionQuery - let domain = activeMastodonAuthenticationBox.domain - let query = Query( - data: Mastodon.API.Subscriptions.QueryData( - alerts: Mastodon.API.Subscriptions.QueryData.Alerts( - favourite: values[0], - follow: values[1], - reblog: values[2], - mention: values[3], - poll: nil - ) - ) - ) - self.context.apiService.updateSubscription( - domain: domain, - mastodonAuthenticationBox: activeMastodonAuthenticationBox, - query: query, - triggerBy: triggerBy, - userID: activeMastodonAuthenticationBox.userID - ) - .sink { (_) in - } receiveValue: { (_) in - } - .store(in: &self.updateDisposeBag) - } + self.setting + .sink(receiveValue: { [weak self] setting in + guard let self = self else { return } + self.processDataSource(setting) + }) .store(in: &disposeBag) - - // build data for table view - buildDataSource() - - // request subsription data for updating or initialization - requestSubscription() - return nil - } - - // MARK: - Private methods - fileprivate func processDataSource(_ settings: Setting?) { - var snapshot = NSDiffableDataSourceSnapshot() - - // appearance - let appearnceMode = SettingsItem.AppearanceMode(rawValue: settings?.appearance ?? "") ?? .automatic - let appearanceItem = SettingsItem.apperance(item: appearnceMode) - let appearance = SettingsSection.apperance(title: L10n.Scene.Settings.Section.Appearance.title, selectedMode:appearanceItem) - snapshot.appendSections([appearance]) - snapshot.appendItems([appearanceItem]) - - // notifications - var switches: [Bool?]? - if let alerts = settings?.subscription?.first(where: { (s) -> Bool in - return s.type == settings?.triggerBy - })?.alert { - var items = [Bool?]() - items.append(alerts.favourite?.boolValue) - items.append(alerts.follow?.boolValue) - items.append(alerts.reblog?.boolValue) - items.append(alerts.mention?.boolValue) - switches = items - } else if let triggerBy = settings?.triggerBy, - let values = self.notificationDefaultValue[triggerBy] { - switches = values - } else { - // fallback a default value - let anyone = L10n.Scene.Settings.Section.Notifications.Trigger.anyone - switches = self.notificationDefaultValue[anyone] - } - - let notifications = [L10n.Scene.Settings.Section.Notifications.favorites, - L10n.Scene.Settings.Section.Notifications.follows, - L10n.Scene.Settings.Section.Notifications.boosts, - L10n.Scene.Settings.Section.Notifications.mentions,] - var notificationItems = [SettingsItem]() - for (i, noti) in notifications.enumerated() { - var value: Bool? = nil - if let switches = switches, i < switches.count { - value = switches[i] - } - - let item = SettingsItem.notification(item: SettingsItem.NotificationSwitch(title: noti, isOn: value == true, enable: value != nil)) - notificationItems.append(item) - } - let notificationSection = SettingsSection.notifications(title: L10n.Scene.Settings.Section.Notifications.title, items: notificationItems) - snapshot.appendSections([notificationSection]) - snapshot.appendItems(notificationItems) - - // boring zone - let boringLinks = [L10n.Scene.Settings.Section.Boringzone.terms, - L10n.Scene.Settings.Section.Boringzone.privacy] - var boringLinkItems = [SettingsItem]() - for l in boringLinks { - let item = SettingsItem.boringZone(item: SettingsItem.Link(title: l, color: .systemBlue)) - boringLinkItems.append(item) - } - let boringSection = SettingsSection.boringZone(title: L10n.Scene.Settings.Section.Boringzone.title, items: boringLinkItems) - snapshot.appendSections([boringSection]) - snapshot.appendItems(boringLinkItems) - - // spicy zone - let spicyLinks = [L10n.Scene.Settings.Section.Spicyzone.clear, - L10n.Scene.Settings.Section.Spicyzone.signout] - var spicyLinkItems = [SettingsItem]() - for l in spicyLinks { - let item = SettingsItem.spicyZone(item: SettingsItem.Link(title: l, color: .systemRed)) - spicyLinkItems.append(item) - } - let spicySection = SettingsSection.spicyZone(title: L10n.Scene.Settings.Section.Spicyzone.title, items: spicyLinkItems) - snapshot.appendSections([spicySection]) - snapshot.appendItems(spicyLinkItems) - - self.dataSource.apply(snapshot, animatingDifferences: false) - } - - private func buildDataSource() { - setting.sink { [weak self] (settings) in - guard let self = self else { return } - self.processDataSource(settings) - } - .store(in: &disposeBag) - } - - private func requestSubscription() { - setting.sink { [weak self] (settings) in - guard let self = self else { return } - guard settings != nil else { return } - guard self.triggerBy != settings?.triggerBy else { return } - self.triggerBy = settings?.triggerBy - - var switches: [Bool?]? - var who: String? - if let alerts = settings?.subscription?.first(where: { (s) -> Bool in - return s.type == settings?.triggerBy - })?.alert { - var items = [Bool?]() - items.append(alerts.favourite?.boolValue) - items.append(alerts.follow?.boolValue) - items.append(alerts.reblog?.boolValue) - items.append(alerts.mention?.boolValue) - switches = items - who = settings?.triggerBy - } else if let triggerBy = settings?.triggerBy, - let values = self.notificationDefaultValue[triggerBy] { - switches = values - who = triggerBy - } else { - // fallback a default value - let anyone = L10n.Scene.Settings.Section.Notifications.Trigger.anyone - switches = self.notificationDefaultValue[anyone] - who = anyone - } - - // should create a subscription whenever change trigger - if let values = switches, let triggerBy = who { - self.createSubscriptionSubject.send((triggerBy: triggerBy, values: values)) - } - } - .store(in: &disposeBag) - - guard let activeMastodonAuthenticationBox = self.context.authenticationService.activeMastodonAuthenticationBox.value else { - return - } - let domain = activeMastodonAuthenticationBox.domain - let userId = activeMastodonAuthenticationBox.userID - - do { - try fetchResultsController.performFetch() - if nil == fetchResultsController.fetchedObjects?.first { - let anyone = L10n.Scene.Settings.Section.Notifications.Trigger.anyone - setting.value = self.context.apiService.createSettingIfNeed(domain: domain, - userId: userId, - triggerBy: anyone) - } else { - setting.value = fetchResultsController.fetchedObjects?.first - } - } catch { - assertionFailure(error.localizedDescription) - } } deinit { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s:", ((#file as NSString).lastPathComponent), #line, #function) } + } -// MARK: - NSFetchedResultsControllerDelegate -extension SettingsViewModel: NSFetchedResultsControllerDelegate { +extension SettingsViewModel { - func controllerWillChangeContent(_ controller: NSFetchedResultsController) { - os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) - } + // MARK: - Private methods + private func processDataSource(_ setting: Setting) { + guard let dataSource = self.dataSource else { return } + var snapshot = NSDiffableDataSourceSnapshot() - func controllerDidChangeContent(_ controller: NSFetchedResultsController) { - guard controller === fetchResultsController else { - return + // appearance + let appearanceItems = [SettingsItem.apperance(settingObjectID: setting.objectID)] + snapshot.appendSections([.apperance]) + snapshot.appendItems(appearanceItems, toSection: .apperance) + + let notificationItems = SettingsItem.NotificationSwitchMode.allCases.map { mode in + SettingsItem.notification(settingObjectID: setting.objectID, switchMode: mode) + } + snapshot.appendSections([.notifications]) + snapshot.appendItems(notificationItems, toSection: .notifications) + + // boring zone + let boringZoneSettingsItems: [SettingsItem] = { + let links: [SettingsItem.Link] = [ + .termsOfService, + .privacyPolicy + ] + let items = links.map { SettingsItem.boringZone(item: $0) } + return items + }() + snapshot.appendSections([.boringZone]) + snapshot.appendItems(boringZoneSettingsItems, toSection: .boringZone) + + let spicyZoneSettingsItems: [SettingsItem] = { + let links: [SettingsItem.Link] = [ + .clearMediaCache, + .signOut + ] + let items = links.map { SettingsItem.spicyZone(item: $0) } + return items + }() + snapshot.appendSections([.spicyZone]) + snapshot.appendItems(spicyZoneSettingsItems, toSection: .spicyZone) + + dataSource.apply(snapshot, animatingDifferences: false) + } + +} + +extension SettingsViewModel { + func setupDiffableDataSource( + for tableView: UITableView, + settingsAppearanceTableViewCellDelegate: SettingsAppearanceTableViewCellDelegate, + settingsToggleCellDelegate: SettingsToggleCellDelegate + ) { + dataSource = UITableViewDiffableDataSource(tableView: tableView) { [ + weak self, + weak settingsAppearanceTableViewCellDelegate, + weak settingsToggleCellDelegate + ] tableView, indexPath, item -> UITableViewCell? in + guard let self = self else { return nil } + + switch item { + case .apperance(let objectID): + let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: SettingsAppearanceTableViewCell.self), for: indexPath) as! SettingsAppearanceTableViewCell + self.context.managedObjectContext.performAndWait { + let setting = self.context.managedObjectContext.object(with: objectID) as! Setting + cell.update(with: setting.appearance) + ManagedObjectObserver.observe(object: setting) + .sink(receiveCompletion: { _ in + // do nothing + }, receiveValue: { [weak cell] change in + guard let cell = cell else { return } + guard case .update(let object) = change.changeType, + let setting = object as? Setting else { return } + cell.update(with: setting.appearance) + }) + .store(in: &cell.disposeBag) + } + cell.delegate = settingsAppearanceTableViewCellDelegate + return cell + case .notification(let objectID, let switchMode): + let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: SettingsToggleTableViewCell.self), for: indexPath) as! SettingsToggleTableViewCell + self.context.managedObjectContext.performAndWait { + let setting = self.context.managedObjectContext.object(with: objectID) as! Setting + if let subscription = setting.activeSubscription { + SettingsViewModel.configureSettingToggle(cell: cell, switchMode: switchMode, subscription: subscription) + } + ManagedObjectObserver.observe(object: setting) + .sink(receiveCompletion: { _ in + // do nothing + }, receiveValue: { [weak cell] change in + guard let cell = cell else { return } + guard case .update(let object) = change.changeType, + let setting = object as? Setting else { return } + guard let subscription = setting.activeSubscription else { return } + SettingsViewModel.configureSettingToggle(cell: cell, switchMode: switchMode, subscription: subscription) + }) + .store(in: &cell.disposeBag) + } + cell.delegate = settingsToggleCellDelegate + return cell + case .boringZone(let item), .spicyZone(let item): + let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: SettingsLinkTableViewCell.self), for: indexPath) as! SettingsLinkTableViewCell + cell.update(with: item) + return cell + } } - setting.value = fetchResultsController.fetchedObjects?.first + processDataSource(self.setting.value) } - } -enum SettingsSection: Hashable { - case apperance(title: String, selectedMode: SettingsItem) - case notifications(title: String, items: [SettingsItem]) - case boringZone(title: String, items: [SettingsItem]) - case spicyZone(title: String, items: [SettingsItem]) +extension SettingsViewModel { - var title: String { - switch self { - case .apperance(let title, _), - .notifications(let title, _), - .boringZone(let title, _), - .spicyZone(let title, _): - return title + static func configureSettingToggle( + cell: SettingsToggleTableViewCell, + switchMode: SettingsItem.NotificationSwitchMode, + subscription: NotificationSubscription + ) { + cell.textLabel?.text = switchMode.title + + let enabled: Bool? + switch switchMode { + case .favorite: enabled = subscription.alert.favourite + case .follow: enabled = subscription.alert.follow + case .reblog: enabled = subscription.alert.reblog + case .mention: enabled = subscription.alert.mention } + cell.update(enabled: enabled) } -} - -enum SettingsItem: Hashable { - enum AppearanceMode: String { - case automatic - case light - case dark - } - - struct NotificationSwitch: Hashable { - let title: String - let isOn: Bool - let enable: Bool - } - - struct Link: Hashable { - let title: String - let color: UIColor - } - - case apperance(item: AppearanceMode) - case notification(item: NotificationSwitch) - case boringZone(item: Link) - case spicyZone(item: Link) + } diff --git a/Mastodon/Scene/Settings/View/Cell/SettingsAppearanceTableViewCell.swift b/Mastodon/Scene/Settings/View/Cell/SettingsAppearanceTableViewCell.swift index a477661ee..44a7e7574 100644 --- a/Mastodon/Scene/Settings/View/Cell/SettingsAppearanceTableViewCell.swift +++ b/Mastodon/Scene/Settings/View/Cell/SettingsAppearanceTableViewCell.swift @@ -6,9 +6,10 @@ // import UIKit +import Combine protocol SettingsAppearanceTableViewCellDelegate: class { - func settingsAppearanceCell(_ view: SettingsAppearanceTableViewCell, didSelect: SettingsItem.AppearanceMode) + func settingsAppearanceCell(_ cell: SettingsAppearanceTableViewCell, didSelectAppearanceMode appearanceMode: SettingsItem.AppearanceMode) } class AppearanceView: UIView { @@ -85,6 +86,9 @@ class AppearanceView: UIView { } class SettingsAppearanceTableViewCell: UITableViewCell { + + var disposeBag = Set() + weak var delegate: SettingsAppearanceTableViewCellDelegate? var appearance: SettingsItem.AppearanceMode = .automatic @@ -123,6 +127,12 @@ class SettingsAppearanceTableViewCell: UITableViewCell { tapGestureRecognizer.addTarget(self, action: #selector(appearanceDidTap(sender:))) return tapGestureRecognizer }() + + override func prepareForReuse() { + super.prepareForReuse() + + disposeBag.removeAll() + } // MARK: - Methods override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { @@ -145,9 +155,8 @@ class SettingsAppearanceTableViewCell: UITableViewCell { } } - func update(with data: SettingsItem.AppearanceMode, delegate: SettingsAppearanceTableViewCellDelegate?) { + func update(with data: SettingsItem.AppearanceMode) { appearance = data - self.delegate = delegate automatic.selected = false light.selected = false @@ -200,6 +209,6 @@ class SettingsAppearanceTableViewCell: UITableViewCell { } guard let delegate = self.delegate else { return } - delegate.settingsAppearanceCell(self, didSelect: appearance) + delegate.settingsAppearanceCell(self, didSelectAppearanceMode: appearance) } } diff --git a/Mastodon/Scene/Settings/View/Cell/SettingsLinkTableViewCell.swift b/Mastodon/Scene/Settings/View/Cell/SettingsLinkTableViewCell.swift index b5d0306d4..7fdbf7f02 100644 --- a/Mastodon/Scene/Settings/View/Cell/SettingsLinkTableViewCell.swift +++ b/Mastodon/Scene/Settings/View/Cell/SettingsLinkTableViewCell.swift @@ -8,6 +8,7 @@ import UIKit class SettingsLinkTableViewCell: UITableViewCell { + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { super.init(style: style, reuseIdentifier: reuseIdentifier) @@ -22,10 +23,13 @@ class SettingsLinkTableViewCell: UITableViewCell { super.setHighlighted(highlighted, animated: animated) textLabel?.alpha = highlighted ? 0.6 : 1.0 } - - // MARK: - Methods - func update(with data: SettingsItem.Link) { - textLabel?.text = data.title - textLabel?.textColor = data.color + +} + +// MARK: - Methods +extension SettingsLinkTableViewCell { + func update(with link: SettingsItem.Link) { + textLabel?.text = link.title + textLabel?.textColor = link.textColor } } diff --git a/Mastodon/Scene/Settings/View/Cell/SettingsToggleTableViewCell.swift b/Mastodon/Scene/Settings/View/Cell/SettingsToggleTableViewCell.swift index b35b2b50f..b4a62635b 100644 --- a/Mastodon/Scene/Settings/View/Cell/SettingsToggleTableViewCell.swift +++ b/Mastodon/Scene/Settings/View/Cell/SettingsToggleTableViewCell.swift @@ -6,18 +6,21 @@ // import UIKit +import Combine protocol SettingsToggleCellDelegate: class { - func settingsToggleCell(_ cell: SettingsToggleTableViewCell, didChangeStatus: Bool) + func settingsToggleCell(_ cell: SettingsToggleTableViewCell, switchValueDidChange switch: UISwitch) } class SettingsToggleTableViewCell: UITableViewCell { - lazy var switchButton: UISwitch = { + + var disposeBag = Set() + + private(set) lazy var switchButton: UISwitch = { let view = UISwitch(frame:.zero) return view }() - var data: SettingsItem.NotificationSwitch? weak var delegate: SettingsToggleCellDelegate? // MARK: - Methods @@ -27,21 +30,8 @@ class SettingsToggleTableViewCell: UITableViewCell { } required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - func update(with data: SettingsItem.NotificationSwitch, delegate: SettingsToggleCellDelegate?) { - self.delegate = delegate - self.data = data - textLabel?.text = data.title - switchButton.isOn = data.isOn - setup(enable: data.enable) - } - - // MARK: Actions - @objc func valueDidChange(sender: UISwitch) { - guard let delegate = delegate else { return } - delegate.settingsToggleCell(self, didChangeStatus: sender.isOn) + super.init(coder: coder) + setupUI() } // MARK: Private methods @@ -49,15 +39,27 @@ class SettingsToggleTableViewCell: UITableViewCell { selectionStyle = .none accessoryView = switchButton - switchButton.addTarget(self, action: #selector(valueDidChange(sender:)), for: .valueChanged) + switchButton.addTarget(self, action: #selector(switchValueDidChange(sender:)), for: .valueChanged) + } + +} + +// MARK: - Actions +extension SettingsToggleTableViewCell { + + @objc private func switchValueDidChange(sender: UISwitch) { + guard let delegate = delegate else { return } + delegate.settingsToggleCell(self, switchValueDidChange: sender) + } + +} + +extension SettingsToggleTableViewCell { + + func update(enabled: Bool?) { + switchButton.isEnabled = enabled != nil + textLabel?.textColor = enabled != nil ? Asset.Colors.Label.primary.color : Asset.Colors.Label.secondary.color + switchButton.isOn = enabled ?? false } - private func setup(enable: Bool) { - if enable { - textLabel?.textColor = Asset.Colors.Label.primary.color - } else { - textLabel?.textColor = Asset.Colors.Label.secondary.color - } - switchButton.isEnabled = enable - } } diff --git a/Mastodon/Scene/Share/View/Content/TimelineHeaderView.swift b/Mastodon/Scene/Share/View/Content/TimelineHeaderView.swift index b5e4c5bde..f095f6f44 100644 --- a/Mastodon/Scene/Share/View/Content/TimelineHeaderView.swift +++ b/Mastodon/Scene/Share/View/Content/TimelineHeaderView.swift @@ -5,6 +5,8 @@ // Created by MainasuK Cirno on 2021-4-6. // +import UIKit + final class TimelineHeaderView: UIView { let iconImageView: UIImageView = { diff --git a/Mastodon/Service/APIService/APIService+Subscriptions.swift b/Mastodon/Service/APIService/APIService+Subscriptions.swift index 5260452ce..3e2d2a0aa 100644 --- a/Mastodon/Service/APIService/APIService+Subscriptions.swift +++ b/Mastodon/Service/APIService/APIService+Subscriptions.swift @@ -5,6 +5,7 @@ // Created by ihugo on 2021/4/9. // +import os.log import Combine import CoreData import CoreDataStack @@ -13,66 +14,14 @@ import MastodonSDK extension APIService { - func subscription( + func createSubscription( + subscriptionObjectID: NSManagedObjectID, + query: Mastodon.API.Subscriptions.CreateSubscriptionQuery, mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox ) -> AnyPublisher, Error> { let authorization = mastodonAuthenticationBox.userAuthorization let domain = mastodonAuthenticationBox.domain - let userID = mastodonAuthenticationBox.userID - let findSettings: Setting? = { - let request = Setting.sortedFetchRequest - request.predicate = Setting.predicate(domain: domain, userID: userID) - request.fetchLimit = 1 - request.returnsObjectsAsFaults = false - do { - return try self.backgroundManagedObjectContext.fetch(request).first - } catch { - assertionFailure(error.localizedDescription) - return nil - } - }() - let triggerBy = findSettings?.triggerBy ?? "anyone" - let setting = self.createSettingIfNeed( - domain: domain, - userId: userID, - triggerBy: triggerBy - ) - return Mastodon.API.Subscriptions.subscription( - session: session, - domain: domain, - authorization: authorization - ) - .flatMap { response -> AnyPublisher, Error> in - return self.backgroundManagedObjectContext.performChanges { - _ = APIService.CoreData.createOrMergeSubscription( - into: self.backgroundManagedObjectContext, - entity: response.value, - domain: domain, - triggerBy: triggerBy, - setting: setting) - } - .setFailureType(to: Error.self) - .map { _ in return response } - .eraseToAnyPublisher() - }.eraseToAnyPublisher() - } - - func createSubscription( - mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox, - query: Mastodon.API.Subscriptions.CreateSubscriptionQuery, - triggerBy: String, - userID: String - ) -> AnyPublisher, Error> { - let authorization = mastodonAuthenticationBox.userAuthorization - let domain = mastodonAuthenticationBox.domain - let userID = mastodonAuthenticationBox.userID - - let setting = self.createSettingIfNeed( - domain: domain, - userId: userID, - triggerBy: triggerBy - ) return Mastodon.API.Subscriptions.createSubscription( session: session, domain: domain, @@ -80,14 +29,18 @@ extension APIService { query: query ) .flatMap { response -> AnyPublisher, Error> in - return self.backgroundManagedObjectContext.performChanges { - _ = APIService.CoreData.createOrMergeSubscription( - into: self.backgroundManagedObjectContext, - entity: response.value, - domain: domain, - triggerBy: triggerBy, - setting: setting - ) + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: create subscription successful ", ((#file as NSString).lastPathComponent), #line, #function) + + let managedObjectContext = self.backgroundManagedObjectContext + return managedObjectContext.performChanges { + guard let subscription = managedObjectContext.object(with: subscriptionObjectID) as? NotificationSubscription else { + assertionFailure() + return + } + subscription.endpoint = response.value.endpoint + subscription.serverKey = response.value.serverKey + subscription.userToken = authorization.accessToken + subscription.didUpdate(at: response.networkDate) } .setFailureType(to: Error.self) .map { _ in return response } @@ -95,72 +48,22 @@ extension APIService { }.eraseToAnyPublisher() } - func updateSubscription( - domain: String, - mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox, - query: Mastodon.API.Subscriptions.UpdateSubscriptionQuery, - triggerBy: String, - userID: String - ) -> AnyPublisher, Error> { + func cancelSubscription( + mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox + ) -> AnyPublisher, Error> { let authorization = mastodonAuthenticationBox.userAuthorization - - let setting = self.createSettingIfNeed(domain: domain, - userId: userID, - triggerBy: triggerBy) - - return Mastodon.API.Subscriptions.updateSubscription( + let domain = mastodonAuthenticationBox.domain + + return Mastodon.API.Subscriptions.removeSubscription( session: session, domain: domain, - authorization: authorization, - query: query + authorization: authorization ) - .flatMap { response -> AnyPublisher, Error> in - return self.backgroundManagedObjectContext.performChanges { - _ = APIService.CoreData.createOrMergeSubscription( - into: self.backgroundManagedObjectContext, - entity: response.value, - domain: domain, - triggerBy: triggerBy, - setting: setting - ) - } - .setFailureType(to: Error.self) - .map { _ in return response } - .eraseToAnyPublisher() - }.eraseToAnyPublisher() - } - - func createSettingIfNeed(domain: String, userId: String, triggerBy: String) -> Setting { - // create setting entity if possible - let oldSetting: Setting? = { - let request = Setting.sortedFetchRequest - request.predicate = Setting.predicate(domain: domain, userID: userId) - request.fetchLimit = 1 - request.returnsObjectsAsFaults = false - do { - return try backgroundManagedObjectContext.fetch(request).first - } catch { - assertionFailure(error.localizedDescription) - return nil - } - }() - var setting: Setting! - if let oldSetting = oldSetting { - setting = oldSetting - } else { - let property = Setting.Property( - appearance: "automatic", - triggerBy: triggerBy, - domain: domain, - userID: userId) - (setting, _) = APIService.CoreData.createOrMergeSetting( - into: backgroundManagedObjectContext, - domain: domain, - userID: userId, - property: property - ) - } - return setting + .handleEvents(receiveOutput: { _ in + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: cancel subscription successful", ((#file as NSString).lastPathComponent), #line, #function) + }) + .eraseToAnyPublisher() } + } diff --git a/Mastodon/Service/APIService/CoreData/APIService+CoreData+Setting.swift b/Mastodon/Service/APIService/CoreData/APIService+CoreData+Setting.swift new file mode 100644 index 000000000..fb6879da9 --- /dev/null +++ b/Mastodon/Service/APIService/CoreData/APIService+CoreData+Setting.swift @@ -0,0 +1,61 @@ +// +// APIService+CoreData+Setting.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-25. +// + + +import os.log +import Foundation +import CoreData +import CoreDataStack +import MastodonSDK + +extension APIService.CoreData { + + static func createOrMergeSetting( + into managedObjectContext: NSManagedObjectContext, + property: Setting.Property + ) -> (Subscription: Setting, isCreated: Bool) { + let oldSetting: Setting? = { + let request = Setting.sortedFetchRequest + request.predicate = Setting.predicate(domain: property.domain, userID: property.userID) + request.fetchLimit = 1 + request.returnsObjectsAsFaults = false + return managedObjectContext.safeFetch(request).first + }() + + if let oldSetting = oldSetting { + return (oldSetting, false) + } else { + let setting = Setting.insert( + into: managedObjectContext, + property: property + ) + let policies: [Mastodon.API.Subscriptions.Policy] = [ + .all, + .followed, + .follower, + .none + ] + let now = Date() + policies.forEach { policy in + let (subscription, _) = createOrFetchSubscription( + into: managedObjectContext, + setting: setting, + policy: policy + ) + if policy == .all { + subscription.update(activedAt: now) + } else { + subscription.update(activedAt: now.addingTimeInterval(-10)) + } + } + + + return (setting, true) + } + } + +} diff --git a/Mastodon/Service/APIService/CoreData/APIService+CoreData+Subscriptions.swift b/Mastodon/Service/APIService/CoreData/APIService+CoreData+Subscriptions.swift index f5a4022ea..5e42a8abe 100644 --- a/Mastodon/Service/APIService/CoreData/APIService+CoreData+Subscriptions.swift +++ b/Mastodon/Service/APIService/CoreData/APIService+CoreData+Subscriptions.swift @@ -13,96 +13,49 @@ import MastodonSDK extension APIService.CoreData { - static func createOrMergeSetting( + static func createOrFetchSubscription( into managedObjectContext: NSManagedObjectContext, - domain: String, - userID: String, - property: Setting.Property - ) -> (Subscription: Setting, isCreated: Bool) { - let oldSetting: Setting? = { - let request = Setting.sortedFetchRequest - request.predicate = Setting.predicate(domain: property.domain, userID: userID) - request.fetchLimit = 1 - request.returnsObjectsAsFaults = false - do { - return try managedObjectContext.fetch(request).first - } catch { - assertionFailure(error.localizedDescription) - return nil - } - }() - - if let oldSetting = oldSetting { - return (oldSetting, false) - } else { - let setting = Setting.insert( - into: managedObjectContext, - property: property) - return (setting, true) - } - } - - static func createOrMergeSubscription( - into managedObjectContext: NSManagedObjectContext, - entity: Mastodon.Entity.Subscription, - domain: String, - triggerBy: String, - setting: Setting - ) -> (Subscription: Subscription, isCreated: Bool) { + setting: Setting, + policy: Mastodon.API.Subscriptions.Policy + ) -> (subscription: Subscription, isCreated: Bool) { let oldSubscription: Subscription? = { let request = Subscription.sortedFetchRequest - request.predicate = Subscription.predicate(type: triggerBy) + request.predicate = Subscription.predicate(policyRaw: policy.rawValue) request.fetchLimit = 1 request.returnsObjectsAsFaults = false - do { - return try managedObjectContext.fetch(request).first - } catch { - assertionFailure(error.localizedDescription) - return nil - } + return managedObjectContext.safeFetch(request).first }() - let property = Subscription.Property( - endpoint: entity.endpoint, - id: entity.id, - serverKey: entity.serverKey, - type: triggerBy - ) - let alertEntity = entity.alerts - let alert = SubscriptionAlerts.Property( - favourite: alertEntity.favouriteNumber, - follow: alertEntity.followNumber, - mention: alertEntity.mentionNumber, - poll: alertEntity.pollNumber, - reblog: alertEntity.reblogNumber - ) if let oldSubscription = oldSubscription { - oldSubscription.updateIfNeed(property: property) - if nil == oldSubscription.alert { - oldSubscription.alert = SubscriptionAlerts.insert( - into: managedObjectContext, - property: alert - ) - } else { - oldSubscription.alert?.updateIfNeed(property: alert) - } - - if oldSubscription.alert?.hasChanges == true || oldSubscription.hasChanges { - // don't expand subscription if add existed subscription - //setting.mutableSetValue(forKey: #keyPath(Setting.subscription)).add(oldSubscription) - oldSubscription.didUpdate(at: Date()) - } return (oldSubscription, false) } else { + let subscriptionProperty = Subscription.Property(policyRaw: policy.rawValue) let subscription = Subscription.insert( into: managedObjectContext, - property: property + property: subscriptionProperty, + setting: setting ) + let alertProperty = SubscriptionAlerts.Property(policy: policy) subscription.alert = SubscriptionAlerts.insert( into: managedObjectContext, - property: alert) - setting.mutableSetValue(forKey: #keyPath(Setting.subscription)).add(subscription) + property: alertProperty, + subscription: subscription + ) + return (subscription, true) } } + +} + +extension APIService.CoreData { + + static func merge( + subscription: Subscription, + property: Subscription.Property, + networkDate: Date + ) { + // TODO: + } + } diff --git a/Mastodon/Service/AuthenticationService.swift b/Mastodon/Service/AuthenticationService.swift index 89ce7a182..6b35486d6 100644 --- a/Mastodon/Service/AuthenticationService.swift +++ b/Mastodon/Service/AuthenticationService.swift @@ -15,6 +15,7 @@ import MastodonSDK final class AuthenticationService: NSObject { var disposeBag = Set() + // input weak var apiService: APIService? let managedObjectContext: NSManagedObjectContext // read-only @@ -23,6 +24,7 @@ final class AuthenticationService: NSObject { // output let mastodonAuthentications = CurrentValueSubject<[MastodonAuthentication], Never>([]) + let mastodonAuthenticationBoxes = CurrentValueSubject<[AuthenticationService.MastodonAuthenticationBox], Never>([]) let activeMastodonAuthentication = CurrentValueSubject(nil) let activeMastodonAuthenticationBox = CurrentValueSubject(nil) @@ -58,16 +60,24 @@ final class AuthenticationService: NSObject { .assign(to: \.value, on: activeMastodonAuthentication) .store(in: &disposeBag) - activeMastodonAuthentication - .map { authentication -> AuthenticationService.MastodonAuthenticationBox? in - guard let authentication = authentication else { return nil } - return AuthenticationService.MastodonAuthenticationBox( - domain: authentication.domain, - userID: authentication.userID, - appAuthorization: Mastodon.API.OAuth.Authorization(accessToken: authentication.appAccessToken), - userAuthorization: Mastodon.API.OAuth.Authorization(accessToken: authentication.userAccessToken) - ) + mastodonAuthentications + .map { authentications -> [AuthenticationService.MastodonAuthenticationBox] in + return authentications + .sorted(by: { $0.activedAt > $1.activedAt }) + .compactMap { authentication -> AuthenticationService.MastodonAuthenticationBox? in + return AuthenticationService.MastodonAuthenticationBox( + domain: authentication.domain, + userID: authentication.userID, + appAuthorization: Mastodon.API.OAuth.Authorization(accessToken: authentication.appAccessToken), + userAuthorization: Mastodon.API.OAuth.Authorization(accessToken: authentication.userAccessToken) + ) + } } + .assign(to: \.value, on: mastodonAuthenticationBoxes) + .store(in: &disposeBag) + + mastodonAuthenticationBoxes + .map { $0.first } .assign(to: \.value, on: activeMastodonAuthenticationBox) .store(in: &disposeBag) @@ -114,16 +124,37 @@ extension AuthenticationService { func signOutMastodonUser(domain: String, userID: MastodonUser.ID) -> AnyPublisher, Never> { var isSignOut = false - return backgroundManagedObjectContext.performChanges { + var _mastodonAutenticationBox: MastodonAuthenticationBox? + let managedObjectContext = backgroundManagedObjectContext + return managedObjectContext.performChanges { let request = MastodonAuthentication.sortedFetchRequest request.predicate = MastodonAuthentication.predicate(domain: domain, userID: userID) request.fetchLimit = 1 - guard let mastodonAutentication = try? self.backgroundManagedObjectContext.fetch(request).first else { + guard let mastodonAutentication = try? managedObjectContext.fetch(request).first else { return } - self.backgroundManagedObjectContext.delete(mastodonAutentication) + _mastodonAutenticationBox = AuthenticationService.MastodonAuthenticationBox( + domain: mastodonAutentication.domain, + userID: mastodonAutentication.userID, + appAuthorization: Mastodon.API.OAuth.Authorization(accessToken: mastodonAutentication.appAccessToken), + userAuthorization: Mastodon.API.OAuth.Authorization(accessToken: mastodonAutentication.userAccessToken) + ) + managedObjectContext.delete(mastodonAutentication) isSignOut = true } + .flatMap { result -> AnyPublisher, Never> in + guard let apiService = self.apiService, + let mastodonAuthenticationBox = _mastodonAutenticationBox else { + return Just(result).eraseToAnyPublisher() + } + + return apiService.cancelSubscription( + mastodonAuthenticationBox: mastodonAuthenticationBox + ) + .map { _ in result } + .catch { _ in Just(result).eraseToAnyPublisher() } + .eraseToAnyPublisher() + } .map { result in return result.map { isSignOut } } diff --git a/Mastodon/Service/NotificationService.swift b/Mastodon/Service/NotificationService.swift index 098fdff62..526c35883 100644 --- a/Mastodon/Service/NotificationService.swift +++ b/Mastodon/Service/NotificationService.swift @@ -19,69 +19,28 @@ final class NotificationService { let workingQueue = DispatchQueue(label: "org.joinmastodon.Mastodon.NotificationService.working-queue") // input - weak var apiService: APIService? weak var authenticationService: AuthenticationService? let isNotificationPermissionGranted = CurrentValueSubject(false) let deviceToken = CurrentValueSubject(nil) - let mastodonAuthenticationBoxes = CurrentValueSubject<[AuthenticationService.MastodonAuthenticationBox], Never>([]) - + // output /// [Token: UserID] - let notificationSubscriptionDict: [String: NotificationSubscription] = [:] + let notificationSubscriptionDict: [String: NotificationViewModel] = [:] init( - apiService: APIService, authenticationService: AuthenticationService ) { - self.apiService = apiService self.authenticationService = authenticationService - authenticationService.mastodonAuthentications - .handleEvents(receiveOutput: { [weak self] mastodonAuthentications in - guard let self = self else { return } - - // request permission when sign-in - guard !mastodonAuthentications.isEmpty else { return } - self.requestNotificationPermission() - }) - .map { authentications -> [AuthenticationService.MastodonAuthenticationBox] in - return authentications.compactMap { authentication -> AuthenticationService.MastodonAuthenticationBox? in - return AuthenticationService.MastodonAuthenticationBox( - domain: authentication.domain, - userID: authentication.userID, - appAuthorization: Mastodon.API.OAuth.Authorization(accessToken: authentication.appAccessToken), - userAuthorization: Mastodon.API.OAuth.Authorization(accessToken: authentication.userAccessToken) - ) - } - } - .assign(to: \.value, on: mastodonAuthenticationBoxes) - .store(in: &disposeBag) - deviceToken .receive(on: DispatchQueue.main) .sink { [weak self] deviceToken in - guard let self = self else { return } + guard let _ = self else { return } guard let deviceToken = deviceToken else { return } let token = [UInt8](deviceToken).toHexString() os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: deviceToken: %s", ((#file as NSString).lastPathComponent), #line, #function, token) } .store(in: &disposeBag) - - Publishers.CombineLatest3( - isNotificationPermissionGranted, - deviceToken, - mastodonAuthenticationBoxes - ) - .sink { [weak self] isNotificationPermissionGranted, deviceToken, mastodonAuthenticationBoxes in - guard let self = self else { return } - guard isNotificationPermissionGranted else { return } - guard let deviceToken = deviceToken else { return } - self.registerNotificationSubscriptions( - deviceToken: [UInt8](deviceToken).toHexString(), - mastodonAuthenticationBoxes: mastodonAuthenticationBoxes - ) - } - .store(in: &disposeBag) } } @@ -102,35 +61,14 @@ extension NotificationService { // Enable or disable features based on the authorization. } } +} + +extension NotificationService { - private func registerNotificationSubscriptions( - deviceToken: String, - mastodonAuthenticationBoxes: [AuthenticationService.MastodonAuthenticationBox] - ) { - for mastodonAuthenticationBox in mastodonAuthenticationBoxes { - guard let notificationSubscription = dequeueNotificationSubscription(mastodonAuthenticationBox: mastodonAuthenticationBox) else { continue } - let token = NotificationSubscription.SubscribeToken( - deviceToken: deviceToken, - authenticationBox: mastodonAuthenticationBox - ) - guard let subscription = subscribe( - notificationSubscription: notificationSubscription, - token: token - ) else { continue } - - subscription - .sink { completion in - // handle error - } receiveValue: { response in - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: did create subscription %s with userToken %s", ((#file as NSString).lastPathComponent), #line, #function, response.value.id, mastodonAuthenticationBox.userAuthorization.accessToken) - // do nothing - } - .store(in: &self.disposeBag) - } - } - - private func dequeueNotificationSubscription(mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox) -> NotificationSubscription? { - var _notificationSubscription: NotificationSubscription? + func dequeueNotificationViewModel( + mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox + ) -> NotificationViewModel? { + var _notificationSubscription: NotificationViewModel? workingQueue.sync { let domain = mastodonAuthenticationBox.domain let userID = mastodonAuthenticationBox.userID @@ -139,56 +77,13 @@ extension NotificationService { if let notificationSubscription = notificationSubscriptionDict[key] { _notificationSubscription = notificationSubscription } else { - let notificationSubscription = NotificationSubscription(domain: domain, userID: userID) + let notificationSubscription = NotificationViewModel(domain: domain, userID: userID) _notificationSubscription = notificationSubscription } } return _notificationSubscription } - private func subscribe( - notificationSubscription: NotificationSubscription, - token: NotificationSubscription.SubscribeToken - ) -> AnyPublisher, Error>? { - guard let apiService = self.apiService else { return nil } - - if let oldToken = notificationSubscription.token { - guard oldToken != token else { return nil } - } - notificationSubscription.token = token - - let appSecret = AppSecret.default - let endpoint = appSecret.notificationEndpoint + "/" + token.deviceToken - let p256dh = appSecret.uncompressionNotificationPublicKeyData - let auth = appSecret.notificationAuth - - let query = Mastodon.API.Subscriptions.CreateSubscriptionQuery( - subscription: Mastodon.API.Subscriptions.QuerySubscription( - endpoint: endpoint, - keys: Mastodon.API.Subscriptions.QuerySubscription.Keys( - p256dh: p256dh, - auth: auth - ) - ), - data: Mastodon.API.Subscriptions.QueryData( - alerts: Mastodon.API.Subscriptions.QueryData.Alerts( - favourite: true, - follow: true, - reblog: true, - mention: true, - poll: true - ) - ) - ) - - return apiService.createSubscription( - mastodonAuthenticationBox: token.authenticationBox, - query: query, - triggerBy: "anyone", - userID: token.authenticationBox.userID - ) - } - static func createRandomAuthBytes() -> Data { let byteCount = 16 var bytes = Data(count: byteCount) @@ -198,7 +93,7 @@ extension NotificationService { } extension NotificationService { - final class NotificationSubscription { + final class NotificationViewModel { var disposeBag = Set() @@ -206,36 +101,39 @@ extension NotificationService { let domain: String let userID: Mastodon.Entity.Account.ID - var token: SubscribeToken? + // output init(domain: String, userID: Mastodon.Entity.Account.ID) { self.domain = domain self.userID = userID } - - struct SubscribeToken: Equatable { - - let deviceToken: String - let authenticationBox: AuthenticationService.MastodonAuthenticationBox - // TODO: set other parameter - - init( - deviceToken: String, - authenticationBox: AuthenticationService.MastodonAuthenticationBox - ) { - self.deviceToken = deviceToken - self.authenticationBox = authenticationBox - } - - static func == ( - lhs: NotificationService.NotificationSubscription.SubscribeToken, - rhs: NotificationService.NotificationSubscription.SubscribeToken - ) -> Bool { - return lhs.deviceToken == rhs.deviceToken && - lhs.authenticationBox.domain == rhs.authenticationBox.domain && - lhs.authenticationBox.userID == rhs.authenticationBox.userID && - lhs.authenticationBox.userAuthorization.accessToken == rhs.authenticationBox.userAuthorization.accessToken - } - } + } +} + +extension NotificationService.NotificationViewModel { + func createSubscribeQuery( + deviceToken: Data, + queryData: Mastodon.API.Subscriptions.QueryData, + mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox + ) -> Mastodon.API.Subscriptions.CreateSubscriptionQuery { + let deviceToken = [UInt8](deviceToken).toHexString() + + let appSecret = AppSecret.default + let endpoint = appSecret.notificationEndpoint + "/" + deviceToken + let p256dh = appSecret.uncompressionNotificationPublicKeyData + let auth = appSecret.notificationAuth + + let query = Mastodon.API.Subscriptions.CreateSubscriptionQuery( + subscription: Mastodon.API.Subscriptions.QuerySubscription( + endpoint: endpoint, + keys: Mastodon.API.Subscriptions.QuerySubscription.Keys( + p256dh: p256dh, + auth: auth + ) + ), + data: queryData + ) + + return query } } diff --git a/Mastodon/Service/SettingService.swift b/Mastodon/Service/SettingService.swift new file mode 100644 index 000000000..e097b4dc4 --- /dev/null +++ b/Mastodon/Service/SettingService.swift @@ -0,0 +1,162 @@ +// +// SettingService.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-25. +// + +import UIKit +import Combine +import CoreDataStack +import MastodonSDK + +final class SettingService { + + var disposeBag = Set() + + private var currentSettingUpdateSubscription: AnyCancellable? + + // input + weak var apiService: APIService? + weak var authenticationService: AuthenticationService? + weak var notificationService: NotificationService? + + // output + let settingFetchedResultController: SettingFetchedResultController + let currentSetting = CurrentValueSubject(nil) + + init( + apiService: APIService, + authenticationService: AuthenticationService, + notificationService: NotificationService + ) { + self.apiService = apiService + self.authenticationService = authenticationService + self.notificationService = notificationService + self.settingFetchedResultController = SettingFetchedResultController( + managedObjectContext: authenticationService.managedObjectContext, + additionalPredicate: nil + ) + + // create setting (if non-exist) for authenticated users + authenticationService.mastodonAuthenticationBoxes + .compactMap { [weak self] mastodonAuthenticationBoxes -> AnyPublisher<[AuthenticationService.MastodonAuthenticationBox], Never>? in + guard let self = self else { return nil } + guard let authenticationService = self.authenticationService else { return nil } + guard let activeMastodonAuthenticationBox = mastodonAuthenticationBoxes.first else { return nil } + + let domain = activeMastodonAuthenticationBox.domain + let userID = activeMastodonAuthenticationBox.userID + return authenticationService.backgroundManagedObjectContext.performChanges { + _ = APIService.CoreData.createOrMergeSetting( + into: authenticationService.backgroundManagedObjectContext, + property: Setting.Property( + domain: domain, + userID: userID, + appearanceRaw: SettingsItem.AppearanceMode.automatic.rawValue + ) + ) + } + .map { _ in mastodonAuthenticationBoxes } + .eraseToAnyPublisher() + } + .sink { _ in + // do nothing + } + .store(in: &disposeBag) + + // bind current setting + Publishers.CombineLatest( + authenticationService.activeMastodonAuthenticationBox, + settingFetchedResultController.settings + ) + .sink { [weak self] activeMastodonAuthenticationBox, settings in + guard let self = self else { return } + guard let activeMastodonAuthenticationBox = activeMastodonAuthenticationBox else { return } + let currentSetting = settings.first(where: { setting in + return setting.domain == activeMastodonAuthenticationBox.domain && + setting.userID == activeMastodonAuthenticationBox.userID + }) + self.currentSetting.value = currentSetting + } + .store(in: &disposeBag) + + // observe current setting + currentSetting + .receive(on: DispatchQueue.main) + .sink { [weak self] setting in + guard let self = self else { return } + guard let setting = setting else { + self.currentSettingUpdateSubscription = nil + return + } + + self.currentSettingUpdateSubscription = ManagedObjectObserver.observe(object: setting) + .sink(receiveCompletion: { _ in + // do nothing + }, receiveValue: { change in + guard case .update(let object) = change.changeType, + let setting = object as? Setting else { return } + + // observe apparance mode + switch setting.appearance { + case .automatic: UserDefaults.shared.customUserInterfaceStyle = .unspecified + case .light: UserDefaults.shared.customUserInterfaceStyle = .light + case .dark: UserDefaults.shared.customUserInterfaceStyle = .dark + } + }) + } + .store(in: &disposeBag) + + Publishers.CombineLatest( + notificationService.deviceToken, + currentSetting + ) + .compactMap { [weak self] deviceToken, setting -> AnyPublisher, Error>? in + guard let self = self else { return nil } + guard let apiService = self.apiService else { return nil } + guard let deviceToken = deviceToken else { return nil } + guard let authenticationBox = self.authenticationService?.activeMastodonAuthenticationBox.value else { return nil } + guard let setting = setting else { return nil } + guard let subscription = setting.activeSubscription else { return nil } + + guard setting.domain == authenticationBox.domain, + setting.userID == authenticationBox.userID else { return nil } + + let _viewModel = self.notificationService?.dequeueNotificationViewModel( + mastodonAuthenticationBox: authenticationBox + ) + guard let viewModel = _viewModel else { return nil } + let queryData = Mastodon.API.Subscriptions.QueryData( + policy: subscription.policy, + alerts: Mastodon.API.Subscriptions.QueryData.Alerts( + favourite: subscription.alert.favourite, + follow: subscription.alert.follow, + reblog: subscription.alert.reblog, + mention: subscription.alert.mention, + poll: subscription.alert.poll + ) + ) + let query = viewModel.createSubscribeQuery( + deviceToken: deviceToken, + queryData: queryData, + mastodonAuthenticationBox: authenticationBox + ) + + return apiService.createSubscription( + subscriptionObjectID: subscription.objectID, + query: query, + mastodonAuthenticationBox: authenticationBox + ) + } + .switchToLatest() + .sink { _ in + // do nothing + } receiveValue: { _ in + // do nothing + } + .store(in: &disposeBag) + + } + +} diff --git a/Mastodon/State/AppContext.swift b/Mastodon/State/AppContext.swift index 1ed2c283f..0c40ff127 100644 --- a/Mastodon/State/AppContext.swift +++ b/Mastodon/State/AppContext.swift @@ -29,6 +29,7 @@ class AppContext: ObservableObject { let statusPrefetchingService: StatusPrefetchingService let statusPublishService = StatusPublishService() let notificationService: NotificationService + let settingService: SettingService let documentStore: DocumentStore private var documentStoreSubscription: AnyCancellable! @@ -59,10 +60,16 @@ class AppContext: ObservableObject { statusPrefetchingService = StatusPrefetchingService( apiService: _apiService ) - notificationService = NotificationService( - apiService: _apiService, + let _notificationService = NotificationService( authenticationService: _authenticationService ) + notificationService = _notificationService + + settingService = SettingService( + apiService: _apiService, + authenticationService: _authenticationService, + notificationService: _notificationService + ) documentStore = DocumentStore() documentStoreSubscription = documentStore.objectWillChange diff --git a/Mastodon/Supporting Files/AppSharedName.swift b/Mastodon/Supporting Files/AppSharedName.swift new file mode 100644 index 000000000..3570c68da --- /dev/null +++ b/Mastodon/Supporting Files/AppSharedName.swift @@ -0,0 +1,12 @@ +// +// AppSharedName.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-26. +// + +import Foundation + +enum AppSharedName { + static let groupID = "group.org.joinmastodon.mastodon-temp" +} diff --git a/Mastodon/Supporting Files/SceneDelegate.swift b/Mastodon/Supporting Files/SceneDelegate.swift index 0f5e2bd59..1e6c13e41 100644 --- a/Mastodon/Supporting Files/SceneDelegate.swift +++ b/Mastodon/Supporting Files/SceneDelegate.swift @@ -6,10 +6,13 @@ // import UIKit +import Combine import CoreDataStack class SceneDelegate: UIResponder, UIWindowSceneDelegate { + var observations = Set() + var window: UIWindow? var coordinator: SceneCoordinator? @@ -28,8 +31,11 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { sceneCoordinator.setupOnboardingIfNeeds(animated: false) window.makeKeyAndVisible() - // update `overrideUserInterfaceStyle` with current setting - SettingsViewController.updateOverrideUserInterfaceStyle(window: window) + UserDefaults.shared.observe(\.customUserInterfaceStyle, options: [.initial, .new]) { [weak self] defaults, _ in + guard let self = self else { return } + self.window?.overrideUserInterfaceStyle = defaults.customUserInterfaceStyle + } + .store(in: &observations) } func sceneDidDisconnect(_ scene: UIScene) { diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Push.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Push.swift index f78c2c6c6..b66b89aad 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Push.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Push.swift @@ -21,7 +21,7 @@ extension Mastodon.API.Subscriptions { /// - Since: 2.4.0 /// - Version: 3.3.0 /// # Last Update - /// 2021/4/9 + /// 2021/4/25 /// # Reference /// [Document](https://docs.joinmastodon.org/methods/notifications/push/) /// - Parameters: @@ -54,7 +54,7 @@ extension Mastodon.API.Subscriptions { /// - Since: 2.4.0 /// - Version: 3.3.0 /// # Last Update - /// 2021/4/9 + /// 2021/4/25 /// # Reference /// [Document](https://docs.joinmastodon.org/methods/notifications/push/) /// - Parameters: @@ -88,7 +88,7 @@ extension Mastodon.API.Subscriptions { /// - Since: 2.4.0 /// - Version: 3.3.0 /// # Last Update - /// 2021/4/9 + /// 2021/4/25 /// # Reference /// [Document](https://docs.joinmastodon.org/methods/notifications/push/) /// - Parameters: @@ -114,10 +114,45 @@ extension Mastodon.API.Subscriptions { } .eraseToAnyPublisher() } + + /// Remove current subscription + /// + /// Removes the current Web Push API subscription. + /// + /// - Since: 2.4.0 + /// - Version: 3.3.0 + /// # Last Update + /// 2021/4/26 + /// # Reference + /// [Document](https://docs.joinmastodon.org/methods/notifications/push/) + /// - Parameters: + /// - session: `URLSession` + /// - domain: Mastodon instance domain. e.g. "example.com" + /// - authorization: User token. Could be nil if status is public + /// - Returns: `AnyPublisher` contains `Subscription` nested in the response + public static func removeSubscription( + session: URLSession, + domain: String, + authorization: Mastodon.API.OAuth.Authorization + ) -> AnyPublisher, Error> { + let request = Mastodon.API.delete( + url: pushEndpointURL(domain: domain), + query: nil, + authorization: authorization + ) + return session.dataTaskPublisher(for: request) + .tryMap { data, response in + let value = try Mastodon.API.decode(type: Mastodon.Entity.EmptySubscription.self, from: data, response: response) + return Mastodon.Response.Content(value: value, response: response) + } + .eraseToAnyPublisher() + } } extension Mastodon.API.Subscriptions { + public typealias Policy = QueryData.Policy + public struct QuerySubscription: Codable { let endpoint: String let keys: Keys @@ -142,9 +177,14 @@ extension Mastodon.API.Subscriptions { } public struct QueryData: Codable { + let policy: Policy? let alerts: Alerts - public init(alerts: Mastodon.API.Subscriptions.QueryData.Alerts) { + public init( + policy: Policy?, + alerts: Mastodon.API.Subscriptions.QueryData.Alerts + ) { + self.policy = policy self.alerts = alerts } @@ -163,8 +203,39 @@ extension Mastodon.API.Subscriptions { self.poll = poll } } + + public enum Policy: RawRepresentable, Codable { + case all + case followed + case follower + case none + + case _other(String) + + public init?(rawValue: String) { + switch rawValue { + case "all": self = .all + case "followed": self = .followed + case "follower": self = .follower + case "none": self = .none + + default: self = ._other(rawValue) + } + } + + public var rawValue: String { + switch self { + case .all: return "all" + case .followed: return "followed" + case .follower: return "follower" + case .none: return "none" + case ._other(let value): return value + } + } + } } + public struct CreateSubscriptionQuery: Codable, PostQuery { let subscription: QuerySubscription let data: QueryData diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift index 1a4496ed3..4fcc8fc9e 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift @@ -151,6 +151,14 @@ extension Mastodon.API { ) -> URLRequest { return buildRequest(url: url, method: .PUT, query: query, authorization: authorization) } + + static func delete( + url: URL, + query: DeleteQuery?, + authorization: OAuth.Authorization? + ) -> URLRequest { + return buildRequest(url: url, method: .DELETE, query: query, authorization: authorization) + } private static func buildRequest( url: URL, diff --git a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Subscription.swift b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Subscription.swift index 3ae5718e6..e968c32d6 100644 --- a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Subscription.swift +++ b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Subscription.swift @@ -1,5 +1,5 @@ // -// File.swift +// Mastodon+Entity+Subscription.swift // // // Created by ihugo on 2021/4/9. @@ -14,7 +14,7 @@ extension Mastodon.Entity { /// - Since: 2.4.0 /// - Version: 3.3.0 /// # Last Update - /// 2021/4/9 + /// 2021/4/26 /// # Reference /// [Document](https://docs.joinmastodon.org/entities/pushsubscription/) public struct Subscription: Codable { @@ -33,30 +33,19 @@ extension Mastodon.Entity { public struct Alerts: Codable { public let follow: Bool? + public let followRequest: Bool? public let favourite: Bool? public let reblog: Bool? public let mention: Bool? public let poll: Bool? - public var followNumber: NSNumber? { - guard let value = follow else { return nil } - return NSNumber(booleanLiteral: value) - } - public var favouriteNumber: NSNumber? { - guard let value = favourite else { return nil } - return NSNumber(booleanLiteral: value) - } - public var reblogNumber: NSNumber? { - guard let value = reblog else { return nil } - return NSNumber(booleanLiteral: value) - } - public var mentionNumber: NSNumber? { - guard let value = mention else { return nil } - return NSNumber(booleanLiteral: value) - } - public var pollNumber: NSNumber? { - guard let value = poll else { return nil } - return NSNumber(booleanLiteral: value) + enum CodingKeys: String, CodingKey { + case follow + case followRequest = "follow_request" + case favourite + case reblog + case mention + case poll } } @@ -74,4 +63,8 @@ extension Mastodon.Entity { serverKey = try container.decode(String.self, forKey: .serverKey) } } + + public struct EmptySubscription: Codable { + + } } diff --git a/MastodonSDK/Sources/MastodonSDK/Query/Query.swift b/MastodonSDK/Sources/MastodonSDK/Query/Query.swift index 39f6e3ec4..b729129bd 100644 --- a/MastodonSDK/Sources/MastodonSDK/Query/Query.swift +++ b/MastodonSDK/Sources/MastodonSDK/Query/Query.swift @@ -58,3 +58,5 @@ protocol PatchQuery: RequestQuery { } // PUT protocol PutQuery: RequestQuery { } +// DELETE +protocol DeleteQuery: RequestQuery { } From 59760696b57c06dfb268a7ff33f85c17a6630329 Mon Sep 17 00:00:00 2001 From: CMK Date: Mon, 26 Apr 2021 18:19:20 +0800 Subject: [PATCH 03/19] feat: set badge auto increment and clear when app resume --- Mastodon.xcodeproj/project.pbxproj | 10 ++++++++++ .../Preference/NotificationPreference.swift | 20 +++++++++++++++++++ Mastodon/Supporting Files/SceneDelegate.swift | 4 ++++ NotificationService/NotificationService.swift | 3 +++ 4 files changed, 37 insertions(+) create mode 100644 Mastodon/Preference/NotificationPreference.swift diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index 8bca2db49..512c89938 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -392,6 +392,10 @@ DBE3CE07261D6A0E00430CC6 /* FavoriteViewModel+Diffable.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBE3CE06261D6A0E00430CC6 /* FavoriteViewModel+Diffable.swift */; }; DBE3CE0D261D767100430CC6 /* FavoriteViewController+StatusProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBE3CE0C261D767100430CC6 /* FavoriteViewController+StatusProvider.swift */; }; DBE3CE13261D7D4200430CC6 /* StatusTableViewControllerAspect.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBE3CE12261D7D4200430CC6 /* StatusTableViewControllerAspect.swift */; }; + DBE54AB92636C87B004E7C0B /* AppSharedName.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6D1B2A2636852000ACB481 /* AppSharedName.swift */; }; + DBE54ABF2636C889004E7C0B /* UserDefaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6D1B23263684C600ACB481 /* UserDefaults.swift */; }; + DBE54AC62636C89F004E7C0B /* NotificationPreference.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBE54AC52636C89F004E7C0B /* NotificationPreference.swift */; }; + DBE54ACC2636C8FD004E7C0B /* NotificationPreference.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBE54AC52636C89F004E7C0B /* NotificationPreference.swift */; }; DBE64A8B260C49D200E6359A /* TwitterTextEditor in Frameworks */ = {isa = PBXBuildFile; productRef = DBE64A8A260C49D200E6359A /* TwitterTextEditor */; }; DBE64A8C260C49D200E6359A /* TwitterTextEditor in Embed Frameworks */ = {isa = PBXBuildFile; productRef = DBE64A8A260C49D200E6359A /* TwitterTextEditor */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; }; DBF8AE16263293E400C9C23C /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBF8AE15263293E400C9C23C /* NotificationService.swift */; }; @@ -862,6 +866,7 @@ DBE3CE06261D6A0E00430CC6 /* FavoriteViewModel+Diffable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FavoriteViewModel+Diffable.swift"; sourceTree = ""; }; DBE3CE0C261D767100430CC6 /* FavoriteViewController+StatusProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FavoriteViewController+StatusProvider.swift"; sourceTree = ""; }; DBE3CE12261D7D4200430CC6 /* StatusTableViewControllerAspect.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusTableViewControllerAspect.swift; sourceTree = ""; }; + DBE54AC52636C89F004E7C0B /* NotificationPreference.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationPreference.swift; sourceTree = ""; }; DBF53F5F25C14E88008AAC7B /* Mastodon.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; name = Mastodon.xctestplan; path = Mastodon/Mastodon.xctestplan; sourceTree = ""; }; DBF53F6025C14E9D008AAC7B /* MastodonSDK.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = MastodonSDK.xctestplan; sourceTree = ""; }; DBF8AE13263293E400C9C23C /* NotificationService.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = NotificationService.appex; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -1605,6 +1610,7 @@ children = ( DB5086BD25CC0D9900C2C187 /* SplashPreference.swift */, DB6D1B3C2636857500ACB481 /* AppearancePreference.swift */, + DBE54AC52636C89F004E7C0B /* NotificationPreference.swift */, ); path = Preference; sourceTree = ""; @@ -2795,6 +2801,7 @@ 2D206B8025F5F45E00143C56 /* UIImage.swift in Sources */, DB6D9F502635761F008423CD /* SubscriptionAlerts.swift in Sources */, 0F20220726134DA4000C64BF /* HashtagTimelineViewModel+Diffable.swift in Sources */, + DBE54AC62636C89F004E7C0B /* NotificationPreference.swift in Sources */, 2D5A3D2825CF8BC9002347D6 /* HomeTimelineViewModel+Diffable.swift in Sources */, DB98339C25C96DE600AD9700 /* APIService+Account.swift in Sources */, 2D42FF6B25C817D2004A627A /* MastodonStatusContent.swift in Sources */, @@ -2918,7 +2925,10 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + DBE54ACC2636C8FD004E7C0B /* NotificationPreference.swift in Sources */, DB6D9F232635195E008423CD /* String.swift in Sources */, + DBE54AB92636C87B004E7C0B /* AppSharedName.swift in Sources */, + DBE54ABF2636C889004E7C0B /* UserDefaults.swift in Sources */, DB6D9F3B26352019008423CD /* AppSecret.swift in Sources */, DB6D9F3526351B7A008423CD /* NotificationService+Decrypt.swift in Sources */, DBF8AE16263293E400C9C23C /* NotificationService.swift in Sources */, diff --git a/Mastodon/Preference/NotificationPreference.swift b/Mastodon/Preference/NotificationPreference.swift new file mode 100644 index 000000000..b634d77f3 --- /dev/null +++ b/Mastodon/Preference/NotificationPreference.swift @@ -0,0 +1,20 @@ +// +// NotificationPreference.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-26. +// + +import UIKit + +extension UserDefaults { + + @objc dynamic var notificationBadgeCount: Int { + get { + register(defaults: [#function: 0]) + return integer(forKey: #function) + } + set { UserDefaults.shared[#function] = newValue } + } + +} diff --git a/Mastodon/Supporting Files/SceneDelegate.swift b/Mastodon/Supporting Files/SceneDelegate.swift index 1e6c13e41..8dd978a8c 100644 --- a/Mastodon/Supporting Files/SceneDelegate.swift +++ b/Mastodon/Supporting Files/SceneDelegate.swift @@ -48,6 +48,10 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { func sceneDidBecomeActive(_ scene: UIScene) { // Called when the scene has moved from an inactive state to an active state. // Use this method to restart any tasks that were paused (or not yet started) when the scene was inactive. + + // reset notification badge + UserDefaults.shared.notificationBadgeCount = 0 + UIApplication.shared.applicationIconBadgeNumber = 0 } func sceneWillResignActive(_ scene: UIScene) { diff --git a/NotificationService/NotificationService.swift b/NotificationService/NotificationService.swift index 911866f17..e20fc23b2 100644 --- a/NotificationService/NotificationService.swift +++ b/NotificationService/NotificationService.swift @@ -59,6 +59,9 @@ class NotificationService: UNNotificationServiceExtension { bestAttemptContent.subtitle = "" bestAttemptContent.body = notification.body + UserDefaults.shared.notificationBadgeCount += 1 + bestAttemptContent.badge = NSNumber(integerLiteral: UserDefaults.shared.notificationBadgeCount) + if let urlString = notification.icon, let url = URL(string: urlString) { let temporaryDirectoryURL = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("notification-attachments") try? FileManager.default.createDirectory(at: temporaryDirectoryURL, withIntermediateDirectories: true, attributes: nil) From 42f63808df17c75be307fed7f62b0e9dd8882e94 Mon Sep 17 00:00:00 2001 From: sunxiaojian Date: Tue, 27 Apr 2021 14:27:27 +0800 Subject: [PATCH 04/19] feature: add follow request notification --- Localization/app.json | 3 +- .../xcshareddata/swiftpm/Package.resolved | 2 +- .../Section/NotificationSection.swift | 1 + .../Mastodon+Entity+Notification+Type.swift | 6 +++ Mastodon/Generated/Strings.swift | 2 + .../Resources/en.lproj/Localizable.strings | 1 + ...otificationViewModel+LoadLatestState.swift | 2 +- .../NotificationTableViewCell.swift | 52 ++++++++++++++++--- 8 files changed, 58 insertions(+), 11 deletions(-) diff --git a/Localization/app.json b/Localization/app.json index 1f5ccade3..6526ca4b8 100644 --- a/Localization/app.json +++ b/Localization/app.json @@ -347,7 +347,8 @@ "favourite": "favorited your post", "reblog": "rebloged your post", "poll": "Your poll has ended", - "mention": "mentioned you" + "mention": "mentioned you", + "follow_request": "request to follow you" }, }, "thread": { diff --git a/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved index 741947371..4c5c26898 100644 --- a/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -51,7 +51,7 @@ "repositoryURL": "https://github.com/onevcat/Kingfisher.git", "state": { "branch": null, - "revision": "bbc4bc4def7eb05a7ba8e1219f80ee9be327334e", + "revision": "15d199e84677303a7004ed2c5ecaa1a90f3863f8", "version": "6.2.1" } }, diff --git a/Mastodon/Diffiable/Section/NotificationSection.swift b/Mastodon/Diffiable/Section/NotificationSection.swift index 9c59350b4..57c755818 100644 --- a/Mastodon/Diffiable/Section/NotificationSection.swift +++ b/Mastodon/Diffiable/Section/NotificationSection.swift @@ -108,6 +108,7 @@ extension NotificationSection { if let actionImage = UIImage(systemName: actionImageName, withConfiguration: UIImage.SymbolConfiguration(pointSize: 12, weight: .semibold))?.withRenderingMode(.alwaysTemplate) { cell.actionImageView.image = actionImage } + cell.buttonStackView.isHidden = (type != .followRequest) return cell } case .bottomLoader: diff --git a/Mastodon/Extension/MastodonSDK/Mastodon+Entity+Notification+Type.swift b/Mastodon/Extension/MastodonSDK/Mastodon+Entity+Notification+Type.swift index 77a7b412e..2037f54a2 100644 --- a/Mastodon/Extension/MastodonSDK/Mastodon+Entity+Notification+Type.swift +++ b/Mastodon/Extension/MastodonSDK/Mastodon+Entity+Notification+Type.swift @@ -24,6 +24,8 @@ extension Mastodon.Entity.Notification.NotificationType { color = Asset.Colors.Notification.mention.color case .poll: color = Asset.Colors.brandBlue.color + case .followRequest: + color = Asset.Colors.brandBlue.color default: color = .clear } @@ -45,6 +47,8 @@ extension Mastodon.Entity.Notification.NotificationType { actionText = L10n.Scene.Notification.Action.mention case .poll: actionText = L10n.Scene.Notification.Action.poll + case .followRequest: + actionText = L10n.Scene.Notification.Action.followRequest default: actionText = "" } @@ -66,6 +70,8 @@ extension Mastodon.Entity.Notification.NotificationType { actionImageName = "at" case .poll: actionImageName = "list.bullet" + case .followRequest: + actionImageName = "person.crop.circle" default: actionImageName = "" } diff --git a/Mastodon/Generated/Strings.swift b/Mastodon/Generated/Strings.swift index 6d7af089d..0b657949f 100644 --- a/Mastodon/Generated/Strings.swift +++ b/Mastodon/Generated/Strings.swift @@ -371,6 +371,8 @@ internal enum L10n { internal static let favourite = L10n.tr("Localizable", "Scene.Notification.Action.Favourite") /// followed you internal static let follow = L10n.tr("Localizable", "Scene.Notification.Action.Follow") + /// request to follow you + internal static let followRequest = L10n.tr("Localizable", "Scene.Notification.Action.FollowRequest") /// mentioned you internal static let mention = L10n.tr("Localizable", "Scene.Notification.Action.Mention") /// Your poll has ended diff --git a/Mastodon/Resources/en.lproj/Localizable.strings b/Mastodon/Resources/en.lproj/Localizable.strings index ce7a3a2fe..e87982016 100644 --- a/Mastodon/Resources/en.lproj/Localizable.strings +++ b/Mastodon/Resources/en.lproj/Localizable.strings @@ -125,6 +125,7 @@ tap the link to confirm your account."; "Scene.HomeTimeline.Title" = "Home"; "Scene.Notification.Action.Favourite" = "favorited your post"; "Scene.Notification.Action.Follow" = "followed you"; +"Scene.Notification.Action.FollowRequest" = "request to follow you"; "Scene.Notification.Action.Mention" = "mentioned you"; "Scene.Notification.Action.Poll" = "Your poll has ended"; "Scene.Notification.Action.Reblog" = "rebloged your post"; diff --git a/Mastodon/Scene/Notification/NotificationViewModel+LoadLatestState.swift b/Mastodon/Scene/Notification/NotificationViewModel+LoadLatestState.swift index 0e6b0d622..e2b04804d 100644 --- a/Mastodon/Scene/Notification/NotificationViewModel+LoadLatestState.swift +++ b/Mastodon/Scene/Notification/NotificationViewModel+LoadLatestState.swift @@ -53,7 +53,7 @@ extension NotificationViewModel.LoadLatestState { sinceID: nil, minID: nil, limit: nil, - excludeTypes: [.followRequest], + excludeTypes: [], accountID: nil ) viewModel.context.apiService.allNotifications( diff --git a/Mastodon/Scene/Notification/TableViewCell/NotificationTableViewCell.swift b/Mastodon/Scene/Notification/TableViewCell/NotificationTableViewCell.swift index 619bffa17..809dc9b2b 100644 --- a/Mastodon/Scene/Notification/TableViewCell/NotificationTableViewCell.swift +++ b/Mastodon/Scene/Notification/TableViewCell/NotificationTableViewCell.swift @@ -21,6 +21,10 @@ protocol NotificationTableViewCellDelegate: AnyObject { func notificationStatusTableViewCell(_ cell: NotificationStatusTableViewCell, statusView: StatusView, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView) func notificationStatusTableViewCell(_ cell: NotificationStatusTableViewCell, statusView: StatusView, playerContainerView: PlayerContainerView, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView) +// func notificationStatusTableViewCell(_ cell: NotificationStatusTableViewCell, notification: MastodonNotification, acceptButtonDidPressed button: UIButton) +// +// func notificationStatusTableViewCell(_ cell: NotificationStatusTableViewCell, notification: MastodonNotification, denyButtonDidPressed button: UIButton) + } final class NotificationTableViewCell: UITableViewCell { @@ -76,6 +80,24 @@ final class NotificationTableViewCell: UITableViewCell { return label }() + let acceptButton: UIButton = { + let button = UIButton(type: .custom) + let actionImage = UIImage(systemName: "checkmark.circle.fill", withConfiguration: UIImage.SymbolConfiguration(pointSize: 28, weight: .semibold))?.withRenderingMode(.alwaysTemplate) + button.setImage(actionImage, for: .normal) + button.tintColor = Asset.Colors.Label.secondary.color + return button + }() + + let rejectButton: UIButton = { + let button = UIButton(type: .custom) + let actionImage = UIImage(systemName: "xmark.circle.fill", withConfiguration: UIImage.SymbolConfiguration(pointSize: 28, weight: .semibold))?.withRenderingMode(.alwaysTemplate) + button.setImage(actionImage, for: .normal) + button.tintColor = Asset.Colors.Label.secondary.color + return button + }() + + let buttonStackView = UIStackView() + override func prepareForReuse() { super.prepareForReuse() avatatImageView.af.cancelImageRequest() @@ -97,9 +119,8 @@ extension NotificationTableViewCell { func configure() { let containerStackView = UIStackView() - containerStackView.axis = .horizontal - containerStackView.alignment = .center - containerStackView.spacing = 4 + containerStackView.axis = .vertical + containerStackView.alignment = .fill containerStackView.layoutMargins = UIEdgeInsets(top: 14, left: 0, bottom: 12, right: 0) containerStackView.isLayoutMarginsRelativeArrangement = true containerStackView.translatesAutoresizingMaskIntoConstraints = false @@ -110,8 +131,13 @@ extension NotificationTableViewCell { contentView.readableContentGuide.trailingAnchor.constraint(equalTo: containerStackView.trailingAnchor), contentView.bottomAnchor.constraint(equalTo: containerStackView.bottomAnchor), ]) + + let horizontalStackView = UIStackView() + horizontalStackView.translatesAutoresizingMaskIntoConstraints = false + horizontalStackView.axis = .horizontal + horizontalStackView.spacing = 6 - containerStackView.addArrangedSubview(avatarContainer) + horizontalStackView.addArrangedSubview(avatarContainer) avatarContainer.translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ avatarContainer.heightAnchor.constraint(equalToConstant: 47).priority(.required - 1), @@ -144,13 +170,23 @@ extension NotificationTableViewCell { ]) nameLabel.translatesAutoresizingMaskIntoConstraints = false - containerStackView.addArrangedSubview(nameLabel) + horizontalStackView.addArrangedSubview(nameLabel) actionLabel.translatesAutoresizingMaskIntoConstraints = false - containerStackView.addArrangedSubview(actionLabel) - nameLabel.setContentCompressionResistancePriority(.required - 1, for: .vertical) - nameLabel.setContentHuggingPriority(.defaultHigh, for: .horizontal) + horizontalStackView.addArrangedSubview(actionLabel) + nameLabel.setContentCompressionResistancePriority(.required - 1, for: .horizontal) + nameLabel.setContentHuggingPriority(.required - 1, for: .horizontal) actionLabel.setContentHuggingPriority(.defaultLow, for: .horizontal) + containerStackView.addArrangedSubview(horizontalStackView) + + buttonStackView.translatesAutoresizingMaskIntoConstraints = false + buttonStackView.axis = .horizontal + buttonStackView.distribution = .fillEqually + acceptButton.translatesAutoresizingMaskIntoConstraints = false + denyButton.translatesAutoresizingMaskIntoConstraints = false + buttonStackView.addArrangedSubview(acceptButton) + buttonStackView.addArrangedSubview(rejectButton) + containerStackView.addArrangedSubview(buttonStackView) } override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { From 124d4eef0a863ad37c9462e15a90b9090b0a6391 Mon Sep 17 00:00:00 2001 From: sunxiaojian Date: Tue, 27 Apr 2021 14:43:38 +0800 Subject: [PATCH 05/19] feature: add followRequest API --- .../NotificationTableViewCell.swift | 2 +- .../Mastodon+API+Account+FollowRequest.swift | 89 +++++++++++++++++++ 2 files changed, 90 insertions(+), 1 deletion(-) create mode 100644 MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Account+FollowRequest.swift diff --git a/Mastodon/Scene/Notification/TableViewCell/NotificationTableViewCell.swift b/Mastodon/Scene/Notification/TableViewCell/NotificationTableViewCell.swift index 809dc9b2b..dc5c4c19c 100644 --- a/Mastodon/Scene/Notification/TableViewCell/NotificationTableViewCell.swift +++ b/Mastodon/Scene/Notification/TableViewCell/NotificationTableViewCell.swift @@ -183,7 +183,7 @@ extension NotificationTableViewCell { buttonStackView.axis = .horizontal buttonStackView.distribution = .fillEqually acceptButton.translatesAutoresizingMaskIntoConstraints = false - denyButton.translatesAutoresizingMaskIntoConstraints = false + rejectButton.translatesAutoresizingMaskIntoConstraints = false buttonStackView.addArrangedSubview(acceptButton) buttonStackView.addArrangedSubview(rejectButton) containerStackView.addArrangedSubview(buttonStackView) diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Account+FollowRequest.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Account+FollowRequest.swift new file mode 100644 index 000000000..447ce714f --- /dev/null +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Account+FollowRequest.swift @@ -0,0 +1,89 @@ +// +// Mastodon+API+Account+FollowRequest.swift +// +// +// Created by sxiaojian on 2021/4/27. +// + +import Foundation +import Combine + +// MARK: - Account credentials +extension Mastodon.API.Account { + + static func acceptFollowRequestEndpointURL(domain: String, userID: String) -> URL { + return Mastodon.API.endpointURL(domain: domain).appendingPathComponent("follow_requests") + .appendingPathComponent(userID) + .appendingPathComponent("authorize") + } + + static func rejectFollowRequestEndpointURL(domain: String, userID: String) -> URL { + return Mastodon.API.endpointURL(domain: domain).appendingPathComponent("follow_requests") + .appendingPathComponent(userID) + .appendingPathComponent("reject") + } + + /// Accept Follow + /// + /// + /// - Since: 0.0.0 + /// - Version: 3.0.0 + /// # Reference + /// [Document](https://docs.joinmastodon.org/methods/accounts/follow_requests/) + /// - Parameters: + /// - session: `URLSession` + /// - domain: Mastodon instance domain. e.g. "example.com" + /// - userID: ID of the account in the database + /// - authorization: App token + /// - Returns: `AnyPublisher` contains `Account` nested in the response + public static func acceptFollowRequest( + session: URLSession, + domain: String, + userID: String, + authorization: Mastodon.API.OAuth.Authorization + ) -> AnyPublisher, Error> { + let request = Mastodon.API.post( + url: acceptFollowRequestEndpointURL(domain: domain, userID: userID), + query: nil, + authorization: authorization + ) + return session.dataTaskPublisher(for: request) + .tryMap { data, response in + let value = try Mastodon.API.decode(type: Mastodon.Entity.Account.self, from: data, response: response) + return Mastodon.Response.Content(value: value, response: response) + } + .eraseToAnyPublisher() + } + + /// Reject Follow + /// + /// + /// - Since: 0.0.0 + /// - Version: 3.0.0 + /// # Reference + /// [Document](https://docs.joinmastodon.org/methods/accounts/follow_requests/) + /// - Parameters: + /// - session: `URLSession` + /// - domain: Mastodon instance domain. e.g. "example.com" + /// - userID: ID of the account in the database + /// - authorization: App token + /// - Returns: `AnyPublisher` contains `Account` nested in the response + public static func rejectFollowRequest( + session: URLSession, + domain: String, + userID: String, + authorization: Mastodon.API.OAuth.Authorization + ) -> AnyPublisher, Error> { + let request = Mastodon.API.post( + url: rejectFollowRequestEndpointURL(domain: domain, userID: userID), + query: nil, + authorization: authorization + ) + return session.dataTaskPublisher(for: request) + .tryMap { data, response in + let value = try Mastodon.API.decode(type: Mastodon.Entity.Account.self, from: data, response: response) + return Mastodon.Response.Content(value: value, response: response) + } + .eraseToAnyPublisher() + } +} From 381bf379267955976da26599a05a2c575842741b Mon Sep 17 00:00:00 2001 From: sunxiaojian Date: Tue, 27 Apr 2021 15:33:47 +0800 Subject: [PATCH 06/19] fix: delete old notifications in CoreData --- Mastodon.xcodeproj/project.pbxproj | 4 + .../UserProvider/UserProviderFacade.swift | 3 +- .../SuggestionAccountViewModel.swift | 3 +- .../APIService/APIService+Follow.swift | 23 ++-- .../APIService/APIService+FollowRequest.swift | 105 ++++++++++++++++++ .../APIService/APIService+Notification.swift | 8 ++ .../Mastodon+API+Account+FollowRequest.swift | 12 +- 7 files changed, 135 insertions(+), 23 deletions(-) create mode 100644 Mastodon/Service/APIService/APIService+FollowRequest.swift diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index d9af89e1b..ad9f94dad 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -110,6 +110,7 @@ 2D8434F525FF465D00EECE90 /* HomeTimelineNavigationBarTitleViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D8434F425FF465D00EECE90 /* HomeTimelineNavigationBarTitleViewModel.swift */; }; 2D8434FB25FF46B300EECE90 /* HomeTimelineNavigationBarTitleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D8434FA25FF46B300EECE90 /* HomeTimelineNavigationBarTitleView.swift */; }; 2D84350525FF858100EECE90 /* UIScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D84350425FF858100EECE90 /* UIScrollView.swift */; }; + 2D8FCA082637EABB00137F46 /* APIService+FollowRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D8FCA072637EABB00137F46 /* APIService+FollowRequest.swift */; }; 2D927F0225C7E4F2004F19B8 /* Mention.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D927F0125C7E4F2004F19B8 /* Mention.swift */; }; 2D927F0825C7E9A8004F19B8 /* Tag.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D927F0725C7E9A8004F19B8 /* Tag.swift */; }; 2D927F0E25C7E9C9004F19B8 /* History.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D927F0D25C7E9C9004F19B8 /* History.swift */; }; @@ -528,6 +529,7 @@ 2D8434F425FF465D00EECE90 /* HomeTimelineNavigationBarTitleViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeTimelineNavigationBarTitleViewModel.swift; sourceTree = ""; }; 2D8434FA25FF46B300EECE90 /* HomeTimelineNavigationBarTitleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeTimelineNavigationBarTitleView.swift; sourceTree = ""; }; 2D84350425FF858100EECE90 /* UIScrollView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIScrollView.swift; sourceTree = ""; }; + 2D8FCA072637EABB00137F46 /* APIService+FollowRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+FollowRequest.swift"; sourceTree = ""; }; 2D927F0125C7E4F2004F19B8 /* Mention.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Mention.swift; sourceTree = ""; }; 2D927F0725C7E9A8004F19B8 /* Tag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tag.swift; sourceTree = ""; }; 2D927F0D25C7E9C9004F19B8 /* History.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = History.swift; sourceTree = ""; }; @@ -1477,6 +1479,7 @@ 0F202212261351F5000C64BF /* APIService+HashtagTimeline.swift */, DBCC3B9426157E6E0045B23D /* APIService+Relationship.swift */, DBAE3F932616E28B004B8251 /* APIService+Follow.swift */, + 2D8FCA072637EABB00137F46 /* APIService+FollowRequest.swift */, DBAE3F8D2616E0B1004B8251 /* APIService+Block.swift */, DBAE3F9D2616E308004B8251 /* APIService+Mute.swift */, 5B90C48426259BF10002E742 /* APIService+Subscriptions.swift */, @@ -2406,6 +2409,7 @@ DBA0A10925FB3C2B0079C110 /* RoundedEdgesButton.swift in Sources */, DBABE3EC25ECAC4B00879EE5 /* WelcomeIllustrationView.swift in Sources */, DB71FD4625F8C6D200512AE1 /* StatusProvider+UITableViewDataSourcePrefetching.swift in Sources */, + 2D8FCA082637EABB00137F46 /* APIService+FollowRequest.swift in Sources */, 2D152A8C25C295CC009AA50C /* StatusView.swift in Sources */, 2DE0FAC82615F5F000CDF649 /* SearchRecommendAccountsCollectionViewCell.swift in Sources */, 2DFAD5272616F9D300F9EE7C /* SearchViewController+Searching.swift in Sources */, diff --git a/Mastodon/Protocol/UserProvider/UserProviderFacade.swift b/Mastodon/Protocol/UserProvider/UserProviderFacade.swift index b64bfe79d..b5f4dd32f 100644 --- a/Mastodon/Protocol/UserProvider/UserProviderFacade.swift +++ b/Mastodon/Protocol/UserProvider/UserProviderFacade.swift @@ -44,8 +44,7 @@ extension UserProviderFacade { return context.apiService.toggleFollow( for: mastodonUser, - activeMastodonAuthenticationBox: activeMastodonAuthenticationBox, - needFeedback: true + activeMastodonAuthenticationBox: activeMastodonAuthenticationBox ) } .switchToLatest() diff --git a/Mastodon/Scene/SuggestionAccount/SuggestionAccountViewModel.swift b/Mastodon/Scene/SuggestionAccount/SuggestionAccountViewModel.swift index d5ef6f6c7..7a508fc75 100644 --- a/Mastodon/Scene/SuggestionAccount/SuggestionAccountViewModel.swift +++ b/Mastodon/Scene/SuggestionAccount/SuggestionAccountViewModel.swift @@ -188,8 +188,7 @@ final class SuggestionAccountViewModel: NSObject { let mastodonUser = context.managedObjectContext.object(with: objectID) as! MastodonUser return context.apiService.toggleFollow( for: mastodonUser, - activeMastodonAuthenticationBox: activeMastodonAuthenticationBox, - needFeedback: false + activeMastodonAuthenticationBox: activeMastodonAuthenticationBox ) } diff --git a/Mastodon/Service/APIService/APIService+Follow.swift b/Mastodon/Service/APIService/APIService+Follow.swift index 6db612942..53634ab4b 100644 --- a/Mastodon/Service/APIService/APIService+Follow.swift +++ b/Mastodon/Service/APIService/APIService+Follow.swift @@ -24,15 +24,12 @@ extension APIService { /// - Returns: publisher for `Relationship` func toggleFollow( for mastodonUser: MastodonUser, - activeMastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox, - needFeedback: Bool + activeMastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox ) -> AnyPublisher, Error> { - var impactFeedbackGenerator: UIImpactFeedbackGenerator? - var notificationFeedbackGenerator: UINotificationFeedbackGenerator? - if needFeedback { - impactFeedbackGenerator = UIImpactFeedbackGenerator(style: .light) - notificationFeedbackGenerator = UINotificationFeedbackGenerator() - } + + let impactFeedbackGenerator = UIImpactFeedbackGenerator(style: .light) + let notificationFeedbackGenerator = UINotificationFeedbackGenerator() + return followUpdateLocal( mastodonUserObjectID: mastodonUser.objectID, @@ -40,9 +37,9 @@ extension APIService { ) .receive(on: DispatchQueue.main) .handleEvents { _ in - impactFeedbackGenerator?.prepare() + impactFeedbackGenerator.prepare() } receiveOutput: { _ in - impactFeedbackGenerator?.impactOccurred() + impactFeedbackGenerator.impactOccurred() } receiveCompletion: { completion in switch completion { case .failure(let error): @@ -79,13 +76,13 @@ extension APIService { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Friendship] rollback finish", ((#file as NSString).lastPathComponent), #line, #function) } receiveValue: { _ in // do nothing - notificationFeedbackGenerator?.prepare() - notificationFeedbackGenerator?.notificationOccurred(.error) + notificationFeedbackGenerator.prepare() + notificationFeedbackGenerator.notificationOccurred(.error) } .store(in: &self.disposeBag) case .finished: - notificationFeedbackGenerator?.notificationOccurred(.success) + notificationFeedbackGenerator.notificationOccurred(.success) os_log("%{public}s[%{public}ld], %{public}s: [Friendship] remote friendship update success", ((#file as NSString).lastPathComponent), #line, #function) } }) diff --git a/Mastodon/Service/APIService/APIService+FollowRequest.swift b/Mastodon/Service/APIService/APIService+FollowRequest.swift new file mode 100644 index 000000000..c40fcad52 --- /dev/null +++ b/Mastodon/Service/APIService/APIService+FollowRequest.swift @@ -0,0 +1,105 @@ +// +// APIService+FollowRequest.swift +// Mastodon +// +// Created by sxiaojian on 2021/4/27. +// + +import Foundation + +import UIKit +import Combine +import CoreData +import CoreDataStack +import CommonOSLog +import MastodonSDK + +extension APIService { + func acceptFollowRequest( + mastodonUserID: MastodonUser.ID, + mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox + ) -> AnyPublisher, Error> { + let domain = mastodonAuthenticationBox.domain + let authorization = mastodonAuthenticationBox.userAuthorization + let requestMastodonUserID = mastodonAuthenticationBox.userID + + return Mastodon.API.Account.acceptFollowRequest( + session: session, + domain: domain, + userID: mastodonUserID, + authorization: authorization) + .flatMap { response -> AnyPublisher, Error> in + let managedObjectContext = self.backgroundManagedObjectContext + return managedObjectContext.performChanges { + let requestMastodonUserRequest = MastodonUser.sortedFetchRequest + requestMastodonUserRequest.predicate = MastodonUser.predicate(domain: domain, id: requestMastodonUserID) + requestMastodonUserRequest.fetchLimit = 1 + guard let requestMastodonUser = managedObjectContext.safeFetch(requestMastodonUserRequest).first else { return } + + let lookUpMastodonUserRequest = MastodonUser.sortedFetchRequest + lookUpMastodonUserRequest.predicate = MastodonUser.predicate(domain: domain, id: mastodonUserID) + lookUpMastodonUserRequest.fetchLimit = 1 + let lookUpMastodonuser = managedObjectContext.safeFetch(lookUpMastodonUserRequest).first + + if let lookUpMastodonuser = lookUpMastodonuser { + let entity = response.value + APIService.CoreData.update(user: lookUpMastodonuser, entity: entity, requestMastodonUser: requestMastodonUser, domain: domain, networkDate: response.networkDate) + } + } + .tryMap { result -> Mastodon.Response.Content in + switch result { + case .success: + return response + case .failure(let error): + throw error + } + } + .eraseToAnyPublisher() + } + .eraseToAnyPublisher() + } + + func rejectFollowRequest( + mastodonUserID: MastodonUser.ID, + mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox + ) -> AnyPublisher, Error> { + let domain = mastodonAuthenticationBox.domain + let authorization = mastodonAuthenticationBox.userAuthorization + let requestMastodonUserID = mastodonAuthenticationBox.userID + + return Mastodon.API.Account.rejectFollowRequest( + session: session, + domain: domain, + userID: mastodonUserID, + authorization: authorization) + .flatMap { response -> AnyPublisher, Error> in + let managedObjectContext = self.backgroundManagedObjectContext + return managedObjectContext.performChanges { + let requestMastodonUserRequest = MastodonUser.sortedFetchRequest + requestMastodonUserRequest.predicate = MastodonUser.predicate(domain: domain, id: requestMastodonUserID) + requestMastodonUserRequest.fetchLimit = 1 + guard let requestMastodonUser = managedObjectContext.safeFetch(requestMastodonUserRequest).first else { return } + + let lookUpMastodonUserRequest = MastodonUser.sortedFetchRequest + lookUpMastodonUserRequest.predicate = MastodonUser.predicate(domain: domain, id: mastodonUserID) + lookUpMastodonUserRequest.fetchLimit = 1 + let lookUpMastodonuser = managedObjectContext.safeFetch(lookUpMastodonUserRequest).first + + if let lookUpMastodonuser = lookUpMastodonuser { + let entity = response.value + APIService.CoreData.update(user: lookUpMastodonuser, entity: entity, requestMastodonUser: requestMastodonUser, domain: domain, networkDate: response.networkDate) + } + } + .tryMap { result -> Mastodon.Response.Content in + switch result { + case .success: + return response + case .failure(let error): + throw error + } + } + .eraseToAnyPublisher() + } + .eraseToAnyPublisher() + } +} diff --git a/Mastodon/Service/APIService/APIService+Notification.swift b/Mastodon/Service/APIService/APIService+Notification.swift index ee8f5186c..a27aae2ae 100644 --- a/Mastodon/Service/APIService/APIService+Notification.swift +++ b/Mastodon/Service/APIService/APIService+Notification.swift @@ -28,6 +28,14 @@ extension APIService { ) .flatMap { response -> AnyPublisher, Error> in let log = OSLog.api + if query.maxID == nil { + let requestMastodonNotificationRequest = MastodonNotification.sortedFetchRequest + requestMastodonNotificationRequest.predicate = MastodonNotification.predicate(domain: domain, userID: userID) + let oldNotifications = self.backgroundManagedObjectContext.safeFetch(requestMastodonNotificationRequest) + oldNotifications.forEach { notification in + self.backgroundManagedObjectContext.delete(notification) + } + } return self.backgroundManagedObjectContext.performChanges { response.value.forEach { notification in let (mastodonUser, _) = APIService.CoreData.createOrMergeMastodonUser(into: self.backgroundManagedObjectContext, for: nil, in: domain, entity: notification.account, userCache: nil, networkDate: Date(), log: log) diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Account+FollowRequest.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Account+FollowRequest.swift index 447ce714f..f08e888b5 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Account+FollowRequest.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Account+FollowRequest.swift @@ -35,13 +35,13 @@ extension Mastodon.API.Account { /// - domain: Mastodon instance domain. e.g. "example.com" /// - userID: ID of the account in the database /// - authorization: App token - /// - Returns: `AnyPublisher` contains `Account` nested in the response + /// - Returns: `AnyPublisher` contains `Relationship` nested in the response public static func acceptFollowRequest( session: URLSession, domain: String, userID: String, authorization: Mastodon.API.OAuth.Authorization - ) -> AnyPublisher, Error> { + ) -> AnyPublisher, Error> { let request = Mastodon.API.post( url: acceptFollowRequestEndpointURL(domain: domain, userID: userID), query: nil, @@ -49,7 +49,7 @@ extension Mastodon.API.Account { ) return session.dataTaskPublisher(for: request) .tryMap { data, response in - let value = try Mastodon.API.decode(type: Mastodon.Entity.Account.self, from: data, response: response) + let value = try Mastodon.API.decode(type: Mastodon.Entity.Relationship.self, from: data, response: response) return Mastodon.Response.Content(value: value, response: response) } .eraseToAnyPublisher() @@ -67,13 +67,13 @@ extension Mastodon.API.Account { /// - domain: Mastodon instance domain. e.g. "example.com" /// - userID: ID of the account in the database /// - authorization: App token - /// - Returns: `AnyPublisher` contains `Account` nested in the response + /// - Returns: `AnyPublisher` contains `Relationship` nested in the response public static func rejectFollowRequest( session: URLSession, domain: String, userID: String, authorization: Mastodon.API.OAuth.Authorization - ) -> AnyPublisher, Error> { + ) -> AnyPublisher, Error> { let request = Mastodon.API.post( url: rejectFollowRequestEndpointURL(domain: domain, userID: userID), query: nil, @@ -81,7 +81,7 @@ extension Mastodon.API.Account { ) return session.dataTaskPublisher(for: request) .tryMap { data, response in - let value = try Mastodon.API.decode(type: Mastodon.Entity.Account.self, from: data, response: response) + let value = try Mastodon.API.decode(type: Mastodon.Entity.Relationship.self, from: data, response: response) return Mastodon.Response.Content(value: value, response: response) } .eraseToAnyPublisher() From aca358db26bfe260980f41616eb6dadf8358c816 Mon Sep 17 00:00:00 2001 From: CMK Date: Tue, 27 Apr 2021 16:26:59 +0800 Subject: [PATCH 07/19] feat: persist notification keys into Keychian --- AppShared/AppName.swift | 12 + AppShared/AppSecret.swift | 103 ++++++ AppShared/AppShared.h | 18 + AppShared/Info.plist | 22 ++ AppShared/UserDefaults.swift | 12 + CoreDataStack/CoreDataStack.swift | 3 +- Mastodon.xcodeproj/project.pbxproj | 327 ++++++++++++++++-- .../xcschemes/xcschememanagement.plist | 9 +- .../xcshareddata/swiftpm/Package.resolved | 9 + Mastodon/Extension/String.swift | 22 ++ Mastodon/Extension/UserDefaults.swift | 5 +- .../Preference/AppearancePreference.swift | 2 +- .../Preference/NotificationPreference.swift | 2 +- .../APIService/APIService+Subscriptions.swift | 5 +- .../APIService+CoreData+Setting.swift | 57 +-- .../APIService+CoreData+Subscriptions.swift | 1 + Mastodon/Service/NotificationService.swift | 21 +- Mastodon/Service/SettingService.swift | 37 +- Mastodon/Supporting Files/AppDelegate.swift | 26 +- Mastodon/Supporting Files/AppSecret.swift | 51 --- Mastodon/Supporting Files/AppSharedName.swift | 12 - NotificationService/Extension/String.swift | 31 -- .../MastodonNotification.swift | 35 ++ .../NotificationService+Decrypt.swift | 37 +- .../NotificationService.entitlements | 10 + NotificationService/NotificationService.swift | 9 +- Podfile | 10 +- Podfile.lock | 2 +- README.md | 1 + 29 files changed, 673 insertions(+), 218 deletions(-) create mode 100644 AppShared/AppName.swift create mode 100644 AppShared/AppSecret.swift create mode 100644 AppShared/AppShared.h create mode 100644 AppShared/Info.plist create mode 100644 AppShared/UserDefaults.swift delete mode 100644 Mastodon/Supporting Files/AppSecret.swift delete mode 100644 Mastodon/Supporting Files/AppSharedName.swift delete mode 100644 NotificationService/Extension/String.swift create mode 100644 NotificationService/MastodonNotification.swift create mode 100644 NotificationService/NotificationService.entitlements diff --git a/AppShared/AppName.swift b/AppShared/AppName.swift new file mode 100644 index 000000000..9dbca78d8 --- /dev/null +++ b/AppShared/AppName.swift @@ -0,0 +1,12 @@ +// +// AppName.swift +// AppShared +// +// Created by MainasuK Cirno on 2021-4-27. +// + +import Foundation + +public enum AppName { + public static let groupID = "group.org.joinmastodon.mastodon-temp" +} diff --git a/AppShared/AppSecret.swift b/AppShared/AppSecret.swift new file mode 100644 index 000000000..e2305ef1a --- /dev/null +++ b/AppShared/AppSecret.swift @@ -0,0 +1,103 @@ +// +// AppSecret.swift +// AppShared +// +// Created by MainasuK Cirno on 2021-4-27. +// + + +import Foundation +import CryptoKit +import KeychainAccess +import Keys + +public final class AppSecret { + + public static let keychain = Keychain(service: "org.joinmastodon.Mastodon.keychain", accessGroup: AppName.groupID) + + static let notificationPrivateKeyName = "notification-private-key-base64" + static let notificationAuthName = "notification-auth-base64" + + public let notificationEndpoint: String + + public var notificationPrivateKey: P256.KeyAgreement.PrivateKey { + AppSecret.createOrFetchNotificationPrivateKey() + } + public var notificationPublicKey: P256.KeyAgreement.PublicKey { + notificationPrivateKey.publicKey + } + public var notificationAuth: Data { + AppSecret.createOrFetchNotificationAuth() + } + + public static let `default`: AppSecret = { + return AppSecret() + }() + + init() { + let keys = MastodonKeys() + + #if DEBUG + self.notificationEndpoint = keys.notification_endpoint_debug + #else + self.notificationEndpoint = keys.notification_endpoint + #endif + } + + public func register() { + _ = AppSecret.createOrFetchNotificationPrivateKey() + _ = AppSecret.createOrFetchNotificationAuth() + } + +} + +extension AppSecret { + + private static func createOrFetchNotificationPrivateKey() -> P256.KeyAgreement.PrivateKey { + if let encoded = AppSecret.keychain[AppSecret.notificationPrivateKeyName], + let data = Data(base64Encoded: encoded) { + do { + let privateKey = try P256.KeyAgreement.PrivateKey(rawRepresentation: data) + return privateKey + } catch { + assertionFailure() + return AppSecret.resetNotificationPrivateKey() + } + } else { + return AppSecret.resetNotificationPrivateKey() + } + } + + private static func resetNotificationPrivateKey() -> P256.KeyAgreement.PrivateKey { + let privateKey = P256.KeyAgreement.PrivateKey() + keychain[AppSecret.notificationPrivateKeyName] = privateKey.rawRepresentation.base64EncodedString() + return privateKey + } + +} + +extension AppSecret { + + private static func createOrFetchNotificationAuth() -> Data { + if let encoded = keychain[AppSecret.notificationAuthName], + let data = Data(base64Encoded: encoded) { + return data + } else { + return AppSecret.resetNotificationAuth() + } + } + + private static func resetNotificationAuth() -> Data { + let auth = AppSecret.createRandomAuthBytes() + keychain[AppSecret.notificationAuthName] = auth.base64EncodedString() + return auth + } + + private static func createRandomAuthBytes() -> Data { + let byteCount = 16 + var bytes = Data(count: byteCount) + _ = bytes.withUnsafeMutableBytes { SecRandomCopyBytes(kSecRandomDefault, byteCount, $0.baseAddress!) } + return bytes + } + +} diff --git a/AppShared/AppShared.h b/AppShared/AppShared.h new file mode 100644 index 000000000..3258d4fcb --- /dev/null +++ b/AppShared/AppShared.h @@ -0,0 +1,18 @@ +// +// AppShared.h +// AppShared +// +// Created by MainasuK Cirno on 2021-4-27. +// + +#import + +//! Project version number for AppShared. +FOUNDATION_EXPORT double AppSharedVersionNumber; + +//! Project version string for AppShared. +FOUNDATION_EXPORT const unsigned char AppSharedVersionString[]; + +// In this header, you should import all the public headers of your framework using statements like #import + + diff --git a/AppShared/Info.plist b/AppShared/Info.plist new file mode 100644 index 000000000..9bcb24442 --- /dev/null +++ b/AppShared/Info.plist @@ -0,0 +1,22 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + 1.0 + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + + diff --git a/AppShared/UserDefaults.swift b/AppShared/UserDefaults.swift new file mode 100644 index 000000000..9cecdcf60 --- /dev/null +++ b/AppShared/UserDefaults.swift @@ -0,0 +1,12 @@ +// +// UserDefaults.swift +// AppShared +// +// Created by MainasuK Cirno on 2021-4-27. +// + +import UIKit + +extension UserDefaults { + public static let shared = UserDefaults(suiteName: AppName.groupID)! +} diff --git a/CoreDataStack/CoreDataStack.swift b/CoreDataStack/CoreDataStack.swift index 766bcf4de..64bf9c857 100644 --- a/CoreDataStack/CoreDataStack.swift +++ b/CoreDataStack/CoreDataStack.swift @@ -8,6 +8,7 @@ import os import Foundation import CoreData +import AppShared public final class CoreDataStack { @@ -18,7 +19,7 @@ public final class CoreDataStack { } public convenience init(databaseName: String = "shared") { - let storeURL = URL.storeURL(for: AppSharedName.groupID, databaseName: databaseName) + let storeURL = URL.storeURL(for: AppName.groupID, databaseName: databaseName) let storeDescription = NSPersistentStoreDescription(url: storeURL) self.init(persistentStoreDescriptions: [storeDescription]) } diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index 512c89938..37b92876b 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -167,6 +167,7 @@ 5E44BF88AD33646E64727BCF /* Pods_MastodonTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CD92E0F10BDE4FE7C4B999F2 /* Pods_MastodonTests.framework */; }; 7D603B3DC600BB75956FAD7D /* Pods_Mastodon_NotificationService.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 79D7EB88A6A8B34DFDFC96FC /* Pods_Mastodon_NotificationService.framework */; }; 87FFDA5D898A5C42ADCB35E7 /* Pods_Mastodon.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A4ABE34829701A4496C5BB64 /* Pods_Mastodon.framework */; }; + D86919F5080C3F228CCD17D1 /* Pods_Mastodon_AppShared.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 46531DECCAB422F507B2274D /* Pods_Mastodon_AppShared.framework */; }; DB00CA972632DDB600A54956 /* CommonOSLog in Frameworks */ = {isa = PBXBuildFile; productRef = DB00CA962632DDB600A54956 /* CommonOSLog */; }; DB0140A125C40C0600F9F3CF /* MastodonPinBasedAuthenticationViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0140A025C40C0600F9F3CF /* MastodonPinBasedAuthenticationViewController.swift */; }; DB0140A825C40C1500F9F3CF /* MastodonPinBasedAuthenticationViewModelNavigationDelegateShim.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0140A725C40C1500F9F3CF /* MastodonPinBasedAuthenticationViewModelNavigationDelegateShim.swift */; }; @@ -180,7 +181,6 @@ DB118A8225E4B6E600FAB162 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = DB118A8125E4B6E600FAB162 /* Preview Assets.xcassets */; }; DB118A8C25E4BFB500FAB162 /* HighlightDimmableButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB118A8B25E4BFB500FAB162 /* HighlightDimmableButton.swift */; }; DB1D186C25EF5BA7003F1F23 /* PollTableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB1D186B25EF5BA7003F1F23 /* PollTableView.swift */; }; - DB1E05E1263180F500201847 /* AppSecret.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB1E05E0263180F500201847 /* AppSecret.swift */; }; DB1E346825F518E20079D7DF /* CategoryPickerSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB1E346725F518E20079D7DF /* CategoryPickerSection.swift */; }; DB1E347825F519300079D7DF /* PickServerItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB1E347725F519300079D7DF /* PickServerItem.swift */; }; DB1FD43625F26899004CFCFC /* MastodonPickServerViewModel+LoadIndexedServerState.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB1FD43525F26899004CFCFC /* MastodonPickServerViewModel+LoadIndexedServerState.swift */; }; @@ -248,6 +248,21 @@ DB66728C25F9F8DC00D60309 /* ComposeViewModel+Diffable.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB66728B25F9F8DC00D60309 /* ComposeViewModel+Diffable.swift */; }; DB66729625F9F91600D60309 /* ComposeStatusSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB66729525F9F91600D60309 /* ComposeStatusSection.swift */; }; DB66729C25F9F91F00D60309 /* ComposeStatusItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB66729B25F9F91F00D60309 /* ComposeStatusItem.swift */; }; + DB68045B2636DC6A00430867 /* MastodonNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB68045A2636DC6A00430867 /* MastodonNotification.swift */; }; + DB6804662636DC9000430867 /* String.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D939AB425EDD8A90076FA61 /* String.swift */; }; + DB68046C2636DC9E00430867 /* MastodonNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB68045A2636DC6A00430867 /* MastodonNotification.swift */; }; + DB6804832637CD4C00430867 /* AppShared.h in Headers */ = {isa = PBXBuildFile; fileRef = DB6804812637CD4C00430867 /* AppShared.h */; settings = {ATTRIBUTES = (Public, ); }; }; + DB6804862637CD4C00430867 /* AppShared.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DB68047F2637CD4C00430867 /* AppShared.framework */; }; + DB6804872637CD4C00430867 /* AppShared.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = DB68047F2637CD4C00430867 /* AppShared.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + DB6804922637CD8700430867 /* AppName.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6804912637CD8700430867 /* AppName.swift */; }; + DB6804A52637CDCC00430867 /* AppShared.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DB68047F2637CD4C00430867 /* AppShared.framework */; }; + DB6804A62637CDCC00430867 /* AppShared.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = DB68047F2637CD4C00430867 /* AppShared.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + DB6804C82637CE2F00430867 /* AppShared.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DB68047F2637CD4C00430867 /* AppShared.framework */; }; + DB6804D12637CE4700430867 /* UserDefaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6804D02637CE4700430867 /* UserDefaults.swift */; }; + DB6804FD2637CFEC00430867 /* AppSecret.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6804FC2637CFEC00430867 /* AppSecret.swift */; }; + DB6805102637D0F800430867 /* KeychainAccess in Frameworks */ = {isa = PBXBuildFile; productRef = DB68050F2637D0F800430867 /* KeychainAccess */; }; + DB6805262637D7DD00430867 /* AppShared.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DB68047F2637CD4C00430867 /* AppShared.framework */; }; + DB6805272637D7DD00430867 /* AppShared.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = DB68047F2637CD4C00430867 /* AppShared.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; DB68586425E619B700F0A850 /* NSKeyValueObservation.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB68586325E619B700F0A850 /* NSKeyValueObservation.swift */; }; DB68A04A25E9027700CFDF14 /* AdaptiveStatusBarStyleNavigationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB68A04925E9027700CFDF14 /* AdaptiveStatusBarStyleNavigationController.swift */; }; DB68A05D25E9055900CFDF14 /* Settings.bundle in Resources */ = {isa = PBXBuildFile; fileRef = DB68A05C25E9055900CFDF14 /* Settings.bundle */; }; @@ -256,13 +271,9 @@ DB6B351E2601FAEE00DC1E11 /* ComposeStatusAttachmentCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6B351D2601FAEE00DC1E11 /* ComposeStatusAttachmentCollectionViewCell.swift */; }; DB6C8C0F25F0A6AE00AAA452 /* Mastodon+Entity+Error.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6C8C0E25F0A6AE00AAA452 /* Mastodon+Entity+Error.swift */; }; DB6D1B24263684C600ACB481 /* UserDefaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6D1B23263684C600ACB481 /* UserDefaults.swift */; }; - DB6D1B2B2636852000ACB481 /* AppSharedName.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6D1B2A2636852000ACB481 /* AppSharedName.swift */; }; - DB6D1B312636853100ACB481 /* AppSharedName.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6D1B2A2636852000ACB481 /* AppSharedName.swift */; }; DB6D1B3D2636857500ACB481 /* AppearancePreference.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6D1B3C2636857500ACB481 /* AppearancePreference.swift */; }; DB6D1B44263691CF00ACB481 /* Mastodon+API+Subscriptions+Policy.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6D1B43263691CF00ACB481 /* Mastodon+API+Subscriptions+Policy.swift */; }; - DB6D9F232635195E008423CD /* String.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6D9F222635195E008423CD /* String.swift */; }; DB6D9F3526351B7A008423CD /* NotificationService+Decrypt.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6D9F3426351B7A008423CD /* NotificationService+Decrypt.swift */; }; - DB6D9F3B26352019008423CD /* AppSecret.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB1E05E0263180F500201847 /* AppSecret.swift */; }; DB6D9F42263527CE008423CD /* AlamofireImage in Frameworks */ = {isa = PBXBuildFile; productRef = DB6D9F41263527CE008423CD /* AlamofireImage */; }; DB6D9F4926353FD7008423CD /* Subscription.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6D9F4826353FD6008423CD /* Subscription.swift */; }; DB6D9F502635761F008423CD /* SubscriptionAlerts.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6D9F4F2635761F008423CD /* SubscriptionAlerts.swift */; }; @@ -392,7 +403,6 @@ DBE3CE07261D6A0E00430CC6 /* FavoriteViewModel+Diffable.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBE3CE06261D6A0E00430CC6 /* FavoriteViewModel+Diffable.swift */; }; DBE3CE0D261D767100430CC6 /* FavoriteViewController+StatusProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBE3CE0C261D767100430CC6 /* FavoriteViewController+StatusProvider.swift */; }; DBE3CE13261D7D4200430CC6 /* StatusTableViewControllerAspect.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBE3CE12261D7D4200430CC6 /* StatusTableViewControllerAspect.swift */; }; - DBE54AB92636C87B004E7C0B /* AppSharedName.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6D1B2A2636852000ACB481 /* AppSharedName.swift */; }; DBE54ABF2636C889004E7C0B /* UserDefaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6D1B23263684C600ACB481 /* UserDefaults.swift */; }; DBE54AC62636C89F004E7C0B /* NotificationPreference.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBE54AC52636C89F004E7C0B /* NotificationPreference.swift */; }; DBE54ACC2636C8FD004E7C0B /* NotificationPreference.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBE54AC52636C89F004E7C0B /* NotificationPreference.swift */; }; @@ -419,6 +429,34 @@ remoteGlobalIDString = DB427DD125BAA00100D1B89D; remoteInfo = Mastodon; }; + DB6804842637CD4C00430867 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = DB427DCA25BAA00100D1B89D /* Project object */; + proxyType = 1; + remoteGlobalIDString = DB68047E2637CD4C00430867; + remoteInfo = AppShared; + }; + DB6804A72637CDCC00430867 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = DB427DCA25BAA00100D1B89D /* Project object */; + proxyType = 1; + remoteGlobalIDString = DB68047E2637CD4C00430867; + remoteInfo = AppShared; + }; + DB6804C92637CE3000430867 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = DB427DCA25BAA00100D1B89D /* Project object */; + proxyType = 1; + remoteGlobalIDString = DB68047E2637CD4C00430867; + remoteInfo = AppShared; + }; + DB6805282637D7DD00430867 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = DB427DCA25BAA00100D1B89D /* Project object */; + proxyType = 1; + remoteGlobalIDString = DB68047E2637CD4C00430867; + remoteInfo = AppShared; + }; DB89B9F825C10FD0008580ED /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = DB427DCA25BAA00100D1B89D /* Project object */; @@ -450,12 +488,35 @@ /* End PBXContainerItemProxy section */ /* Begin PBXCopyFilesBuildPhase section */ + DB6804A92637CDCC00430867 /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + DB6804A62637CDCC00430867 /* AppShared.framework in Embed Frameworks */, + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; + DB68052A2637D7DD00430867 /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + DB6805272637D7DD00430867 /* AppShared.framework in Embed Frameworks */, + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; DB89BA0825C10FD0008580ED /* Embed Frameworks */ = { isa = PBXCopyFilesBuildPhase; buildActionMask = 2147483647; dstPath = ""; dstSubfolderSpec = 10; files = ( + DB6804872637CD4C00430867 /* AppShared.framework in Embed Frameworks */, DBE64A8C260C49D200E6359A /* TwitterTextEditor in Embed Frameworks */, DB89BA0425C10FD0008580ED /* CoreDataStack.framework in Embed Frameworks */, ); @@ -608,6 +669,7 @@ 3C030226D3C73DCC23D67452 /* Pods_Mastodon_MastodonUITests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Mastodon_MastodonUITests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 452147B2903DF38070FE56A2 /* Pods_MastodonTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_MastodonTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 459EA4F43058CAB47719E963 /* Pods-Mastodon-MastodonUITests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Mastodon-MastodonUITests.debug.xcconfig"; path = "Target Support Files/Pods-Mastodon-MastodonUITests/Pods-Mastodon-MastodonUITests.debug.xcconfig"; sourceTree = ""; }; + 46531DECCAB422F507B2274D /* Pods_Mastodon_AppShared.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Mastodon_AppShared.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 5B90C456262599800002E742 /* SettingsViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SettingsViewModel.swift; sourceTree = ""; }; 5B90C459262599800002E742 /* SettingsToggleTableViewCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SettingsToggleTableViewCell.swift; sourceTree = ""; }; 5B90C45A262599800002E742 /* SettingsAppearanceTableViewCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SettingsAppearanceTableViewCell.swift; sourceTree = ""; }; @@ -636,9 +698,11 @@ 8ED8C4B1F1BA2DCFF2926BB1 /* Pods-Mastodon-NotificationService.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Mastodon-NotificationService.debug.xcconfig"; path = "Target Support Files/Pods-Mastodon-NotificationService/Pods-Mastodon-NotificationService.debug.xcconfig"; sourceTree = ""; }; 9780A4C98FFC65B32B50D1C0 /* Pods-MastodonTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-MastodonTests.release.xcconfig"; path = "Target Support Files/Pods-MastodonTests/Pods-MastodonTests.release.xcconfig"; sourceTree = ""; }; A4ABE34829701A4496C5BB64 /* Pods_Mastodon.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Mastodon.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + B31D44635FCF6452F7E1B865 /* Pods-Mastodon-AppShared.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Mastodon-AppShared.release.xcconfig"; path = "Target Support Files/Pods-Mastodon-AppShared/Pods-Mastodon-AppShared.release.xcconfig"; sourceTree = ""; }; B44342AC2B6585F8295F1DDF /* Pods-Mastodon-NotificationService.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Mastodon-NotificationService.release.xcconfig"; path = "Target Support Files/Pods-Mastodon-NotificationService/Pods-Mastodon-NotificationService.release.xcconfig"; sourceTree = ""; }; BB482D32A7B9825BF5327C4F /* Pods-Mastodon-MastodonUITests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Mastodon-MastodonUITests.release.xcconfig"; path = "Target Support Files/Pods-Mastodon-MastodonUITests/Pods-Mastodon-MastodonUITests.release.xcconfig"; sourceTree = ""; }; CD92E0F10BDE4FE7C4B999F2 /* Pods_MastodonTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_MastodonTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + D7D7CF93E262178800077512 /* Pods-Mastodon-AppShared.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Mastodon-AppShared.debug.xcconfig"; path = "Target Support Files/Pods-Mastodon-AppShared/Pods-Mastodon-AppShared.debug.xcconfig"; sourceTree = ""; }; DB0140A025C40C0600F9F3CF /* MastodonPinBasedAuthenticationViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonPinBasedAuthenticationViewController.swift; sourceTree = ""; }; DB0140A725C40C1500F9F3CF /* MastodonPinBasedAuthenticationViewModelNavigationDelegateShim.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MastodonPinBasedAuthenticationViewModelNavigationDelegateShim.swift; sourceTree = ""; }; DB0140AD25C40C7300F9F3CF /* MastodonPinBasedAuthenticationViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonPinBasedAuthenticationViewModel.swift; sourceTree = ""; }; @@ -650,7 +714,6 @@ DB118A8125E4B6E600FAB162 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; DB118A8B25E4BFB500FAB162 /* HighlightDimmableButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HighlightDimmableButton.swift; sourceTree = ""; }; DB1D186B25EF5BA7003F1F23 /* PollTableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollTableView.swift; sourceTree = ""; }; - DB1E05E0263180F500201847 /* AppSecret.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSecret.swift; sourceTree = ""; }; DB1E346725F518E20079D7DF /* CategoryPickerSection.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CategoryPickerSection.swift; sourceTree = ""; }; DB1E347725F519300079D7DF /* PickServerItem.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PickServerItem.swift; sourceTree = ""; }; DB1FD43525F26899004CFCFC /* MastodonPickServerViewModel+LoadIndexedServerState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MastodonPickServerViewModel+LoadIndexedServerState.swift"; sourceTree = ""; }; @@ -724,6 +787,14 @@ DB66728B25F9F8DC00D60309 /* ComposeViewModel+Diffable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ComposeViewModel+Diffable.swift"; sourceTree = ""; }; DB66729525F9F91600D60309 /* ComposeStatusSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeStatusSection.swift; sourceTree = ""; }; DB66729B25F9F91F00D60309 /* ComposeStatusItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeStatusItem.swift; sourceTree = ""; }; + DB68045A2636DC6A00430867 /* MastodonNotification.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonNotification.swift; sourceTree = ""; }; + DB68047F2637CD4C00430867 /* AppShared.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = AppShared.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + DB6804812637CD4C00430867 /* AppShared.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = AppShared.h; sourceTree = ""; }; + DB6804822637CD4C00430867 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + DB6804912637CD8700430867 /* AppName.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppName.swift; sourceTree = ""; }; + DB6804D02637CE4700430867 /* UserDefaults.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserDefaults.swift; sourceTree = ""; }; + DB6804FC2637CFEC00430867 /* AppSecret.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSecret.swift; sourceTree = ""; }; + DB68053E2638011000430867 /* NotificationService.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = NotificationService.entitlements; sourceTree = ""; }; DB68586325E619B700F0A850 /* NSKeyValueObservation.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NSKeyValueObservation.swift; sourceTree = ""; }; DB68A04925E9027700CFDF14 /* AdaptiveStatusBarStyleNavigationController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AdaptiveStatusBarStyleNavigationController.swift; sourceTree = ""; }; DB68A05C25E9055900CFDF14 /* Settings.bundle */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.plug-in"; path = Settings.bundle; sourceTree = ""; }; @@ -732,10 +803,8 @@ DB6B351D2601FAEE00DC1E11 /* ComposeStatusAttachmentCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeStatusAttachmentCollectionViewCell.swift; sourceTree = ""; }; DB6C8C0E25F0A6AE00AAA452 /* Mastodon+Entity+Error.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Mastodon+Entity+Error.swift"; sourceTree = ""; }; DB6D1B23263684C600ACB481 /* UserDefaults.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserDefaults.swift; sourceTree = ""; }; - DB6D1B2A2636852000ACB481 /* AppSharedName.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSharedName.swift; sourceTree = ""; }; DB6D1B3C2636857500ACB481 /* AppearancePreference.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppearancePreference.swift; sourceTree = ""; }; DB6D1B43263691CF00ACB481 /* Mastodon+API+Subscriptions+Policy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Mastodon+API+Subscriptions+Policy.swift"; sourceTree = ""; }; - DB6D9F222635195E008423CD /* String.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = String.swift; sourceTree = ""; }; DB6D9F3426351B7A008423CD /* NotificationService+Decrypt.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NotificationService+Decrypt.swift"; sourceTree = ""; }; DB6D9F4826353FD6008423CD /* Subscription.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Subscription.swift; sourceTree = ""; }; DB6D9F4F2635761F008423CD /* SubscriptionAlerts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubscriptionAlerts.swift; sourceTree = ""; }; @@ -888,6 +957,7 @@ 2D939AC825EE14620076FA61 /* CropViewController in Frameworks */, DBB525082611EAC0002F1F29 /* Tabman in Frameworks */, 5D526FE225BE9AC400460CB9 /* MastodonSDK in Frameworks */, + DB6804862637CD4C00430867 /* AppShared.framework in Frameworks */, DB5086B825CC0D6400C2C187 /* Kingfisher in Frameworks */, DBF96326262EC0A6001D8D25 /* AuthenticationServices.framework in Frameworks */, 2D61336925C18A4F00CAE157 /* AlamofireNetworkActivityIndicator in Frameworks */, @@ -895,6 +965,7 @@ DBE64A8B260C49D200E6359A /* TwitterTextEditor in Frameworks */, 2D5981BA25E4D7F8000FB903 /* ThirdPartyMailer in Frameworks */, 87FFDA5D898A5C42ADCB35E7 /* Pods_Mastodon.framework in Frameworks */, + DB6804C82637CE2F00430867 /* AppShared.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -915,10 +986,20 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + DB68047C2637CD4C00430867 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + DB6805102637D0F800430867 /* KeychainAccess in Frameworks */, + D86919F5080C3F228CCD17D1 /* Pods_Mastodon_AppShared.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; DB89B9EB25C10FD0008580ED /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + DB6805262637D7DD00430867 /* AppShared.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -937,6 +1018,7 @@ DB00CA972632DDB600A54956 /* CommonOSLog in Frameworks */, DB6D9F42263527CE008423CD /* AlamofireImage in Frameworks */, DBF8AE862632992800C9C23C /* Base85 in Frameworks */, + DB6804A52637CDCC00430867 /* AppShared.framework in Frameworks */, 7D603B3DC600BB75956FAD7D /* Pods_Mastodon_NotificationService.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -1021,6 +1103,8 @@ 9780A4C98FFC65B32B50D1C0 /* Pods-MastodonTests.release.xcconfig */, 8ED8C4B1F1BA2DCFF2926BB1 /* Pods-Mastodon-NotificationService.debug.xcconfig */, B44342AC2B6585F8295F1DDF /* Pods-Mastodon-NotificationService.release.xcconfig */, + D7D7CF93E262178800077512 /* Pods-Mastodon-AppShared.debug.xcconfig */, + B31D44635FCF6452F7E1B865 /* Pods-Mastodon-AppShared.release.xcconfig */, ); path = Pods; sourceTree = ""; @@ -1351,6 +1435,7 @@ 3C030226D3C73DCC23D67452 /* Pods_Mastodon_MastodonUITests.framework */, 452147B2903DF38070FE56A2 /* Pods_MastodonTests.framework */, 79D7EB88A6A8B34DFDFC96FC /* Pods_Mastodon_NotificationService.framework */, + 46531DECCAB422F507B2274D /* Pods_Mastodon_AppShared.framework */, ); name = Frameworks; sourceTree = ""; @@ -1453,8 +1538,6 @@ children = ( DB427DD525BAA00100D1B89D /* AppDelegate.swift */, DB427DD725BAA00100D1B89D /* SceneDelegate.swift */, - DB1E05E0263180F500201847 /* AppSecret.swift */, - DB6D1B2A2636852000ACB481 /* AppSharedName.swift */, DB427DDB25BAA00100D1B89D /* Main.storyboard */, DB427DE025BAA00100D1B89D /* LaunchScreen.storyboard */, DB68A05C25E9055900CFDF14 /* Settings.bundle */, @@ -1485,6 +1568,7 @@ DB89B9EF25C10FD0008580ED /* CoreDataStack */, DB89B9FC25C10FD0008580ED /* CoreDataStackTests */, DBF8AE14263293E400C9C23C /* NotificationService */, + DB6804802637CD4C00430867 /* AppShared */, DB427DD325BAA00100D1B89D /* Products */, 1EBA4F56E920856A3FC84ACB /* Pods */, 3FE14AD363ED19AE7FF210A6 /* Frameworks */, @@ -1501,6 +1585,7 @@ DB89B9EE25C10FD0008580ED /* CoreDataStack.framework */, DB89B9F625C10FD0008580ED /* CoreDataStackTests.xctest */, DBF8AE13263293E400C9C23C /* NotificationService.appex */, + DB68047F2637CD4C00430867 /* AppShared.framework */, ); name = Products; sourceTree = ""; @@ -1628,6 +1713,18 @@ path = View; sourceTree = ""; }; + DB6804802637CD4C00430867 /* AppShared */ = { + isa = PBXGroup; + children = ( + DB6804812637CD4C00430867 /* AppShared.h */, + DB6804822637CD4C00430867 /* Info.plist */, + DB6804912637CD8700430867 /* AppName.swift */, + DB6804FC2637CFEC00430867 /* AppSecret.swift */, + DB6804D02637CE4700430867 /* UserDefaults.swift */, + ); + path = AppShared; + sourceTree = ""; + }; DB68A03825E900CC00CFDF14 /* Share */ = { isa = PBXGroup; children = ( @@ -1659,14 +1756,6 @@ path = MastodonSDK; sourceTree = ""; }; - DB6D9F2926351961008423CD /* Extension */ = { - isa = PBXGroup; - children = ( - DB6D9F222635195E008423CD /* String.swift */, - ); - path = Extension; - sourceTree = ""; - }; DB72602125E36A2500235243 /* ServerRules */ = { isa = PBXGroup; children = ( @@ -2108,9 +2197,10 @@ DBF8AE14263293E400C9C23C /* NotificationService */ = { isa = PBXGroup; children = ( + DB68053E2638011000430867 /* NotificationService.entitlements */, DBF8AE15263293E400C9C23C /* NotificationService.swift */, DB6D9F3426351B7A008423CD /* NotificationService+Decrypt.swift */, - DB6D9F2926351961008423CD /* Extension */, + DB68045A2636DC6A00430867 /* MastodonNotification.swift */, DBF8AE17263293E400C9C23C /* Info.plist */, ); path = NotificationService; @@ -2119,6 +2209,14 @@ /* End PBXGroup section */ /* Begin PBXHeadersBuildPhase section */ + DB68047A2637CD4C00430867 /* Headers */ = { + isa = PBXHeadersBuildPhase; + buildActionMask = 2147483647; + files = ( + DB6804832637CD4C00430867 /* AppShared.h in Headers */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; DB89B9E925C10FD0008580ED /* Headers */ = { isa = PBXHeadersBuildPhase; buildActionMask = 2147483647; @@ -2148,6 +2246,8 @@ dependencies = ( DB89BA0225C10FD0008580ED /* PBXTargetDependency */, DBF8AE19263293E400C9C23C /* PBXTargetDependency */, + DB6804852637CD4C00430867 /* PBXTargetDependency */, + DB6804CA2637CE3000430867 /* PBXTargetDependency */, ); name = Mastodon; packageProductDependencies = ( @@ -2206,6 +2306,28 @@ productReference = DB427DF325BAA00100D1B89D /* MastodonUITests.xctest */; productType = "com.apple.product-type.bundle.ui-testing"; }; + DB68047E2637CD4C00430867 /* AppShared */ = { + isa = PBXNativeTarget; + buildConfigurationList = DB6804882637CD4C00430867 /* Build configuration list for PBXNativeTarget "AppShared" */; + buildPhases = ( + C6B7D3A8ACD77F6620D0E0AD /* [CP] Check Pods Manifest.lock */, + DB68047A2637CD4C00430867 /* Headers */, + DB68047B2637CD4C00430867 /* Sources */, + DB68047C2637CD4C00430867 /* Frameworks */, + DB68047D2637CD4C00430867 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = AppShared; + packageProductDependencies = ( + DB68050F2637D0F800430867 /* KeychainAccess */, + ); + productName = AppShared; + productReference = DB68047F2637CD4C00430867 /* AppShared.framework */; + productType = "com.apple.product-type.framework"; + }; DB89B9ED25C10FD0008580ED /* CoreDataStack */ = { isa = PBXNativeTarget; buildConfigurationList = DB89BA0525C10FD0008580ED /* Build configuration list for PBXNativeTarget "CoreDataStack" */; @@ -2214,10 +2336,12 @@ DB89B9EA25C10FD0008580ED /* Sources */, DB89B9EB25C10FD0008580ED /* Frameworks */, DB89B9EC25C10FD0008580ED /* Resources */, + DB68052A2637D7DD00430867 /* Embed Frameworks */, ); buildRules = ( ); dependencies = ( + DB6805292637D7DD00430867 /* PBXTargetDependency */, ); name = CoreDataStack; productName = CoreDataStack; @@ -2251,10 +2375,12 @@ DBF8AE0F263293E400C9C23C /* Sources */, DBF8AE10263293E400C9C23C /* Frameworks */, DBF8AE11263293E400C9C23C /* Resources */, + DB6804A92637CDCC00430867 /* Embed Frameworks */, ); buildRules = ( ); dependencies = ( + DB6804A82637CDCC00430867 /* PBXTargetDependency */, ); name = NotificationService; packageProductDependencies = ( @@ -2287,6 +2413,10 @@ CreatedOnToolsVersion = 12.4; TestTargetID = DB427DD125BAA00100D1B89D; }; + DB68047E2637CD4C00430867 = { + CreatedOnToolsVersion = 12.4; + LastSwiftMigration = 1240; + }; DB89B9ED25C10FD0008580ED = { CreatedOnToolsVersion = 12.4; LastSwiftMigration = 1240; @@ -2321,6 +2451,7 @@ DBE64A89260C49D200E6359A /* XCRemoteSwiftPackageReference "TwitterTextEditor" */, DBB525062611EAC0002F1F29 /* XCRemoteSwiftPackageReference "Tabman" */, DBF8AE842632992700C9C23C /* XCRemoteSwiftPackageReference "Base85" */, + DB6804722637CC1200430867 /* XCRemoteSwiftPackageReference "KeychainAccess" */, ); productRefGroup = DB427DD325BAA00100D1B89D /* Products */; projectDirPath = ""; @@ -2332,6 +2463,7 @@ DB89B9ED25C10FD0008580ED /* CoreDataStack */, DB89B9F525C10FD0008580ED /* CoreDataStackTests */, DBF8AE12263293E400C9C23C /* NotificationService */, + DB68047E2637CD4C00430867 /* AppShared */, ); }; /* End PBXProject section */ @@ -2365,6 +2497,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + DB68047D2637CD4C00430867 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; DB89B9EC25C10FD0008580ED /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -2472,6 +2611,28 @@ shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; showEnvVarsInLog = 0; }; + C6B7D3A8ACD77F6620D0E0AD /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-Mastodon-AppShared-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; DB3D100425BAA71500EAA174 /* ShellScript */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; @@ -2772,6 +2933,7 @@ DB0140A825C40C1500F9F3CF /* MastodonPinBasedAuthenticationViewModelNavigationDelegateShim.swift in Sources */, DB1FD45025F26FA1004CFCFC /* MastodonPickServerViewModel+Diffable.swift in Sources */, DB2B3AE925E38850007045F9 /* UIViewPreview.swift in Sources */, + DB68046C2636DC9E00430867 /* MastodonNotification.swift in Sources */, DBAE3F9E2616E308004B8251 /* APIService+Mute.swift in Sources */, DB427DD625BAA00100D1B89D /* AppDelegate.swift in Sources */, DB6D9F57263577D2008423CD /* APIService+CoreData+Setting.swift in Sources */, @@ -2780,7 +2942,6 @@ DBBE1B4525F3474B0081417A /* MastodonPickServerAppearance.swift in Sources */, DB98338725C945ED00AD9700 /* Strings.swift in Sources */, 2D7867192625B77500211898 /* NotificationItem.swift in Sources */, - DB6D1B2B2636852000ACB481 /* AppSharedName.swift in Sources */, DB45FAB625CA5485005A8AC7 /* UIAlertController.swift in Sources */, DBE0821525CD382600FD6BBD /* MastodonRegisterViewController.swift in Sources */, 2D5A3D0325CF8742002347D6 /* ControlContainableScrollViews.swift in Sources */, @@ -2853,7 +3014,6 @@ 2D6DE40026141DF600A63F6A /* SearchViewModel.swift in Sources */, DB51D172262832380062B7A1 /* BlurHashDecode.swift in Sources */, DBCC3B89261454BA0045B23D /* CGImage.swift in Sources */, - DB1E05E1263180F500201847 /* AppSecret.swift in Sources */, DBCCC71E25F73297007E1AB6 /* APIService+Reblog.swift in Sources */, DB789A2B25F9F7AB0071ACA0 /* ComposeRepliedToStatusContentCollectionViewCell.swift in Sources */, DBE3CE13261D7D4200430CC6 /* StatusTableViewControllerAspect.swift in Sources */, @@ -2877,6 +3037,16 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + DB68047B2637CD4C00430867 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + DB6804D12637CE4700430867 /* UserDefaults.swift in Sources */, + DB6804922637CD8700430867 /* AppName.swift in Sources */, + DB6804FD2637CFEC00430867 /* AppSecret.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; DB89B9EA25C10FD0008580ED /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -2898,7 +3068,6 @@ 2D152A9225C2980C009AA50C /* UIFont.swift in Sources */, DB4481B325EE16D000BEFB67 /* PollOption.swift in Sources */, DB89BA4425C1165F008580ED /* Managed.swift in Sources */, - DB6D1B312636853100ACB481 /* AppSharedName.swift in Sources */, 2D6125472625436B00299647 /* Notification.swift in Sources */, DB89BA4325C1165F008580ED /* NetworkUpdatable.swift in Sources */, DB8AF56825C13E2A002E6C99 /* HomeTimelineIndex.swift in Sources */, @@ -2926,11 +3095,10 @@ buildActionMask = 2147483647; files = ( DBE54ACC2636C8FD004E7C0B /* NotificationPreference.swift in Sources */, - DB6D9F232635195E008423CD /* String.swift in Sources */, - DBE54AB92636C87B004E7C0B /* AppSharedName.swift in Sources */, + DB68045B2636DC6A00430867 /* MastodonNotification.swift in Sources */, DBE54ABF2636C889004E7C0B /* UserDefaults.swift in Sources */, - DB6D9F3B26352019008423CD /* AppSecret.swift in Sources */, DB6D9F3526351B7A008423CD /* NotificationService+Decrypt.swift in Sources */, + DB6804662636DC9000430867 /* String.swift in Sources */, DBF8AE16263293E400C9C23C /* NotificationService.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -2948,6 +3116,26 @@ target = DB427DD125BAA00100D1B89D /* Mastodon */; targetProxy = DB427DF425BAA00100D1B89D /* PBXContainerItemProxy */; }; + DB6804852637CD4C00430867 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = DB68047E2637CD4C00430867 /* AppShared */; + targetProxy = DB6804842637CD4C00430867 /* PBXContainerItemProxy */; + }; + DB6804A82637CDCC00430867 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = DB68047E2637CD4C00430867 /* AppShared */; + targetProxy = DB6804A72637CDCC00430867 /* PBXContainerItemProxy */; + }; + DB6804CA2637CE3000430867 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = DB68047E2637CD4C00430867 /* AppShared */; + targetProxy = DB6804C92637CE3000430867 /* PBXContainerItemProxy */; + }; + DB6805292637D7DD00430867 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = DB68047E2637CD4C00430867 /* AppShared */; + targetProxy = DB6805282637D7DD00430867 /* PBXContainerItemProxy */; + }; DB89B9F925C10FD0008580ED /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = DB89B9ED25C10FD0008580ED /* CoreDataStack */; @@ -3126,7 +3314,6 @@ isa = XCBuildConfiguration; baseConfigurationReference = 2E1F6A67FDF9771D3E064FDC /* Pods-Mastodon.debug.xcconfig */; buildSettings = { - ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CLANG_ENABLE_MODULES = YES; @@ -3154,7 +3341,6 @@ isa = XCBuildConfiguration; baseConfigurationReference = 75E3471C898DDD9631729B6E /* Pods-Mastodon.release.xcconfig */; buildSettings = { - ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CLANG_ENABLE_MODULES = YES; @@ -3259,6 +3445,65 @@ }; name = Release; }; + DB6804892637CD4C00430867 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = D7D7CF93E262178800077512 /* Pods-Mastodon-AppShared.debug.xcconfig */; + buildSettings = { + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEFINES_MODULE = YES; + DEVELOPMENT_TEAM = 7LFDZ96332; + DYLIB_COMPATIBILITY_VERSION = 1; + DYLIB_CURRENT_VERSION = 1; + DYLIB_INSTALL_NAME_BASE = "@rpath"; + INFOPLIST_FILE = AppShared/Info.plist; + INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = org.joinmastodon.Mastodon.AppShared; + PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; + SKIP_INSTALL = YES; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + VERSIONING_SYSTEM = "apple-generic"; + VERSION_INFO_PREFIX = ""; + }; + name = Debug; + }; + DB68048A2637CD4C00430867 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = B31D44635FCF6452F7E1B865 /* Pods-Mastodon-AppShared.release.xcconfig */; + buildSettings = { + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEFINES_MODULE = YES; + DEVELOPMENT_TEAM = 7LFDZ96332; + DYLIB_COMPATIBILITY_VERSION = 1; + DYLIB_CURRENT_VERSION = 1; + DYLIB_INSTALL_NAME_BASE = "@rpath"; + INFOPLIST_FILE = AppShared/Info.plist; + INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = org.joinmastodon.Mastodon.AppShared; + PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; + SKIP_INSTALL = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + VERSIONING_SYSTEM = "apple-generic"; + VERSION_INFO_PREFIX = ""; + }; + name = Release; + }; DB89BA0625C10FD0008580ED /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -3360,10 +3605,10 @@ isa = XCBuildConfiguration; baseConfigurationReference = 8ED8C4B1F1BA2DCFF2926BB1 /* Pods-Mastodon-NotificationService.debug.xcconfig */; buildSettings = { + CODE_SIGN_ENTITLEMENTS = NotificationService/NotificationService.entitlements; CODE_SIGN_STYLE = Automatic; DEVELOPMENT_TEAM = 7LFDZ96332; INFOPLIST_FILE = NotificationService/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 14.4; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -3381,10 +3626,10 @@ isa = XCBuildConfiguration; baseConfigurationReference = B44342AC2B6585F8295F1DDF /* Pods-Mastodon-NotificationService.release.xcconfig */; buildSettings = { + CODE_SIGN_ENTITLEMENTS = NotificationService/NotificationService.entitlements; CODE_SIGN_STYLE = Automatic; DEVELOPMENT_TEAM = 7LFDZ96332; INFOPLIST_FILE = NotificationService/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 14.4; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -3437,6 +3682,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + DB6804882637CD4C00430867 /* Build configuration list for PBXNativeTarget "AppShared" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + DB6804892637CD4C00430867 /* Debug */, + DB68048A2637CD4C00430867 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; DB89BA0525C10FD0008580ED /* Build configuration list for PBXNativeTarget "CoreDataStack" */ = { isa = XCConfigurationList; buildConfigurations = ( @@ -3523,6 +3777,14 @@ minimumVersion = 6.1.0; }; }; + DB6804722637CC1200430867 /* XCRemoteSwiftPackageReference "KeychainAccess" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/kishikawakatsumi/KeychainAccess.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 4.2.2; + }; + }; DB9A487C2603456B008B817C /* XCRemoteSwiftPackageReference "UITextView-Placeholder" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/MainasuK/UITextView-Placeholder"; @@ -3602,6 +3864,11 @@ package = DB5086B625CC0D6400C2C187 /* XCRemoteSwiftPackageReference "Kingfisher" */; productName = Kingfisher; }; + DB68050F2637D0F800430867 /* KeychainAccess */ = { + isa = XCSwiftPackageProductDependency; + package = DB6804722637CC1200430867 /* XCRemoteSwiftPackageReference "KeychainAccess" */; + productName = KeychainAccess; + }; DB6D9F41263527CE008423CD /* AlamofireImage */ = { isa = XCSwiftPackageProductDependency; package = DB3D0FF125BAA61700EAA174 /* XCRemoteSwiftPackageReference "AlamofireImage" */; diff --git a/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist b/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist index 3e5f0c5d3..083bcfbbe 100644 --- a/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist +++ b/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist @@ -4,10 +4,15 @@ SchemeUserState + AppShared.xcscheme_^#shared#^_ + + orderHint + 18 + CoreDataStack.xcscheme_^#shared#^_ orderHint - 13 + 17 Mastodon - RTL.xcscheme_^#shared#^_ @@ -27,7 +32,7 @@ NotificationService.xcscheme_^#shared#^_ orderHint - 14 + 18 SuppressBuildableAutocreation diff --git a/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved index a8c0df347..47136a2c7 100644 --- a/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -55,6 +55,15 @@ "version": "0.1.1" } }, + { + "package": "KeychainAccess", + "repositoryURL": "https://github.com/kishikawakatsumi/KeychainAccess.git", + "state": { + "branch": null, + "revision": "84e546727d66f1adc5439debad16270d0fdd04e7", + "version": "4.2.2" + } + }, { "package": "Kingfisher", "repositoryURL": "https://github.com/onevcat/Kingfisher.git", diff --git a/Mastodon/Extension/String.swift b/Mastodon/Extension/String.swift index 87028ffdf..bf70c8937 100644 --- a/Mastodon/Extension/String.swift +++ b/Mastodon/Extension/String.swift @@ -16,3 +16,25 @@ extension String { self = self.capitalizingFirstLetter() } } + +extension String { + static func normalize(base64String: String) -> String { + let base64 = base64String + .replacingOccurrences(of: "-", with: "+") + .replacingOccurrences(of: "_", with: "/") + .padding() + return base64 + } + + private func padding() -> String { + let remainder = self.count % 4 + if remainder > 0 { + return self.padding( + toLength: self.count + 4 - remainder, + withPad: "=", + startingAt: 0 + ) + } + return self + } +} diff --git a/Mastodon/Extension/UserDefaults.swift b/Mastodon/Extension/UserDefaults.swift index 5e067bbe9..619d6c250 100644 --- a/Mastodon/Extension/UserDefaults.swift +++ b/Mastodon/Extension/UserDefaults.swift @@ -6,10 +6,7 @@ // import Foundation - -extension UserDefaults { - static let shared = UserDefaults(suiteName: AppSharedName.groupID)! -} +import AppShared extension UserDefaults { diff --git a/Mastodon/Preference/AppearancePreference.swift b/Mastodon/Preference/AppearancePreference.swift index 8f2818c39..78cf3d332 100644 --- a/Mastodon/Preference/AppearancePreference.swift +++ b/Mastodon/Preference/AppearancePreference.swift @@ -14,7 +14,7 @@ extension UserDefaults { register(defaults: [#function: UIUserInterfaceStyle.unspecified.rawValue]) return UIUserInterfaceStyle(rawValue: integer(forKey: #function)) ?? .unspecified } - set { UserDefaults.shared[#function] = newValue.rawValue } + set { self[#function] = newValue.rawValue } } } diff --git a/Mastodon/Preference/NotificationPreference.swift b/Mastodon/Preference/NotificationPreference.swift index b634d77f3..289cd1fdf 100644 --- a/Mastodon/Preference/NotificationPreference.swift +++ b/Mastodon/Preference/NotificationPreference.swift @@ -14,7 +14,7 @@ extension UserDefaults { register(defaults: [#function: 0]) return integer(forKey: #function) } - set { UserDefaults.shared[#function] = newValue } + set { self[#function] = newValue } } } diff --git a/Mastodon/Service/APIService/APIService+Subscriptions.swift b/Mastodon/Service/APIService/APIService+Subscriptions.swift index 3e2d2a0aa..ceaff45fa 100644 --- a/Mastodon/Service/APIService/APIService+Subscriptions.swift +++ b/Mastodon/Service/APIService/APIService+Subscriptions.swift @@ -29,7 +29,7 @@ extension APIService { query: query ) .flatMap { response -> AnyPublisher, Error> in - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: create subscription successful ", ((#file as NSString).lastPathComponent), #line, #function) + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: create subscription successful %s", ((#file as NSString).lastPathComponent), #line, #function, response.value.endpoint) let managedObjectContext = self.backgroundManagedObjectContext return managedObjectContext.performChanges { @@ -45,7 +45,8 @@ extension APIService { .setFailureType(to: Error.self) .map { _ in return response } .eraseToAnyPublisher() - }.eraseToAnyPublisher() + } + .eraseToAnyPublisher() } func cancelSubscription( diff --git a/Mastodon/Service/APIService/CoreData/APIService+CoreData+Setting.swift b/Mastodon/Service/APIService/CoreData/APIService+CoreData+Setting.swift index fb6879da9..0c23eab6e 100644 --- a/Mastodon/Service/APIService/CoreData/APIService+CoreData+Setting.swift +++ b/Mastodon/Service/APIService/CoreData/APIService+CoreData+Setting.swift @@ -27,35 +27,50 @@ extension APIService.CoreData { }() if let oldSetting = oldSetting { + setupSettingSubscriptions(managedObjectContext: managedObjectContext, setting: oldSetting) return (oldSetting, false) } else { let setting = Setting.insert( into: managedObjectContext, property: property ) - let policies: [Mastodon.API.Subscriptions.Policy] = [ - .all, - .followed, - .follower, - .none - ] - let now = Date() - policies.forEach { policy in - let (subscription, _) = createOrFetchSubscription( - into: managedObjectContext, - setting: setting, - policy: policy - ) - if policy == .all { - subscription.update(activedAt: now) - } else { - subscription.update(activedAt: now.addingTimeInterval(-10)) - } - } - - + setupSettingSubscriptions(managedObjectContext: managedObjectContext, setting: setting) return (setting, true) } } } + +extension APIService.CoreData { + + static func setupSettingSubscriptions( + managedObjectContext: NSManagedObjectContext, + setting: Setting + ) { + guard (setting.subscriptions ?? Set()).isEmpty else { return } + + let now = Date() + let policies: [Mastodon.API.Subscriptions.Policy] = [ + .all, + .followed, + .follower, + .none + ] + policies.forEach { policy in + let (subscription, _) = createOrFetchSubscription( + into: managedObjectContext, + setting: setting, + policy: policy + ) + if policy == .all { + subscription.update(activedAt: now) + } else { + subscription.update(activedAt: now.addingTimeInterval(-10)) + } + } + + // trigger setting update + setting.didUpdate(at: now) + } + +} diff --git a/Mastodon/Service/APIService/CoreData/APIService+CoreData+Subscriptions.swift b/Mastodon/Service/APIService/CoreData/APIService+CoreData+Subscriptions.swift index 5e42a8abe..6eebc9e56 100644 --- a/Mastodon/Service/APIService/CoreData/APIService+CoreData+Subscriptions.swift +++ b/Mastodon/Service/APIService/CoreData/APIService+CoreData+Subscriptions.swift @@ -27,6 +27,7 @@ extension APIService.CoreData { }() if let oldSubscription = oldSubscription { + oldSubscription.setting = setting return (oldSubscription, false) } else { let subscriptionProperty = Subscription.Property(policyRaw: policy.rawValue) diff --git a/Mastodon/Service/NotificationService.swift b/Mastodon/Service/NotificationService.swift index 526c35883..90680e783 100644 --- a/Mastodon/Service/NotificationService.swift +++ b/Mastodon/Service/NotificationService.swift @@ -11,6 +11,7 @@ import Combine import CoreData import CoreDataStack import MastodonSDK +import AppShared final class NotificationService { @@ -32,6 +33,16 @@ final class NotificationService { ) { self.authenticationService = authenticationService + authenticationService.mastodonAuthentications + .sink(receiveValue: { [weak self] mastodonAuthentications in + guard let self = self else { return } + + // request permission when sign-in + guard !mastodonAuthentications.isEmpty else { return } + self.requestNotificationPermission() + }) + .store(in: &disposeBag) + deviceToken .receive(on: DispatchQueue.main) .sink { [weak self] deviceToken in @@ -83,13 +94,7 @@ extension NotificationService { } return _notificationSubscription } - - static func createRandomAuthBytes() -> Data { - let byteCount = 16 - var bytes = Data(count: byteCount) - _ = bytes.withUnsafeMutableBytes { SecRandomCopyBytes(kSecRandomDefault, byteCount, $0.baseAddress!) } - return bytes - } + } extension NotificationService { @@ -120,7 +125,7 @@ extension NotificationService.NotificationViewModel { let appSecret = AppSecret.default let endpoint = appSecret.notificationEndpoint + "/" + deviceToken - let p256dh = appSecret.uncompressionNotificationPublicKeyData + let p256dh = appSecret.notificationPublicKey.x963Representation let auth = appSecret.notificationAuth let query = Mastodon.API.Subscriptions.CreateSubscriptionQuery( diff --git a/Mastodon/Service/SettingService.swift b/Mastodon/Service/SettingService.swift index e097b4dc4..8683b3972 100644 --- a/Mastodon/Service/SettingService.swift +++ b/Mastodon/Service/SettingService.swift @@ -5,6 +5,7 @@ // Created by MainasuK Cirno on 2021-4-25. // +import os.log import UIKit import Combine import CoreDataStack @@ -108,16 +109,17 @@ final class SettingService { } .store(in: &disposeBag) - Publishers.CombineLatest( + Publishers.CombineLatest3( notificationService.deviceToken, - currentSetting + currentSetting.eraseToAnyPublisher(), + authenticationService.activeMastodonAuthenticationBox ) - .compactMap { [weak self] deviceToken, setting -> AnyPublisher, Error>? in + .compactMap { [weak self] deviceToken, setting, activeMastodonAuthenticationBox -> AnyPublisher, Error>? in guard let self = self else { return nil } - guard let apiService = self.apiService else { return nil } guard let deviceToken = deviceToken else { return nil } - guard let authenticationBox = self.authenticationService?.activeMastodonAuthenticationBox.value else { return nil } guard let setting = setting else { return nil } + guard let authenticationBox = activeMastodonAuthenticationBox else { return nil } + guard let subscription = setting.activeSubscription else { return nil } guard setting.domain == authenticationBox.domain, @@ -142,21 +144,30 @@ final class SettingService { queryData: queryData, mastodonAuthenticationBox: authenticationBox ) - + return apiService.createSubscription( subscriptionObjectID: subscription.objectID, query: query, mastodonAuthenticationBox: authenticationBox ) } - .switchToLatest() - .sink { _ in - // do nothing - } receiveValue: { _ in - // do nothing - } + .debounce(for: .seconds(3), scheduler: DispatchQueue.main) // limit subscribe request emit time interval + .sink(receiveValue: { [weak self] publisher in + guard let self = self else { return } + publisher + .sink { completion in + switch completion { + case .failure(let error): + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Push Notification] subscribe failure: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) + case .finished: + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Push Notification] subscribe success", ((#file as NSString).lastPathComponent), #line, #function) + } + } receiveValue: { _ in + // do nothing + } + .store(in: &self.disposeBag) + }) .store(in: &disposeBag) - } } diff --git a/Mastodon/Supporting Files/AppDelegate.swift b/Mastodon/Supporting Files/AppDelegate.swift index fd4c23004..79017e298 100644 --- a/Mastodon/Supporting Files/AppDelegate.swift +++ b/Mastodon/Supporting Files/AppDelegate.swift @@ -7,6 +7,7 @@ import os.log import UIKit +import AppShared @main class AppDelegate: UIResponder, UIApplicationDelegate { @@ -15,11 +16,13 @@ class AppDelegate: UIResponder, UIApplicationDelegate { func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { + AppSecret.default.register() + // Update app version info. See: `Settings.bundle` UserDefaults.standard.setValue(UIApplication.appVersion(), forKey: "Mastodon.appVersion") UserDefaults.standard.setValue(UIApplication.appBuild(), forKey: "Mastodon.appBundle") -// UNUserNotificationCenter.current().delegate = self + UNUserNotificationCenter.current().delegate = self application.registerForRemoteNotifications() return true @@ -57,7 +60,28 @@ extension AppDelegate { // MARK: - UNUserNotificationCenterDelegate extension AppDelegate: UNUserNotificationCenterDelegate { + func userNotificationCenter( + _ center: UNUserNotificationCenter, + willPresent notification: UNNotification, + withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void + ) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Push Notification]", ((#file as NSString).lastPathComponent), #line, #function) + if let plaintext = notification.request.content.userInfo["plaintext"] as? Data, + let mastodonPushNotification = try? JSONDecoder().decode(MastodonPushNotification.self, from: plaintext) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Push Notification] present", ((#file as NSString).lastPathComponent), #line, #function) + + } + completionHandler(.banner) + } + func userNotificationCenter( + _ center: UNUserNotificationCenter, + didReceive response: UNNotificationResponse, + withCompletionHandler completionHandler: @escaping () -> Void + ) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Push Notification]", ((#file as NSString).lastPathComponent), #line, #function) + + } } extension AppContext { diff --git a/Mastodon/Supporting Files/AppSecret.swift b/Mastodon/Supporting Files/AppSecret.swift deleted file mode 100644 index 0a30553a7..000000000 --- a/Mastodon/Supporting Files/AppSecret.swift +++ /dev/null @@ -1,51 +0,0 @@ -// -// AppSecret.swift -// Mastodon -// -// Created by MainasuK Cirno on 2021-4-22. -// - -import Foundation -import CryptoKit -import Keys - -final class AppSecret { - - let notificationEndpoint: String - - let notificationPrivateKey: P256.KeyAgreement.PrivateKey! - let notificationPublicKey: P256.KeyAgreement.PublicKey! - let notificationAuth: Data - - static let `default`: AppSecret = { - return AppSecret() - }() - - init() { - let keys = MastodonKeys() - - #if DEBUG - self.notificationEndpoint = keys.notification_endpoint_debug - let nonce = keys.notification_key_nonce_debug - let auth = keys.notification_key_auth_debug - #else - self.notificationEndpoint = keys.notification_endpoint - let nonce = keys.notification_key_nonce - let auth = keys.notification_key_auth - #endif - - notificationPrivateKey = try! P256.KeyAgreement.PrivateKey(rawRepresentation: Data(base64Encoded: nonce)!) - notificationPublicKey = notificationPrivateKey!.publicKey - notificationAuth = Data(base64Encoded: auth)! - } - - var uncompressionNotificationPublicKeyData: Data { - var data = notificationPublicKey.rawRepresentation - if data.count == 64 { - let prefix: [UInt8] = [0x04] - data = Data(prefix) + data - } - return data - } - -} diff --git a/Mastodon/Supporting Files/AppSharedName.swift b/Mastodon/Supporting Files/AppSharedName.swift deleted file mode 100644 index 3570c68da..000000000 --- a/Mastodon/Supporting Files/AppSharedName.swift +++ /dev/null @@ -1,12 +0,0 @@ -// -// AppSharedName.swift -// Mastodon -// -// Created by MainasuK Cirno on 2021-4-26. -// - -import Foundation - -enum AppSharedName { - static let groupID = "group.org.joinmastodon.mastodon-temp" -} diff --git a/NotificationService/Extension/String.swift b/NotificationService/Extension/String.swift deleted file mode 100644 index edb162428..000000000 --- a/NotificationService/Extension/String.swift +++ /dev/null @@ -1,31 +0,0 @@ -// -// String.swift -// NotificationService -// -// Created by MainasuK Cirno on 2021-4-25. -// - -import Foundation - -extension String { - static func normalize(base64String: String) -> String { - let base64 = base64String - .replacingOccurrences(of: "-", with: "+") - .replacingOccurrences(of: "_", with: "/") - .padding() - return base64 - } - - private func padding() -> String { - let remainder = self.count % 4 - if remainder > 0 { - return self.padding( - toLength: self.count + 4 - remainder, - withPad: "=", - startingAt: 0 - ) - } - return self - } -} - diff --git a/NotificationService/MastodonNotification.swift b/NotificationService/MastodonNotification.swift new file mode 100644 index 000000000..f3941b12d --- /dev/null +++ b/NotificationService/MastodonNotification.swift @@ -0,0 +1,35 @@ +// +// MastodonNotification.swift +// NotificationService +// +// Created by MainasuK Cirno on 2021-4-26. +// + +import Foundation + +struct MastodonPushNotification: Codable { + + private let _accessToken: String + var accessToken: String { + return String.normalize(base64String: _accessToken) + } + + let notificationID: Int + let notificationType: String + + let preferredLocale: String? + let icon: String? + let title: String + let body: String + + enum CodingKeys: String, CodingKey { + case _accessToken = "access_token" + case notificationID = "notification_id" + case notificationType = "notification_type" + case preferredLocale = "preferred_locale" + case icon + case title + case body + } + +} diff --git a/NotificationService/NotificationService+Decrypt.swift b/NotificationService/NotificationService+Decrypt.swift index 065863fda..858e7c2c5 100644 --- a/NotificationService/NotificationService+Decrypt.swift +++ b/NotificationService/NotificationService+Decrypt.swift @@ -32,7 +32,13 @@ extension NotificationService { return nil } - guard let plaintext = try? AES.GCM.open(sealedBox, using: key) else { + var _plaintext: Data? + do { + _plaintext = try AES.GCM.open(sealedBox, using: key) + } catch { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: sealedBox open fail %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) + } + guard let plaintext = _plaintext else { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: failed to open sealedBox", ((#file as NSString).lastPathComponent), #line, #function) return nil } @@ -65,32 +71,3 @@ extension NotificationService { return info } } - -extension NotificationService { - struct MastodonNotification: Codable { - - private let _accessToken: String - var accessToken: String { - return String.normalize(base64String: _accessToken) - } - - let notificationID: Int - let notificationType: String - - let preferredLocale: String? - let icon: String? - let title: String - let body: String - - enum CodingKeys: String, CodingKey { - case _accessToken = "access_token" - case notificationID = "notification_id" - case notificationType = "notification_type" - case preferredLocale = "preferred_locale" - case icon - case title - case body - } - - } -} diff --git a/NotificationService/NotificationService.entitlements b/NotificationService/NotificationService.entitlements new file mode 100644 index 000000000..d334a5e6d --- /dev/null +++ b/NotificationService/NotificationService.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.application-groups + + group.org.joinmastodon.mastodon-temp + + + diff --git a/NotificationService/NotificationService.swift b/NotificationService/NotificationService.swift index e20fc23b2..f40f84ce6 100644 --- a/NotificationService/NotificationService.swift +++ b/NotificationService/NotificationService.swift @@ -10,7 +10,7 @@ import CommonOSLog import CryptoKit import AlamofireImage import Base85 -import Keys +import AppShared class NotificationService: UNNotificationServiceExtension { @@ -25,7 +25,8 @@ class NotificationService: UNNotificationServiceExtension { // Modify the notification content here... os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) - let privateKey = AppSecret.default.notificationPrivateKey! + let privateKey = AppSecret.default.notificationPrivateKey + let auth = AppSecret.default.notificationAuth guard let encodedPayload = bestAttemptContent.userInfo["p"] as? String, let payload = Data(base85Encoded: encodedPayload, options: [], encoding: .z85) else { @@ -48,9 +49,8 @@ class NotificationService: UNNotificationServiceExtension { return } - let auth = AppSecret.default.notificationAuth guard let plaintextData = NotificationService.decrypt(payload: payload, salt: salt, auth: auth, privateKey: privateKey, publicKey: publicKey), - let notification = try? JSONDecoder().decode(MastodonNotification.self, from: plaintextData) else { + let notification = try? JSONDecoder().decode(MastodonPushNotification.self, from: plaintextData) else { contentHandler(bestAttemptContent) return } @@ -58,6 +58,7 @@ class NotificationService: UNNotificationServiceExtension { bestAttemptContent.title = notification.title bestAttemptContent.subtitle = "" bestAttemptContent.body = notification.body + bestAttemptContent.userInfo["plaintext"] = plaintextData UserDefaults.shared.notificationBadgeCount += 1 bestAttemptContent.badge = NSNumber(integerLiteral: UserDefaults.shared.notificationBadgeCount) diff --git a/Podfile b/Podfile index bb929277e..ea7075ea8 100644 --- a/Podfile +++ b/Podfile @@ -27,16 +27,16 @@ target 'Mastodon' do end + target 'AppShared' do + + end + end plugin 'cocoapods-keys', { :project => "Mastodon", :keys => [ "notification_endpoint", - "notification_endpoint_debug", - "notification_key_nonce", - "notification_key_nonce_debug", - "notification_key_auth", - "notification_key_auth_debug" + "notification_endpoint_debug" ] } \ No newline at end of file diff --git a/Podfile.lock b/Podfile.lock index d34a2ada5..e341a2420 100644 --- a/Podfile.lock +++ b/Podfile.lock @@ -30,6 +30,6 @@ SPEC CHECKSUMS: SwiftGen: 67860cc7c3cfc2ed25b9b74cfd55495fc89f9108 "UITextField+Shake": 298ac5a0f239d731bdab999b19b628c956ca0ac3 -PODFILE CHECKSUM: b99204f58cb11d471cfad7269bbf0abb853dc953 +PODFILE CHECKSUM: a8dbae22e6e0bfb84f7db59aef1aa1716793d287 COCOAPODS: 1.10.1 diff --git a/README.md b/README.md index d3cddb071..71194684d 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,7 @@ arch -x86_64 pod install - [CryptoSwift](https://github.com/krzyzanowskim/CryptoSwift) - [DateToolSwift](https://github.com/MatthewYork/DateTools) - [Kanna](https://github.com/tid-kijyun/Kanna) +- [KeychainAccess](https://github.com/kishikawakatsumi/KeychainAccess.git) - [Kingfisher](https://github.com/onevcat/Kingfisher) - [SwiftGen](https://github.com/SwiftGen/SwiftGen) - [SwiftyJSON](https://github.com/SwiftyJSON/SwiftyJSON) From d4552332b32d5504b14ed113d26aedd58ea37756 Mon Sep 17 00:00:00 2001 From: CMK Date: Tue, 27 Apr 2021 16:29:10 +0800 Subject: [PATCH 08/19] chore: update CI script --- .github/scripts/setup.sh | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/scripts/setup.sh b/.github/scripts/setup.sh index e1411fb50..0c2612d51 100755 --- a/.github/scripts/setup.sh +++ b/.github/scripts/setup.sh @@ -1,4 +1,9 @@ #!/bin/bash sudo gem install cocoapods-keys -pod install \ No newline at end of file + +# stub keys. DO NOT use in production +pod keys set notification_endpoint "" +pod keys set notification_endpoint_debug "" + +pod install From d03346c0de35bd4c97441fd209c76a2b4be58c55 Mon Sep 17 00:00:00 2001 From: sunxiaojian Date: Tue, 27 Apr 2021 16:54:23 +0800 Subject: [PATCH 09/19] fix: add followrequest cell action --- .../Section/NotificationSection.swift | 12 +++++++ .../NotificationViewController.swift | 8 +++++ .../Notification/NotificationViewModel.swift | 33 +++++++++++++++++++ .../NotificationTableViewCell.swift | 6 ++-- 4 files changed, 56 insertions(+), 3 deletions(-) diff --git a/Mastodon/Diffiable/Section/NotificationSection.swift b/Mastodon/Diffiable/Section/NotificationSection.swift index 57c755818..d2df0a78b 100644 --- a/Mastodon/Diffiable/Section/NotificationSection.swift +++ b/Mastodon/Diffiable/Section/NotificationSection.swift @@ -91,6 +91,18 @@ extension NotificationSection { cell.actionLabel.text = actionText + " · " + timeText } .store(in: &cell.disposeBag) + cell.acceptButton.publisher(for: .touchUpInside) + .sink { [weak cell] _ in + guard let cell = cell else { return } + cell.delegate?.notificationTableViewCell(cell, notification: notification, acceptButtonDidPressed: cell.acceptButton) + } + .store(in: &cell.disposeBag) + cell.rejectButton.publisher(for: .touchUpInside) + .sink { [weak cell] _ in + guard let cell = cell else { return } + cell.delegate?.notificationTableViewCell(cell, notification: notification, rejectButtonDidPressed: cell.acceptButton) + } + .store(in: &cell.disposeBag) cell.actionImageBackground.backgroundColor = color cell.actionLabel.text = actionText + " · " + timeText cell.nameLabel.text = notification.account.displayName.isEmpty ? notification.account.username : notification.account.displayName diff --git a/Mastodon/Scene/Notification/NotificationViewController.swift b/Mastodon/Scene/Notification/NotificationViewController.swift index 57b5dc639..f3b143f52 100644 --- a/Mastodon/Scene/Notification/NotificationViewController.swift +++ b/Mastodon/Scene/Notification/NotificationViewController.swift @@ -205,6 +205,14 @@ extension NotificationViewController: ContentOffsetAdjustableTimelineViewControl // MARK: - NotificationTableViewCellDelegate extension NotificationViewController: NotificationTableViewCellDelegate { + func notificationTableViewCell(_ cell: NotificationTableViewCell, notification: MastodonNotification, acceptButtonDidPressed button: UIButton) { + viewModel.acceptFollowRequest(notification: notification) + } + + func notificationTableViewCell(_ cell: NotificationTableViewCell, notification: MastodonNotification, rejectButtonDidPressed button: UIButton) { + viewModel.rejectFollowRequest(notification: notification) + } + func userAvatarDidPressed(notification: MastodonNotification) { let viewModel = ProfileViewModel(context: context, optionalMastodonUser: notification.account) DispatchQueue.main.async { diff --git a/Mastodon/Scene/Notification/NotificationViewModel.swift b/Mastodon/Scene/Notification/NotificationViewModel.swift index e026af732..f60e3d76d 100644 --- a/Mastodon/Scene/Notification/NotificationViewModel.swift +++ b/Mastodon/Scene/Notification/NotificationViewModel.swift @@ -12,6 +12,7 @@ import Foundation import GameplayKit import MastodonSDK import UIKit +import OSLog final class NotificationViewModel: NSObject { var disposeBag = Set() @@ -120,6 +121,38 @@ final class NotificationViewModel: NSObject { } .store(in: &disposeBag) } + + func acceptFollowRequest(notification: MastodonNotification) { + guard let activeMastodonAuthenticationBox = self.activeMastodonAuthenticationBox.value else { return } + context.apiService.acceptFollowRequest(mastodonUserID: notification.account.id, mastodonAuthenticationBox: activeMastodonAuthenticationBox) + .sink { [weak self] completion in + switch completion { + case .failure(let error): + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: accept FollowRequest fail: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) + case .finished: + self?.loadLatestStateMachine.enter(NotificationViewModel.LoadLatestState.Loading.self) + } + } receiveValue: { _ in + + } + .store(in: &disposeBag) + } + + func rejectFollowRequest(notification: MastodonNotification) { + guard let activeMastodonAuthenticationBox = self.activeMastodonAuthenticationBox.value else { return } + context.apiService.acceptFollowRequest(mastodonUserID: notification.account.id, mastodonAuthenticationBox: activeMastodonAuthenticationBox) + .sink { [weak self] completion in + switch completion { + case .failure(let error): + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: reject FollowRequest fail: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) + case .finished: + self?.loadLatestStateMachine.enter(NotificationViewModel.LoadLatestState.Loading.self) + } + } receiveValue: { _ in + + } + .store(in: &disposeBag) + } } extension NotificationViewModel { diff --git a/Mastodon/Scene/Notification/TableViewCell/NotificationTableViewCell.swift b/Mastodon/Scene/Notification/TableViewCell/NotificationTableViewCell.swift index dc5c4c19c..c049b961e 100644 --- a/Mastodon/Scene/Notification/TableViewCell/NotificationTableViewCell.swift +++ b/Mastodon/Scene/Notification/TableViewCell/NotificationTableViewCell.swift @@ -21,9 +21,9 @@ protocol NotificationTableViewCellDelegate: AnyObject { func notificationStatusTableViewCell(_ cell: NotificationStatusTableViewCell, statusView: StatusView, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView) func notificationStatusTableViewCell(_ cell: NotificationStatusTableViewCell, statusView: StatusView, playerContainerView: PlayerContainerView, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView) -// func notificationStatusTableViewCell(_ cell: NotificationStatusTableViewCell, notification: MastodonNotification, acceptButtonDidPressed button: UIButton) -// -// func notificationStatusTableViewCell(_ cell: NotificationStatusTableViewCell, notification: MastodonNotification, denyButtonDidPressed button: UIButton) + func notificationTableViewCell(_ cell: NotificationTableViewCell, notification: MastodonNotification, acceptButtonDidPressed button: UIButton) + + func notificationTableViewCell(_ cell: NotificationTableViewCell, notification: MastodonNotification, rejectButtonDidPressed button: UIButton) } From ed9c2ddd8f015fe2f3b2b97366730c890b4e8c7c Mon Sep 17 00:00:00 2001 From: CMK Date: Tue, 27 Apr 2021 17:27:03 +0800 Subject: [PATCH 10/19] feat: handle notification response --- Mastodon/Extension/UIViewController.swift | 47 +++++++++++++++++++ .../Scene/MainTab/MainTabBarController.swift | 24 ++++++++-- .../NotificationViewController.swift | 5 ++ ...otificationViewModel+LoadLatestState.swift | 34 +++++++------- .../APIService/APIService+Notification.swift | 16 +++++++ Mastodon/Service/NotificationService.swift | 13 ++++- Mastodon/State/AppContext.swift | 1 + Mastodon/Supporting Files/AppDelegate.swift | 33 +++++++++++-- .../API/Mastodon+API+Notifications.swift | 2 +- 9 files changed, 148 insertions(+), 27 deletions(-) diff --git a/Mastodon/Extension/UIViewController.swift b/Mastodon/Extension/UIViewController.swift index c3782fa14..9ebb3a0a8 100644 --- a/Mastodon/Extension/UIViewController.swift +++ b/Mastodon/Extension/UIViewController.swift @@ -46,6 +46,53 @@ extension UIViewController { } +extension UIViewController { + + func viewController(of type: T.Type) -> T? { + if let viewController = self as? T { + return viewController + } + + // UITabBarController + if let tabBarController = self as? UITabBarController { + for tab in tabBarController.viewControllers ?? [] { + if let viewController = tab.viewController(of: type) { + return viewController + } + } + } + + // UINavigationController + if let navigationController = self as? UINavigationController { + for page in navigationController.viewControllers { + if let viewController = page.viewController(of: type) { + return viewController + } + } + } + + // UIPageController + if let pageViewController = self as? UIPageViewController { + for page in pageViewController.viewControllers ?? [] { + if let viewController = page.viewController(of: type) { + return viewController + } + } + } + + // child view controller + for subview in self.view?.subviews ?? [] { + if let childViewController = subview.next as? UIViewController, + let viewController = childViewController.viewController(of: type) { + return viewController + } + } + + return nil + } + +} + extension UIViewController { /// https://bluelemonbits.com/2018/08/26/inserting-cells-at-the-top-of-a-uitableview-with-no-scrolling/ diff --git a/Mastodon/Scene/MainTab/MainTabBarController.swift b/Mastodon/Scene/MainTab/MainTabBarController.swift index 9d609234f..d5905cbb7 100644 --- a/Mastodon/Scene/MainTab/MainTabBarController.swift +++ b/Mastodon/Scene/MainTab/MainTabBarController.swift @@ -85,7 +85,6 @@ class MainTabBarController: UITabBarController { extension MainTabBarController { - open override var childForStatusBarStyle: UIViewController? { return selectedViewController } @@ -156,9 +155,26 @@ extension MainTabBarController { } .store(in: &disposeBag) - #if DEBUG - // selectedIndex = 3 - #endif + // handle push notification. toggle entry when finish fetch latest notification + context.notificationService.hasUnreadPushNotification + .receive(on: DispatchQueue.main) + .sink { [weak self] hasUnreadPushNotification in + guard let self = self else { return } + guard let notificationViewController = self.notificationViewController else { return } + + let image = hasUnreadPushNotification ? UIImage(systemName: "bell.badge.fill")! : UIImage(systemName: "bell.fill")! + notificationViewController.tabBarItem.image = image + notificationViewController.navigationController?.tabBarItem.image = image + } + .store(in: &disposeBag) } } + +extension MainTabBarController { + + var notificationViewController: NotificationViewController? { + return viewController(of: NotificationViewController.self) + } + +} diff --git a/Mastodon/Scene/Notification/NotificationViewController.swift b/Mastodon/Scene/Notification/NotificationViewController.swift index 57b5dc639..b27c45817 100644 --- a/Mastodon/Scene/Notification/NotificationViewController.swift +++ b/Mastodon/Scene/Notification/NotificationViewController.swift @@ -88,6 +88,11 @@ extension NotificationViewController { tableView.deselectRow(with: transitionCoordinator, animated: animated) + // fetch latest if has unread push notification + if context.notificationService.hasUnreadPushNotification.value { + viewModel.loadLatestStateMachine.enter(NotificationViewModel.LoadLatestState.Loading.self) + } + // needs trigger manually after onboarding dismiss setNeedsStatusBarAppearanceUpdate() } diff --git a/Mastodon/Scene/Notification/NotificationViewModel+LoadLatestState.swift b/Mastodon/Scene/Notification/NotificationViewModel+LoadLatestState.swift index 0e6b0d622..b9d60ae7c 100644 --- a/Mastodon/Scene/Notification/NotificationViewModel+LoadLatestState.swift +++ b/Mastodon/Scene/Notification/NotificationViewModel+LoadLatestState.swift @@ -61,23 +61,25 @@ extension NotificationViewModel.LoadLatestState { query: query, mastodonAuthenticationBox: activeMastodonAuthenticationBox ) - .sink { completion in - switch completion { - case .failure(let error): - viewModel.isFetchingLatestNotification.value = false - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: fetch notification failed. %s", (#file as NSString).lastPathComponent, #line, #function, error.localizedDescription) - case .finished: - // handle isFetchingLatestTimeline in fetch controller delegate - break - } - - stateMachine.enter(Idle.self) - } receiveValue: { response in - if response.value.isEmpty { - viewModel.isFetchingLatestNotification.value = false - } + .sink { completion in + switch completion { + case .failure(let error): + viewModel.isFetchingLatestNotification.value = false + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: fetch notification failed. %s", (#file as NSString).lastPathComponent, #line, #function, error.localizedDescription) + case .finished: + // toggle unread state + viewModel.context.notificationService.hasUnreadPushNotification.value = false + // handle isFetchingLatestTimeline in fetch controller delegate + break } - .store(in: &viewModel.disposeBag) + + stateMachine.enter(Idle.self) + } receiveValue: { response in + if response.value.isEmpty { + viewModel.isFetchingLatestNotification.value = false + } + } + .store(in: &viewModel.disposeBag) } } diff --git a/Mastodon/Service/APIService/APIService+Notification.swift b/Mastodon/Service/APIService/APIService+Notification.swift index ee8f5186c..cbc0f9ed7 100644 --- a/Mastodon/Service/APIService/APIService+Notification.swift +++ b/Mastodon/Service/APIService/APIService+Notification.swift @@ -64,4 +64,20 @@ extension APIService { } .eraseToAnyPublisher() } + + func notification( + notificationID: Mastodon.Entity.Notification.ID, + mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox + ) -> AnyPublisher, Error> { + let domain = mastodonAuthenticationBox.domain + let authorization = mastodonAuthenticationBox.userAuthorization + + return Mastodon.API.Notifications.getNotification( + session: session, + domain: domain, + notificationID: notificationID, + authorization: authorization + ) + } + } diff --git a/Mastodon/Service/NotificationService.swift b/Mastodon/Service/NotificationService.swift index 90680e783..bfd96df79 100644 --- a/Mastodon/Service/NotificationService.swift +++ b/Mastodon/Service/NotificationService.swift @@ -20,6 +20,7 @@ final class NotificationService { let workingQueue = DispatchQueue(label: "org.joinmastodon.Mastodon.NotificationService.working-queue") // input + weak var apiService: APIService? weak var authenticationService: AuthenticationService? let isNotificationPermissionGranted = CurrentValueSubject(false) let deviceToken = CurrentValueSubject(nil) @@ -27,10 +28,13 @@ final class NotificationService { // output /// [Token: UserID] let notificationSubscriptionDict: [String: NotificationViewModel] = [:] + let hasUnreadPushNotification = CurrentValueSubject(false) init( + apiService: APIService, authenticationService: AuthenticationService ) { + self.apiService = apiService self.authenticationService = authenticationService authenticationService.mastodonAuthentications @@ -94,9 +98,15 @@ extension NotificationService { } return _notificationSubscription } - + + func handlePushNotification(notificationID: Mastodon.Entity.Notification.ID) { + hasUnreadPushNotification.value = true + } + } +// MARK: - NotificationViewModel + extension NotificationService { final class NotificationViewModel { @@ -141,4 +151,5 @@ extension NotificationService.NotificationViewModel { return query } + } diff --git a/Mastodon/State/AppContext.swift b/Mastodon/State/AppContext.swift index 0c40ff127..93287f6eb 100644 --- a/Mastodon/State/AppContext.swift +++ b/Mastodon/State/AppContext.swift @@ -61,6 +61,7 @@ class AppContext: ObservableObject { apiService: _apiService ) let _notificationService = NotificationService( + apiService: _apiService, authenticationService: _authenticationService ) notificationService = _notificationService diff --git a/Mastodon/Supporting Files/AppDelegate.swift b/Mastodon/Supporting Files/AppDelegate.swift index 79017e298..0cca6ddf5 100644 --- a/Mastodon/Supporting Files/AppDelegate.swift +++ b/Mastodon/Supporting Files/AppDelegate.swift @@ -7,6 +7,7 @@ import os.log import UIKit +import UserNotifications import AppShared @main @@ -66,12 +67,15 @@ extension AppDelegate: UNUserNotificationCenterDelegate { withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void ) { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Push Notification]", ((#file as NSString).lastPathComponent), #line, #function) - if let plaintext = notification.request.content.userInfo["plaintext"] as? Data, - let mastodonPushNotification = try? JSONDecoder().decode(MastodonPushNotification.self, from: plaintext) { - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Push Notification] present", ((#file as NSString).lastPathComponent), #line, #function) - + guard let mastodonPushNotification = AppDelegate.mastodonPushNotification(from: notification) else { + completionHandler([]) + return } - completionHandler(.banner) + + let notificationID = String(mastodonPushNotification.notificationID) + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Push Notification] notification %s", ((#file as NSString).lastPathComponent), #line, #function, notificationID) + appContext.notificationService.handlePushNotification(notificationID: notificationID) + completionHandler([.sound]) } func userNotificationCenter( @@ -81,6 +85,25 @@ extension AppDelegate: UNUserNotificationCenterDelegate { ) { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Push Notification]", ((#file as NSString).lastPathComponent), #line, #function) + guard let mastodonPushNotification = AppDelegate.mastodonPushNotification(from: response.notification) else { + completionHandler() + return + } + + let notificationID = String(mastodonPushNotification.notificationID) + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Push Notification] notification %s", ((#file as NSString).lastPathComponent), #line, #function, notificationID) + appContext.notificationService.handlePushNotification(notificationID: notificationID) + + completionHandler() + } + + private static func mastodonPushNotification(from notification: UNNotification) -> MastodonPushNotification? { + guard let plaintext = notification.request.content.userInfo["plaintext"] as? Data, + let mastodonPushNotification = try? JSONDecoder().decode(MastodonPushNotification.self, from: plaintext) else { + return nil + } + + return mastodonPushNotification } } diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Notifications.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Notifications.swift index b0ab13edb..c6b56c9e9 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Notifications.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Notifications.swift @@ -67,7 +67,7 @@ extension Mastodon.API.Notifications { public static func getNotification( session: URLSession, domain: String, - notificationID: String, + notificationID: Mastodon.Entity.Notification.ID, authorization: Mastodon.API.OAuth.Authorization ) -> AnyPublisher, Error> { let request = Mastodon.API.get( From 125f6d0a277a9dbf8a41eb0cae1f01d5156534ea Mon Sep 17 00:00:00 2001 From: CMK Date: Tue, 27 Apr 2021 17:45:11 +0800 Subject: [PATCH 11/19] feat: show received notification status --- .../Scene/MainTab/MainTabBarController.swift | 10 +++++ .../Scene/Thread/RemoteThreadViewModel.swift | 39 +++++++++++++++++++ .../APIService/APIService+Notification.swift | 28 +++++++++++++ Mastodon/Service/NotificationService.swift | 1 + Mastodon/Supporting Files/AppDelegate.swift | 6 ++- 5 files changed, 83 insertions(+), 1 deletion(-) diff --git a/Mastodon/Scene/MainTab/MainTabBarController.swift b/Mastodon/Scene/MainTab/MainTabBarController.swift index d5905cbb7..5fd4c8256 100644 --- a/Mastodon/Scene/MainTab/MainTabBarController.swift +++ b/Mastodon/Scene/MainTab/MainTabBarController.swift @@ -167,6 +167,16 @@ extension MainTabBarController { notificationViewController.navigationController?.tabBarItem.image = image } .store(in: &disposeBag) + + context.notificationService.requestRevealNotificationPublisher + .receive(on: DispatchQueue.main) + .sink { [weak self] notificationID in + guard let self = self else { return } + self.coordinator.switchToTabBar(tab: .notification) + let threadViewModel = RemoteThreadViewModel(context: self.context, notificationID: notificationID) + self.coordinator.present(scene: .thread(viewModel: threadViewModel), from: nil, transition: .show) + } + .store(in: &disposeBag) } } diff --git a/Mastodon/Scene/Thread/RemoteThreadViewModel.swift b/Mastodon/Scene/Thread/RemoteThreadViewModel.swift index e79c355cf..e6e111018 100644 --- a/Mastodon/Scene/Thread/RemoteThreadViewModel.swift +++ b/Mastodon/Scene/Thread/RemoteThreadViewModel.swift @@ -47,4 +47,43 @@ final class RemoteThreadViewModel: ThreadViewModel { } .store(in: &disposeBag) } + + // FIXME: multiple account supports + init(context: AppContext, notificationID: Mastodon.Entity.Notification.ID) { + super.init(context: context, optionalStatus: nil) + + guard let activeMastodonAuthenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { + return + } + let domain = activeMastodonAuthenticationBox.domain + context.apiService.notification( + notificationID: notificationID, + mastodonAuthenticationBox: activeMastodonAuthenticationBox + ) + .retry(3) + .sink { completion in + switch completion { + case .failure(let error): + // TODO: handle error + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: remote notification %s fetch failed: %s", ((#file as NSString).lastPathComponent), #line, #function, notificationID, error.localizedDescription) + case .finished: + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: remote notification %s fetched", ((#file as NSString).lastPathComponent), #line, #function, notificationID) + } + } receiveValue: { [weak self] response in + guard let self = self else { return } + guard let statusID = response.value.status?.id else { return } + + let managedObjectContext = context.managedObjectContext + let request = Status.sortedFetchRequest + request.fetchLimit = 1 + request.predicate = Status.predicate(domain: domain, id: statusID) + guard let status = managedObjectContext.safeFetch(request).first else { + assertionFailure() + return + } + self.rootItem.value = .root(statusObjectID: status.objectID, attribute: Item.StatusAttribute()) + } + .store(in: &disposeBag) + } + } diff --git a/Mastodon/Service/APIService/APIService+Notification.swift b/Mastodon/Service/APIService/APIService+Notification.swift index cbc0f9ed7..590842ce1 100644 --- a/Mastodon/Service/APIService/APIService+Notification.swift +++ b/Mastodon/Service/APIService/APIService+Notification.swift @@ -78,6 +78,34 @@ extension APIService { notificationID: notificationID, authorization: authorization ) + .flatMap { response -> AnyPublisher, Error> in + guard let status = response.value.status else { + return Just(response) + .setFailureType(to: Error.self) + .eraseToAnyPublisher() + } + + return APIService.Persist.persistStatus( + managedObjectContext: self.backgroundManagedObjectContext, + domain: domain, + query: nil, + response: response.map { _ in [status] }, + persistType: .lookUp, + requestMastodonUserID: nil, + log: OSLog.api + ) + .setFailureType(to: Error.self) + .tryMap { result -> Mastodon.Response.Content in + switch result { + case .success: + return response + case .failure(let error): + throw error + } + } + .eraseToAnyPublisher() + } + .eraseToAnyPublisher() } } diff --git a/Mastodon/Service/NotificationService.swift b/Mastodon/Service/NotificationService.swift index bfd96df79..75a5953ed 100644 --- a/Mastodon/Service/NotificationService.swift +++ b/Mastodon/Service/NotificationService.swift @@ -29,6 +29,7 @@ final class NotificationService { /// [Token: UserID] let notificationSubscriptionDict: [String: NotificationViewModel] = [:] let hasUnreadPushNotification = CurrentValueSubject(false) + let requestRevealNotificationPublisher = PassthroughSubject() init( apiService: APIService, diff --git a/Mastodon/Supporting Files/AppDelegate.swift b/Mastodon/Supporting Files/AppDelegate.swift index 0cca6ddf5..73a13bed1 100644 --- a/Mastodon/Supporting Files/AppDelegate.swift +++ b/Mastodon/Supporting Files/AppDelegate.swift @@ -61,6 +61,8 @@ extension AppDelegate { // MARK: - UNUserNotificationCenterDelegate extension AppDelegate: UNUserNotificationCenterDelegate { + + // notification present in the foreground func userNotificationCenter( _ center: UNUserNotificationCenter, willPresent notification: UNNotification, @@ -78,6 +80,7 @@ extension AppDelegate: UNUserNotificationCenterDelegate { completionHandler([.sound]) } + // response to user action for notification func userNotificationCenter( _ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, @@ -93,7 +96,7 @@ extension AppDelegate: UNUserNotificationCenterDelegate { let notificationID = String(mastodonPushNotification.notificationID) os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Push Notification] notification %s", ((#file as NSString).lastPathComponent), #line, #function, notificationID) appContext.notificationService.handlePushNotification(notificationID: notificationID) - + appContext.notificationService.requestRevealNotificationPublisher.send(notificationID) completionHandler() } @@ -105,6 +108,7 @@ extension AppDelegate: UNUserNotificationCenterDelegate { return mastodonPushNotification } + } extension AppContext { From 37480fe67b86030dc2967682e40db03fe1e57716 Mon Sep 17 00:00:00 2001 From: CMK Date: Tue, 27 Apr 2021 18:05:29 +0800 Subject: [PATCH 12/19] feat: cancel sign-out user notification subscription when received --- CoreDataStack/Entity/Subscription.swift | 4 ++ Mastodon/Service/NotificationService.swift | 50 ++++++++++++++++++++- Mastodon/Supporting Files/AppDelegate.swift | 4 +- 3 files changed, 55 insertions(+), 3 deletions(-) diff --git a/CoreDataStack/Entity/Subscription.swift b/CoreDataStack/Entity/Subscription.swift index 6cb1902a1..e1824be1c 100644 --- a/CoreDataStack/Entity/Subscription.swift +++ b/CoreDataStack/Entity/Subscription.swift @@ -80,4 +80,8 @@ extension Subscription { return NSPredicate(format: "%K == %@", #keyPath(Subscription.policyRaw), policyRaw) } + public static func predicate(userToken: String) -> NSPredicate { + return NSPredicate(format: "%K == %@", #keyPath(Subscription.userToken), userToken) + } + } diff --git a/Mastodon/Service/NotificationService.swift b/Mastodon/Service/NotificationService.swift index 75a5953ed..e21a3cff8 100644 --- a/Mastodon/Service/NotificationService.swift +++ b/Mastodon/Service/NotificationService.swift @@ -100,8 +100,56 @@ extension NotificationService { return _notificationSubscription } - func handlePushNotification(notificationID: Mastodon.Entity.Notification.ID) { + func handle(mastodonPushNotification: MastodonPushNotification) { hasUnreadPushNotification.value = true + + // Subscription maybe failed to cancel when sign-out + // Try cancel again if receive that kind push notification + guard let managedObjectContext = authenticationService?.managedObjectContext else { return } + guard let apiService = apiService else { return } + + managedObjectContext.perform { + let subscriptionRequest = NotificationSubscription.sortedFetchRequest + subscriptionRequest.predicate = NotificationSubscription.predicate(userToken: mastodonPushNotification.accessToken) + let subscriptions = managedObjectContext.safeFetch(subscriptionRequest) + + // note: assert setting remove after cancel subscription + guard let subscription = subscriptions.first else { return } + guard let setting = subscription.setting else { return } + let domain = setting.domain + let userID = setting.userID + + let authenticationRequest = MastodonAuthentication.sortedFetchRequest + authenticationRequest.predicate = MastodonAuthentication.predicate(domain: domain, userID: userID) + let authentication = managedObjectContext.safeFetch(authenticationRequest).first + + guard authentication == nil else { + // do nothing if still sign-in + return + } + + // cancel subscription if sign-out + let accessToken = mastodonPushNotification.accessToken + let mastodonAuthenticationBox = AuthenticationService.MastodonAuthenticationBox( + domain: domain, + userID: userID, + appAuthorization: .init(accessToken: accessToken), + userAuthorization: .init(accessToken: accessToken) + ) + apiService + .cancelSubscription(mastodonAuthenticationBox: mastodonAuthenticationBox) + .sink { completion in + switch completion { + case .failure(let error): + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Push Notification] failed to cancel sign-out user subscription: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) + case .finished: + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Push Notification] cancel sign-out user subscription", ((#file as NSString).lastPathComponent), #line, #function) + } + } receiveValue: { _ in + // do nothing + } + .store(in: &self.disposeBag) + } } } diff --git a/Mastodon/Supporting Files/AppDelegate.swift b/Mastodon/Supporting Files/AppDelegate.swift index 73a13bed1..6c49638da 100644 --- a/Mastodon/Supporting Files/AppDelegate.swift +++ b/Mastodon/Supporting Files/AppDelegate.swift @@ -76,7 +76,7 @@ extension AppDelegate: UNUserNotificationCenterDelegate { let notificationID = String(mastodonPushNotification.notificationID) os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Push Notification] notification %s", ((#file as NSString).lastPathComponent), #line, #function, notificationID) - appContext.notificationService.handlePushNotification(notificationID: notificationID) + appContext.notificationService.handle(mastodonPushNotification: mastodonPushNotification) completionHandler([.sound]) } @@ -95,7 +95,7 @@ extension AppDelegate: UNUserNotificationCenterDelegate { let notificationID = String(mastodonPushNotification.notificationID) os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Push Notification] notification %s", ((#file as NSString).lastPathComponent), #line, #function, notificationID) - appContext.notificationService.handlePushNotification(notificationID: notificationID) + appContext.notificationService.handle(mastodonPushNotification: mastodonPushNotification) appContext.notificationService.requestRevealNotificationPublisher.send(notificationID) completionHandler() } From 40b5472d1fb4f9e2c0f777bf8441efa3cfc0a7c1 Mon Sep 17 00:00:00 2001 From: CMK Date: Tue, 27 Apr 2021 18:23:13 +0800 Subject: [PATCH 13/19] feat: add sound for push notification --- NotificationService/NotificationService.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/NotificationService/NotificationService.swift b/NotificationService/NotificationService.swift index f40f84ce6..08ba12be4 100644 --- a/NotificationService/NotificationService.swift +++ b/NotificationService/NotificationService.swift @@ -58,6 +58,7 @@ class NotificationService: UNNotificationServiceExtension { bestAttemptContent.title = notification.title bestAttemptContent.subtitle = "" bestAttemptContent.body = notification.body + bestAttemptContent.sound = .default bestAttemptContent.userInfo["plaintext"] = plaintextData UserDefaults.shared.notificationBadgeCount += 1 From 193b69b6b10338ae367e16a781cd6dfa4683c75b Mon Sep 17 00:00:00 2001 From: sunxiaojian Date: Tue, 27 Apr 2021 19:41:55 +0800 Subject: [PATCH 14/19] fix: change accept to reject --- Mastodon/Diffiable/Section/NotificationSection.swift | 2 +- Mastodon/Scene/Notification/NotificationViewModel.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Mastodon/Diffiable/Section/NotificationSection.swift b/Mastodon/Diffiable/Section/NotificationSection.swift index d2df0a78b..ead5d48f8 100644 --- a/Mastodon/Diffiable/Section/NotificationSection.swift +++ b/Mastodon/Diffiable/Section/NotificationSection.swift @@ -100,7 +100,7 @@ extension NotificationSection { cell.rejectButton.publisher(for: .touchUpInside) .sink { [weak cell] _ in guard let cell = cell else { return } - cell.delegate?.notificationTableViewCell(cell, notification: notification, rejectButtonDidPressed: cell.acceptButton) + cell.delegate?.notificationTableViewCell(cell, notification: notification, rejectButtonDidPressed: cell.rejectButton) } .store(in: &cell.disposeBag) cell.actionImageBackground.backgroundColor = color diff --git a/Mastodon/Scene/Notification/NotificationViewModel.swift b/Mastodon/Scene/Notification/NotificationViewModel.swift index f60e3d76d..f535c5598 100644 --- a/Mastodon/Scene/Notification/NotificationViewModel.swift +++ b/Mastodon/Scene/Notification/NotificationViewModel.swift @@ -140,7 +140,7 @@ final class NotificationViewModel: NSObject { func rejectFollowRequest(notification: MastodonNotification) { guard let activeMastodonAuthenticationBox = self.activeMastodonAuthenticationBox.value else { return } - context.apiService.acceptFollowRequest(mastodonUserID: notification.account.id, mastodonAuthenticationBox: activeMastodonAuthenticationBox) + context.apiService.rejectFollowRequest(mastodonUserID: notification.account.id, mastodonAuthenticationBox: activeMastodonAuthenticationBox) .sink { [weak self] completion in switch completion { case .failure(let error): From a9fdd2efa3f6beb47258906dde41c48037923dac Mon Sep 17 00:00:00 2001 From: sunxiaojian Date: Tue, 2 Mar 2021 13:27:53 +0800 Subject: [PATCH 15/19] fix: acct lookup support --- .../MastodonPickServerViewController.swift | 1 + .../Register/MastodonRegisterViewModel.swift | 35 +++++++++++++ .../MastodonServerRulesViewController.swift | 2 +- .../APIService/APIService+Account.swift | 13 +++++ .../API/Mastodon+API+Account.swift | 51 +++++++++++++++++++ 5 files changed, 101 insertions(+), 1 deletion(-) diff --git a/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewController.swift b/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewController.swift index 685709719..638734c11 100644 --- a/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewController.swift +++ b/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewController.swift @@ -336,6 +336,7 @@ extension MastodonPickServerViewController { } else { let mastodonRegisterViewModel = MastodonRegisterViewModel( domain: server.domain, + context: self.context, authenticateInfo: response.authenticateInfo, instance: response.instance.value, applicationToken: response.applicationToken.value diff --git a/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewModel.swift b/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewModel.swift index cd6106c23..919443f2d 100644 --- a/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewModel.swift +++ b/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewModel.swift @@ -18,6 +18,7 @@ final class MastodonRegisterViewModel { let authenticateInfo: AuthenticationViewModel.AuthenticateInfo let instance: Mastodon.Entity.Instance let applicationToken: Mastodon.Entity.Token + let context: AppContext let username = CurrentValueSubject("") let displayName = CurrentValueSubject("") @@ -46,11 +47,13 @@ final class MastodonRegisterViewModel { init( domain: String, + context: AppContext, authenticateInfo: AuthenticationViewModel.AuthenticateInfo, instance: Mastodon.Entity.Instance, applicationToken: Mastodon.Entity.Token ) { self.domain = domain + self.context = context self.authenticateInfo = authenticateInfo self.instance = instance self.applicationToken = applicationToken @@ -78,6 +81,21 @@ final class MastodonRegisterViewModel { } .assign(to: \.value, on: usernameValidateState) .store(in: &disposeBag) + + username.debounce(for: .milliseconds(300), scheduler: DispatchQueue.main).removeDuplicates() + .sink { [weak self] text in + self?.lookupAccount(by: text) + } + .store(in: &disposeBag) + + usernameValidateState + .sink { [weak self] validateState in + if validateState == .valid { + self?.usernameErrorPrompt.value = nil + } + } + .store(in: &disposeBag) + displayName .map { displayname in guard !displayname.isEmpty else { return .empty } @@ -145,6 +163,23 @@ final class MastodonRegisterViewModel { .assign(to: \.value, on: isAllValid) .store(in: &disposeBag) } + + func lookupAccount(by acct: String) { + if acct.isEmpty { + return + } + let query = Mastodon.API.Account.AccountLookupQuery(acct: acct) + context.apiService.accountLookup(domain: domain, query: query, authorization: applicationAuthorization) + .sink { _ in + + } receiveValue: { [weak self] account in + guard let self = self else { return } + let text = L10n.Scene.Register.Error.Reason.taken(L10n.Scene.Register.Error.Item.username) + self.usernameErrorPrompt.value = MastodonRegisterViewModel.errorPromptAttributedString(for: text) + } + .store(in: &disposeBag) + + } } extension MastodonRegisterViewModel { diff --git a/Mastodon/Scene/Onboarding/ServerRules/MastodonServerRulesViewController.swift b/Mastodon/Scene/Onboarding/ServerRules/MastodonServerRulesViewController.swift index fb86e81e1..447896c22 100644 --- a/Mastodon/Scene/Onboarding/ServerRules/MastodonServerRulesViewController.swift +++ b/Mastodon/Scene/Onboarding/ServerRules/MastodonServerRulesViewController.swift @@ -204,7 +204,7 @@ extension MastodonServerRulesViewController { @objc private func confirmButtonPressed(_ sender: UIButton) { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) - let viewModel = MastodonRegisterViewModel(domain: self.viewModel.domain, authenticateInfo: self.viewModel.authenticateInfo, instance: self.viewModel.instance, applicationToken: self.viewModel.applicationToken) + let viewModel = MastodonRegisterViewModel(domain: self.viewModel.domain,context: self.context, authenticateInfo: self.viewModel.authenticateInfo, instance: self.viewModel.instance, applicationToken: self.viewModel.applicationToken) self.coordinator.present(scene: .mastodonRegister(viewModel: viewModel), from: self, transition: .show) } } diff --git a/Mastodon/Service/APIService/APIService+Account.swift b/Mastodon/Service/APIService/APIService+Account.swift index 04908514b..7638f2444 100644 --- a/Mastodon/Service/APIService/APIService+Account.swift +++ b/Mastodon/Service/APIService/APIService+Account.swift @@ -152,4 +152,17 @@ extension APIService { ) } + func accountLookup( + domain: String, + query: Mastodon.API.Account.AccountLookupQuery, + authorization: Mastodon.API.OAuth.Authorization + ) -> AnyPublisher, Error> { + return Mastodon.API.Account.lookupAccount( + session: session, + domain: domain, + query: query, + authorization: authorization + ) + } + } diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Account.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Account.swift index 0f98dbe05..78f338b6f 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Account.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Account.swift @@ -132,3 +132,54 @@ extension Mastodon.API.Account { } } + +extension Mastodon.API.Account { + static func accountsLookupEndpointURL(domain: String) -> URL { + return Mastodon.API.endpointURL(domain: domain).appendingPathComponent("accounts/lookup") + } + + public struct AccountLookupQuery: GetQuery { + + public var acct: String + + public init(acct: String) { + self.acct = acct + } + + var queryItems: [URLQueryItem]? { + var items: [URLQueryItem] = [] + items.append(URLQueryItem(name: "acct", value: acct)) + return items + } + } + + /// lookup account by acct. + /// + /// - Version: 3.3.1 + + /// - Parameters: + /// - session: `URLSession` + /// - domain: Mastodon instance domain. e.g. "example.com" + /// - query: `AccountInfoQuery` with account query information, + /// - authorization: user token + /// - Returns: `AnyPublisher` contains `Account` nested in the response + public static func lookupAccount( + session: URLSession, + domain: String, + query: AccountLookupQuery, + authorization: Mastodon.API.OAuth.Authorization? + ) -> AnyPublisher, Error> { + let request = Mastodon.API.get( + url: accountsLookupEndpointURL(domain: domain), + query: query, + authorization: authorization + ) + return session.dataTaskPublisher(for: request) + .tryMap { data, response in + let value = try Mastodon.API.decode(type: Mastodon.Entity.Account.self, from: data, response: response) + return Mastodon.Response.Content(value: value, response: response) + } + .eraseToAnyPublisher() + } + +} From 9768721247437a9780b6c6e944cce70fc0373c56 Mon Sep 17 00:00:00 2001 From: sunxiaojian Date: Thu, 29 Apr 2021 16:20:18 +0800 Subject: [PATCH 16/19] fix: the Core Data thread-safe issue --- .../APIService/APIService+Notification.swift | 16 ++++++++-------- .../API/Mastodon+API+Account+FollowRequest.swift | 12 ++++++------ 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/Mastodon/Service/APIService/APIService+Notification.swift b/Mastodon/Service/APIService/APIService+Notification.swift index a27aae2ae..6e8af70bc 100644 --- a/Mastodon/Service/APIService/APIService+Notification.swift +++ b/Mastodon/Service/APIService/APIService+Notification.swift @@ -28,15 +28,15 @@ extension APIService { ) .flatMap { response -> AnyPublisher, Error> in let log = OSLog.api - if query.maxID == nil { - let requestMastodonNotificationRequest = MastodonNotification.sortedFetchRequest - requestMastodonNotificationRequest.predicate = MastodonNotification.predicate(domain: domain, userID: userID) - let oldNotifications = self.backgroundManagedObjectContext.safeFetch(requestMastodonNotificationRequest) - oldNotifications.forEach { notification in - self.backgroundManagedObjectContext.delete(notification) - } - } return self.backgroundManagedObjectContext.performChanges { + if query.maxID == nil { + let requestMastodonNotificationRequest = MastodonNotification.sortedFetchRequest + requestMastodonNotificationRequest.predicate = MastodonNotification.predicate(domain: domain, userID: userID) + let oldNotifications = self.backgroundManagedObjectContext.safeFetch(requestMastodonNotificationRequest) + oldNotifications.forEach { notification in + self.backgroundManagedObjectContext.delete(notification) + } + } response.value.forEach { notification in let (mastodonUser, _) = APIService.CoreData.createOrMergeMastodonUser(into: self.backgroundManagedObjectContext, for: nil, in: domain, entity: notification.account, userCache: nil, networkDate: Date(), log: log) var status: Status? diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Account+FollowRequest.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Account+FollowRequest.swift index f08e888b5..004197143 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Account+FollowRequest.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Account+FollowRequest.swift @@ -11,13 +11,13 @@ import Combine // MARK: - Account credentials extension Mastodon.API.Account { - static func acceptFollowRequestEndpointURL(domain: String, userID: String) -> URL { + static func acceptFollowRequestEndpointURL(domain: String, userID: Mastodon.Entity.Account.ID) -> URL { return Mastodon.API.endpointURL(domain: domain).appendingPathComponent("follow_requests") .appendingPathComponent(userID) .appendingPathComponent("authorize") } - static func rejectFollowRequestEndpointURL(domain: String, userID: String) -> URL { + static func rejectFollowRequestEndpointURL(domain: String, userID: Mastodon.Entity.Account.ID) -> URL { return Mastodon.API.endpointURL(domain: domain).appendingPathComponent("follow_requests") .appendingPathComponent(userID) .appendingPathComponent("reject") @@ -34,12 +34,12 @@ extension Mastodon.API.Account { /// - session: `URLSession` /// - domain: Mastodon instance domain. e.g. "example.com" /// - userID: ID of the account in the database - /// - authorization: App token + /// - authorization: User token /// - Returns: `AnyPublisher` contains `Relationship` nested in the response public static func acceptFollowRequest( session: URLSession, domain: String, - userID: String, + userID: Mastodon.Entity.Account.ID, authorization: Mastodon.API.OAuth.Authorization ) -> AnyPublisher, Error> { let request = Mastodon.API.post( @@ -66,12 +66,12 @@ extension Mastodon.API.Account { /// - session: `URLSession` /// - domain: Mastodon instance domain. e.g. "example.com" /// - userID: ID of the account in the database - /// - authorization: App token + /// - authorization: User token /// - Returns: `AnyPublisher` contains `Relationship` nested in the response public static func rejectFollowRequest( session: URLSession, domain: String, - userID: String, + userID: Mastodon.Entity.Account.ID, authorization: Mastodon.API.OAuth.Authorization ) -> AnyPublisher, Error> { let request = Mastodon.API.post( From 40e62a8a439c407d166167741913734f276bd817 Mon Sep 17 00:00:00 2001 From: sunxiaojian Date: Thu, 29 Apr 2021 17:02:46 +0800 Subject: [PATCH 17/19] fix: change version of followRequest --- .../MastodonSDK/API/Mastodon+API+Account+FollowRequest.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Account+FollowRequest.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Account+FollowRequest.swift index 004197143..87c879ea0 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Account+FollowRequest.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Account+FollowRequest.swift @@ -27,7 +27,7 @@ extension Mastodon.API.Account { /// /// /// - Since: 0.0.0 - /// - Version: 3.0.0 + /// - Version: 3.3.0 /// # Reference /// [Document](https://docs.joinmastodon.org/methods/accounts/follow_requests/) /// - Parameters: @@ -59,7 +59,7 @@ extension Mastodon.API.Account { /// /// /// - Since: 0.0.0 - /// - Version: 3.0.0 + /// - Version: 3.3.0 /// # Reference /// [Document](https://docs.joinmastodon.org/methods/accounts/follow_requests/) /// - Parameters: From 1e5daf5a7714c4a85aa8e4807fa3dfb26ebcda3f Mon Sep 17 00:00:00 2001 From: sunxiaojian Date: Thu, 29 Apr 2021 16:12:04 +0800 Subject: [PATCH 18/19] fix: the race-condition issue in username checking --- .../Register/MastodonRegisterViewModel.swift | 56 +++++++++++-------- .../MastodonServerRulesViewController.swift | 2 +- .../API/Mastodon+API+Account.swift | 2 +- 3 files changed, 34 insertions(+), 26 deletions(-) diff --git a/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewModel.swift b/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewModel.swift index 919443f2d..5fd8c31b6 100644 --- a/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewModel.swift +++ b/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewModel.swift @@ -82,10 +82,36 @@ final class MastodonRegisterViewModel { .assign(to: \.value, on: usernameValidateState) .store(in: &disposeBag) - username.debounce(for: .milliseconds(300), scheduler: DispatchQueue.main).removeDuplicates() - .sink { [weak self] text in - self?.lookupAccount(by: text) + username + .filter { !$0.isEmpty } + .debounce(for: .milliseconds(300), scheduler: DispatchQueue.main) + .removeDuplicates() + .compactMap { [weak self] text -> AnyPublisher, Error>, Never>? in + guard let self = self else { return nil } + let query = Mastodon.API.Account.AccountLookupQuery(acct: text) + return context.apiService.accountLookup(domain: domain, query: query, authorization: self.applicationAuthorization) + .map { + response -> Result, Error>in + Result.success(response) + } + .catch { error in + Just(Result.failure(error)) + } + .eraseToAnyPublisher() } + .switchToLatest() + .sink(receiveCompletion: { _ in + + }, receiveValue: { [weak self] result in + guard let self = self else { return } + switch result { + case .success: + let text = L10n.Scene.Register.Error.Reason.taken(L10n.Scene.Register.Error.Item.username) + self.usernameErrorPrompt.value = MastodonRegisterViewModel.errorPromptAttributedString(for: text) + case .failure: + break + } + }) .store(in: &disposeBag) usernameValidateState @@ -133,7 +159,8 @@ final class MastodonRegisterViewModel { let error = error as? Mastodon.API.Error let mastodonError = error?.mastodonError if case let .generic(genericMastodonError) = mastodonError, - let details = genericMastodonError.details { + let details = genericMastodonError.details + { self.usernameErrorPrompt.value = details.usernameErrorDescriptions.first.flatMap { MastodonRegisterViewModel.errorPromptAttributedString(for: $0) } self.emailErrorPrompt.value = details.emailErrorDescriptions.first.flatMap { MastodonRegisterViewModel.errorPromptAttributedString(for: $0) } self.passwordErrorPrompt.value = details.passwordErrorDescriptions.first.flatMap { MastodonRegisterViewModel.errorPromptAttributedString(for: $0) } @@ -157,29 +184,12 @@ final class MastodonRegisterViewModel { Publishers.CombineLatest( publisherOne, - approvalRequired ? reasonValidateState.map {$0 == .valid}.eraseToAnyPublisher() : Just(true).eraseToAnyPublisher() + approvalRequired ? reasonValidateState.map { $0 == .valid }.eraseToAnyPublisher() : Just(true).eraseToAnyPublisher() ) .map { $0 && $1 } .assign(to: \.value, on: isAllValid) .store(in: &disposeBag) } - - func lookupAccount(by acct: String) { - if acct.isEmpty { - return - } - let query = Mastodon.API.Account.AccountLookupQuery(acct: acct) - context.apiService.accountLookup(domain: domain, query: query, authorization: applicationAuthorization) - .sink { _ in - - } receiveValue: { [weak self] account in - guard let self = self else { return } - let text = L10n.Scene.Register.Error.Reason.taken(L10n.Scene.Register.Error.Item.username) - self.usernameErrorPrompt.value = MastodonRegisterViewModel.errorPromptAttributedString(for: text) - } - .store(in: &disposeBag) - - } } extension MastodonRegisterViewModel { @@ -191,7 +201,6 @@ extension MastodonRegisterViewModel { } extension MastodonRegisterViewModel { - static func isValidEmail(_ email: String) -> Bool { let emailRegEx = "[A-Z0-9a-z._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,64}" @@ -241,5 +250,4 @@ extension MastodonRegisterViewModel { return attributeString } - } diff --git a/Mastodon/Scene/Onboarding/ServerRules/MastodonServerRulesViewController.swift b/Mastodon/Scene/Onboarding/ServerRules/MastodonServerRulesViewController.swift index 447896c22..d8638421a 100644 --- a/Mastodon/Scene/Onboarding/ServerRules/MastodonServerRulesViewController.swift +++ b/Mastodon/Scene/Onboarding/ServerRules/MastodonServerRulesViewController.swift @@ -204,7 +204,7 @@ extension MastodonServerRulesViewController { @objc private func confirmButtonPressed(_ sender: UIButton) { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) - let viewModel = MastodonRegisterViewModel(domain: self.viewModel.domain,context: self.context, authenticateInfo: self.viewModel.authenticateInfo, instance: self.viewModel.instance, applicationToken: self.viewModel.applicationToken) + let viewModel = MastodonRegisterViewModel(domain: self.viewModel.domain, context: self.context, authenticateInfo: self.viewModel.authenticateInfo, instance: self.viewModel.instance, applicationToken: self.viewModel.applicationToken) self.coordinator.present(scene: .mastodonRegister(viewModel: viewModel), from: self, transition: .show) } } diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Account.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Account.swift index 78f338b6f..d1c5458c4 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Account.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Account.swift @@ -161,7 +161,7 @@ extension Mastodon.API.Account { /// - session: `URLSession` /// - domain: Mastodon instance domain. e.g. "example.com" /// - query: `AccountInfoQuery` with account query information, - /// - authorization: user token + /// - authorization: app token /// - Returns: `AnyPublisher` contains `Account` nested in the response public static func lookupAccount( session: URLSession, From ca320c555aa22d7322ffd35304c85ddbf7d32f91 Mon Sep 17 00:00:00 2001 From: sunxiaojian Date: Thu, 29 Apr 2021 19:24:03 +0800 Subject: [PATCH 19/19] fix: code format --- .../Onboarding/Register/MastodonRegisterViewModel.swift | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewModel.swift b/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewModel.swift index 5fd8c31b6..309204a9a 100644 --- a/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewModel.swift +++ b/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewModel.swift @@ -91,7 +91,7 @@ final class MastodonRegisterViewModel { let query = Mastodon.API.Account.AccountLookupQuery(acct: text) return context.apiService.accountLookup(domain: domain, query: query, authorization: self.applicationAuthorization) .map { - response -> Result, Error>in + response -> Result, Error> in Result.success(response) } .catch { error in @@ -100,9 +100,7 @@ final class MastodonRegisterViewModel { .eraseToAnyPublisher() } .switchToLatest() - .sink(receiveCompletion: { _ in - - }, receiveValue: { [weak self] result in + .sink { [weak self] result in guard let self = self else { return } switch result { case .success: @@ -111,7 +109,7 @@ final class MastodonRegisterViewModel { case .failure: break } - }) + } .store(in: &disposeBag) usernameValidateState