diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index b42d95944..067ac0559 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -62,7 +62,6 @@ 2AE202AC297FE19100F66E55 /* FollowersCountIntentHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2AE202AB297FE19100F66E55 /* FollowersCountIntentHandler.swift */; }; 2AE202AD297FE1CD00F66E55 /* WidgetExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A72813E297EC762004138C5 /* WidgetExtension.swift */; }; 2AE244482927831100BDBF7C /* UIImage+SFSymbols.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2AE244472927831100BDBF7C /* UIImage+SFSymbols.swift */; }; - 2AF2E7BF2B19DC6E00D98917 /* FileManager+Timeline.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2AF2E7BE2B19DC6E00D98917 /* FileManager+Timeline.swift */; }; 2D198643261BF09500F0B013 /* SearchResultItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D198642261BF09500F0B013 /* SearchResultItem.swift */; }; 2D198649261C0B8500F0B013 /* SearchResultSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D198648261C0B8500F0B013 /* SearchResultSection.swift */; }; 2D206B8625F5FB0900143C56 /* Double.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D206B8525F5FB0900143C56 /* Double.swift */; }; @@ -135,6 +134,7 @@ D8099078294BC8A30050219F /* PrivacyTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8099077294BC8A30050219F /* PrivacyTableViewController.swift */; }; D809907A294BC9390050219F /* PrivacyTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8099079294BC9390050219F /* PrivacyTableViewCell.swift */; }; D809907C294D25510050219F /* PrivacyViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D809907B294D25510050219F /* PrivacyViewModel.swift */; }; + D80F627C2B5C32C500877059 /* NotificationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D80F627A2B5C32C500877059 /* NotificationView.swift */; }; D81439862AD415DE0071A88F /* AboutInstance.swift in Sources */ = {isa = PBXBuildFile; fileRef = D81439852AD415DE0071A88F /* AboutInstance.swift */; }; D81439882AD450A40071A88F /* AboutInstanceTableViewDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = D81439872AD450A40071A88F /* AboutInstanceTableViewDataSource.swift */; }; D81A22752AB4643200905D71 /* SearchResultsOverviewTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D81A22742AB4643200905D71 /* SearchResultsOverviewTableViewController.swift */; }; @@ -164,14 +164,12 @@ D886FBD329DF710F00272017 /* WelcomeSeparatorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D886FBD229DF710F00272017 /* WelcomeSeparatorView.swift */; }; D8916DC029211BE500124085 /* ContentSizedTableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8916DBF29211BE500124085 /* ContentSizedTableView.swift */; }; D8A6AB6C291C5136003AB663 /* MastodonLoginViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8A6AB6B291C5136003AB663 /* MastodonLoginViewController.swift */; }; - D8AC98762B0F61680045EC2B /* FileManager+SearchHistory.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8AC98752B0F61680045EC2B /* FileManager+SearchHistory.swift */; }; - D8AC98792B0F622B0045EC2B /* SearchHistory.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8AC98782B0F622B0045EC2B /* SearchHistory.swift */; }; D8B5E4EE2A4EB8930008970C /* NotificationSettingTableViewToggleCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8B5E4ED2A4EB8920008970C /* NotificationSettingTableViewToggleCell.swift */; }; D8B5E4F02A4EB8A00008970C /* NotificationSettingTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8B5E4EF2A4EB8A00008970C /* NotificationSettingTableViewCell.swift */; }; D8B5E4F22A4EBCF90008970C /* NotificationSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8B5E4F12A4EBCF90008970C /* NotificationSettings.swift */; }; D8B5E4F42A4ED0240008970C /* NotificationSettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8B5E4F32A4ED0240008970C /* NotificationSettingsViewModel.swift */; }; D8BE30B32A179E26006B8270 /* SuggestionAccountTableViewFooter.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8BE30B22A179E26006B8270 /* SuggestionAccountTableViewFooter.swift */; }; - D8BEBCB62A1B7FFD0004F475 /* SuggestionAccountTableViewCell+ViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8BEBCB52A1B7FFD0004F475 /* SuggestionAccountTableViewCell+ViewModel.swift */; }; + D8CF45832B50893900C84D70 /* Tab.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8CF45822B50893900C84D70 /* Tab.swift */; }; D8D688F62AB869CB000F651A /* SearchResultsProfileTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8D688F52AB869CB000F651A /* SearchResultsProfileTableViewCell.swift */; }; D8D688F92AB8B970000F651A /* SearchResultOverviewCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8D688F82AB8B970000F651A /* SearchResultOverviewCoordinator.swift */; }; D8E5C346296DAB84007E76A7 /* DataSourceFacade+Status+History.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8E5C345296DAB84007E76A7 /* DataSourceFacade+Status+History.swift */; }; @@ -410,7 +408,6 @@ DB98EB6027B10E150082E365 /* ReportCommentTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB98EB5F27B10E150082E365 /* ReportCommentTableViewCell.swift */; }; DB98EB6227B215EB0082E365 /* ReportResultViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB98EB6127B215EB0082E365 /* ReportResultViewController.swift */; }; DB98EB6527B216500082E365 /* ReportResultViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB98EB6427B216500082E365 /* ReportResultViewModel.swift */; }; - DB98EB6927B21A7C0082E365 /* ReportResultActionTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB98EB6827B21A7C0082E365 /* ReportResultActionTableViewCell.swift */; }; DB9D6BE925E4F5340051B173 /* SearchViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9D6BE825E4F5340051B173 /* SearchViewController.swift */; }; DB9D6BF825E4F5690051B173 /* NotificationViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9D6BF725E4F5690051B173 /* NotificationViewController.swift */; }; DB9D6BFF25E4F5940051B173 /* ProfileViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9D6BFE25E4F5940051B173 /* ProfileViewController.swift */; }; @@ -429,7 +426,6 @@ DBA94436265CBB7400C537E1 /* ProfileFieldItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBA94435265CBB7400C537E1 /* ProfileFieldItem.swift */; }; DBA9443E265CFA6400C537E1 /* ProfileFieldCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBA9443D265CFA6400C537E1 /* ProfileFieldCollectionViewCell.swift */; }; DBABE3EC25ECAC4B00879EE5 /* WelcomeIllustrationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBABE3EB25ECAC4B00879EE5 /* WelcomeIllustrationView.swift */; }; - DBAE3FAF26172FC0004B8251 /* RemoteProfileViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBAE3FAE26172FC0004B8251 /* RemoteProfileViewModel.swift */; }; DBB3BA2A26A81C020004F2D4 /* FLAnimatedImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBB3BA2926A81C020004F2D4 /* FLAnimatedImageView.swift */; }; DBB3BA2B26A81D060004F2D4 /* FLAnimatedImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBB3BA2926A81C020004F2D4 /* FLAnimatedImageView.swift */; }; DBB45B5627B39FC9002DC5A7 /* MediaPreviewVideoViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBB45B5527B39FC9002DC5A7 /* MediaPreviewVideoViewController.swift */; }; @@ -443,7 +439,6 @@ DBB525502611ED6D002F1F29 /* ProfileHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBB5254F2611ED6D002F1F29 /* ProfileHeaderView.swift */; }; DBB525562611EDCA002F1F29 /* UserTimelineViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBB525552611EDCA002F1F29 /* UserTimelineViewModel.swift */; }; DBB5255E2611F07A002F1F29 /* ProfileViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBB5255D2611F07A002F1F29 /* ProfileViewModel.swift */; }; - DBB525642612C988002F1F29 /* MeProfileViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBB525632612C988002F1F29 /* MeProfileViewModel.swift */; }; DBB8AB4626AECDE200F6D281 /* SendPostIntentHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBB8AB4526AECDE200F6D281 /* SendPostIntentHandler.swift */; }; DBB8AB4F26AED13F00F6D281 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = DB427DDE25BAA00100D1B89D /* Assets.xcassets */; }; DBB9759C262462E1004620BD /* ThreadMetaView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBB9759B262462E1004620BD /* ThreadMetaView.swift */; }; @@ -700,7 +695,6 @@ 2AE202A9297FDDF500F66E55 /* WidgetExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = WidgetExtension.entitlements; sourceTree = ""; }; 2AE202AB297FE19100F66E55 /* FollowersCountIntentHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FollowersCountIntentHandler.swift; sourceTree = ""; }; 2AE244472927831100BDBF7C /* UIImage+SFSymbols.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIImage+SFSymbols.swift"; sourceTree = ""; }; - 2AF2E7BE2B19DC6E00D98917 /* FileManager+Timeline.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FileManager+Timeline.swift"; sourceTree = ""; }; 2D198642261BF09500F0B013 /* SearchResultItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultItem.swift; sourceTree = ""; }; 2D198648261C0B8500F0B013 /* SearchResultSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultSection.swift; sourceTree = ""; }; 2D206B8525F5FB0900143C56 /* Double.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Double.swift; sourceTree = ""; }; @@ -794,6 +788,7 @@ D8099077294BC8A30050219F /* PrivacyTableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrivacyTableViewController.swift; sourceTree = ""; }; D8099079294BC9390050219F /* PrivacyTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrivacyTableViewCell.swift; sourceTree = ""; }; D809907B294D25510050219F /* PrivacyViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrivacyViewModel.swift; sourceTree = ""; }; + D80F627A2B5C32C500877059 /* NotificationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationView.swift; sourceTree = ""; }; D81439852AD415DE0071A88F /* AboutInstance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutInstance.swift; sourceTree = ""; }; D81439872AD450A40071A88F /* AboutInstanceTableViewDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutInstanceTableViewDataSource.swift; sourceTree = ""; }; D81A22742AB4643200905D71 /* SearchResultsOverviewTableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultsOverviewTableViewController.swift; sourceTree = ""; }; @@ -839,14 +834,12 @@ D8A6FE6429325F5900666A47 /* StringsConvertor */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = StringsConvertor; sourceTree = ""; }; D8A6FE6529325F5900666A47 /* ios-infoPlist.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "ios-infoPlist.json"; sourceTree = ""; }; D8A6FE6629325F5900666A47 /* Localizable.stringsdict */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; path = Localizable.stringsdict; sourceTree = ""; }; - D8AC98752B0F61680045EC2B /* FileManager+SearchHistory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FileManager+SearchHistory.swift"; sourceTree = ""; }; - D8AC98782B0F622B0045EC2B /* SearchHistory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchHistory.swift; sourceTree = ""; }; D8B5E4ED2A4EB8920008970C /* NotificationSettingTableViewToggleCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NotificationSettingTableViewToggleCell.swift; sourceTree = ""; }; D8B5E4EF2A4EB8A00008970C /* NotificationSettingTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationSettingTableViewCell.swift; sourceTree = ""; }; D8B5E4F12A4EBCF90008970C /* NotificationSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationSettings.swift; sourceTree = ""; }; D8B5E4F32A4ED0240008970C /* NotificationSettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationSettingsViewModel.swift; sourceTree = ""; }; D8BE30B22A179E26006B8270 /* SuggestionAccountTableViewFooter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionAccountTableViewFooter.swift; sourceTree = ""; }; - D8BEBCB52A1B7FFD0004F475 /* SuggestionAccountTableViewCell+ViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SuggestionAccountTableViewCell+ViewModel.swift"; sourceTree = ""; }; + D8CF45822B50893900C84D70 /* Tab.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tab.swift; sourceTree = ""; }; D8D688F52AB869CB000F651A /* SearchResultsProfileTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultsProfileTableViewCell.swift; sourceTree = ""; }; D8D688F82AB8B970000F651A /* SearchResultOverviewCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultOverviewCoordinator.swift; sourceTree = ""; }; D8E5C345296DAB84007E76A7 /* DataSourceFacade+Status+History.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DataSourceFacade+Status+History.swift"; sourceTree = ""; }; @@ -1140,7 +1133,6 @@ DB98EB5F27B10E150082E365 /* ReportCommentTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportCommentTableViewCell.swift; sourceTree = ""; }; DB98EB6127B215EB0082E365 /* ReportResultViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportResultViewController.swift; sourceTree = ""; }; DB98EB6427B216500082E365 /* ReportResultViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportResultViewModel.swift; sourceTree = ""; }; - DB98EB6827B21A7C0082E365 /* ReportResultActionTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportResultActionTableViewCell.swift; sourceTree = ""; }; DB9D6BE825E4F5340051B173 /* SearchViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchViewController.swift; sourceTree = ""; }; DB9D6BF725E4F5690051B173 /* NotificationViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationViewController.swift; sourceTree = ""; }; DB9D6BFE25E4F5940051B173 /* ProfileViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileViewController.swift; sourceTree = ""; }; @@ -1171,7 +1163,6 @@ DBA94435265CBB7400C537E1 /* ProfileFieldItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileFieldItem.swift; sourceTree = ""; }; DBA9443D265CFA6400C537E1 /* ProfileFieldCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileFieldCollectionViewCell.swift; sourceTree = ""; }; DBABE3EB25ECAC4B00879EE5 /* WelcomeIllustrationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WelcomeIllustrationView.swift; sourceTree = ""; }; - DBAE3FAE26172FC0004B8251 /* RemoteProfileViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteProfileViewModel.swift; sourceTree = ""; }; DBB3BA2926A81C020004F2D4 /* FLAnimatedImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FLAnimatedImageView.swift; sourceTree = ""; }; DBB45B5527B39FC9002DC5A7 /* MediaPreviewVideoViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPreviewVideoViewController.swift; sourceTree = ""; }; DBB45B5827B39FE4002DC5A7 /* MediaPreviewVideoViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPreviewVideoViewModel.swift; sourceTree = ""; }; @@ -1184,7 +1175,6 @@ DBB5254F2611ED6D002F1F29 /* ProfileHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileHeaderView.swift; sourceTree = ""; }; DBB525552611EDCA002F1F29 /* UserTimelineViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserTimelineViewModel.swift; sourceTree = ""; }; DBB5255D2611F07A002F1F29 /* ProfileViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileViewModel.swift; sourceTree = ""; }; - DBB525632612C988002F1F29 /* MeProfileViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeProfileViewModel.swift; sourceTree = ""; }; DBB8AB4526AECDE200F6D281 /* SendPostIntentHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SendPostIntentHandler.swift; sourceTree = ""; }; DBB9759B262462E1004620BD /* ThreadMetaView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadMetaView.swift; sourceTree = ""; }; DBBE1B4425F3474B0081417A /* MastodonPickServerAppearance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonPickServerAppearance.swift; sourceTree = ""; }; @@ -1555,7 +1545,6 @@ children = ( DB6746EA278ED8B0008A6B94 /* PollOptionView+Configuration.swift */, DB0FCB992797F7AD006C02E2 /* UserView+Configuration.swift */, - DB63F776279A9A2A00455B82 /* NotificationView+Configuration.swift */, 2D694A7325F9EB4E0038ADDC /* ContentWarningOverlayView.swift */, 2D571B2E26004EC000540450 /* NavigationBarProgressView.swift */, DBE3CDCE261C42ED00430CC6 /* TimelineHeaderView.swift */, @@ -1724,7 +1713,6 @@ isa = PBXGroup; children = ( 2DAC9E45262FC9FD0062E1A6 /* SuggestionAccountTableViewCell.swift */, - D8BEBCB52A1B7FFD0004F475 /* SuggestionAccountTableViewCell+ViewModel.swift */, D8BE30B22A179E26006B8270 /* SuggestionAccountTableViewFooter.swift */, ); path = "TableView-Components"; @@ -1833,6 +1821,15 @@ path = Privacy; sourceTree = ""; }; + D80F627E2B5C32E400877059 /* NotificationView */ = { + isa = PBXGroup; + children = ( + D80F627A2B5C32C500877059 /* NotificationView.swift */, + DB63F776279A9A2A00455B82 /* NotificationView+Configuration.swift */, + ); + path = NotificationView; + sourceTree = ""; + }; D81A22732AB4641F00905D71 /* Search Results Overview */ = { isa = PBXGroup; children = ( @@ -1898,24 +1895,6 @@ path = Localization; sourceTree = ""; }; - D8AC98742B0F615E0045EC2B /* Persistence */ = { - isa = PBXGroup; - children = ( - D8AC98772B0F62230045EC2B /* Model */, - D8AC98752B0F61680045EC2B /* FileManager+SearchHistory.swift */, - 2AF2E7BE2B19DC6E00D98917 /* FileManager+Timeline.swift */, - ); - path = Persistence; - sourceTree = ""; - }; - D8AC98772B0F62230045EC2B /* Model */ = { - isa = PBXGroup; - children = ( - D8AC98782B0F622B0045EC2B /* SearchHistory.swift */, - ); - path = Model; - sourceTree = ""; - }; D8E5C347296DB896007E76A7 /* Edit History */ = { isa = PBXGroup; children = ( @@ -2201,7 +2180,6 @@ DB427DD425BAA00100D1B89D /* Mastodon */ = { isa = PBXGroup; children = ( - D8AC98742B0F615E0045EC2B /* Persistence */, DB89BA1025C10FF5008580ED /* Mastodon.entitlements */, DB427DE325BAA00100D1B89D /* Info.plist */, 2D76319C25C151DE00929FB9 /* Diffable */, @@ -2594,6 +2572,7 @@ DB03A794272A981400EE37C5 /* ContentSplitViewController.swift */, DB852D1A26FAED0100FC9D81 /* Sidebar */, DB8AF54E25C13703002E6C99 /* MainTab */, + D8CF45822B50893900C84D70 /* Tab.swift */, ); path = Root; sourceTree = ""; @@ -2707,7 +2686,6 @@ DB98EB4827B0F0CD0082E365 /* ReportStatusTableViewCell.swift */, DB98EB4B27B0F2BC0082E365 /* ReportStatusTableViewCell+ViewModel.swift */, DB98EB5F27B10E150082E365 /* ReportCommentTableViewCell.swift */, - DB98EB6827B21A7C0082E365 /* ReportResultActionTableViewCell.swift */, ); path = Cell; sourceTree = ""; @@ -2766,6 +2744,7 @@ children = ( DB63F765279A5E5600455B82 /* NotificationTimeline */, 2D35237F26256F470031AF25 /* Cell */, + D80F627E2B5C32E400877059 /* NotificationView */, DB9D6BF725E4F5690051B173 /* NotificationViewController.swift */, 2D607AD726242FC500B70763 /* NotificationViewModel.swift */, ); @@ -2788,8 +2767,6 @@ DBFEEC97279BDC6A004F81DD /* About */, DB9D6BFE25E4F5940051B173 /* ProfileViewController.swift */, DBB5255D2611F07A002F1F29 /* ProfileViewModel.swift */, - DBAE3FAE26172FC0004B8251 /* RemoteProfileViewModel.swift */, - DBB525632612C988002F1F29 /* MeProfileViewModel.swift */, ); path = Profile; sourceTree = ""; @@ -3755,7 +3732,6 @@ 0F202201261326E6000C64BF /* HashtagTimelineViewModel.swift in Sources */, D81A94172B07A1D30067A19D /* ProfileCardView+Configuration.swift in Sources */, DB63F7452799056400455B82 /* HashtagTableViewCell.swift in Sources */, - DBAE3FAF26172FC0004B8251 /* RemoteProfileViewModel.swift in Sources */, D82BD7552ABC73AF009A374A /* NotificationPolicyTableViewCell.swift in Sources */, DB3EA8EB281B7E0700598866 /* DiscoveryCommunityViewModel+State.swift in Sources */, DB0F8150264D1E2500F2A12B /* PickServerLoaderTableViewCell.swift in Sources */, @@ -3821,6 +3797,7 @@ DB3E6FEF2806D82600B035AE /* DiscoveryNewsViewModel.swift in Sources */, DBC7A672260C897100E57475 /* StatusContentWarningEditorView.swift in Sources */, DB6B750427300B4000C70B6E /* TimelineFooterTableViewCell.swift in Sources */, + D80F627C2B5C32C500877059 /* NotificationView.swift in Sources */, DB98EB4C27B0F2BC0082E365 /* ReportStatusTableViewCell+ViewModel.swift in Sources */, DB852D1F26FB037800FC9D81 /* SidebarViewModel.swift in Sources */, DB63F769279A5EBB00455B82 /* NotificationTimelineViewModel+Diffable.swift in Sources */, @@ -3857,7 +3834,6 @@ DB1E346825F518E20079D7DF /* CategoryPickerSection.swift in Sources */, DB7274F4273BB9B200577D95 /* ListBatchFetchViewModel.swift in Sources */, DB0618052785A73D0030EE79 /* RegisterItem.swift in Sources */, - DBB525642612C988002F1F29 /* MeProfileViewModel.swift in Sources */, DB3EA8EF281B837000598866 /* DiscoveryCommunityViewController+DataSourceProvider.swift in Sources */, DB6B74EF272FB55000C70B6E /* FollowerListViewController.swift in Sources */, DB4AA6B327BA34B6009EC082 /* CellFrameCacheContainer.swift in Sources */, @@ -3871,7 +3847,6 @@ 2A1FE47C2938BB2600784BF1 /* FollowedTagsViewModel+DiffableDataSource.swift in Sources */, DB852D1C26FB021500FC9D81 /* RootSplitViewController.swift in Sources */, DB697DD1278F4871004EF2F7 /* AutoGenerateTableViewDelegate.swift in Sources */, - D8AC98792B0F622B0045EC2B /* SearchHistory.swift in Sources */, DB02CDBF2625AE5000D0A2AF /* AdaptiveUserInterfaceStyleBarButtonItem.swift in Sources */, DB3E6FFA2807C47900B035AE /* DiscoveryForYouViewModel+Diffable.swift in Sources */, DB1FD44425F26CCC004CFCFC /* PickServerSection.swift in Sources */, @@ -3879,7 +3854,6 @@ DB6180E626391B550018D199 /* MediaPreviewTransitionController.swift in Sources */, DB0FCB922796DE19006C02E2 /* TrendSectionHeaderCollectionReusableView.swift in Sources */, DB2F073525E8ECF000957B2D /* AuthenticationViewModel.swift in Sources */, - D8AC98762B0F61680045EC2B /* FileManager+SearchHistory.swift in Sources */, DB63F779279ABF9C00455B82 /* DataSourceFacade+Reblog.swift in Sources */, DB4F0963269ED06300D62E92 /* SearchResultViewController.swift in Sources */, DB603111279EB38500A935FE /* DataSourceFacade+Mute.swift in Sources */, @@ -3918,7 +3892,6 @@ 2D76319F25C1521200929FB9 /* StatusSection.swift in Sources */, DBA5A53526F0A36A00CACBAA /* AddAccountTableViewCell.swift in Sources */, 2D35237A26256D920031AF25 /* NotificationSection.swift in Sources */, - DB98EB6927B21A7C0082E365 /* ReportResultActionTableViewCell.swift in Sources */, DB9282B225F3222800823B15 /* PickServerEmptyStateView.swift in Sources */, DB697DDF278F524F004EF2F7 /* DataSourceFacade+Profile.swift in Sources */, DB1FD45025F26FA1004CFCFC /* MastodonPickServerViewModel+Diffable.swift in Sources */, @@ -3948,7 +3921,6 @@ DB6B74FC272FF55800C70B6E /* UserSection.swift in Sources */, DB0FCB862796BDA1006C02E2 /* SearchSection.swift in Sources */, DB1D61CF26F1B33600DA8662 /* WelcomeViewModel.swift in Sources */, - D8BEBCB62A1B7FFD0004F475 /* SuggestionAccountTableViewCell+ViewModel.swift in Sources */, D8B5E4F42A4ED0240008970C /* NotificationSettingsViewModel.swift in Sources */, DBD376B2269302A4007FEC24 /* UITableViewCell.swift in Sources */, DB4F0966269ED52200D62E92 /* SearchResultViewModel.swift in Sources */, @@ -3984,7 +3956,6 @@ DB9F58EC26EF435000E7BBE9 /* AccountViewController.swift in Sources */, D8318A802A4466D300C0FB73 /* SettingsCoordinator.swift in Sources */, DB3E6FF12806D96900B035AE /* DiscoveryNewsViewModel+Diffable.swift in Sources */, - 2AF2E7BF2B19DC6E00D98917 /* FileManager+Timeline.swift in Sources */, DB3E6FF82807C45300B035AE /* DiscoveryForYouViewModel.swift in Sources */, DB0F9D56283EB46200379AF8 /* ProfileHeaderView+Configuration.swift in Sources */, DB6746F0278F463B008A6B94 /* AutoGenerateProtocolDelegate.swift in Sources */, @@ -4017,6 +3988,7 @@ 2A3F6FE5292F6E44002E6DA7 /* FollowedTagsTableViewCell.swift in Sources */, C24C97032922F30500BAE8CB /* RefreshControl.swift in Sources */, DB023D2A27A0FE5C005AC798 /* DataSourceProvider+NotificationTableViewCellDelegate.swift in Sources */, + D8CF45832B50893900C84D70 /* Tab.swift in Sources */, D8363B1629469CE200A74079 /* OnboardingNextView.swift in Sources */, DB98EB6027B10E150082E365 /* ReportCommentTableViewCell.swift in Sources */, DB0FCB962797E6C2006C02E2 /* SearchResultViewController+DataSourceProvider.swift in Sources */, diff --git a/Mastodon/Coordinator/SceneCoordinator.swift b/Mastodon/Coordinator/SceneCoordinator.swift index f85a7c96f..997a15aeb 100644 --- a/Mastodon/Coordinator/SceneCoordinator.swift +++ b/Mastodon/Coordinator/SceneCoordinator.swift @@ -46,12 +46,13 @@ final public class SceneCoordinator { self.appContext = appContext scene.session.sceneCoordinator = self - + appContext.notificationService.requestRevealNotificationPublisher .receive(on: DispatchQueue.main) - .sink(receiveValue: { [weak self] pushNotification in - guard let self = self else { return } - Task { + .sink(receiveValue: { + [weak self] pushNotification in + guard let self else { return } + Task { @MainActor in guard let currentActiveAuthenticationBox = self.authContext?.mastodonAuthenticationBox else { return } let accessToken = pushNotification.accessToken // use raw accessToken value without normalize if currentActiveAuthenticationBox.userAuthorization.accessToken == accessToken { @@ -67,54 +68,76 @@ final public class SceneCoordinator { let userID = authentication.userID let isSuccess = try await appContext.authenticationService.activeMastodonUser(domain: domain, userID: userID) guard isSuccess else { return } - + self.setup() try await Task.sleep(nanoseconds: .second * 1) - + // redirect to notifications tab self.switchToTabBar(tab: .notifications) - - // Delay in next run loop - DispatchQueue.main.async { [weak self] in - guard let self = self else { return } - - // Note: - // show (push) on phone and pad - let from: UIViewController? = { - if let splitViewController = self.splitViewController { - if splitViewController.compactMainTabBarViewController.topMost?.view.window != nil { - // compact - return splitViewController.compactMainTabBarViewController.topMost - } else { - // expand - return splitViewController.contentSplitViewController.mainTabBarController.topMost - } + + // Note: + // show (push) on phone and pad + let from: UIViewController? = { + if let splitViewController = self.splitViewController { + if splitViewController.compactMainTabBarViewController.topMost?.view.window != nil { + // compact + return splitViewController.compactMainTabBarViewController.topMost } else { - return self.tabBarController.topMost + // expand + return splitViewController.contentSplitViewController.mainTabBarController.topMost } - }() - - // show notification related content - guard let type = Mastodon.Entity.Notification.NotificationType(rawValue: pushNotification.notificationType) else { return } - guard let authContext = self.authContext else { return } - let notificationID = String(pushNotification.notificationID) - - switch type { - case .follow: - let profileViewModel = RemoteProfileViewModel(context: appContext, authContext: authContext, notificationID: notificationID) - _ = self.present(scene: .profile(viewModel: profileViewModel), from: from, transition: .show) - case .followRequest: - // do nothing - break - case .mention, .reblog, .favourite, .poll, .status: - let threadViewModel = RemoteThreadViewModel(context: appContext, authContext: authContext, notificationID: notificationID) - _ = self.present(scene: .thread(viewModel: threadViewModel), from: from, transition: .show) - case ._other: - assertionFailure() - break + } else { + return self.tabBarController.topMost } - } // end DispatchQueue.main.async - + }() + + // show notification related content + guard let type = Mastodon.Entity.Notification.NotificationType(rawValue: pushNotification.notificationType) else { return } + guard let authContext = self.authContext else { return } + guard let me = authContext.mastodonAuthenticationBox.authentication.account() else { return } + let notificationID = String(pushNotification.notificationID) + + switch type { + case .follow: + let account = try await appContext.apiService.notification( + notificationID: notificationID, + authenticationBox: authContext.mastodonAuthenticationBox + ).value.account + + let relationship = try await appContext.apiService.relationship(forAccounts: [account], authenticationBox: authContext.mastodonAuthenticationBox).value.first + + let profileViewModel = ProfileViewModel( + context: appContext, + authContext: authContext, + account: account, + relationship: relationship, + me: me + ) + _ = self.present( + scene: .profile(viewModel: profileViewModel), + from: from, + transition: .show + ) + case .followRequest: + // do nothing + break + case .mention, .reblog, .favourite, .poll, .status: + let threadViewModel = RemoteThreadViewModel( + context: appContext, + authContext: authContext, + notificationID: notificationID + ) + _ = self.present( + scene: .thread(viewModel: threadViewModel), + from: from, + transition: .show + ) + + case ._other: + assertionFailure() + break + } + } catch { assertionFailure(error.localizedDescription) return @@ -140,7 +163,7 @@ extension SceneCoordinator { case activityViewControllerPresent(animated: Bool, completion: (() -> Void)? = nil) case none } - + enum Scene { // onboarding case welcome @@ -357,7 +380,7 @@ extension SceneCoordinator { return viewController } - func switchToTabBar(tab: MainTabBarController.Tab) { + func switchToTabBar(tab: Tab) { splitViewController?.contentSplitViewController.currentSupplementaryTab = tab splitViewController?.compactMainTabBarViewController.selectedIndex = tab.rawValue @@ -472,9 +495,7 @@ private extension SceneCoordinator { _viewController.viewModel = viewModel viewController = _viewController case .report(let viewModel): - let _viewController = ReportViewController() - _viewController.viewModel = viewModel - viewController = _viewController + viewController = ReportViewController(viewModel: viewModel) case .reportServerRules(let viewModel): let _viewController = ReportServerRulesViewController() _viewController.viewModel = viewModel diff --git a/Mastodon/Diffable/Notification/NotificationSection.swift b/Mastodon/Diffable/Notification/NotificationSection.swift index 0b446336f..7965ddc0a 100644 --- a/Mastodon/Diffable/Notification/NotificationSection.swift +++ b/Mastodon/Diffable/Notification/NotificationSection.swift @@ -73,9 +73,6 @@ extension NotificationSection { viewModel: NotificationTableViewCell.ViewModel, configuration: Configuration ) { - cell.notificationView.viewModel.context = context - cell.notificationView.viewModel.authContext = configuration.authContext - StatusSection.setupStatusPollDataSource( context: context, authContext: configuration.authContext, @@ -91,7 +88,8 @@ extension NotificationSection { cell.configure( tableView: tableView, viewModel: viewModel, - delegate: configuration.notificationTableViewCellDelegate + delegate: configuration.notificationTableViewCellDelegate, + authenticationBox: configuration.authContext.mastodonAuthenticationBox ) cell.notificationView.statusView.viewModel.filterContext = configuration.filterContext diff --git a/Mastodon/Diffable/Report/ReportItem.swift b/Mastodon/Diffable/Report/ReportItem.swift index ed083f427..cd5d9e9cf 100644 --- a/Mastodon/Diffable/Report/ReportItem.swift +++ b/Mastodon/Diffable/Report/ReportItem.swift @@ -13,7 +13,6 @@ enum ReportItem: Hashable { case header(context: HeaderContext) case status(record: MastodonStatus) case comment(context: CommentContext) - case result(record: ManagedObjectRecord) case bottomLoader } diff --git a/Mastodon/Diffable/Report/ReportSection.swift b/Mastodon/Diffable/Report/ReportSection.swift index 94161f28c..4461dc2f6 100644 --- a/Mastodon/Diffable/Report/ReportSection.swift +++ b/Mastodon/Diffable/Report/ReportSection.swift @@ -35,7 +35,6 @@ extension ReportSection { tableView.register(ReportHeadlineTableViewCell.self, forCellReuseIdentifier: String(describing: ReportHeadlineTableViewCell.self)) tableView.register(ReportStatusTableViewCell.self, forCellReuseIdentifier: String(describing: ReportStatusTableViewCell.self)) tableView.register(ReportCommentTableViewCell.self, forCellReuseIdentifier: String(describing: ReportCommentTableViewCell.self)) - tableView.register(ReportResultActionTableViewCell.self, forCellReuseIdentifier: String(describing: ReportResultActionTableViewCell.self)) tableView.register(TimelineBottomLoaderTableViewCell.self, forCellReuseIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self)) return UITableViewDiffableDataSource(tableView: tableView) { tableView, indexPath, item -> UITableViewCell? in @@ -72,13 +71,6 @@ extension ReportSection { } .store(in: &cell.disposeBag) return cell - case .result(let record): - let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: ReportResultActionTableViewCell.self), for: indexPath) as! ReportResultActionTableViewCell - context.managedObjectContext.performAndWait { - guard let user = record.object(in: context.managedObjectContext) else { return } - cell.avatarImageView.configure(configuration: .init(url: user.avatarImageURL())) - } - return cell case .bottomLoader: let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self), for: indexPath) as! TimelineBottomLoaderTableViewCell cell.activityIndicatorView.startAnimating() diff --git a/Mastodon/Diffable/User/UserSection.swift b/Mastodon/Diffable/User/UserSection.swift index 2d99d2f36..1483de2e4 100644 --- a/Mastodon/Diffable/User/UserSection.swift +++ b/Mastodon/Diffable/User/UserSection.swift @@ -37,7 +37,7 @@ extension UserSection { case .account(let account, let relationship): let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: UserTableViewCell.self), for: indexPath) as! UserTableViewCell - guard let me = authContext.mastodonAuthenticationBox.authentication.user(in: context.managedObjectContext) else { return cell } + guard let me = authContext.mastodonAuthenticationBox.authentication.account() else { return cell } cell.userView.setButtonState(.loading) cell.configure( diff --git a/Mastodon/Extension/AppContext+NextAccount.swift b/Mastodon/Extension/AppContext+NextAccount.swift index db3df4194..a2b0b34b3 100644 --- a/Mastodon/Extension/AppContext+NextAccount.swift +++ b/Mastodon/Extension/AppContext+NextAccount.swift @@ -5,8 +5,6 @@ // Created by Marcus Kida on 17.11.22. // -import CoreData -import CoreDataStack import MastodonCore import MastodonSDK diff --git a/Mastodon/Persistence/Model/SearchHistory.swift b/Mastodon/Persistence/Model/SearchHistory.swift deleted file mode 100644 index 057536747..000000000 --- a/Mastodon/Persistence/Model/SearchHistory.swift +++ /dev/null @@ -1,25 +0,0 @@ -// Copyright © 2023 Mastodon gGmbH. All rights reserved. - -import Foundation -import MastodonCore -import MastodonSDK - -extension Persistence.SearchHistory { - struct Item: Codable, Hashable, Equatable { - let updatedAt: Date - let userID: Mastodon.Entity.Account.ID - - let account: Mastodon.Entity.Account? - let hashtag: Mastodon.Entity.Tag? - - func hash(into hasher: inout Hasher) { - hasher.combine(userID) - hasher.combine(account) - hasher.combine(hashtag) - } - - public static func == (lhs: Persistence.SearchHistory.Item, rhs: Persistence.SearchHistory.Item) -> Bool { - return lhs.account == rhs.account && lhs.hashtag == rhs.hashtag && lhs.userID == rhs.userID - } - } -} diff --git a/Mastodon/Protocol/Provider/DataSourceFacade+Block.swift b/Mastodon/Protocol/Provider/DataSourceFacade+Block.swift index 298330219..b5d59ef67 100644 --- a/Mastodon/Protocol/Provider/DataSourceFacade+Block.swift +++ b/Mastodon/Protocol/Provider/DataSourceFacade+Block.swift @@ -13,56 +13,40 @@ import MastodonSDK extension DataSourceFacade { static func responseToUserBlockAction( dependency: NeedsDependency & AuthContextProvider, - user: ManagedObjectRecord - ) async throws { - let selectionFeedbackGenerator = await UISelectionFeedbackGenerator() - await selectionFeedbackGenerator.selectionChanged() - - let apiService = dependency.context.apiService - let authBox = dependency.authContext.mastodonAuthenticationBox - - _ = try await apiService.toggleBlock( - user: user, - authenticationBox: authBox - ) - - try await dependency.context.apiService.getBlocked( - authenticationBox: authBox - ) - dependency.context.authenticationService.fetchFollowingAndBlockedAsync() - } - - static func responseToUserBlockAction( - dependency: NeedsDependency & AuthContextProvider, - user: Mastodon.Entity.Account - ) async throws { + account: Mastodon.Entity.Account + ) async throws -> Mastodon.Entity.Relationship { let selectionFeedbackGenerator = await UISelectionFeedbackGenerator() await selectionFeedbackGenerator.selectionChanged() let apiService = dependency.context.apiService let authBox = dependency.authContext.mastodonAuthenticationBox - _ = try await apiService.toggleBlock( - user: user, + let response = try await apiService.toggleBlock( + account: account, authenticationBox: authBox ) - try await dependency.context.apiService.getBlocked( - authenticationBox: authBox - ) - dependency.context.authenticationService.fetchFollowingAndBlockedAsync() + let userInfo = [ + UserInfoKey.relationship: response.value, + ] + + NotificationCenter.default.post(name: .relationshipChanged, object: self, userInfo: userInfo) + + return response.value } static func responseToDomainBlockAction( dependency: NeedsDependency & AuthContextProvider, - user: ManagedObjectRecord - ) async throws { + account: Mastodon.Entity.Account + ) async throws -> Mastodon.Entity.Empty { let selectionFeedbackGenerator = await UISelectionFeedbackGenerator() await selectionFeedbackGenerator.selectionChanged() let apiService = dependency.context.apiService let authBox = dependency.authContext.mastodonAuthenticationBox - _ = try await apiService.toggleDomainBlock(user: user, authenticationBox: authBox) + let response = try await apiService.toggleDomainBlock(account: account, authenticationBox: authBox) + + return response.value } } diff --git a/Mastodon/Protocol/Provider/DataSourceFacade+Follow.swift b/Mastodon/Protocol/Provider/DataSourceFacade+Follow.swift index e3445115d..b024cfa96 100644 --- a/Mastodon/Protocol/Provider/DataSourceFacade+Follow.swift +++ b/Mastodon/Protocol/Provider/DataSourceFacade+Follow.swift @@ -7,7 +7,6 @@ import UIKit import CoreDataStack -import class CoreDataStack.Notification import MastodonCore import MastodonSDK import MastodonLocalization @@ -15,32 +14,22 @@ import MastodonLocalization extension DataSourceFacade { static func responseToUserFollowAction( dependency: NeedsDependency & AuthContextProvider, - user: ManagedObjectRecord - ) async throws { - let selectionFeedbackGenerator = await UISelectionFeedbackGenerator() - await selectionFeedbackGenerator.selectionChanged() - - _ = try await dependency.context.apiService.toggleFollow( - user: user, - authenticationBox: dependency.authContext.mastodonAuthenticationBox - ) - dependency.context.authenticationService.fetchFollowingAndBlockedAsync() - } - - static func responseToUserFollowAction( - dependency: NeedsDependency & AuthContextProvider, - user: Mastodon.Entity.Account + account: Mastodon.Entity.Account ) async throws -> Mastodon.Entity.Relationship { let selectionFeedbackGenerator = await UISelectionFeedbackGenerator() await selectionFeedbackGenerator.selectionChanged() let response = try await dependency.context.apiService.toggleFollow( - user: user, + account: account, authenticationBox: dependency.authContext.mastodonAuthenticationBox ).value dependency.context.authenticationService.fetchFollowingAndBlockedAsync() + NotificationCenter.default.post(name: .relationshipChanged, object: nil, userInfo: [ + UserInfoKey.relationship: response + ]) + return response } @@ -50,44 +39,51 @@ extension DataSourceFacade { static func responseToUserFollowRequestAction( dependency: NeedsDependency & AuthContextProvider, notification: MastodonNotification, + notificationView: NotificationView, query: Mastodon.API.Account.FollowRequestQuery ) async throws { let selectionFeedbackGenerator = await UISelectionFeedbackGenerator() await selectionFeedbackGenerator.selectionChanged() - - let managedObjectContext = dependency.context.managedObjectContext - let _userID: MastodonUser.ID? = try await managedObjectContext.perform { - return notification.account.id - } - - guard let userID = _userID else { - assertionFailure() - throw APIService.APIError.implicit(.badRequest) - } - + + let userID = notification.account.id let state: MastodonFollowRequestState = notification.followRequestState - guard state.state == .none else { - return - } - + guard state.state == .none else { return } + switch query { case .accept: notification.transientFollowRequestState = .init(state: .isAccepting) case .reject: notification.transientFollowRequestState = .init(state: .isRejecting) } - + + await notificationView.configure(notification: notification, authenticationBox: dependency.authContext.mastodonAuthenticationBox) + do { - _ = try await dependency.context.apiService.followRequest( + let newRelationship = try await dependency.context.apiService.followRequest( userID: userID, query: query, authenticationBox: dependency.authContext.mastodonAuthenticationBox - ) + ).value + + switch query { + case .accept: + notification.transientFollowRequestState = .init(state: .isAccept) + notification.followRequestState = .init(state: .isAccept) + case .reject: + break + } + + NotificationCenter.default.post(name: .relationshipChanged, object: nil, userInfo: [ + UserInfoKey.relationship: newRelationship + ]) + + await notificationView.configure(notification: notification, authenticationBox: dependency.authContext.mastodonAuthenticationBox) } catch { // reset state when failure notification.transientFollowRequestState = .init(state: .none) - + await notificationView.configure(notification: notification, authenticationBox: dependency.authContext.mastodonAuthenticationBox) + if let error = error as? Mastodon.API.Error { switch error.httpResponseStatus { case .notFound: @@ -103,36 +99,25 @@ extension DataSourceFacade { ) } } - - return } - switch query { - case .accept: - notification.transientFollowRequestState = .init(state: .isAccept) - notification.followRequestState = .init(state: .isAccept) - case .reject: - break - } } } extension DataSourceFacade { - static func responseToShowHideReblogAction( - dependency: NeedsDependency & AuthContextProvider, - user: ManagedObjectRecord - ) async throws { - _ = try await dependency.context.apiService.toggleShowReblogs( - for: user, - authenticationBox: dependency.authContext.mastodonAuthenticationBox) - } - static func responseToShowHideReblogAction( - dependency: NeedsDependency & AuthContextProvider, - user: Mastodon.Entity.Account + dependency: NeedsDependency & AuthContextProvider, + account: Mastodon.Entity.Account ) async throws { - _ = try await dependency.context.apiService.toggleShowReblogs( - for: user, - authenticationBox: dependency.authContext.mastodonAuthenticationBox) + let newRelationship = try await dependency.context.apiService.toggleShowReblogs( + for: account, + authenticationBox: dependency.authContext.mastodonAuthenticationBox + ) + + let userInfo = [ + UserInfoKey.relationship: newRelationship, + ] + + NotificationCenter.default.post(name: .relationshipChanged, object: self, userInfo: userInfo) } } diff --git a/Mastodon/Protocol/Provider/DataSourceFacade+Hashtag.swift b/Mastodon/Protocol/Provider/DataSourceFacade+Hashtag.swift index 8c4844166..dcf21d99f 100644 --- a/Mastodon/Protocol/Provider/DataSourceFacade+Hashtag.swift +++ b/Mastodon/Protocol/Provider/DataSourceFacade+Hashtag.swift @@ -28,30 +28,4 @@ extension DataSourceFacade { transition: .show ) } - - @MainActor - static func coordinateToHashtagScene( - provider: DataSourceProvider & AuthContextProvider, - tag: ManagedObjectRecord - ) async { - let managedObjectContext = provider.context.managedObjectContext - let _name: String? = try? await managedObjectContext.perform { - guard let tag = tag.object(in: managedObjectContext) else { return nil } - return tag.name - } - - guard let name = _name else { return } - - let hashtagTimelineViewModel = HashtagTimelineViewModel( - context: provider.context, - authContext: provider.authContext, - hashtag: name - ) - - _ = provider.coordinator.present( - scene: .hashtagTimeline(viewModel: hashtagTimelineViewModel), - from: provider, - transition: .show - ) - } } diff --git a/Mastodon/Protocol/Provider/DataSourceFacade+Media.swift b/Mastodon/Protocol/Provider/DataSourceFacade+Media.swift index 45622dba4..1cbc15b9d 100644 --- a/Mastodon/Protocol/Provider/DataSourceFacade+Media.swift +++ b/Mastodon/Protocol/Provider/DataSourceFacade+Media.swift @@ -65,7 +65,6 @@ extension DataSourceFacade { status: MastodonStatus, previewContext: AttachmentPreviewContext ) async throws { - let managedObjectContext = dependency.context.managedObjectContext let status = status.reblog ?? status let attachments = status.entity.mastodonAttachments @@ -140,87 +139,61 @@ extension DataSourceFacade { case profileBanner(ProfileHeaderView) } - func thumbnail() async -> UIImage? { - return await imageView.image + func thumbnail() -> UIImage? { + return imageView.image } } @MainActor static func coordinateToMediaPreviewScene( dependency: NeedsDependency & MediaPreviewableViewController, - user: ManagedObjectRecord, + account: Mastodon.Entity.Account, previewContext: ImagePreviewContext ) async throws { - let managedObjectContext = dependency.context.managedObjectContext + + let avatarAssetURL = account.avatar + let headerAssetURL = account.header + + let thumbnail = previewContext.thumbnail() - var _avatarAssetURL: String? - var _headerAssetURL: String? - - try await managedObjectContext.perform { - guard let user = user.object(in: managedObjectContext) else { return } - _avatarAssetURL = user.avatar - _headerAssetURL = user.header + let source: MediaPreviewTransitionItem.Source + switch previewContext.containerView { + case .profileAvatar(let view): source = .profileAvatar(view) + case .profileBanner(let view): source = .profileBanner(view) } - - let thumbnail = await previewContext.thumbnail() - - let source: MediaPreviewTransitionItem.Source = { + + let mediaPreviewTransitionItem = MediaPreviewTransitionItem( + source: source, + previewableViewController: dependency + ) + + let imageView = previewContext.imageView + mediaPreviewTransitionItem.initialFrame = imageView.superview?.convert(imageView.frame, to: nil) + mediaPreviewTransitionItem.image = thumbnail + mediaPreviewTransitionItem.aspectRatio = thumbnail?.size ?? CGSize(width: 100, height: 100) + mediaPreviewTransitionItem.sourceImageViewCornerRadius = { switch previewContext.containerView { - case .profileAvatar(let view): return .profileAvatar(view) - case .profileBanner(let view): return .profileBanner(view) - } - }() - - let mediaPreviewTransitionItem: MediaPreviewTransitionItem = { - let item = MediaPreviewTransitionItem( - source: source, - previewableViewController: dependency - ) - - let imageView = previewContext.imageView - item.initialFrame = { - let initialFrame = imageView.superview!.convert(imageView.frame, to: nil) - assert(initialFrame != .zero) - return initialFrame - }() - - item.image = thumbnail - - item.aspectRatio = { - if let thumbnail = thumbnail { - return thumbnail.size - } - return CGSize(width: 100, height: 100) - }() - - item.sourceImageViewCornerRadius = { - switch previewContext.containerView { case .profileAvatar: return ProfileHeaderView.avatarImageViewCornerRadius case .profileBanner: return 0 - } - }() - - return item + } }() - - - let mediaPreviewItem: MediaPreviewViewModel.PreviewItem = { - switch previewContext.containerView { + + let mediaPreviewItem: MediaPreviewViewModel.PreviewItem + switch previewContext.containerView { case .profileAvatar: - return .profileAvatar(.init( - assetURL: _avatarAssetURL, + mediaPreviewItem = .profileAvatar(.init( + assetURL: avatarAssetURL, thumbnail: thumbnail )) case .profileBanner: - return .profileBanner(.init( - assetURL: _headerAssetURL, + mediaPreviewItem = .profileBanner(.init( + assetURL: headerAssetURL, thumbnail: thumbnail )) - } - }() - + } + guard mediaPreviewItem.isAssetURLValid else { return } diff --git a/Mastodon/Protocol/Provider/DataSourceFacade+Mute.swift b/Mastodon/Protocol/Provider/DataSourceFacade+Mute.swift index 1db94bd4f..11a975381 100644 --- a/Mastodon/Protocol/Provider/DataSourceFacade+Mute.swift +++ b/Mastodon/Protocol/Provider/DataSourceFacade+Mute.swift @@ -6,20 +6,28 @@ // import UIKit -import CoreDataStack +import MastodonSDK import MastodonCore extension DataSourceFacade { static func responseToUserMuteAction( dependency: NeedsDependency & AuthContextProvider, - user: ManagedObjectRecord - ) async throws { + account: Mastodon.Entity.Account + ) async throws -> Mastodon.Entity.Relationship { let selectionFeedbackGenerator = await UISelectionFeedbackGenerator() await selectionFeedbackGenerator.selectionChanged() - _ = try await dependency.context.apiService.toggleMute( - user: user, - authenticationBox: dependency.authContext.mastodonAuthenticationBox + let response = try await dependency.context.apiService.toggleMute( + authenticationBox: dependency.authContext.mastodonAuthenticationBox, + account: account ) - } // end func + + let userInfo = [ + UserInfoKey.relationship: response.value, + ] + + NotificationCenter.default.post(name: .relationshipChanged, object: self, userInfo: userInfo) + + return response.value + } } diff --git a/Mastodon/Protocol/Provider/DataSourceFacade+Profile.swift b/Mastodon/Protocol/Provider/DataSourceFacade+Profile.swift index b1d39a4dd..89fd2547a 100644 --- a/Mastodon/Protocol/Provider/DataSourceFacade+Profile.swift +++ b/Mastodon/Protocol/Provider/DataSourceFacade+Profile.swift @@ -41,58 +41,88 @@ extension DataSourceFacade { assertionFailure() return } + await coordinateToProfileScene( provider: provider, account: redirectRecord ) } - - @MainActor - static func coordinateToProfileScene( - provider: ViewControllerWithDependencies & AuthContextProvider, - user: ManagedObjectRecord - ) async { - guard let user = user.object(in: provider.context.managedObjectContext) else { - assertionFailure() - return - } - - let profileViewModel = ProfileViewModel( - context: provider.context, - authContext: provider.authContext, - optionalMastodonUser: user - ) - - _ = provider.coordinator.present( - scene: .profile(viewModel: profileViewModel), - from: provider, - transition: .show - ) - } @MainActor static func coordinateToProfileScene( + provider: ViewControllerWithDependencies & AuthContextProvider, + username: String, + domain: String + ) async { + provider.coordinator.showLoading() + + do { + guard let account = try await provider.context.apiService.fetchUser( + username: username, + domain: domain, + authenticationBox: provider.authContext.mastodonAuthenticationBox + ) else { + return provider.coordinator.hideLoading() + } + + provider.coordinator.hideLoading() + + await coordinateToProfileScene(provider: provider, account: account) + } catch { + provider.coordinator.hideLoading() + } + } + + @MainActor + static func coordinateToProfileScene( + provider: ViewControllerWithDependencies & AuthContextProvider, + domain: String, + accountID: String + ) async { + provider.coordinator.showLoading() + + do { + let account = try await provider.context.apiService.accountInfo( + domain: domain, + userID: accountID, + authorization: provider.authContext.mastodonAuthenticationBox.userAuthorization + ).value + + provider.coordinator.hideLoading() + + await coordinateToProfileScene(provider: provider, account: account) + } catch { + provider.coordinator.hideLoading() + } + } + + @MainActor + public static func coordinateToProfileScene( provider: ViewControllerWithDependencies & AuthContextProvider, account: Mastodon.Entity.Account ) async { provider.coordinator.showLoading() - - guard let domain = account.domain else { return provider.coordinator.hideLoading() } - - Task { - do { - let user = try await provider.context.apiService.fetchUser(username: account.username, - domain: domain, - authenticationBox: provider.authContext.mastodonAuthenticationBox) - provider.coordinator.hideLoading() - - if let user { - await coordinateToProfileScene(provider: provider, user: user.asRecord) - } - } catch { - provider.coordinator.hideLoading() - } + + guard let me = provider.authContext.mastodonAuthenticationBox.authentication.account(), + let relationship = try? await provider.context.apiService.relationship(forAccounts: [account], authenticationBox: provider.authContext.mastodonAuthenticationBox).value.first else { + return provider.coordinator.hideLoading() } + + provider.coordinator.hideLoading() + + let profileViewModel = ProfileViewModel( + context: provider.context, + authContext: provider.authContext, + account: account, + relationship: relationship, + me: me + ) + + _ = provider.coordinator.present( + scene: .profile(viewModel: profileViewModel), + from: provider, + transition: .show + ) } } @@ -113,74 +143,31 @@ extension DataSourceFacade { else { return } - let mentions = status.entity.mentions ?? [] - + guard let mention = mentions.first(where: { $0.url == href }) else { - _ = provider.coordinator.present( + _ = provider.coordinator.present( scene: .safari(url: url), from: provider, transition: .safariPresent(animated: true, completion: nil) ) + return } - - let userID = mention.id - let profileViewModel: ProfileViewModel = { - // check if self - guard userID != provider.authContext.mastodonAuthenticationBox.userID else { - return MeProfileViewModel(context: provider.context, authContext: provider.authContext) - } - let request = MastodonUser.sortedFetchRequest - request.fetchLimit = 1 - request.predicate = MastodonUser.predicate(domain: domain, id: userID) - let _user = provider.context.managedObjectContext.safeFetch(request).first - - if let user = _user { - return ProfileViewModel(context: provider.context, authContext: provider.authContext, optionalMastodonUser: user) - } else { - return RemoteProfileViewModel(context: provider.context, authContext: provider.authContext, userID: userID) - } - }() - - _ = provider.coordinator.present( - scene: .profile(viewModel: profileViewModel), - from: provider, - transition: .show - ) + await DataSourceFacade.coordinateToProfileScene(provider: provider, domain: domain, accountID: mention.id) } } extension DataSourceFacade { - - struct ProfileActionMenuContext { - let isMuting: Bool - let isBlocking: Bool - let isMyself: Bool - - let cell: UITableViewCell? - let sourceView: UIView? - let barButtonItem: UIBarButtonItem? - } - static func createActivityViewController( dependency: NeedsDependency, - user: ManagedObjectRecord - ) async throws -> UIActivityViewController? { - let managedObjectContext = dependency.context.managedObjectContext - let activityItems: [Any] = try await managedObjectContext.perform { - guard let user = user.object(in: managedObjectContext) else { return [] } - return user.activityItems - } - guard !activityItems.isEmpty else { - assertionFailure() - return nil - } - - let activityViewController = await UIActivityViewController( - activityItems: activityItems, + account: Mastodon.Entity.Account + ) -> UIActivityViewController { + + let activityViewController = UIActivityViewController( + activityItems: [account.url], applicationActivities: [SafariActivity(sceneCoordinator: dependency.coordinator)] ) return activityViewController diff --git a/Mastodon/Protocol/Provider/DataSourceFacade+SearchHistory.swift b/Mastodon/Protocol/Provider/DataSourceFacade+SearchHistory.swift index 5fd9c1681..76b19fa10 100644 --- a/Mastodon/Protocol/Provider/DataSourceFacade+SearchHistory.swift +++ b/Mastodon/Protocol/Provider/DataSourceFacade+SearchHistory.swift @@ -27,7 +27,7 @@ extension DataSourceFacade { hashtag: nil ) - try? FileManager.default.addSearchItem(searchEntry) + try? FileManager.default.addSearchItem(searchEntry, for: provider.authContext.mastodonAuthenticationBox) case .hashtag(let tag): let now = Date() @@ -39,11 +39,9 @@ extension DataSourceFacade { hashtag: tag ) - try? FileManager.default.addSearchItem(searchEntry) + try? FileManager.default.addSearchItem(searchEntry, for: provider.authContext.mastodonAuthenticationBox) case .status: break - case .user(_): - break case .notification: break diff --git a/Mastodon/Protocol/Provider/DataSourceFacade+Status.swift b/Mastodon/Protocol/Provider/DataSourceFacade+Status.swift index 3140dc70c..63d52a515 100644 --- a/Mastodon/Protocol/Provider/DataSourceFacade+Status.swift +++ b/Mastodon/Protocol/Provider/DataSourceFacade+Status.swift @@ -137,18 +137,18 @@ extension DataSourceFacade { extension DataSourceFacade { struct MenuContext { - let author: ManagedObjectRecord? // todo: Remove once IOS-192 is ready - let authorEntity: Mastodon.Entity.Account? + let author: Mastodon.Entity.Account let statusViewModel: StatusView.ViewModel? let button: UIButton? let barButtonItem: UIBarButtonItem? } @MainActor - static func responseToMenuAction( + static func responseToMenuAction( dependency: UIViewController & NeedsDependency & AuthContextProvider & DataSourceProvider, action: MastodonMenu.Action, - menuContext: MenuContext + menuContext: MenuContext, + completion: ((T) -> Void)? = { (param: Void) in } ) async throws { switch action { case .hideReblogs(let actionContext): @@ -169,17 +169,9 @@ extension DataSourceFacade { guard let dependency else { return } Task { - let managedObjectContext = dependency.context.managedObjectContext - let _user: ManagedObjectRecord? = try? await managedObjectContext.perform { - guard let user = menuContext.author?.object(in: managedObjectContext) else { return nil } - return ManagedObjectRecord(objectID: user.objectID) - } - - guard let user = _user else { return } - try await DataSourceFacade.responseToShowHideReblogAction( dependency: dependency, - user: user + account: menuContext.author ) } } @@ -200,19 +192,17 @@ extension DataSourceFacade { title: actionContext.isMuting ? L10n.Common.Controls.Friendship.unmute : L10n.Common.Controls.Friendship.mute, style: .destructive ) { [weak dependency] _ in - guard let dependency = dependency else { return } + guard let dependency else { return } Task { - let managedObjectContext = dependency.context.managedObjectContext - let _user: ManagedObjectRecord? = try? await managedObjectContext.perform { - guard let user = menuContext.author?.object(in: managedObjectContext) else { return nil } - return ManagedObjectRecord(objectID: user.objectID) - } - guard let user = _user else { return } - try await DataSourceFacade.responseToUserMuteAction( + let newRelationship = try await DataSourceFacade.responseToUserMuteAction( dependency: dependency, - user: user + account: menuContext.author ) - } // end Task + + if let completion, let relationship = newRelationship as? T { + completion(relationship) + } + } } alertController.addAction(confirmAction) let cancelAction = UIAlertAction(title: L10n.Common.Controls.Actions.cancel, style: .cancel) @@ -228,52 +218,44 @@ extension DataSourceFacade { title: actionContext.isBlocking ? L10n.Common.Controls.Friendship.unblock : L10n.Common.Controls.Friendship.block, style: .destructive ) { [weak dependency] _ in - guard let dependency = dependency else { return } + guard let dependency else { return } Task { - let managedObjectContext = dependency.context.managedObjectContext - let _user: ManagedObjectRecord? = try? await managedObjectContext.perform { - guard let user = menuContext.author?.object(in: managedObjectContext) else { return nil } - return ManagedObjectRecord(objectID: user.objectID) - } - guard let user = _user else { return } - try await DataSourceFacade.responseToUserBlockAction( + let newRelationship = try await DataSourceFacade.responseToUserBlockAction( dependency: dependency, - user: user + account: menuContext.author ) - } // end Task + + if let completion, let relationship = newRelationship as? T { + completion(relationship) + } + } } alertController.addAction(confirmAction) let cancelAction = UIAlertAction(title: L10n.Common.Controls.Actions.cancel, style: .cancel) alertController.addAction(cancelAction) dependency.present(alertController, animated: true) case .reportUser: - Task { - guard let user = menuContext.author else { return } - - let reportViewModel = ReportViewModel( - context: dependency.context, - authContext: dependency.authContext, - user: user, - status: menuContext.statusViewModel?.originalStatus - ) - - _ = dependency.coordinator.present( - scene: .report(viewModel: reportViewModel), - from: dependency, - transition: .modal(animated: true, completion: nil) - ) - } // end Task - - case .shareUser: - guard let user = menuContext.author else { - assertionFailure() - return - } - let _activityViewController = try await DataSourceFacade.createActivityViewController( - dependency: dependency, - user: user + guard let relationship = try? await dependency.context.apiService.relationship(forAccounts: [menuContext.author], authenticationBox: dependency.authContext.mastodonAuthenticationBox).value.first else { return } + + let reportViewModel = ReportViewModel( + context: dependency.context, + authContext: dependency.authContext, + account: menuContext.author, + relationship: relationship, + status: menuContext.statusViewModel?.originalStatus ) - guard let activityViewController = _activityViewController else { return } + + _ = dependency.coordinator.present( + scene: .report(viewModel: reportViewModel), + from: dependency, + transition: .modal(animated: true, completion: nil) + ) + case .shareUser: + let activityViewController = DataSourceFacade.createActivityViewController( + dependency: dependency, + account: menuContext.author + ) + _ = dependency.coordinator.present( scene: .activityViewController( activityViewController: activityViewController, @@ -284,7 +266,6 @@ extension DataSourceFacade { transition: .activityViewControllerPresent(animated: true, completion: nil) ) case .bookmarkStatus: - Task { guard let status = menuContext.statusViewModel?.originalStatus else { assertionFailure() return @@ -293,30 +274,26 @@ extension DataSourceFacade { provider: dependency, status: status ) - } // end Task case .shareStatus: - Task { - let managedObjectContext = dependency.context.managedObjectContext - guard let status: MastodonStatus = menuContext.statusViewModel?.originalStatus?.reblog ?? menuContext.statusViewModel?.originalStatus else { - assertionFailure() - return - } + guard let status: MastodonStatus = menuContext.statusViewModel?.originalStatus?.reblog ?? menuContext.statusViewModel?.originalStatus else { + assertionFailure() + return + } - let activityViewController = try await DataSourceFacade.createActivityViewController( - dependency: dependency, - status: status - ) - - _ = dependency.coordinator.present( - scene: .activityViewController( - activityViewController: activityViewController, - sourceView: menuContext.button, - barButtonItem: menuContext.barButtonItem - ), - from: dependency, - transition: .activityViewControllerPresent(animated: true, completion: nil) - ) - } // end Task + let activityViewController = try await DataSourceFacade.createActivityViewController( + dependency: dependency, + status: status + ) + + _ = dependency.coordinator.present( + scene: .activityViewController( + activityViewController: activityViewController, + sourceView: menuContext.button, + barButtonItem: menuContext.barButtonItem + ), + from: dependency, + transition: .activityViewControllerPresent(animated: true, completion: nil) + ) case .deleteStatus: let alertController = UIAlertController( title: L10n.Common.Alerts.DeletePost.title, @@ -373,11 +350,8 @@ extension DataSourceFacade { // do nothing, as the translation is reverted in `StatusTableViewCellDelegate` in `DataSourceProvider+StatusTableViewCellDelegate.swift`. break case .followUser(_): - - guard let author = menuContext.author else { return } - - try await DataSourceFacade.responseToUserFollowAction(dependency: dependency, - user: author) + _ = try await DataSourceFacade.responseToUserFollowAction(dependency: dependency, + account: menuContext.author) case .blockDomain(let context): let title: String let message: String @@ -400,17 +374,11 @@ extension DataSourceFacade { ) let confirmAction = UIAlertAction(title: actionTitle, style: .destructive ) { [weak dependency] _ in - guard let dependency = dependency else { return } + guard let dependency else { return } Task { - let managedObjectContext = dependency.context.managedObjectContext - let _user: ManagedObjectRecord? = try? await managedObjectContext.perform { - guard let user = menuContext.author?.object(in: managedObjectContext) else { return nil } - return ManagedObjectRecord(objectID: user.objectID) - } - guard let user = _user else { return } try await DataSourceFacade.responseToDomainBlockAction( dependency: dependency, - user: user + account: menuContext.author ) } } diff --git a/Mastodon/Protocol/Provider/DataSourceFacade+UserView.swift b/Mastodon/Protocol/Provider/DataSourceFacade+UserView.swift index a34c2ad4a..de70e6304 100644 --- a/Mastodon/Protocol/Provider/DataSourceFacade+UserView.swift +++ b/Mastodon/Protocol/Provider/DataSourceFacade+UserView.swift @@ -9,105 +9,15 @@ import MastodonSDK extension DataSourceFacade { static func responseToUserViewButtonAction( dependency: NeedsDependency & AuthContextProvider, - user: ManagedObjectRecord, + account: Mastodon.Entity.Account, buttonState: UserView.ButtonState ) async throws { switch buttonState { - case .follow: - try await DataSourceFacade.responseToUserFollowAction( - dependency: dependency, - user: user - ) - - if let userObject = user.object(in: dependency.context.managedObjectContext) { - dependency.authContext.mastodonAuthenticationBox.inMemoryCache.followingUserIds.append(userObject.id) - } - - case .request: - try await DataSourceFacade.responseToUserFollowAction( - dependency: dependency, - user: user - ) - - if let userObject = user.object(in: dependency.context.managedObjectContext) { - dependency.authContext.mastodonAuthenticationBox.inMemoryCache.followRequestedUserIDs.append(userObject.id) - } - - case .unfollow: - try await DataSourceFacade.responseToUserFollowAction( - dependency: dependency, - user: user - ) - if let userObject = user.object(in: dependency.context.managedObjectContext) { - dependency.authContext.mastodonAuthenticationBox.inMemoryCache.followingUserIds.removeAll(where: { $0 == userObject.id }) - } - case .blocked: - try await DataSourceFacade.responseToUserBlockAction( - dependency: dependency, - user: user - ) - - if let userObject = user.object(in: dependency.context.managedObjectContext) { - dependency.authContext.mastodonAuthenticationBox.inMemoryCache.blockedUserIds.append(userObject.id) - } - - case .pending: - try await DataSourceFacade.responseToUserFollowAction( - dependency: dependency, - user: user - ) - - if let userObject = user.object(in: dependency.context.managedObjectContext) { - dependency.authContext.mastodonAuthenticationBox.inMemoryCache.followRequestedUserIDs.removeAll(where: { $0 == userObject.id }) - } - case .none, .loading: - break //no-op - } - } - - static func responseToUserViewButtonAction( - dependency: NeedsDependency & AuthContextProvider, - user: Mastodon.Entity.Account, - buttonState: UserView.ButtonState - ) async throws { - switch buttonState { - case .follow: + case .follow, .request, .unfollow, .blocked, .pending: _ = try await DataSourceFacade.responseToUserFollowAction( dependency: dependency, - user: user + account: account ) - - dependency.authContext.mastodonAuthenticationBox.inMemoryCache.followingUserIds.append(user.id) - - case .request: - _ = try await DataSourceFacade.responseToUserFollowAction( - dependency: dependency, - user: user - ) - - dependency.authContext.mastodonAuthenticationBox.inMemoryCache.followRequestedUserIDs.append(user.id) - case .unfollow: - _ = try await DataSourceFacade.responseToUserFollowAction( - dependency: dependency, - user: user - ) - - dependency.authContext.mastodonAuthenticationBox.inMemoryCache.followingUserIds.removeAll(where: { $0 == user.id }) - case .blocked: - try await DataSourceFacade.responseToUserBlockAction( - dependency: dependency, - user: user - ) - - dependency.authContext.mastodonAuthenticationBox.inMemoryCache.blockedUserIds.append(user.id) - - case .pending: - _ = try await DataSourceFacade.responseToUserFollowAction( - dependency: dependency, - user: user - ) - - dependency.authContext.mastodonAuthenticationBox.inMemoryCache.followRequestedUserIDs.removeAll(where: { $0 == user.id }) case .none, .loading: break //no-op } diff --git a/Mastodon/Protocol/Provider/DataSourceProvider+NotificationTableViewCellDelegate.swift b/Mastodon/Protocol/Provider/DataSourceProvider+NotificationTableViewCellDelegate.swift index c3d2d3f6a..2e9e7ad50 100644 --- a/Mastodon/Protocol/Provider/DataSourceProvider+NotificationTableViewCellDelegate.swift +++ b/Mastodon/Protocol/Provider/DataSourceProvider+NotificationTableViewCellDelegate.swift @@ -30,27 +30,42 @@ extension NotificationTableViewCellDelegate where Self: DataSourceProvider & Aut assertionFailure("only works for status data provider") return } - - let _author: ManagedObjectRecord? = try await self.context.managedObjectContext.perform { - return .init(objectID: notification.account.objectID) - } - guard let author = _author else { - assertionFailure() - return - } - - try await DataSourceFacade.responseToMenuAction( - dependency: self, - action: action, - menuContext: .init( - author: author, - authorEntity: notification.entity.account, - statusViewModel: nil, - button: button, - barButtonItem: nil + + // we only allow to mute/block and to report users on notification-screen + switch action { + case .muteUser(_), .blockUser(_): + _ = try await DataSourceFacade.responseToMenuAction( + dependency: self, + action: action, + menuContext: .init( + author: notification.entity.account, + statusViewModel: nil, + button: button, + barButtonItem: nil + ), + completion: { (newRelationship: Mastodon.Entity.Relationship) in + notification.relationship = newRelationship + Task { @MainActor in + notificationView.configure(notification: notification, authenticationBox: self.authContext.mastodonAuthenticationBox) + } + } ) - ) - } // end Task + case .reportUser(_): + _ = try await DataSourceFacade.responseToMenuAction( + dependency: self, + action: action, + menuContext: .init( + author: notification.entity.account, + statusViewModel: nil, + button: button, + barButtonItem: nil + ) + ) + case .translateStatus(_), .showOriginal, .shareUser(_), .blockDomain(_), .bookmarkStatus(_), .hideReblogs(_), .shareStatus, .deleteStatus, .editStatus, .followUser(_): + // Do Nothing + break + } + } } } @@ -71,16 +86,10 @@ extension NotificationTableViewCellDelegate where Self: DataSourceProvider & Aut assertionFailure("only works for status data provider") return } - let _author: ManagedObjectRecord? = try await self.context.managedObjectContext.perform { - return .init(objectID: notification.account.objectID) - } - guard let author = _author else { - assertionFailure() - return - } + await DataSourceFacade.coordinateToProfileScene( provider: self, - user: author + account: notification.entity.account ) } // end Task } @@ -105,28 +114,14 @@ extension NotificationTableViewCellDelegate where Self: DataSourceProvider & Aut assertionFailure("only works for status data provider") return } - - let originalTransientFollowRequestState = notificationView.viewModel.transientFollowRequestState - let originalFollowRequestState = notificationView.viewModel.followRequestState - - notificationView.viewModel.transientFollowRequestState = .init(state: .isAccepting) - notificationView.viewModel.followRequestState = .init(state: .isAccepting) - - do { - try await DataSourceFacade.responseToUserFollowRequestAction( - dependency: self, - notification: notification, - query: .accept - ) - - notificationView.viewModel.transientFollowRequestState = .init(state: .isAccept) - notificationView.viewModel.followRequestState = .init(state: .isAccept) - } catch { - notificationView.viewModel.transientFollowRequestState = originalTransientFollowRequestState - notificationView.viewModel.followRequestState = originalFollowRequestState - throw error - } - } // end Task + + try await DataSourceFacade.responseToUserFollowRequestAction( + dependency: self, + notification: notification, + notificationView: notificationView, + query: .accept + ) + } } @MainActor @@ -145,30 +140,15 @@ extension NotificationTableViewCellDelegate where Self: DataSourceProvider & Aut assertionFailure("only works for status data provider") return } - - let originalTransientFollowRequestState = notificationView.viewModel.transientFollowRequestState - let originalFollowRequestState = notificationView.viewModel.followRequestState - - notificationView.viewModel.transientFollowRequestState = .init(state: .isRejecting) - notificationView.viewModel.followRequestState = .init(state: .isRejecting) - - do { - try await DataSourceFacade.responseToUserFollowRequestAction( - dependency: self, - notification: notification, - query: .reject - ) - - notificationView.viewModel.transientFollowRequestState = .init(state: .isReject) - notificationView.viewModel.followRequestState = .init(state: .isReject) - } catch { - notificationView.viewModel.transientFollowRequestState = originalTransientFollowRequestState - notificationView.viewModel.followRequestState = originalFollowRequestState - throw error - } - } // end Task + + try await DataSourceFacade.responseToUserFollowRequestAction( + dependency: self, + notification: notification, + notificationView: notificationView, + query: .reject + ) + } } - } // MARK: - Status Content @@ -267,7 +247,6 @@ extension NotificationTableViewCellDelegate where Self: DataSourceProvider & Med return } - let managedObjectContext = self.context.managedObjectContext let _mediaTransitionContext: NotificationMediaTransitionContext? = { guard let status = record.status?.reblog ?? record.status else { return nil } return NotificationMediaTransitionContext( @@ -352,9 +331,11 @@ extension NotificationTableViewCellDelegate where Self: DataSourceProvider & Aut return } + guard let account = notification.status?.entity.account else { return } + await DataSourceFacade.coordinateToProfileScene( provider: self, - user: notification.account.asRecord + account: account ) } // end Task } @@ -532,14 +513,11 @@ extension NotificationTableViewCellDelegate where Self: DataSourceProvider & Aut target: .status, // remove reblog wrapper status: status ) - case .user(let user): - await DataSourceFacade.coordinateToProfileScene( - provider: self, - user: user - ) + case .account(let account, _): + await DataSourceFacade.coordinateToProfileScene(provider: self, account: account) case .notification: assertionFailure("TODO") - default: + case .hashtag(_): assertionFailure("TODO") } } // end Task diff --git a/Mastodon/Protocol/Provider/DataSourceProvider+StatusTableViewCellDelegate.swift b/Mastodon/Protocol/Provider/DataSourceProvider+StatusTableViewCellDelegate.swift index 8ae264a20..7d19b668e 100644 --- a/Mastodon/Protocol/Provider/DataSourceProvider+StatusTableViewCellDelegate.swift +++ b/Mastodon/Protocol/Provider/DataSourceProvider+StatusTableViewCellDelegate.swift @@ -35,37 +35,23 @@ extension StatusTableViewCellDelegate where Self: DataSourceProvider & AuthConte } switch await statusView.viewModel.header { - case .none: - break - case .reply: - let _replyToAuthor: ManagedObjectRecord? = try? await context.managedObjectContext.perform { - guard let inReplyToAccountID = status.entity.inReplyToAccountID else { return nil } - let request = MastodonUser.sortedFetchRequest - request.predicate = MastodonUser.predicate(domain: domain, id: inReplyToAccountID) - request.fetchLimit = 1 - guard let author = self.context.managedObjectContext.safeFetch(request).first else { return nil } - return .init(objectID: author.objectID) - } - guard let replyToAuthor = _replyToAuthor else { - assertionFailure() - return - } - - await DataSourceFacade.coordinateToProfileScene( - provider: self, - user: replyToAuthor - ) + case .none: + break + case .reply: + guard let replyToAccountID = status.entity.inReplyToAccountID else { return } + await DataSourceFacade.coordinateToProfileScene(provider: self, + domain: domain, + accountID: replyToAccountID) - case .repost: - await DataSourceFacade.coordinateToProfileScene( - provider: self, - target: .reblog, // keep the wrapper for header author - status: status - ) + case .repost: + await DataSourceFacade.coordinateToProfileScene( + provider: self, + target: .reblog, // keep the wrapper for header author + status: status + ) } } } - } // MARK: - avatar button @@ -136,16 +122,6 @@ extension StatusTableViewCellDelegate where Self: DataSourceProvider & AuthConte didTapCardWithURL url: URL ) { Task { - let source = DataSourceItem.Source(tableViewCell: cell, indexPath: nil) - guard let item = await item(from: source) else { - assertionFailure() - return - } - guard case let .status(status) = item else { - assertionFailure("only works for status data provider") - return - } - await DataSourceFacade.responseToURLAction( provider: self, url: url @@ -160,16 +136,6 @@ extension StatusTableViewCellDelegate where Self: DataSourceProvider & AuthConte didTapURL url: URL ) { Task { - let source = DataSourceItem.Source(tableViewCell: cell, indexPath: nil) - guard let item = await item(from: source) else { - assertionFailure() - return - } - guard case let .status(status) = item else { - assertionFailure("only works for status data provider") - return - } - await DataSourceFacade.responseToURLAction( provider: self, url: url @@ -463,21 +429,9 @@ extension StatusTableViewCellDelegate where Self: DataSourceProvider & AuthConte assertionFailure("only works for status data provider") return } - + let status = _status.reblog ?? _status - let _author: ManagedObjectRecord? = try await self.context.managedObjectContext.perform { - let request = MastodonUser.sortedFetchRequest - request.predicate = MastodonUser.predicate(domain: self.authContext.mastodonAuthenticationBox.domain, id: status.entity.account.id) - request.fetchLimit = 1 - guard let author = self.context.managedObjectContext.safeFetch(request).first else { return nil } - return .init(objectID: author.objectID) - } - guard let author = _author else { - assertionFailure() - return - } - if case .translateStatus = action { DispatchQueue.main.async { if let cell = cell as? StatusTableViewCell { @@ -511,8 +465,7 @@ extension StatusTableViewCellDelegate where Self: DataSourceProvider & AuthConte dependency: self, action: action, menuContext: .init( - author: author, - authorEntity: status.entity.account, + author: status.entity.account, statusViewModel: statusViewModel, button: button, barButtonItem: nil @@ -709,14 +662,14 @@ extension StatusTableViewCellDelegate where Self: DataSourceProvider & AuthConte target: .status, // remove reblog wrapper status: status ) - case .user(let user): + case .account(let account, _): await DataSourceFacade.coordinateToProfileScene( provider: self, - user: user + account: account ) case .notification: assertionFailure("TODO") - default: + case .hashtag(_): assertionFailure("TODO") } } diff --git a/Mastodon/Protocol/Provider/DataSourceProvider+UITableViewDelegate.swift b/Mastodon/Protocol/Provider/DataSourceProvider+UITableViewDelegate.swift index 276500c03..dc2efdeeb 100644 --- a/Mastodon/Protocol/Provider/DataSourceProvider+UITableViewDelegate.swift +++ b/Mastodon/Protocol/Provider/DataSourceProvider+UITableViewDelegate.swift @@ -24,43 +24,34 @@ extension UITableViewDelegate where Self: DataSourceProvider & AuthContextProvid switch item { case .account(let account, relationship: _): await DataSourceFacade.coordinateToProfileScene(provider: self, account: account) - case .status(let status): - await DataSourceFacade.coordinateToStatusThreadScene( - provider: self, - target: .status, // remove reblog wrapper - status: status - ) - case .user(let user): - await DataSourceFacade.coordinateToProfileScene( - provider: self, - user: user - ) - case .hashtag(let tag): - await DataSourceFacade.coordinateToHashtagScene( - provider: self, - tag: tag - ) - case .notification(let notification): - let _status: MastodonStatus? = notification.status - if let status = _status { + case .status(let status): await DataSourceFacade.coordinateToStatusThreadScene( provider: self, - target: .status, // remove reblog wrapper + target: .status, // remove reblog wrapper status: status ) - } else { - let _author: ManagedObjectRecord? = notification.account.asRecord - if let author = _author { + case .hashtag(let tag): + await DataSourceFacade.coordinateToHashtagScene( + provider: self, + tag: tag + ) + case .notification(let notification): + let _status: MastodonStatus? = notification.status + if let status = _status { + await DataSourceFacade.coordinateToStatusThreadScene( + provider: self, + target: .status, // remove reblog wrapper + status: status + ) + } else { await DataSourceFacade.coordinateToProfileScene( provider: self, - user: author + account: notification.entity.account ) - } - } - } - } // end Task - } // end func - + } // end Task + } // end func + } + } } extension UITableViewDelegate where Self: DataSourceProvider & MediaPreviewableViewController { diff --git a/Mastodon/Protocol/Provider/DataSourceProvider.swift b/Mastodon/Protocol/Provider/DataSourceProvider.swift index 8f0d6ab51..fe886800e 100644 --- a/Mastodon/Protocol/Provider/DataSourceProvider.swift +++ b/Mastodon/Protocol/Provider/DataSourceProvider.swift @@ -9,11 +9,9 @@ import UIKit import CoreDataStack import MastodonSDK -import class CoreDataStack.Notification enum DataSourceItem: Hashable { case status(record: MastodonStatus) - case user(record: ManagedObjectRecord) case hashtag(tag: Mastodon.Entity.Tag) case notification(record: MastodonNotification) case account(account: Mastodon.Entity.Account, relationship: Mastodon.Entity.Relationship?) diff --git a/Mastodon/Scene/Account/AccountListViewModel.swift b/Mastodon/Scene/Account/AccountListViewModel.swift index 5919596bf..ea2206703 100644 --- a/Mastodon/Scene/Account/AccountListViewModel.swift +++ b/Mastodon/Scene/Account/AccountListViewModel.swift @@ -103,25 +103,25 @@ extension AccountListViewModel { authentication: MastodonAuthentication, activeAuthentication: MastodonAuthentication ) { - guard let user = authentication.user(in: context) else { return } - + guard let account = authentication.account() else { return } + // avatar cell.avatarButton.avatarImageView.configure( - configuration: .init(url: user.avatarImageURL()) + configuration: .init(url: account.avatarImageURL()) ) // name do { - let content = MastodonContent(content: user.displayNameWithFallback, emojis: user.emojis.asDictionary) + let content = MastodonContent(content: account.displayNameWithFallback, emojis: account.emojis.asDictionary) let metaContent = try MastodonMetaContent.convert(document: content) cell.nameLabel.configure(content: metaContent) } catch { assertionFailure() - cell.nameLabel.configure(content: PlaintextMetaContent(string: user.displayNameWithFallback)) + cell.nameLabel.configure(content: PlaintextMetaContent(string: account.displayNameWithFallback)) } // username - let usernameMetaContent = PlaintextMetaContent(string: "@" + user.acctWithDomain) + let usernameMetaContent = PlaintextMetaContent(string: "@" + account.acctWithDomain) cell.usernameLabel.configure(content: usernameMetaContent) // badge diff --git a/Mastodon/Scene/Discovery/ForYou/DiscoveryForYouViewController.swift b/Mastodon/Scene/Discovery/ForYou/DiscoveryForYouViewController.swift index 90a136611..290f12202 100644 --- a/Mastodon/Scene/Discovery/ForYou/DiscoveryForYouViewController.swift +++ b/Mastodon/Scene/Discovery/ForYou/DiscoveryForYouViewController.swift @@ -119,7 +119,7 @@ extension DiscoveryForYouViewController: ProfileCardTableViewCellDelegate { cell.profileCardView.setButtonState(.loading) Task { - let newRelationship = try await DataSourceFacade.responseToUserFollowAction(dependency: self, user: account) + let newRelationship = try await DataSourceFacade.responseToUserFollowAction(dependency: self, account: account) let isMe = (account.id == authContext.mastodonAuthenticationBox.userID) diff --git a/Mastodon/Scene/Discovery/ForYou/ProfileCardView+Configuration.swift b/Mastodon/Scene/Discovery/ForYou/ProfileCardView+Configuration.swift index 4192e59b7..ecb4e3db1 100644 --- a/Mastodon/Scene/Discovery/ForYou/ProfileCardView+Configuration.swift +++ b/Mastodon/Scene/Discovery/ForYou/ProfileCardView+Configuration.swift @@ -22,7 +22,7 @@ extension ProfileCardView { viewModel.followersCount = account.followersCount viewModel.authorAvatarImageURL = account.avatarImageURL() - let emojis = account.emojis?.asDictionary ?? [:] + let emojis = account.emojis.asDictionary do { let content = MastodonContent(content: account.displayNameWithFallback, emojis: emojis) diff --git a/Mastodon/Scene/Discovery/ForYou/ProfileCardView+ViewModel.swift b/Mastodon/Scene/Discovery/ForYou/ProfileCardView+ViewModel.swift index e7bf776c7..05bd54fca 100644 --- a/Mastodon/Scene/Discovery/ForYou/ProfileCardView+ViewModel.swift +++ b/Mastodon/Scene/Discovery/ForYou/ProfileCardView+ViewModel.swift @@ -57,7 +57,7 @@ extension ProfileCardView.ViewModel { private func bindHeader(view: ProfileCardView) { $authorBannerImageURL .sink { url in - guard let url = url, !url.absoluteString.hasSuffix("missing.png") else { + guard let url, !url.absoluteString.hasSuffix(Mastodon.Entity.Account.missingImageName) else { view.bannerImageView.image = .placeholder(color: .systemGray3) return } diff --git a/Mastodon/Scene/Discovery/ForYou/ProfileCardView.swift b/Mastodon/Scene/Discovery/ForYou/ProfileCardView.swift index 6dcdc6f49..e2d92c98e 100644 --- a/Mastodon/Scene/Discovery/ForYou/ProfileCardView.swift +++ b/Mastodon/Scene/Discovery/ForYou/ProfileCardView.swift @@ -316,9 +316,9 @@ extension ProfileCardView { buttonState = .none } else if relationship.following { buttonState = .unfollow - } else if relationship.blocking || (relationship.domainBlocking ?? false) { + } else if relationship.blocking || relationship.domainBlocking { buttonState = .blocked - } else if relationship.requested ?? false { + } else if relationship.requested { buttonState = .pending } else { buttonState = .follow diff --git a/Mastodon/Scene/Discovery/Posts/DiscoveryPostsViewModel.swift b/Mastodon/Scene/Discovery/Posts/DiscoveryPostsViewModel.swift index feff4f77a..47f695987 100644 --- a/Mastodon/Scene/Discovery/Posts/DiscoveryPostsViewModel.swift +++ b/Mastodon/Scene/Discovery/Posts/DiscoveryPostsViewModel.swift @@ -46,7 +46,6 @@ final class DiscoveryPostsViewModel { self.context = context self.authContext = authContext self.dataController = StatusDataController() - // end init Task { await checkServerEndpoint() diff --git a/Mastodon/Scene/HashtagTimeline/HashtagTimelineHeaderView.swift b/Mastodon/Scene/HashtagTimeline/HashtagTimelineHeaderView.swift index 96bc4ed13..647546e0c 100644 --- a/Mastodon/Scene/HashtagTimeline/HashtagTimelineHeaderView.swift +++ b/Mastodon/Scene/HashtagTimeline/HashtagTimelineHeaderView.swift @@ -38,20 +38,6 @@ final class HashtagTimelineHeaderView: UIView { postsTodayCount: Int(entity.history?.first?.uses ?? "0") ?? 0 ) } - - static func from(_ entity: Tag) -> Self { - Data( - name: entity.name, - following: entity.following, - postCount: entity.histories.reduce(0) { res, acc in - res + (Int(acc.uses) ?? 0) - }, - participantsCount: entity.histories.reduce(0) { res, acc in - res + (Int(acc.accounts) ?? 0) - }, - postsTodayCount: Int(entity.histories.first?.uses ?? "0") ?? 0 - ) - } } let titleLabel = UILabel() diff --git a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel.swift b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel.swift index 2e3054f80..4635ee503 100644 --- a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel.swift +++ b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel.swift @@ -61,18 +61,7 @@ final class HashtagTimelineViewModel { } func viewWillAppear() { - let predicate = Tag.predicate( - domain: authContext.mastodonAuthenticationBox.domain, - name: hashtag - ) - - guard - let object = Tag.findOrFetch(in: context.managedObjectContext, matching: predicate) - else { - return hashtagDetails.send(hashtagDetails.value?.copy(following: false)) - } - - hashtagDetails.send(hashtagDetails.value?.copy(following: object.following)) + hashtagDetails.send(hashtagDetails.value?.copy(following: hashtagEntity.value?.following ?? false)) } } diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DataSourceProvider.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DataSourceProvider.swift index 47a844b28..3a0457983 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DataSourceProvider.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DataSourceProvider.swift @@ -16,7 +16,7 @@ extension HomeTimelineViewController: DataSourceProvider { } guard let indexPath = _indexPath else { return nil } - guard let item = viewModel.diffableDataSource?.itemIdentifier(for: indexPath) else { + guard let item = viewModel?.diffableDataSource?.itemIdentifier(for: indexPath) else { return nil } @@ -34,7 +34,7 @@ extension HomeTimelineViewController: DataSourceProvider { } func update(status: MastodonStatus, intent: MastodonStatus.UpdateIntent) { - viewModel.dataController.update(status: status, intent: intent) + viewModel?.dataController.update(status: status, intent: intent) } @MainActor diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift index 06e1b2d8e..9666bf12e 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift @@ -25,8 +25,8 @@ final class HomeTimelineViewController: UIViewController, NeedsDependency, Media weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } } var disposeBag = Set() - var viewModel: HomeTimelineViewModel! - + var viewModel: HomeTimelineViewModel? + let mediaPreviewTransitionController = MediaPreviewTransitionController() let friendsAssetImageView: UIImageView = { @@ -82,7 +82,7 @@ extension HomeTimelineViewController { title = L10n.Scene.HomeTimeline.title view.backgroundColor = .secondarySystemBackground - viewModel.$displaySettingBarButtonItem + viewModel?.$displaySettingBarButtonItem .receive(on: DispatchQueue.main) .sink { [weak self] displaySettingBarButtonItem in guard let self = self else { return } @@ -97,7 +97,7 @@ extension HomeTimelineViewController { navigationItem.titleView = titleView titleView.delegate = self - viewModel.homeTimelineNavigationBarTitleViewModel.state + viewModel?.homeTimelineNavigationBarTitleViewModel.state .removeDuplicates() .receive(on: DispatchQueue.main) .sink { [weak self] state in @@ -106,7 +106,7 @@ extension HomeTimelineViewController { } .store(in: &disposeBag) - viewModel.homeTimelineNavigationBarTitleViewModel.state + viewModel?.homeTimelineNavigationBarTitleViewModel.state .removeDuplicates() .filter { $0 == .publishedButton } .receive(on: DispatchQueue.main) @@ -137,27 +137,27 @@ extension HomeTimelineViewController { publishProgressView.trailingAnchor.constraint(equalTo: view.trailingAnchor), ]) - viewModel.tableView = tableView + viewModel?.tableView = tableView tableView.delegate = self - viewModel.setupDiffableDataSource( + viewModel?.setupDiffableDataSource( tableView: tableView, statusTableViewCellDelegate: self, timelineMiddleLoaderTableViewCellDelegate: self ) // setup batch fetch - viewModel.listBatchFetchViewModel.setup(scrollView: tableView) - viewModel.listBatchFetchViewModel.shouldFetch + viewModel?.listBatchFetchViewModel.setup(scrollView: tableView) + viewModel?.listBatchFetchViewModel.shouldFetch .receive(on: DispatchQueue.main) .sink { [weak self] _ in guard let self = self else { return } guard self.view.window != nil else { return } - self.viewModel.loadOldestStateMachine.enter(HomeTimelineViewModel.LoadOldestState.Loading.self) + self.viewModel?.loadOldestStateMachine.enter(HomeTimelineViewModel.LoadOldestState.Loading.self) } .store(in: &disposeBag) // bind refresh control - viewModel.didLoadLatest + viewModel?.didLoadLatest .receive(on: DispatchQueue.main) .sink { [weak self] _ in guard let self = self else { return } @@ -170,8 +170,8 @@ extension HomeTimelineViewController { context.publisherService.statusPublishResult.receive(on: DispatchQueue.main).sink { result in if case .success(.edit(let status)) = result { - self.viewModel.hasPendingStatusEditReload = true - self.viewModel.dataController.update(status: .fromEntity(status.value), intent: .edit) + self.viewModel?.hasPendingStatusEditReload = true + self.viewModel?.dataController.update(status: .fromEntity(status.value), intent: .edit) } }.store(in: &disposeBag) @@ -204,24 +204,23 @@ extension HomeTimelineViewController { } .store(in: &disposeBag) - viewModel.timelineIsEmpty + viewModel?.timelineIsEmpty .receive(on: DispatchQueue.main) .sink { [weak self] isEmpty in if isEmpty { self?.showEmptyView() let userDoesntFollowPeople: Bool - if let managedObjectContext = self?.context.managedObjectContext, - let authContext = self?.authContext, - let me = authContext.mastodonAuthenticationBox.authentication.user(in: managedObjectContext){ + if let authContext = self?.authContext, + let me = authContext.mastodonAuthenticationBox.authentication.account() { userDoesntFollowPeople = me.followersCount == 0 } else { userDoesntFollowPeople = true } - if (self?.viewModel.presentedSuggestions == false) && userDoesntFollowPeople { + if (self?.viewModel?.presentedSuggestions == false) && userDoesntFollowPeople { self?.findPeopleButtonPressed(self) - self?.viewModel.presentedSuggestions = true + self?.viewModel?.presentedSuggestions = true } } else { self?.emptyView.removeFromSuperview() @@ -265,16 +264,16 @@ extension HomeTimelineViewController { override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) - if let timestamp = viewModel.lastAutomaticFetchTimestamp { + if let timestamp = viewModel?.lastAutomaticFetchTimestamp { let now = Date() if now.timeIntervalSince(timestamp) > 60 { - self.viewModel.lastAutomaticFetchTimestamp = now - self.viewModel.homeTimelineNeedRefresh.send() + self.viewModel?.lastAutomaticFetchTimestamp = now + self.viewModel?.homeTimelineNeedRefresh.send() } else { // do nothing } } else { - self.viewModel.homeTimelineNeedRefresh.send() + self.viewModel?.homeTimelineNeedRefresh.send() } } @@ -285,7 +284,7 @@ extension HomeTimelineViewController { // do nothing } completion: { _ in // fix AutoLayout cell height not update after rotate issue - self.viewModel.cellFrameCache.removeAllObjects() + self.viewModel?.cellFrameCache.removeAllObjects() self.tableView.reloadData() } } @@ -357,7 +356,9 @@ extension HomeTimelineViewController { extension HomeTimelineViewController { @objc private func findPeopleButtonPressed(_ sender: Any?) { - let suggestionAccountViewModel = SuggestionAccountViewModel(context: context, authContext: viewModel.authContext) + guard let authContext = viewModel?.authContext else { return } + + let suggestionAccountViewModel = SuggestionAccountViewModel(context: context, authContext: authContext) suggestionAccountViewModel.delegate = viewModel _ = coordinator.present( scene: .suggestionAccount(viewModel: suggestionAccountViewModel), @@ -367,7 +368,9 @@ extension HomeTimelineViewController { } @objc private func manuallySearchButtonPressed(_ sender: UIButton) { - let searchDetailViewModel = SearchDetailViewModel(authContext: viewModel.authContext) + guard let authContext = viewModel?.authContext else { return } + + let searchDetailViewModel = SearchDetailViewModel(authContext: authContext) _ = coordinator.present(scene: .searchDetail(viewModel: searchDetailViewModel), from: self, transition: .modal(animated: true, completion: nil)) } @@ -378,16 +381,18 @@ extension HomeTimelineViewController { } @objc private func refreshControlValueChanged(_ sender: RefreshControl) { - guard viewModel.loadLatestStateMachine.enter(HomeTimelineViewModel.LoadLatestState.LoadingManually.self) else { + guard let viewModel, viewModel.loadLatestStateMachine.enter(HomeTimelineViewModel.LoadLatestState.LoadingManually.self) else { sender.endRefreshing() return } } @objc func signOutAction(_ sender: UIAction) { + guard let authContext = viewModel?.authContext else { return } + Task { @MainActor in - try await context.authenticationService.signOutMastodonUser(authenticationBox: viewModel.authContext.mastodonAuthenticationBox) - let userIdentifier = viewModel.authContext.mastodonAuthenticationBox + try await context.authenticationService.signOutMastodonUser(authenticationBox: authContext.mastodonAuthenticationBox) + let userIdentifier = authContext.mastodonAuthenticationBox FileManager.default.invalidateHomeTimelineCache(for: userIdentifier) FileManager.default.invalidateNotificationsAll(for: userIdentifier) FileManager.default.invalidateNotificationsMentions(for: userIdentifier) @@ -401,7 +406,7 @@ extension HomeTimelineViewController { func scrollViewDidScroll(_ scrollView: UIScrollView) { switch scrollView { case tableView: - viewModel.homeTimelineNavigationBarTitleViewModel.handleScrollViewDidScroll(scrollView) + viewModel?.homeTimelineNavigationBarTitleViewModel.handleScrollViewDidScroll(scrollView) default: break } @@ -412,7 +417,7 @@ extension HomeTimelineViewController { case tableView: let indexPath = IndexPath(row: 0, section: 0) - guard viewModel.diffableDataSource?.itemIdentifier(for: indexPath) != nil else { + guard viewModel?.diffableDataSource?.itemIdentifier(for: indexPath) != nil else { return true } // save position @@ -429,7 +434,7 @@ extension HomeTimelineViewController { private func savePositionBeforeScrollToTop() { // check save action interval // should not fast than 0.5s to prevent save when scrollToTop on-flying - if let record = viewModel.scrollPositionRecord { + if let record = viewModel?.scrollPositionRecord { let now = Date() guard now.timeIntervalSince(record.timestamp) > 0.5 else { // skip this save action @@ -437,7 +442,7 @@ extension HomeTimelineViewController { } } - guard let diffableDataSource = viewModel.diffableDataSource else { return } + guard let diffableDataSource = viewModel?.diffableDataSource else { return } guard let anchorIndexPaths = tableView.indexPathsForVisibleRows?.sorted() else { return } guard !anchorIndexPaths.isEmpty else { return } let anchorIndexPath = anchorIndexPaths[anchorIndexPaths.count / 2] @@ -448,7 +453,7 @@ extension HomeTimelineViewController { let cellFrameInView = tableView.convert(anchorCell.frame, to: view) return cellFrameInView.origin.y }() - viewModel.scrollPositionRecord = HomeTimelineViewModel.ScrollPositionRecord( + viewModel?.scrollPositionRecord = HomeTimelineViewModel.ScrollPositionRecord( item: anchorItem, offset: offset, timestamp: Date() @@ -463,19 +468,19 @@ extension HomeTimelineViewController { } private func restorePositionWhenScrollToTop() { - guard let diffableDataSource = self.viewModel.diffableDataSource else { return } - guard let record = self.viewModel.scrollPositionRecord, + guard let diffableDataSource = viewModel?.diffableDataSource else { return } + guard let record = viewModel?.scrollPositionRecord, let indexPath = diffableDataSource.indexPath(for: record.item) else { return } tableView.scrollToRow(at: indexPath, at: .middle, animated: true) - viewModel.scrollPositionRecord = nil + viewModel?.scrollPositionRecord = nil } } // MARK: - AuthContextProvider extension HomeTimelineViewController: AuthContextProvider { - var authContext: AuthContext { viewModel.authContext } + var authContext: AuthContext { viewModel!.authContext } } // MARK: - UITableViewDelegate @@ -508,7 +513,7 @@ extension HomeTimelineViewController: UITableViewDelegate, AutoGenerateTableView func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) { if indexPath.row == tableView.numberOfRows(inSection: indexPath.section) - 1 { - viewModel.timelineDidReachEnd() + viewModel?.timelineDidReachEnd() } } } @@ -516,12 +521,12 @@ extension HomeTimelineViewController: UITableViewDelegate, AutoGenerateTableView // MARK: - TimelineMiddleLoaderTableViewCellDelegate extension HomeTimelineViewController: TimelineMiddleLoaderTableViewCellDelegate { func timelineMiddleLoaderTableViewCell(_ cell: TimelineMiddleLoaderTableViewCell, loadMoreButtonDidPressed button: UIButton) { - guard let diffableDataSource = viewModel.diffableDataSource else { return } + guard let diffableDataSource = viewModel?.diffableDataSource else { return } guard let indexPath = tableView.indexPath(for: cell) else { return } guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return } Task { - await viewModel.loadMore(item: item) + await viewModel?.loadMore(item: item) } } } @@ -532,6 +537,8 @@ extension HomeTimelineViewController: ScrollViewContainer { var scrollView: UIScrollView { return tableView } func scrollToTop(animated: Bool) { + guard let viewModel else { return } + if scrollView.contentOffset.y < scrollView.frame.height, viewModel.loadLatestStateMachine.canEnterState(HomeTimelineViewModel.LoadLatestState.Loading.self), (scrollView.contentOffset.y + scrollView.adjustedContentInset.top) == 0.0, @@ -570,7 +577,7 @@ extension HomeTimelineViewController: HomeTimelineNavigationBarTitleViewDelegate func homeTimelineNavigationBarTitleView(_ titleView: HomeTimelineNavigationBarTitleView, buttonDidPressed sender: UIButton) { switch titleView.state { case .newPostButton: - guard let diffableDataSource = viewModel.diffableDataSource else { return } + guard let diffableDataSource = viewModel?.diffableDataSource else { return } let indexPath = IndexPath(row: 0, section: 0) guard diffableDataSource.itemIdentifier(for: indexPath) != nil else { return } diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+LoadLatestState.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+LoadLatestState.swift index 594faba35..a88bafebe 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+LoadLatestState.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+LoadLatestState.swift @@ -92,6 +92,7 @@ extension HomeTimelineViewModel.LoadLatestState { } do { + await AuthenticationServiceProvider.shared.fetchAccounts(apiService: viewModel.context.apiService) let response = try await viewModel.context.apiService.homeTimeline( authenticationBox: viewModel.authContext.mastodonAuthenticationBox ) @@ -99,8 +100,6 @@ extension HomeTimelineViewModel.LoadLatestState { await enter(state: Idle.self) viewModel.homeTimelineNavigationBarTitleViewModel.receiveLoadingStateCompletion(.finished) - viewModel.context.instanceService.updateMutesAndBlocks() - // stop refresher if no new statuses let statuses = response.value let newStatuses = statuses.filter { status in !latestStatusIDs.contains(where: { $0 == status.reblog?.id || $0 == status.id }) } diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+LoadOldestState.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+LoadOldestState.swift index 3f980bada..3e993141b 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+LoadOldestState.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+LoadOldestState.swift @@ -8,6 +8,7 @@ import Foundation import GameplayKit import MastodonSDK +import MastodonCore extension HomeTimelineViewModel { class LoadOldestState: GKState { @@ -60,6 +61,8 @@ extension HomeTimelineViewModel.LoadOldestState { } do { + await AuthenticationServiceProvider.shared.fetchAccounts(apiService: viewModel.context.apiService) + let response = try await viewModel.context.apiService.homeTimeline( maxID: maxID, authenticationBox: viewModel.authContext.mastodonAuthenticationBox diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel.swift index d56155d9a..f4efba2a3 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel.swift @@ -147,7 +147,9 @@ extension HomeTimelineViewModel { // reconfigure item snapshot.reconfigureItems([item]) await updateSnapshotUsingReloadData(snapshot: snapshot) - + + await AuthenticationServiceProvider.shared.fetchAccounts(apiService: context.apiService) + // fetch data let maxID = status.id _ = try? await context.apiService.homeTimeline( diff --git a/Mastodon/Scene/MediaPreview/MediaPreviewViewModel.swift b/Mastodon/Scene/MediaPreview/MediaPreviewViewModel.swift index a6b604d6f..334dc703f 100644 --- a/Mastodon/Scene/MediaPreview/MediaPreviewViewModel.swift +++ b/Mastodon/Scene/MediaPreview/MediaPreviewViewModel.swift @@ -11,6 +11,7 @@ import CoreData import CoreDataStack import Pageboy import MastodonCore +import MastodonSDK protocol MediaPreviewPage: UIViewController { func setShowingChrome(_ showingChrome: Bool) @@ -151,8 +152,8 @@ extension MediaPreviewViewModel { return true // default valid case .profileBanner(let item): guard let assertURL = item.assetURL else { return false } - guard !assertURL.hasSuffix("missing.png") else { return false } - return true + + return assertURL.hasSuffix(Mastodon.Entity.Account.missingImageName) == false } } } diff --git a/Mastodon/Scene/Notification/Cell/NotificationTableViewCell+ViewModel.swift b/Mastodon/Scene/Notification/Cell/NotificationTableViewCell+ViewModel.swift index 135a7adc2..9e0c4722a 100644 --- a/Mastodon/Scene/Notification/Cell/NotificationTableViewCell+ViewModel.swift +++ b/Mastodon/Scene/Notification/Cell/NotificationTableViewCell+ViewModel.swift @@ -9,6 +9,7 @@ import UIKit import Combine import CoreDataStack import MastodonSDK +import MastodonCore extension NotificationTableViewCell { final class ViewModel { @@ -29,7 +30,8 @@ extension NotificationTableViewCell { func configure( tableView: UITableView, viewModel: ViewModel, - delegate: NotificationTableViewCellDelegate? + delegate: NotificationTableViewCellDelegate?, + authenticationBox: MastodonAuthenticationBox ) { if notificationView.frame == .zero { // set status view width @@ -41,7 +43,7 @@ extension NotificationTableViewCell { switch viewModel.value { case .feed(let feed): - notificationView.configure(feed: feed) + notificationView.configure(feed: feed, authenticationBox: authenticationBox) } self.delegate = delegate @@ -57,7 +59,7 @@ extension NotificationTableViewCell { UIView.performWithoutAnimation { tableView.beginUpdates() - tableView.endUpdates() + tableView.endUpdates() } } .store(in: &disposeBag) diff --git a/Mastodon/Scene/Notification/NotificationTimeline/NotificationTimelineViewController+DataSourceProvider.swift b/Mastodon/Scene/Notification/NotificationTimeline/NotificationTimelineViewController+DataSourceProvider.swift index 13c5d315c..e4bcc8a12 100644 --- a/Mastodon/Scene/Notification/NotificationTimeline/NotificationTimelineViewController+DataSourceProvider.swift +++ b/Mastodon/Scene/Notification/NotificationTimeline/NotificationTimelineViewController+DataSourceProvider.swift @@ -22,10 +22,11 @@ extension NotificationTimelineViewController: DataSourceProvider { switch item { case .feed(let feed): - let managedObjectContext = context.managedObjectContext let item: DataSourceItem? = { guard feed.kind == .notificationAll || feed.kind == .notificationMentions else { return nil } - if let notification = feed.notification, let mastodonNotification = MastodonNotification.fromEntity(notification, using: managedObjectContext, domain: authContext.mastodonAuthenticationBox.domain) { + + if let notification = feed.notification { + let mastodonNotification = MastodonNotification.fromEntity(notification, relationship: nil) return .notification(record: mastodonNotification) } else { return nil @@ -36,13 +37,13 @@ extension NotificationTimelineViewController: DataSourceProvider { return nil } } - + func update(status: MastodonStatus, intent: MastodonStatus.UpdateIntent) { Task { await viewModel.loadLatest() } } - + @MainActor private func indexPath(for cell: UITableViewCell) async -> IndexPath? { return tableView.indexPath(for: cell) diff --git a/Mastodon/Scene/Notification/NotificationTimeline/NotificationTimelineViewController.swift b/Mastodon/Scene/Notification/NotificationTimeline/NotificationTimelineViewController.swift index 053ce9e76..e5c29cbd6 100644 --- a/Mastodon/Scene/Notification/NotificationTimeline/NotificationTimelineViewController.swift +++ b/Mastodon/Scene/Notification/NotificationTimeline/NotificationTimelineViewController.swift @@ -38,8 +38,6 @@ final class NotificationTimelineViewController: UIViewController, NeedsDependenc }() let cellFrameCache = NSCache() - - } extension NotificationTimelineViewController { @@ -276,7 +274,6 @@ extension NotificationTimelineViewController: TableViewControllerNavigateable { guard let indexPathForSelectedRow = tableView.indexPathForSelectedRow else { return } guard let diffableDataSource = viewModel.diffableDataSource else { return } guard let item = diffableDataSource.itemIdentifier(for: indexPathForSelectedRow) else { return } - let domain = authContext.mastodonAuthenticationBox.domain Task { @MainActor in switch item { @@ -295,25 +292,8 @@ extension NotificationTimelineViewController: TableViewControllerNavigateable { transition: .show ) } else { - context.managedObjectContext.perform { - let mastodonUserRequest = MastodonUser.sortedFetchRequest - mastodonUserRequest.predicate = MastodonUser.predicate(domain: domain, id: notification.account.id) - mastodonUserRequest.fetchLimit = 1 - guard let mastodonUser = try? self.context.managedObjectContext.fetch(mastodonUserRequest).first else { - return - } - - let profileViewModel = ProfileViewModel( - context: self.context, - authContext: self.viewModel.authContext, - optionalMastodonUser: mastodonUser - ) - _ = self.coordinator.present( - scene: .profile(viewModel: profileViewModel), - from: self, - transition: .show - ) - } + + await DataSourceFacade.coordinateToProfileScene(provider: self, account: notification.account) } default: break diff --git a/Mastodon/Scene/Notification/NotificationTimeline/NotificationTimelineViewModel+LoadOldestState.swift b/Mastodon/Scene/Notification/NotificationTimeline/NotificationTimelineViewModel+LoadOldestState.swift index 17222acd7..099961856 100644 --- a/Mastodon/Scene/Notification/NotificationTimeline/NotificationTimelineViewModel+LoadOldestState.swift +++ b/Mastodon/Scene/Notification/NotificationTimeline/NotificationTimelineViewModel+LoadOldestState.swift @@ -54,7 +54,6 @@ extension NotificationTimelineViewModel.LoadOldestState { let scope = viewModel.scope Task { - let managedObjectContext = viewModel.context.managedObjectContext let _maxID: Mastodon.Entity.Notification.ID? = lastFeedRecord.notification?.id guard let maxID = _maxID else { diff --git a/Mastodon/Scene/Notification/NotificationTimeline/NotificationTimelineViewModel.swift b/Mastodon/Scene/Notification/NotificationTimeline/NotificationTimelineViewModel.swift index a3bcbb877..c2ac144f8 100644 --- a/Mastodon/Scene/Notification/NotificationTimeline/NotificationTimelineViewModel.swift +++ b/Mastodon/Scene/Notification/NotificationTimeline/NotificationTimelineViewModel.swift @@ -53,18 +53,18 @@ final class NotificationTimelineViewModel { self.authContext = authContext self.scope = scope self.dataController = FeedDataController(context: context, authContext: authContext) - + switch scope { case .everything: self.dataController.records = (try? FileManager.default.cachedNotificationsAll(for: authContext.mastodonAuthenticationBox))?.map({ notification in - MastodonFeed.fromNotification(notification, kind: .notificationAll) + MastodonFeed.fromNotification(notification, relationship: nil, kind: .notificationAll) }) ?? [] case .mentions: self.dataController.records = (try? FileManager.default.cachedNotificationsMentions(for: authContext.mastodonAuthenticationBox))?.map({ notification in - MastodonFeed.fromNotification(notification, kind: .notificationMentions) + MastodonFeed.fromNotification(notification, relationship: nil, kind: .notificationMentions) }) ?? [] } - + self.dataController.$records .removeDuplicates() .receive(on: DispatchQueue.main) diff --git a/Mastodon/Scene/Notification/NotificationView/NotificationView+Configuration.swift b/Mastodon/Scene/Notification/NotificationView/NotificationView+Configuration.swift new file mode 100644 index 000000000..26c6cc2e4 --- /dev/null +++ b/Mastodon/Scene/Notification/NotificationView/NotificationView+Configuration.swift @@ -0,0 +1,281 @@ +// +// NotificationView+Configuration.swift +// Mastodon +// +// Created by MainasuK on 2022-1-21. +// + +import UIKit +import Combine +import MastodonUI +import CoreDataStack +import MetaTextKit +import MastodonMeta +import Meta +import MastodonAsset +import MastodonCore +import MastodonLocalization +import MastodonSDK + +extension NotificationView { + public func configure(feed: MastodonFeed, authenticationBox: MastodonAuthenticationBox) { + guard let notification = feed.notification else { + assertionFailure() + return + } + + let entity = MastodonNotification.fromEntity( + notification, + relationship: feed.relationship + ) + + configure(notification: entity, authenticationBox: authenticationBox) + } +} + +extension NotificationView { + public func configure(notification: MastodonNotification, authenticationBox: MastodonAuthenticationBox) { + configureAuthor(notification: notification, authenticationBox: authenticationBox) + + switch notification.entity.type { + case .follow: + setAuthorContainerBottomPaddingViewDisplay() + case .followRequest: + setFollowRequestAdaptiveMarginContainerViewDisplay() + case .mention, .status: + if let status = notification.status { + statusView.configure(status: status) + setStatusViewDisplay() + } + case .reblog, .favourite, .poll: + if let status = notification.status { + quoteStatusView.configure(status: status) + setQuoteStatusViewDisplay() + } + case ._other: + setAuthorContainerBottomPaddingViewDisplay() + assertionFailure() + } + + } + + private func configureAuthor(notification: MastodonNotification, authenticationBox: MastodonAuthenticationBox) { + let author = notification.account + + // author avatar + let configuration = AvatarImageView.Configuration(url: author.avatarImageURL()) + avatarButton.avatarImageView.configure(configuration: configuration) + avatarButton.avatarImageView.configure(cornerConfiguration: .init(corner: .fixed(radius: 12))) + + // author name + let metaAuthorName: MetaContent + do { + let content = MastodonContent(content: author.displayNameWithFallback, emojis: author.emojis.asDictionary) + metaAuthorName = try MastodonMetaContent.convert(document: content) + } catch { + assertionFailure(error.localizedDescription) + metaAuthorName = PlaintextMetaContent(string: author.displayNameWithFallback) + } + authorNameLabel.configure(content: metaAuthorName) + + // username + let metaUsername = PlaintextMetaContent(string: "@\(author.acct)") + authorUsernameLabel.configure(content: metaUsername) + + let visibility = notification.entity.status?.mastodonVisibility ?? ._other("") + visibilityIconImageView.image = visibility.image + + // notification type indicator + let notificationIndicatorText: MetaContent? + if let type = MastodonNotificationType(rawValue: notification.entity.type.rawValue) { + // TODO: fix the i18n. The subject should assert place at the string beginning + func createMetaContent(text: String, emojis: MastodonContent.Emojis) -> MetaContent { + let content = MastodonContent(content: text, emojis: emojis) + guard let metaContent = try? MastodonMetaContent.convert(document: content) else { + return PlaintextMetaContent(string: text) + } + return metaContent + } + + switch type { + case .follow: + notificationIndicatorText = createMetaContent( + text: L10n.Scene.Notification.NotificationDescription.followedYou, + emojis: author.emojis.asDictionary + ) + case .followRequest: + notificationIndicatorText = createMetaContent( + text: L10n.Scene.Notification.NotificationDescription.requestToFollowYou, + emojis: author.emojis.asDictionary + ) + case .mention: + notificationIndicatorText = createMetaContent( + text: L10n.Scene.Notification.NotificationDescription.mentionedYou, + emojis: author.emojis.asDictionary + ) + case .reblog: + notificationIndicatorText = createMetaContent( + text: L10n.Scene.Notification.NotificationDescription.rebloggedYourPost, + emojis: author.emojis.asDictionary + ) + case .favourite: + notificationIndicatorText = createMetaContent( + text: L10n.Scene.Notification.NotificationDescription.favoritedYourPost, + emojis: author.emojis.asDictionary + ) + case .poll: + notificationIndicatorText = createMetaContent( + text: L10n.Scene.Notification.NotificationDescription.pollHasEnded, + emojis: author.emojis.asDictionary + ) + case .status: + notificationIndicatorText = createMetaContent( + text: .empty, + emojis: author.emojis.asDictionary + ) + case ._other: + notificationIndicatorText = nil + } + + var actions = [UIAccessibilityCustomAction]() + + // these notifications can be directly actioned to view the profile + if type != .follow, type != .followRequest { + actions.append( + UIAccessibilityCustomAction( + name: L10n.Common.Controls.Status.showUserProfile, + image: nil + ) { [weak self] _ in + guard let self, let delegate = self.delegate else { return false } + delegate.notificationView(self, authorAvatarButtonDidPressed: self.avatarButton) + return true + } + ) + } + + if type == .followRequest { + actions.append( + UIAccessibilityCustomAction( + name: L10n.Common.Controls.Actions.confirm, + image: Asset.Editing.checkmark20.image + ) { [weak self] _ in + guard let self, let delegate = self.delegate else { return false } + delegate.notificationView(self, acceptFollowRequestButtonDidPressed: self.acceptFollowRequestButton) + return true + } + ) + + actions.append( + UIAccessibilityCustomAction( + name: L10n.Common.Controls.Actions.delete, + image: Asset.Circles.forbidden20.image + ) { [weak self] _ in + guard let self, let delegate = self.delegate else { return false } + delegate.notificationView(self, rejectFollowRequestButtonDidPressed: self.rejectFollowRequestButton) + return true + } + ) + } + + notificationActions = actions + + } else { + notificationIndicatorText = nil + notificationActions = [] + } + + if let notificationIndicatorText { + notificationTypeIndicatorLabel.configure(content: notificationIndicatorText) + } else { + notificationTypeIndicatorLabel.reset() + } + + if let me = authenticationBox.authentication.account() { + let isMyself = (author == me) + let isMuting: Bool + let isBlocking: Bool + + if let relationship = notification.relationship { + isMuting = relationship.muting + isBlocking = relationship.blocking || relationship.domainBlocking + } else { + isMuting = false + isBlocking = false + } + + let menuContext = NotificationView.AuthorMenuContext(name: metaAuthorName.string, isMuting: isMuting, isBlocking: isBlocking, isMyself: isMyself) + let (menu, actions) = setupAuthorMenu(menuContext: menuContext) + menuButton.menu = menu + authorActions = actions + menuButton.showsMenuAsPrimaryAction = true + + menuButton.isHidden = menuContext.isMyself + } + + timestampUpdatePublisher + .prepend(Date()) + .eraseToAnyPublisher() + .sink { [weak self] now in + guard let self, let type = MastodonNotificationType(rawValue: notification.entity.type.rawValue) else { return } + + let formattedTimestamp = now.localizedTimeAgo(since: notification.entity.createdAt) + dateLabel.configure(content: PlaintextMetaContent(string: formattedTimestamp)) + + self.accessibilityLabel = [ + "\(author.displayNameWithFallback) \(type)", + author.acct, + formattedTimestamp + ].joined(separator: ", ") + if self.statusView.isHidden == false { + self.accessibilityLabel! += ", " + (self.statusView.accessibilityLabel ?? "") + } + if self.quoteStatusViewContainerView.isHidden == false { + self.accessibilityLabel! += ", " + (self.quoteStatusView.accessibilityLabel ?? "") + } + + } + .store(in: &disposeBag) + + switch notification.followRequestState.state { + case .isAccept: + self.rejectFollowRequestButtonShadowBackgroundContainer.isHidden = true + self.acceptFollowRequestButton.isUserInteractionEnabled = false + self.acceptFollowRequestButton.setImage(nil, for: .normal) + self.acceptFollowRequestButton.setTitle(L10n.Scene.Notification.FollowRequest.accepted, for: .normal) + case .isReject: + self.acceptFollowRequestButtonShadowBackgroundContainer.isHidden = true + self.rejectFollowRequestButton.isUserInteractionEnabled = false + self.rejectFollowRequestButton.setImage(nil, for: .normal) + self.rejectFollowRequestButton.setTitle(L10n.Scene.Notification.FollowRequest.rejected, for: .normal) + default: + break + } + + let state = notification.transientFollowRequestState.state + if state == .isAccepting { + self.acceptFollowRequestActivityIndicatorView.startAnimating() + self.acceptFollowRequestButton.tintColor = .clear + self.acceptFollowRequestButton.setTitleColor(.clear, for: .normal) + } else { + self.acceptFollowRequestActivityIndicatorView.stopAnimating() + self.acceptFollowRequestButton.tintColor = .white + self.acceptFollowRequestButton.setTitleColor(.white, for: .normal) + } + if state == .isRejecting { + self.rejectFollowRequestActivityIndicatorView.startAnimating() + self.rejectFollowRequestButton.tintColor = .clear + self.rejectFollowRequestButton.setTitleColor(.clear, for: .normal) + } else { + self.rejectFollowRequestActivityIndicatorView.stopAnimating() + self.rejectFollowRequestButton.tintColor = .black + self.rejectFollowRequestButton.setTitleColor(.black, for: .normal) + } + + if state == .isAccept { + self.rejectFollowRequestButtonShadowBackgroundContainer.isHidden = true + } + if state == .isReject { + self.acceptFollowRequestButtonShadowBackgroundContainer.isHidden = true + } + } +} diff --git a/MastodonSDK/Sources/MastodonUI/View/Content/NotificationView.swift b/Mastodon/Scene/Notification/NotificationView/NotificationView.swift similarity index 98% rename from MastodonSDK/Sources/MastodonUI/View/Content/NotificationView.swift rename to Mastodon/Scene/Notification/NotificationView/NotificationView.swift index 394904dc1..a4ccb2dd3 100644 --- a/MastodonSDK/Sources/MastodonUI/View/Content/NotificationView.swift +++ b/Mastodon/Scene/Notification/NotificationView/NotificationView.swift @@ -12,6 +12,7 @@ import Meta import MastodonCore import MastodonAsset import MastodonLocalization +import MastodonUI public protocol NotificationViewDelegate: AnyObject { func notificationView(_ notificationView: NotificationView, authorAvatarButtonDidPressed button: AvatarButton) @@ -47,12 +48,6 @@ public final class NotificationView: UIView { var notificationActions = [UIAccessibilityCustomAction]() var authorActions = [UIAccessibilityCustomAction]() - public private(set) lazy var viewModel: ViewModel = { - let viewModel = ViewModel() - viewModel.bind(notificationView: self) - return viewModel - }() - let containerStackView: UIStackView = { let stackView = UIStackView() stackView.axis = .vertical @@ -175,14 +170,16 @@ public final class NotificationView: UIView { public let quoteStatusViewContainerView = UIView() public let quoteBackgroundView = UIView() public let quoteStatusView = StatusView() - + + let timestampUpdatePublisher = Timer.publish(every: 1.0, on: .main, in: .common) + .autoconnect() + .share() + .eraseToAnyPublisher() + public func prepareForReuse() { disposeBag.removeAll() - - viewModel.objects.removeAll() - viewModel.authContext = nil - viewModel.authorAvatarImageURL = nil + avatarButton.avatarImageView.image = nil avatarButton.avatarImageView.cancelTask() authorContainerViewBottomPaddingView.isHidden = true @@ -478,8 +475,14 @@ extension NotificationView: AdaptiveContainerView { } extension NotificationView { - public typealias AuthorMenuContext = StatusAuthorView.AuthorMenuContext + public struct AuthorMenuContext { + public let name: String + public let isMuting: Bool + public let isBlocking: Bool + public let isMyself: Bool + } + public func setupAuthorMenu(menuContext: AuthorMenuContext) -> (UIMenu, [UIAccessibilityCustomAction]) { var actions: [[MastodonMenu.Action]] = [] var upperActions: [MastodonMenu.Action] = [] diff --git a/Mastodon/Scene/Notification/NotificationViewController.swift b/Mastodon/Scene/Notification/NotificationViewController.swift index a266049bd..f3f48a1ec 100644 --- a/Mastodon/Scene/Notification/NotificationViewController.swift +++ b/Mastodon/Scene/Notification/NotificationViewController.swift @@ -21,7 +21,7 @@ final class NotificationViewController: TabmanViewController, NeedsDependency { var disposeBag = Set() var observations = Set() - var viewModel: NotificationViewModel! + var viewModel: NotificationViewModel? let pageSegmentedControl = UISegmentedControl() @@ -38,7 +38,7 @@ final class NotificationViewController: TabmanViewController, NeedsDependency { animated: animated ) - viewModel.currentPageIndex = index + viewModel?.currentPageIndex = index } } @@ -49,7 +49,7 @@ extension NotificationViewController { view.backgroundColor = .secondarySystemBackground - setupSegmentedControl(scopes: viewModel.scopes) + setupSegmentedControl(scopes: APIService.MastodonNotificationScope.allCases) pageSegmentedControl.translatesAutoresizingMaskIntoConstraints = false navigationItem.titleView = pageSegmentedControl NSLayoutConstraint.activate([ @@ -58,7 +58,7 @@ extension NotificationViewController { pageSegmentedControl.addTarget(self, action: #selector(NotificationViewController.pageSegmentedControlValueChanged(_:)), for: .valueChanged) dataSource = viewModel - viewModel.$viewControllers + viewModel?.$viewControllers .receive(on: DispatchQueue.main) .sink { [weak self] viewControllers in guard let self = self else { return } @@ -68,11 +68,11 @@ extension NotificationViewController { } .store(in: &disposeBag) - viewModel.viewControllers = viewModel.scopes.map { scope in + viewModel?.viewControllers = APIService.MastodonNotificationScope.allCases.map { scope in createViewController(for: scope) } - viewModel.$currentPageIndex + viewModel?.$currentPageIndex .receive(on: DispatchQueue.main) .sink { [weak self] currentPageIndex in guard let self = self else { return } @@ -127,7 +127,7 @@ extension NotificationViewController { } // set initial selection - guard !pageSegmentedControl.isSelected else { return } + guard let viewModel, !pageSegmentedControl.isSelected else { return } if viewModel.currentPageIndex < pageSegmentedControl.numberOfSegments { pageSegmentedControl.selectedSegmentIndex = viewModel.currentPageIndex } else { @@ -136,12 +136,13 @@ extension NotificationViewController { } private func createViewController(for scope: NotificationTimelineViewModel.Scope) -> UIViewController { + guard let authContext = viewModel?.authContext else { return UITableViewController() } let viewController = NotificationTimelineViewController() viewController.context = context viewController.coordinator = coordinator viewController.viewModel = NotificationTimelineViewModel( context: context, - authContext: viewModel.authContext, + authContext: authContext, scope: scope ) return viewController diff --git a/Mastodon/Scene/Notification/NotificationViewModel.swift b/Mastodon/Scene/Notification/NotificationViewModel.swift index 39c1b9ca5..8fa48da2f 100644 --- a/Mastodon/Scene/Notification/NotificationViewModel.swift +++ b/Mastodon/Scene/Notification/NotificationViewModel.swift @@ -22,7 +22,6 @@ final class NotificationViewModel { let viewDidLoad = PassthroughSubject() // output - let scopes = NotificationTimelineViewModel.Scope.allCases @Published var viewControllers: [UIViewController] = [] @Published var currentPageIndex = 0 { didSet { diff --git a/Mastodon/Scene/Onboarding/Login/MastodonLoginViewController.swift b/Mastodon/Scene/Onboarding/Login/MastodonLoginViewController.swift index 1ca300c8f..fc35c6f83 100644 --- a/Mastodon/Scene/Onboarding/Login/MastodonLoginViewController.swift +++ b/Mastodon/Scene/Onboarding/Login/MastodonLoginViewController.swift @@ -139,30 +139,22 @@ class MastodonLoginViewController: UIViewController, NeedsDependency { @objc func login() { guard let server = viewModel.selectedServer else { return } - + authenticationViewModel - .authenticated - .asyncMap { domain, user -> Result in - do { - let result = try await self.context.authenticationService.activeMastodonUser(domain: domain, userID: user.id) - return .success(result) - } catch { - return .failure(error) - } - } - .receive(on: DispatchQueue.main) - .sink { [weak self] result in - guard let self = self else { return } - switch result { - case .failure(let error): - assertionFailure(error.localizedDescription) - case .success(let isActived): - assert(isActived) - self.coordinator.setup() + .authenticated.sink { (domain, account) in + Task { @MainActor in + do { + _ = try await self.context.authenticationService.activeMastodonUser(domain: domain, userID: account.id) + FileManager.default.store(account: account, forUserID: MastodonUserIdentifier(domain: domain, userID: account.id)) + + self.coordinator.setup() + } catch { + assertionFailure(error.localizedDescription) + } } } .store(in: &disposeBag) - + authenticationViewModel.isAuthenticating.send(true) context.apiService.createApplication(domain: server.domain) .tryMap { response -> AuthenticationViewModel.AuthenticateInfo in diff --git a/Mastodon/Scene/Onboarding/Share/AuthenticationViewModel.swift b/Mastodon/Scene/Onboarding/Share/AuthenticationViewModel.swift index 192eefb65..9f4a4ff90 100644 --- a/Mastodon/Scene/Onboarding/Share/AuthenticationViewModel.swift +++ b/Mastodon/Scene/Onboarding/Share/AuthenticationViewModel.swift @@ -187,7 +187,6 @@ extension AuthenticationViewModel { userToken: Mastodon.Entity.Token ) -> AnyPublisher, Error> { let authorization = Mastodon.API.OAuth.Authorization(accessToken: userToken.accessToken) - let managedObjectContext = context.backgroundManagedObjectContext return context.apiService.accountVerifyCredentials( domain: info.domain, @@ -195,23 +194,21 @@ extension AuthenticationViewModel { ) .tryMap { response -> Mastodon.Response.Content in let account = response.value - let mastodonUserRequest = MastodonUser.sortedFetchRequest - mastodonUserRequest.predicate = MastodonUser.predicate(domain: info.domain, id: account.id) - mastodonUserRequest.fetchLimit = 1 - guard let mastodonUser = try? managedObjectContext.fetch(mastodonUserRequest).first else { - throw AuthenticationError.badCredentials - } - + + let authentication = MastodonAuthentication.createFrom(domain: info.domain, + userID: account.id, + username: account.username, + appAccessToken: userToken.accessToken, // TODO: swap app token + userAccessToken: userToken.accessToken, + clientID: info.clientID, + clientSecret: info.clientSecret) + AuthenticationServiceProvider.shared .authentications - .insert(MastodonAuthentication.createFrom(domain: info.domain, - userID: mastodonUser.id, - username: mastodonUser.username, - appAccessToken: userToken.accessToken, // TODO: swap app token - userAccessToken: userToken.accessToken, - clientID: info.clientID, - clientSecret: info.clientSecret), at: 0) - + .insert(authentication, at: 0) + + FileManager.default.store(account: account, forUserID: authentication.userIdentifier()) + return response } .eraseToAnyPublisher() diff --git a/Mastodon/Scene/Profile/About/ProfileAboutViewModel.swift b/Mastodon/Scene/Profile/About/ProfileAboutViewModel.swift index f9a0b1c9d..5b5488b6d 100644 --- a/Mastodon/Scene/Profile/About/ProfileAboutViewModel.swift +++ b/Mastodon/Scene/Profile/About/ProfileAboutViewModel.swift @@ -19,7 +19,7 @@ final class ProfileAboutViewModel { // input let context: AppContext - @Published var user: MastodonUser? + @Published var account: Mastodon.Entity.Account @Published var isEditing = false @Published var accountForEdit: Mastodon.Entity.Account? @@ -32,25 +32,13 @@ final class ProfileAboutViewModel { @Published var emojiMeta: MastodonContent.Emojis = [:] @Published var createdAt: Date = Date() - init(context: AppContext) { + init(context: AppContext, account: Mastodon.Entity.Account) { + self.account = account self.context = context - // end init - - $user - .compactMap { $0 } - .flatMap { $0.publisher(for: \.emojis) } - .map { $0.asDictionary } - .assign(to: &$emojiMeta) - - $user - .compactMap { $0 } - .flatMap { $0.publisher(for: \.fields) } - .assign(to: &$fields) - $user - .compactMap { $0 } - .flatMap { $0.publisher(for: \.createdAt) } - .assign(to: &$createdAt) + emojiMeta = account.emojiMeta + fields = account.mastodonFields + createdAt = account.createdAt Publishers.CombineLatest( $fields, diff --git a/Mastodon/Scene/Profile/Favorite/FavoriteViewModel+State.swift b/Mastodon/Scene/Profile/Favorite/FavoriteViewModel+State.swift index cb246448c..a5c2ded6e 100644 --- a/Mastodon/Scene/Profile/Favorite/FavoriteViewModel+State.swift +++ b/Mastodon/Scene/Profile/Favorite/FavoriteViewModel+State.swift @@ -58,7 +58,7 @@ extension FavoriteViewModel.State { Task { // reset await viewModel.dataController.reset() - + stateMachine.enter(Loading.self) } } diff --git a/Mastodon/Scene/Profile/Follower/FollowerListViewModel.swift b/Mastodon/Scene/Profile/Follower/FollowerListViewModel.swift index b0d31417a..0f4b2fba1 100644 --- a/Mastodon/Scene/Profile/Follower/FollowerListViewModel.swift +++ b/Mastodon/Scene/Profile/Follower/FollowerListViewModel.swift @@ -7,8 +7,6 @@ import Foundation import Combine -import CoreData -import CoreDataStack import GameplayKit import MastodonSDK import MastodonCore diff --git a/Mastodon/Scene/Profile/Header/ProfileHeaderViewController.swift b/Mastodon/Scene/Profile/Header/ProfileHeaderViewController.swift index bc3c1dfa8..81f723402 100644 --- a/Mastodon/Scene/Profile/Header/ProfileHeaderViewController.swift +++ b/Mastodon/Scene/Profile/Header/ProfileHeaderViewController.swift @@ -18,6 +18,7 @@ import MastodonCore import MastodonUI import MastodonLocalization import TabBarPager +import MastodonSDK protocol ProfileHeaderViewControllerDelegate: AnyObject { func profileHeaderViewController(_ profileHeaderViewController: ProfileHeaderViewController, profileHeaderView: ProfileHeaderView, relationshipButtonDidPressed button: ProfileRelationshipActionButton) @@ -29,12 +30,12 @@ final class ProfileHeaderViewController: UIViewController, NeedsDependency, Medi static let segmentedControlHeight: CGFloat = 50 static let headerMinHeight: CGFloat = segmentedControlHeight - weak var context: AppContext! { willSet { precondition(!isViewLoaded) } } + weak var context: AppContext! weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } } var disposeBag = Set() - var viewModel: ProfileHeaderViewModel! - + let viewModel: ProfileHeaderViewModel + weak var delegate: ProfileHeaderViewControllerDelegate? weak var headerDelegate: TabBarPagerHeaderDelegate? @@ -51,7 +52,7 @@ final class ProfileHeaderViewController: UIViewController, NeedsDependency, Medi return titleView }() - let profileHeaderView = ProfileHeaderView() + let profileHeaderView: ProfileHeaderView // private var isBannerPinned = false @@ -81,14 +82,29 @@ final class ProfileHeaderViewController: UIViewController, NeedsDependency, Medi return documentPickerController }() - -} + init(context: AppContext, authContext: AuthContext, coordinator: SceneCoordinator, profileViewModel: ProfileViewModel) { + self.context = context + self.coordinator = coordinator + self.viewModel = ProfileHeaderViewModel(context: context, authContext: authContext, account: profileViewModel.account, me: profileViewModel.me, relationship: profileViewModel.relationship) + self.profileHeaderView = ProfileHeaderView(account: profileViewModel.account, me: profileViewModel.me, relationship: profileViewModel.relationship) -extension ProfileHeaderViewController { + super.init(nibName: nil, bundle: nil) + + viewModel.$account + .receive(on: DispatchQueue.main) + .sink { [weak self] account in + guard let self else { return } + + self.profileHeaderView.configuration(account: account) + } + .store(in: &disposeBag) + } + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + override func viewDidLoad() { super.viewDidLoad() - + view.setContentHuggingPriority(.required - 1, for: .vertical) view.backgroundColor = .systemBackground @@ -128,17 +144,11 @@ extension ProfileHeaderViewController { self.titleView.subtitleLabel.alpha = isTitleViewContentOffsetDidSet ? 1 : 0 } .store(in: &disposeBag) - viewModel.$user - .receive(on: DispatchQueue.main) - .sink { [weak self] user in - guard let self = self else { return } - guard let user = user else { return } - self.profileHeaderView.prepareForReuse() - self.profileHeaderView.configuration(user: user) - } + viewModel.$relationship + .assign(to: \.relationship, on: profileHeaderView.viewModel) .store(in: &disposeBag) - viewModel.$relationshipActionOptionSet - .assign(to: \.relationshipActionOptionSet, on: profileHeaderView.viewModel) + viewModel.$account + .assign(to: \.account, on: profileHeaderView.viewModel) .store(in: &disposeBag) viewModel.$isMyself .assign(to: \.isMyself, on: profileHeaderView.viewModel) @@ -263,41 +273,35 @@ extension ProfileHeaderViewController { profileHeaderView.avatarButton.alpha = alpha profileHeaderView.editAvatarBackgroundView.alpha = alpha } - + } // MARK: - ProfileHeaderViewDelegate extension ProfileHeaderViewController: ProfileHeaderViewDelegate { func profileHeaderView(_ profileHeaderView: ProfileHeaderView, avatarButtonDidPressed button: AvatarButton) { - guard let user = viewModel.user else { return } - let record: ManagedObjectRecord = .init(objectID: user.objectID) - Task { try await DataSourceFacade.coordinateToMediaPreviewScene( dependency: self, - user: record, + account: viewModel.account, previewContext: DataSourceFacade.ImagePreviewContext( imageView: button.avatarImageView, containerView: .profileAvatar(profileHeaderView) ) ) - } // end Task + } } func profileHeaderView(_ profileHeaderView: ProfileHeaderView, bannerImageViewDidPressed imageView: UIImageView) { - guard let user = viewModel.user else { return } - let record: ManagedObjectRecord = .init(objectID: user.objectID) - Task { try await DataSourceFacade.coordinateToMediaPreviewScene( dependency: self, - user: record, + account: viewModel.account, previewContext: DataSourceFacade.ImagePreviewContext( imageView: imageView, containerView: .profileBanner(profileHeaderView) ) ) - } // end Task + } } func profileHeaderView( @@ -329,37 +333,37 @@ extension ProfileHeaderViewController: ProfileHeaderViewDelegate { switch meter { case .post: // do nothing - break - case .follower: - guard let domain = viewModel.user?.domain, - let userID = viewModel.user?.id - else { return } - let followerListViewModel = FollowerListViewModel( - context: context, - authContext: viewModel.authContext, - domain: domain, - userID: userID - ) - _ = coordinator.present( - scene: .follower(viewModel: followerListViewModel), - from: self, - transition: .show - ) - case .following: - guard let domain = viewModel.user?.domain, - let userID = viewModel.user?.id - else { return } - let followingListViewModel = FollowingListViewModel( - context: context, - authContext: viewModel.authContext, - domain: domain, - userID: userID - ) - _ = coordinator.present( - scene: .following(viewModel: followingListViewModel), - from: self, - transition: .show - ) + break + case .follower: + guard let domain = viewModel.account.domain else { return } + let userID = viewModel.account.id + let followerListViewModel = FollowerListViewModel( + context: context, + authContext: viewModel.authContext, + domain: domain, + userID: userID + ) + _ = coordinator.present( + scene: .follower(viewModel: followerListViewModel), + from: self, + transition: .show + ) + + case .following: + guard let domain = viewModel.account.domain else { return } + + let userID = viewModel.account.id + let followingListViewModel = FollowingListViewModel( + context: context, + authContext: viewModel.authContext, + domain: domain, + userID: userID + ) + _ = coordinator.present( + scene: .following(viewModel: followingListViewModel), + from: self, + transition: .show + ) } } diff --git a/Mastodon/Scene/Profile/Header/ProfileHeaderViewModel.swift b/Mastodon/Scene/Profile/Header/ProfileHeaderViewModel.swift index 9301aea50..11a499109 100644 --- a/Mastodon/Scene/Profile/Header/ProfileHeaderViewModel.swift +++ b/Mastodon/Scene/Profile/Header/ProfileHeaderViewModel.swift @@ -26,8 +26,9 @@ final class ProfileHeaderViewModel { let context: AppContext let authContext: AuthContext - @Published var user: MastodonUser? - @Published var relationshipActionOptionSet: RelationshipActionOptionSet = .none + @Published var me: Mastodon.Entity.Account + @Published var account: Mastodon.Entity.Account + @Published var relationship: Mastodon.Entity.Relationship? @Published var isMyself = false @Published var isEditing = false @@ -44,10 +45,13 @@ final class ProfileHeaderViewModel { @Published var isTitleViewDisplaying = false @Published var isTitleViewContentOffsetSet = false - init(context: AppContext, authContext: AuthContext) { + init(context: AppContext, authContext: AuthContext, account: Mastodon.Entity.Account, me: Mastodon.Entity.Account, relationship: Mastodon.Entity.Relationship?) { self.context = context self.authContext = authContext - + self.account = account + self.me = me + self.relationship = relationship + $accountForEdit .receive(on: DispatchQueue.main) .sink { [weak self] account in diff --git a/Mastodon/Scene/Profile/Header/View/ProfileHeaderView+Configuration.swift b/Mastodon/Scene/Profile/Header/View/ProfileHeaderView+Configuration.swift index 8e1693142..2d8a6cdd0 100644 --- a/Mastodon/Scene/Profile/Header/View/ProfileHeaderView+Configuration.swift +++ b/Mastodon/Scene/Profile/Header/View/ProfileHeaderView+Configuration.swift @@ -7,49 +7,19 @@ import UIKit import Combine -import CoreDataStack +import MastodonSDK extension ProfileHeaderView { - func configuration(user: MastodonUser) { - // header - user.publisher(for: \.header) - .map { _ in user.headerImageURL() } - .assign(to: \.headerImageURL, on: viewModel) - .store(in: &disposeBag) - // avatar - user.publisher(for: \.avatar) - .map { _ in user.avatarImageURL() } - .assign(to: \.avatarImageURL, on: viewModel) - .store(in: &disposeBag) - // emojiMeta - user.publisher(for: \.emojis) - .map { $0.asDictionary } - .assign(to: \.emojiMeta, on: viewModel) - .store(in: &disposeBag) - // name - user.publisher(for: \.displayName) - .map { _ in user.displayNameWithFallback } - .assign(to: \.name, on: viewModel) - .store(in: &disposeBag) - // username - viewModel.acct = user.acctWithDomain - // bio - user.publisher(for: \.note) - .assign(to: \.note, on: viewModel) - .store(in: &disposeBag) - // dashboard - user.publisher(for: \.statusesCount) - .map { Int($0) } - .assign(to: \.statusesCount, on: viewModel) - .store(in: &disposeBag) - user.publisher(for: \.followingCount) - .map { Int($0) } - .assign(to: \.followingCount, on: viewModel) - .store(in: &disposeBag) - user.publisher(for: \.followersCount) - .map { Int($0) } - .assign(to: \.followersCount, on: viewModel) - .store(in: &disposeBag) + func configuration(account: Mastodon.Entity.Account) { + viewModel.headerImageURL = account.headerImageURL() + viewModel.avatarImageURL = account.avatarImageURL() + viewModel.emojiMeta = account.emojiMeta + viewModel.name = account.displayNameWithFallback + viewModel.acct = account.acctWithDomain + viewModel.note = account.note + viewModel.statusesCount = account.statusesCount + viewModel.followingCount = account.followingCount + viewModel.followersCount = account.followersCount } } diff --git a/Mastodon/Scene/Profile/Header/View/ProfileHeaderView+ViewModel.swift b/Mastodon/Scene/Profile/Header/View/ProfileHeaderView+ViewModel.swift index fdfef4a2e..809eb9a4c 100644 --- a/Mastodon/Scene/Profile/Header/View/ProfileHeaderView+ViewModel.swift +++ b/Mastodon/Scene/Profile/Header/View/ProfileHeaderView+ViewModel.swift @@ -14,6 +14,7 @@ import MastodonCore import MastodonUI import MastodonAsset import MastodonLocalization +import MastodonSDK extension ProfileHeaderView { class ViewModel: ObservableObject { @@ -45,15 +46,16 @@ extension ProfileHeaderView { @Published var fields: [MastodonField] = [] - @Published var relationshipActionOptionSet: RelationshipActionOptionSet = .none + @Published var me: Mastodon.Entity.Account + @Published var account: Mastodon.Entity.Account + @Published var relationship: Mastodon.Entity.Relationship? @Published var isRelationshipActionButtonHidden = false @Published var isMyself = false - init() { - $relationshipActionOptionSet - .compactMap { $0.highPriorityAction(except: []) } - .map { $0 == .none } - .assign(to: &$isRelationshipActionButtonHidden) + init(account: Mastodon.Entity.Account, me: Mastodon.Entity.Account, relationship: Mastodon.Entity.Relationship?) { + self.account = account + self.me = me + self.relationship = relationship } } } @@ -96,13 +98,16 @@ extension ProfileHeaderView.ViewModel { } .store(in: &disposeBag) // follows you - $relationshipActionOptionSet - .map { $0.contains(.followingBy) && !$0.contains(.isMyself) } + Publishers.CombineLatest($relationship, $isMyself) + .map { relationship, isMyself in + return (relationship?.followedBy ?? false) && (isMyself == false) + } .receive(on: DispatchQueue.main) - .sink { isFollowingBy in - view.followsYouBlurEffectView.isHidden = !isFollowingBy + .sink { followsYou in + view.followsYouBlurEffectView.isHidden = (followsYou == false) } .store(in: &disposeBag) + // avatar Publishers.CombineLatest4( $avatarImageURL, @@ -118,8 +123,12 @@ extension ProfileHeaderView.ViewModel { } .store(in: &disposeBag) // blur for blocking & blockingBy - $relationshipActionOptionSet - .map { $0.contains(.blocking) || $0.contains(.blockingBy) || $0.contains(.domainBlocking) } + $relationship + .compactMap { relationship in + guard let relationship else { return false } + + return relationship.blocking || relationship.blockedBy || relationship.domainBlocking + } .sink { needsImageOverlayBlurred in UIView.animate(withDuration: 0.33) { let bannerEffect: UIVisualEffect? = needsImageOverlayBlurred ? ProfileHeaderView.bannerImageViewOverlayBlurEffect : nil @@ -182,17 +191,26 @@ extension ProfileHeaderView.ViewModel { view.bioMetaText.configure(content: metaContent) } .store(in: &disposeBag) - $relationshipActionOptionSet - .receive(on: DispatchQueue.main) - .sink { optionSet in - let isBlocking = optionSet.contains(.blocking) || optionSet.contains(.domainBlocking) - let isBlockedBy = optionSet.contains(.blockingBy) - let isSuspended = optionSet.contains(.suspended) + + Publishers.CombineLatest($relationship, $account) + .compactMap { relationship, account in + + guard let relationship else { return nil } + + let isBlocking = relationship.blocking || relationship.domainBlocking + let isBlockedBy = relationship.blockedBy + let isSuspended = account.suspended ?? false + let isNeedsHidden = isBlocking || isBlockedBy || isSuspended + return isNeedsHidden + } + .receive(on: DispatchQueue.main) + .sink { isNeedsHidden in view.bioMetaText.textView.isHidden = isNeedsHidden } .store(in: &disposeBag) + // dashboard $isMyself .receive(on: DispatchQueue.main) @@ -243,22 +261,20 @@ extension ProfileHeaderView.ViewModel { .store(in: &disposeBag) // relationship $isRelationshipActionButtonHidden - .assign(to: \.isHidden, on: view.relationshipActionButtonShadowContainer) + .assign(to: \.isHidden, on: view.relationshipActionButton) .store(in: &disposeBag) + Publishers.CombineLatest3( - $relationshipActionOptionSet, + Publishers.CombineLatest3($me, $account, $relationship).eraseToAnyPublisher(), $isEditing, $isUpdating ) .receive(on: DispatchQueue.main) - .sink { relationshipActionOptionSet, isEditing, isUpdating in - if relationshipActionOptionSet.contains(.edit) { - // check .edit state and set .editing when isEditing - view.relationshipActionButton.configure(actionOptionSet: isUpdating ? .updating : (isEditing ? .editing : .edit)) - view.configure(state: isEditing ? .editing : .normal) - } else { - view.relationshipActionButton.configure(actionOptionSet: relationshipActionOptionSet) - } + .sink { tuple, isEditing, isUpdating in + let (me, account, relationship) = tuple + guard let relationship else { return } + + view.relationshipActionButton.configure(relationship: relationship, between: account, and: me, isEditing: isEditing, isUpdating: isUpdating) } .store(in: &disposeBag) } diff --git a/Mastodon/Scene/Profile/Header/View/ProfileHeaderView.swift b/Mastodon/Scene/Profile/Header/View/ProfileHeaderView.swift index 92845fa4c..046b74876 100644 --- a/Mastodon/Scene/Profile/Header/View/ProfileHeaderView.swift +++ b/Mastodon/Scene/Profile/Header/View/ProfileHeaderView.swift @@ -13,6 +13,7 @@ import MastodonAsset import MastodonCore import MastodonLocalization import MastodonUI +import MastodonSDK protocol ProfileHeaderViewDelegate: AnyObject { func profileHeaderView(_ profileHeaderView: ProfileHeaderView, avatarButtonDidPressed button: AvatarButton) @@ -42,12 +43,8 @@ final class ProfileHeaderView: UIView { disposeBag.removeAll() } - private(set) lazy var viewModel: ViewModel = { - let viewModel = ViewModel() - viewModel.bind(view: self) - return viewModel - }() - + private(set) var viewModel: ViewModel + let bannerImageViewSingleTapGestureRecognizer = UITapGestureRecognizer.singleTapGestureRecognizer let bannerContainerView = UIView() let bannerImageView: UIImageView = { @@ -197,7 +194,6 @@ final class ProfileHeaderView: UIView { let statusDashboardView = ProfileStatusDashboardView() - let relationshipActionButtonShadowContainer = ShadowBackgroundContainer() let relationshipActionButton: ProfileRelationshipActionButton = { let button = ProfileRelationshipActionButton() button.titleLabel?.font = .systemFont(ofSize: 17, weight: .semibold) @@ -238,20 +234,14 @@ final class ProfileHeaderView: UIView { return metaText }() - override init(frame: CGRect) { - super.init(frame: frame) - _init() - } - - required init?(coder: NSCoder) { - super.init(coder: coder) - _init() - } - -} + init(account: Mastodon.Entity.Account, me: Mastodon.Entity.Account, relationship: Mastodon.Entity.Relationship?) { + + viewModel = ViewModel(account: account, me: me, relationship: relationship) + + super.init(frame: .zero) + + viewModel.bind(view: self) -extension ProfileHeaderView { - private func _init() { setColors() // banner @@ -378,7 +368,7 @@ extension ProfileHeaderView { avatarImageViewBackgroundView.bottomAnchor.constraint(equalTo: dashboardContainer.bottomAnchor), ]) - // authorContainer: H - [ nameContainer | padding | relationshipActionButtonShadowContainer ] + // authorContainer: H - [ nameContainer | padding | relationshipActionButton ] let authorContainer = UIStackView() authorContainer.axis = .horizontal authorContainer.alignment = .top @@ -429,11 +419,9 @@ extension ProfileHeaderView { authorContainer.addArrangedSubview(nameContainerStackView) authorContainer.addArrangedSubview(UIView()) - authorContainer.addArrangedSubview(relationshipActionButtonShadowContainer) - + authorContainer.addArrangedSubview(relationshipActionButton) + relationshipActionButton.translatesAutoresizingMaskIntoConstraints = false - relationshipActionButtonShadowContainer.addSubview(relationshipActionButton) - relationshipActionButton.pinToParent() NSLayoutConstraint.activate([ relationshipActionButton.widthAnchor.constraint(greaterThanOrEqualToConstant: ProfileHeaderView.friendshipActionButtonSize.width).priority(.required - 1), relationshipActionButton.heightAnchor.constraint(equalToConstant: ProfileHeaderView.friendshipActionButtonSize.height).priority(.defaultHigh), @@ -460,6 +448,8 @@ extension ProfileHeaderView { updateLayoutMargins() } + + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } private func setColors() { backgroundColor = .systemBackground @@ -542,27 +532,3 @@ extension ProfileHeaderView: ProfileStatusDashboardViewDelegate { delegate?.profileHeaderView(self, profileStatusDashboardView: dashboardView, dashboardMeterViewDidPressed: dashboardMeterView, meter: meter) } } - -#if DEBUG -import SwiftUI - -struct ProfileHeaderView_Previews: PreviewProvider { - static var previews: some View { - Group { - UIViewPreview(width: 375) { - let banner = ProfileHeaderView() - banner.bannerImageView.image = UIImage(named: "lucas-ludwig") - return banner - } - .previewLayout(.fixed(width: 375, height: 800)) - UIViewPreview(width: 375) { - let banner = ProfileHeaderView() - //banner.bannerImageView.image = UIImage(named: "peter-luo") - return banner - } - .preferredColorScheme(.dark) - .previewLayout(.fixed(width: 375, height: 800)) - } - } -} -#endif diff --git a/Mastodon/Scene/Profile/MeProfileViewModel.swift b/Mastodon/Scene/Profile/MeProfileViewModel.swift deleted file mode 100644 index ecbaef01e..000000000 --- a/Mastodon/Scene/Profile/MeProfileViewModel.swift +++ /dev/null @@ -1,56 +0,0 @@ -// -// MeProfileViewModel.swift -// Mastodon -// -// Created by MainasuK Cirno on 2021-3-30. -// - -import UIKit -import Combine -import CoreData -import CoreDataStack -import MastodonCore -import MastodonSDK - -final class MeProfileViewModel: ProfileViewModel { - - @MainActor - init(context: AppContext, authContext: AuthContext) { - let user = authContext.mastodonAuthenticationBox.authentication.user(in: context.managedObjectContext) - super.init( - context: context, - authContext: authContext, - optionalMastodonUser: user - ) - - $me - .sink { [weak self] me in - guard let self = self else { return } - self.user = me - } - .store(in: &disposeBag) - } - - override func viewDidLoad() { - - super.viewDidLoad() - - Task { - do { - - _ = try await context.apiService.authenticatedUserInfo(authenticationBox: authContext.mastodonAuthenticationBox).value - - try await context.managedObjectContext.performChanges { - guard let me = self.authContext.mastodonAuthenticationBox.authentication.user(in: self.context.managedObjectContext) else { - assertionFailure() - return - } - - self.me = me - } - } catch { - // do nothing? - } - } - } -} diff --git a/Mastodon/Scene/Profile/ProfileViewController.swift b/Mastodon/Scene/Profile/ProfileViewController.swift index 89023de90..35702c0d8 100644 --- a/Mastodon/Scene/Profile/ProfileViewController.swift +++ b/Mastodon/Scene/Profile/ProfileViewController.swift @@ -119,10 +119,7 @@ final class ProfileViewController: UIViewController, NeedsDependency, MediaPrevi private(set) lazy var tabBarPagerController = TabBarPagerController() private(set) lazy var profileHeaderViewController: ProfileHeaderViewController = { - let viewController = ProfileHeaderViewController() - viewController.context = context - viewController.coordinator = coordinator - viewController.viewModel = ProfileHeaderViewModel(context: context, authContext: viewModel.authContext) + let viewController = ProfileHeaderViewController(context: context, authContext: authContext, coordinator: coordinator, profileViewModel: viewModel) return viewController }() @@ -169,6 +166,8 @@ extension ProfileViewController { override func viewDidLoad() { super.viewDidLoad() + NotificationCenter.default.addObserver(self, selector: #selector(ProfileViewController.relationshipChanged(_:)), name: .relationshipChanged, object: nil) + view.backgroundColor = .secondarySystemBackground let barAppearance = UINavigationBarAppearance() if isModal { @@ -202,33 +201,38 @@ extension ProfileViewController { } .store(in: &disposeBag) - Publishers.CombineLatest4 ( - viewModel.relationshipViewModel.$isSuspended, + // build items + Publishers.CombineLatest4( + viewModel.$relationship, profileHeaderViewController.viewModel.$isTitleViewDisplaying, - editingAndUpdatingPublisher.eraseToAnyPublisher(), - barButtonItemHiddenPublisher.eraseToAnyPublisher() + editingAndUpdatingPublisher, + barButtonItemHiddenPublisher ) .receive(on: DispatchQueue.main) - .sink { [weak self] isSuspended, isTitleViewDisplaying, tuple1, tuple2 in - guard let self = self else { return } + .sink { [weak self] account, isTitleViewDisplaying, tuple1, tuple2 in + guard let self else { return } let (isEditing, _) = tuple1 let (isMeBarButtonItemsHidden, isReplyBarButtonItemHidden, isMoreMenuBarButtonItemHidden) = tuple2 var items: [UIBarButtonItem] = [] defer { - self.navigationItem.rightBarButtonItems = !items.isEmpty ? items : nil + if items.isNotEmpty { + self.navigationItem.rightBarButtonItems = items + } else { + self.navigationItem.rightBarButtonItems = nil + } } - guard !isSuspended else { + if let suspended = self.viewModel.account.suspended, suspended == true { return } - guard !isEditing else { + guard isEditing == false else { items.append(self.cancelEditingBarButtonItem) return } - guard !isTitleViewDisplaying else { + guard isTitleViewDisplaying == false else { return } @@ -280,8 +284,6 @@ extension ProfileViewController { bindTitleView() bindMoreBarButtonItem() bindPager() - - viewModel.viewDidLoad() } override func viewWillAppear(_ animated: Bool) { @@ -292,7 +294,8 @@ extension ProfileViewController { override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) - + viewModel.viewDidAppear.send() + setNeedsStatusBarAppearanceUpdate() } @@ -302,9 +305,9 @@ extension ProfileViewController { private func bindViewModel() { // header - let headerViewModel = profileHeaderViewController.viewModel! - viewModel.$user - .assign(to: \.user, on: headerViewModel) + let headerViewModel = profileHeaderViewController.viewModel + viewModel.$account + .assign(to: \.account, on: headerViewModel) .store(in: &disposeBag) viewModel.$isEditing .assign(to: \.isEditing, on: headerViewModel) @@ -312,33 +315,39 @@ extension ProfileViewController { viewModel.$isUpdating .assign(to: \.isUpdating, on: headerViewModel) .store(in: &disposeBag) - viewModel.relationshipViewModel.$isMyself - .assign(to: \.isMyself, on: headerViewModel) - .store(in: &disposeBag) - viewModel.relationshipViewModel.$optionSet - .map { $0 ?? .none } - .assign(to: \.relationshipActionOptionSet, on: headerViewModel) + viewModel.$relationship + .assign(to: \.relationship, on: headerViewModel) .store(in: &disposeBag) viewModel.$accountForEdit .assign(to: \.accountForEdit, on: headerViewModel) .store(in: &disposeBag) - - // timeline + [ viewModel.postsUserTimelineViewModel, viewModel.repliesUserTimelineViewModel, viewModel.mediaUserTimelineViewModel, ].forEach { userTimelineViewModel in - viewModel.relationshipViewModel.$isBlocking.assign(to: \.isBlocking, on: userTimelineViewModel).store(in: &disposeBag) - viewModel.relationshipViewModel.$isBlockingBy.assign(to: \.isBlockedBy, on: userTimelineViewModel).store(in: &disposeBag) - viewModel.relationshipViewModel.$isSuspended.assign(to: \.isSuspended, on: userTimelineViewModel).store(in: &disposeBag) - viewModel.relationshipViewModel.$isDomainBlocking.assign(to: \.isDomainBlocking, on: userTimelineViewModel).store(in: &disposeBag) + + viewModel.relationship.publisher + .map { $0.blocking } + .assign(to: \UserTimelineViewModel.isBlocking, on: userTimelineViewModel) + .store(in: &disposeBag) + + viewModel.relationship.publisher + .compactMap { $0.blockedBy } + .assign(to: \UserTimelineViewModel.isBlockedBy, on: userTimelineViewModel) + .store(in: &disposeBag) + + viewModel.$account + .compactMap { $0.suspended } + .assign(to: \UserTimelineViewModel.isSuspended, on: userTimelineViewModel) + .store(in: &disposeBag) } // about let aboutViewModel = viewModel.profileAboutViewModel - viewModel.$user - .assign(to: \.user, on: aboutViewModel) + viewModel.$account + .assign(to: \.account, on: aboutViewModel) .store(in: &disposeBag) viewModel.$isEditing .assign(to: \.isEditing, on: aboutViewModel) @@ -380,45 +389,53 @@ extension ProfileViewController { self.navigationItem.title = name } .store(in: &disposeBag) - Publishers.CombineLatest( - profileHeaderViewController.viewModel.$user, - profileHeaderViewController.profileHeaderView.viewModel.viewDidAppear - ) - .sink { [weak self] (user, _) in - guard let self, let user else { return } - Task { - _ = try await self.context.apiService.fetchUser( - username: user.username, - domain: user.domainFromAcct, - authenticationBox: self.authContext.mastodonAuthenticationBox - ) - } - } - .store(in: &disposeBag) + + profileHeaderViewController.profileHeaderView.viewModel.viewDidAppear + .sink(receiveValue: { [weak self] _ in + + guard let self else { return } + let account = self.viewModel.account + guard let domain = account.domainFromAcct else { return } + Task { + let account = try await self.context.apiService.fetchUser( + username: account.username, + domain: domain, + authenticationBox: self.authContext.mastodonAuthenticationBox + ) + + guard let account else { return } + + let relationship = try await self.context.apiService.relationship(forAccounts: [account], authenticationBox: self.authContext.mastodonAuthenticationBox).value.first + + guard let relationship else { return } + + self.viewModel.relationship = relationship + self.viewModel.account = account + } + }) + .store(in: &disposeBag) } private func bindMoreBarButtonItem() { Publishers.CombineLatest( - viewModel.$user, - viewModel.relationshipViewModel.$optionSet + viewModel.$account, + viewModel.$relationship ) - .asyncMap { [weak self] user, relationshipSet -> UIMenu? in - guard let self, let user else { return nil } + .asyncMap { [weak self] user, relationship -> UIMenu? in + guard let self, let relationship, let domain = user.domainFromAcct else { return nil } let name = user.displayNameWithFallback - let domain = user.domainFromAcct - let _ = ManagedObjectRecord(objectID: user.objectID) var menuActions: [MastodonMenu.Action] = [ - .muteUser(.init(name: name, isMuting: self.viewModel.relationshipViewModel.isMuting)), - .blockUser(.init(name: name, isBlocking: self.viewModel.relationshipViewModel.isBlocking)), - .blockDomain(.init(domain: domain, isBlocking: self.viewModel.relationshipViewModel.isDomainBlocking)), + .muteUser(.init(name: name, isMuting: relationship.muting)), + .blockUser(.init(name: name, isBlocking: relationship.blocking)), + .blockDomain(.init(domain: domain, isBlocking: relationship.domainBlocking)), .reportUser(.init(name: name)), .shareUser(.init(name: name)), ] - if let me = self.viewModel?.me, me.following.contains(user) { - let showReblogs = me.showingReblogsBy.contains(user) + if relationship.following { + let showReblogs = relationship.showingReblogs// me.showingReblogsBy.contains(user) let context = MastodonMenu.HideReblogsActionContext(showReblogs: showReblogs) menuActions.insert(.hideReblogs(context), at: 1) } @@ -480,26 +497,6 @@ extension ProfileViewController { .store(in: &disposeBag) } -// private func bindProfileRelationship() { -// -// Publishers.CombineLatest3( -// viewModel.isBlocking.eraseToAnyPublisher(), -// viewModel.isBlockedBy.eraseToAnyPublisher(), -// viewModel.suspended.eraseToAnyPublisher() -// ) -// .receive(on: DispatchQueue.main) -// .sink { [weak self] isBlocking, isBlockedBy, suspended in -// guard let self = self else { return } -// let isNeedSetHidden = isBlocking || isBlockedBy || suspended -// self.profileHeaderViewController.viewModel.needsSetupBottomShadow.value = !isNeedSetHidden -// self.profileHeaderViewController.profileHeaderView.bioContainerView.isHidden = isNeedSetHidden -// self.profileHeaderViewController.viewModel.needsFiledCollectionViewHidden.value = isNeedSetHidden -// self.profileHeaderViewController.buttonBar.isUserInteractionEnabled = !isNeedSetHidden -// self.viewModel.needsPagePinToTop.value = isNeedSetHidden -// } -// .store(in: &disposeBag) -// } // end func bindProfileRelationship - private func handleMetaPress(_ meta: Meta) { switch meta { case .url(_, _, let url, _): @@ -532,24 +529,19 @@ extension ProfileViewController { } @objc private func shareBarButtonItemPressed(_ sender: UIBarButtonItem) { - guard let user = viewModel.user else { return } - let record: ManagedObjectRecord = .init(objectID: user.objectID) - Task { - let _activityViewController = try await DataSourceFacade.createActivityViewController( - dependency: self, - user: record - ) - guard let activityViewController = _activityViewController else { return } - _ = self.coordinator.present( - scene: .activityViewController( - activityViewController: activityViewController, - sourceView: nil, - barButtonItem: sender - ), - from: self, - transition: .activityViewControllerPresent(animated: true, completion: nil) - ) - } // end Task + let activityViewController = DataSourceFacade.createActivityViewController( + dependency: self, + account: viewModel.account + ) + _ = self.coordinator.present( + scene: .activityViewController( + activityViewController: activityViewController, + sourceView: nil, + barButtonItem: sender + ), + from: self, + transition: .activityViewControllerPresent(animated: true, completion: nil) + ) } @objc private func favoriteBarButtonItemPressed(_ sender: UIBarButtonItem) { @@ -563,8 +555,8 @@ extension ProfileViewController { } @objc private func replyBarButtonItemPressed(_ sender: UIBarButtonItem) { - guard let mastodonUser = viewModel.user else { return } - let mention = "@" + mastodonUser.acct + + let mention = "@" + viewModel.account.acct UITextChecker.learnWord(mention) let composeViewModel = ComposeViewModel( context: context, @@ -586,11 +578,24 @@ extension ProfileViewController { userTimelineViewController.viewModel.stateMachine.enter(UserTimelineViewModel.State.Reloading.self) } - // trigger authenticated user account update - viewModel.context.authenticationService.updateActiveUserAccountPublisher.send() + Task { + let account = viewModel.account + if let domain = account.domain, + let updatedAccount = try? await context.apiService.fetchUser(username: account.acct, domain: domain, authenticationBox: authContext.mastodonAuthenticationBox), + let updatedRelationship = try? await context.apiService.relationship(forAccounts: [updatedAccount], authenticationBox: authContext.mastodonAuthenticationBox).value.first + { + viewModel.account = updatedAccount + viewModel.relationship = updatedRelationship + } - DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { - sender.endRefreshing() + if let updatedMe = try? await context.apiService.authenticatedUserInfo(authenticationBox: authContext.mastodonAuthenticationBox).value { + viewModel.me = updatedMe + FileManager.default.store(account: updatedMe, forUserID: authContext.mastodonAuthenticationBox.authentication.userIdentifier()) + } + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + sender.endRefreshing() + } } } @@ -690,34 +695,6 @@ extension ProfileViewController: TabBarPagerDataSource { } } -//// MARK: - UIScrollViewDelegate -//extension ProfileViewController: UIScrollViewDelegate { -// -// func scrollViewDidScroll(_ scrollView: UIScrollView) { -// contentOffsets[profileSegmentedViewController.pagingViewController.currentIndex!] = scrollView.contentOffset.y -// let topMaxContentOffsetY = profileSegmentedViewController.view.frame.minY - ProfileHeaderViewController.headerMinHeight - containerScrollView.safeAreaInsets.top -// if scrollView.contentOffset.y < topMaxContentOffsetY { -// self.containerScrollView.contentOffset.y = scrollView.contentOffset.y -// for postTimelineView in profileSegmentedViewController.pagingViewController.viewModel.viewControllers { -// postTimelineView.scrollView?.contentOffset.y = 0 -// } -// contentOffsets.removeAll() -// } else { -// containerScrollView.contentOffset.y = topMaxContentOffsetY -// if viewModel.needsPagePinToTop.value { -// // do nothing -// } else { -// if let customScrollViewContainerController = profileSegmentedViewController.pagingViewController.currentViewController as? ScrollViewContainer { -// let contentOffsetY = scrollView.contentOffset.y - containerScrollView.contentOffset.y -// customScrollViewContainerController.scrollView?.contentOffset.y = contentOffsetY -// } -// } -// -// } -// } -// -//} - // MARK: - AuthContextProvider extension ProfileViewController: AuthContextProvider { var authContext: AuthContext { viewModel.authContext } @@ -730,60 +707,65 @@ extension ProfileViewController: ProfileHeaderViewControllerDelegate { profileHeaderView: ProfileHeaderView, relationshipButtonDidPressed button: ProfileRelationshipActionButton ) { - let relationshipActionSet = viewModel.relationshipViewModel.optionSet ?? .none - - // handle edit logic for editable profile - // handle relationship logic for non-editable profile - if relationshipActionSet.contains(.edit) { - // do nothing when updating - guard !viewModel.isUpdating else { return } + if viewModel.me == viewModel.account { + editProfile() + } else { + editRelationship() + } + } - guard let profileHeaderViewModel = profileHeaderViewController.viewModel else { return } - guard let profileAboutViewModel = profilePagingViewController.viewModel.profileAboutViewController.viewModel else { return } - - let isEdited = profileHeaderViewModel.isEdited || profileAboutViewModel.isEdited - - if isEdited { - // update profile when edited - viewModel.isUpdating = true - Task { @MainActor in - do { - // TODO: handle error - _ = try await viewModel.updateProfileInfo( - headerProfileInfo: profileHeaderViewModel.profileInfoEditing, - aboutProfileInfo: profileAboutViewModel.profileInfoEditing - ) - self.viewModel.isEditing = false - - } catch { - let alertController = UIAlertController( - for: error, - title: L10n.Common.Alerts.EditProfileFailure.title, - preferredStyle: .alert - ) - let okAction = UIAlertAction(title: L10n.Common.Controls.Actions.ok, style: .default) - alertController.addAction(okAction) - self.present(alertController, animated: true) + + private func editProfile() { + // do nothing when updating + guard !viewModel.isUpdating else { return } + + let profileHeaderViewModel = profileHeaderViewController.viewModel + guard let profileAboutViewModel = profilePagingViewController.viewModel.profileAboutViewController.viewModel else { return } + + let isEdited = profileHeaderViewModel.isEdited || profileAboutViewModel.isEdited + + if isEdited { + // update profile when edited + viewModel.isUpdating = true + Task { @MainActor in + do { + // TODO: handle error + let updatedAccount = try await viewModel.updateProfileInfo( + headerProfileInfo: profileHeaderViewModel.profileInfoEditing, + aboutProfileInfo: profileAboutViewModel.profileInfoEditing + ).value + self.viewModel.isEditing = false + self.viewModel.account = updatedAccount + + } catch { + let alertController = UIAlertController( + for: error, + title: L10n.Common.Alerts.EditProfileFailure.title, + preferredStyle: .alert + ) + let okAction = UIAlertAction(title: L10n.Common.Controls.Actions.ok, style: .default) + alertController.addAction(okAction) + self.present(alertController, animated: true) + } + + // finish updating + self.viewModel.isUpdating = false + } + } else { + // set `updating` then toggle `edit` state + viewModel.isUpdating = true + viewModel.fetchEditProfileInfo() + .receive(on: DispatchQueue.main) + .sink { [weak self] completion in + guard let self = self else { return } + defer { + // finish updating + self.viewModel.isUpdating = false } - - // finish updating - self.viewModel.isUpdating = false - } // end Task - } else { - // set `updating` then toggle `edit` state - viewModel.isUpdating = true - viewModel.fetchEditProfileInfo() - .receive(on: DispatchQueue.main) - .sink { [weak self] completion in - guard let self = self else { return } - defer { - // finish updating - self.viewModel.isUpdating = false - } - switch completion { + switch completion { case .failure(let error): let alertController = UIAlertController(for: error, title: L10n.Common.Alerts.EditProfileFailure.title, preferredStyle: .alert) - let okAction = UIAlertAction(title: L10n.Common.Controls.Actions.ok, style: .default, handler: nil) + let okAction = UIAlertAction(title: L10n.Common.Controls.Actions.ok, style: .default) alertController.addAction(okAction) _ = self.coordinator.present( scene: .alertController(alertController: alertController), @@ -793,101 +775,105 @@ extension ProfileViewController: ProfileHeaderViewControllerDelegate { case .finished: // enter editing mode self.viewModel.isEditing.toggle() - } - } receiveValue: { [weak self] response in - guard let self = self else { return } - self.viewModel.accountForEdit = response.value } - .store(in: &disposeBag) - } - } else { - guard let relationshipAction = relationshipActionSet.highPriorityAction(except: .editOptions) else { return } - switch relationshipAction { - case .none: - break - case .follow, .request, .pending, .following: - guard let user = viewModel.user else { return } - let record = ManagedObjectRecord(objectID: user.objectID) + } receiveValue: { [weak self] response in + guard let self = self else { return } + self.viewModel.accountForEdit = response.value + } + .store(in: &disposeBag) + } + } + + private func editRelationship() { + guard let relationship = viewModel.relationship else { return } + + let account = viewModel.account + + viewModel.isUpdating = true + + if relationship.blocking { + let name = account.displayNameWithFallback + + let alertController = UIAlertController( + title: L10n.Scene.Profile.RelationshipActionAlert.ConfirmUnblockUser.title, + message: L10n.Scene.Profile.RelationshipActionAlert.ConfirmUnblockUser.message(name), + preferredStyle: .alert + ) + let unblockAction = UIAlertAction(title: L10n.Common.Controls.Friendship.unblock, style: .default) { [weak self] _ in + guard let self else { return } Task { - try await DataSourceFacade.responseToUserFollowAction( + _ = try await DataSourceFacade.responseToUserBlockAction( dependency: self, - user: record + account: account ) } - case .muting: - guard let user = viewModel.user else { return } - let name = user.displayNameWithFallback - - let alertController = UIAlertController( - title: L10n.Scene.Profile.RelationshipActionAlert.ConfirmUnmuteUser.title, - message: L10n.Scene.Profile.RelationshipActionAlert.ConfirmUnmuteUser.message(name), - preferredStyle: .alert - ) - let record = ManagedObjectRecord(objectID: user.objectID) - let unmuteAction = UIAlertAction(title: L10n.Common.Controls.Friendship.unmute, style: .default) { [weak self] _ in - guard let self = self else { return } - Task { - try await DataSourceFacade.responseToUserMuteAction( - dependency: self, - user: record - ) - } - } - alertController.addAction(unmuteAction) - let cancelAction = UIAlertAction(title: L10n.Common.Controls.Actions.cancel, style: .cancel, handler: nil) - alertController.addAction(cancelAction) - present(alertController, animated: true, completion: nil) - case .domainBlocking: - guard let user = viewModel.user else { return } - let domain = user.domainFromAcct + } + alertController.addAction(unblockAction) + let cancelAction = UIAlertAction(title: L10n.Common.Controls.Actions.cancel, style: .cancel) + alertController.addAction(cancelAction) + coordinator.present(scene: .alertController(alertController: alertController), transition: .alertController(animated: true)) + } else if relationship.domainBlocking { + guard let domain = account.domain else { return } - let alertController = UIAlertController( - title: L10n.Scene.Profile.RelationshipActionAlert.ConfirmUnblockDomain.title, - message: L10n.Scene.Profile.RelationshipActionAlert.ConfirmUnblockDomain.message(domain), - preferredStyle: .alert - ) - let record = ManagedObjectRecord(objectID: user.objectID) - let unblockAction = UIAlertAction(title: L10n.Common.Controls.Friendship.unblock, style: .default) { [weak self] _ in - guard let self = self else { return } - Task { - try await DataSourceFacade.responseToDomainBlockAction(dependency: self, user: record) - } - } - alertController.addAction(unblockAction) - let cancelAction = UIAlertAction(title: L10n.Common.Controls.Actions.cancel, style: .cancel, handler: nil) - alertController.addAction(cancelAction) - present(alertController, animated: true, completion: nil) + let alertController = UIAlertController( + title: L10n.Scene.Profile.RelationshipActionAlert.ConfirmUnblockDomain.title, + message: L10n.Scene.Profile.RelationshipActionAlert.ConfirmUnblockDomain.message(domain), + preferredStyle: .alert + ) - case .blocking: - guard let user = viewModel.user else { return } - let name = user.displayNameWithFallback - - let alertController = UIAlertController( - title: L10n.Scene.Profile.RelationshipActionAlert.ConfirmUnblockUser.title, - message: L10n.Scene.Profile.RelationshipActionAlert.ConfirmUnblockUser.message(name), - preferredStyle: .alert - ) - let record = ManagedObjectRecord(objectID: user.objectID) - let unblockAction = UIAlertAction(title: L10n.Common.Controls.Friendship.unblock, style: .default) { [weak self] _ in - guard let self = self else { return } - Task { - try await DataSourceFacade.responseToUserBlockAction( - dependency: self, - user: record - ) - } + let unblockAction = UIAlertAction(title: L10n.Common.Controls.Actions.unblockDomain(domain), style: .default) { [weak self] _ in + guard let self else { return } + Task { + _ = try await DataSourceFacade.responseToDomainBlockAction(dependency: self, account: account) + + guard let newRelationship = try await self.context.apiService.relationship(forAccounts: [account], authenticationBox: self.authContext.mastodonAuthenticationBox).value.first else { return } + + self.viewModel.isUpdating = false + + // we need to trigger this here as domain block doesn't return a relationship + let userInfo = [ + UserInfoKey.relationship: newRelationship, + ] + + NotificationCenter.default.post(name: .relationshipChanged, object: self, userInfo: userInfo) } - alertController.addAction(unblockAction) - let cancelAction = UIAlertAction(title: L10n.Common.Controls.Actions.cancel, style: .cancel, handler: nil) - alertController.addAction(cancelAction) - present(alertController, animated: true, completion: nil) - case .blocked, .showReblogs, .isMyself,.followingBy, .blockingBy, .suspended, .edit, .editing, .updating: - break + } + alertController.addAction(unblockAction) + let cancelAction = UIAlertAction(title: L10n.Common.Controls.Actions.cancel, style: .cancel) + alertController.addAction(cancelAction) + coordinator.present(scene: .alertController(alertController: alertController), transition: .alertController(animated: true)) + + } else if relationship.muting { + let name = account.displayNameWithFallback + + let alertController = UIAlertController( + title: L10n.Scene.Profile.RelationshipActionAlert.ConfirmUnmuteUser.title, + message: L10n.Scene.Profile.RelationshipActionAlert.ConfirmUnmuteUser.message(name), + preferredStyle: .alert + ) + + let unmuteAction = UIAlertAction(title: L10n.Common.Controls.Friendship.unmute, style: .default) { [weak self] _ in + guard let self else { return } + Task { + _ = try await DataSourceFacade.responseToUserMuteAction(dependency: self, account: account) + } + } + alertController.addAction(unmuteAction) + let cancelAction = UIAlertAction(title: L10n.Common.Controls.Actions.cancel, style: .cancel) + alertController.addAction(cancelAction) + coordinator.present(scene: .alertController(alertController: alertController), transition: .alertController(animated: true)) + } else { + Task { [weak self] in + guard let self else { return } + + _ = try await DataSourceFacade.responseToUserFollowAction( + dependency: self, + account: viewModel.account + ) } } - } - + func profileHeaderViewController( _ profileHeaderViewController: ProfileHeaderViewController, profileHeaderView: ProfileHeaderView, @@ -913,23 +899,44 @@ extension ProfileViewController: ProfileAboutViewControllerDelegate { // MARK: - MastodonMenuDelegate extension ProfileViewController: MastodonMenuDelegate { func menuAction(_ action: MastodonMenu.Action) { - guard let user = viewModel.user else { return } + switch action { + case .muteUser(_), + .blockUser(_), + .blockDomain(_), + .hideReblogs(_): + Task { + try await DataSourceFacade.responseToMenuAction( + dependency: self, + action: action, + menuContext: DataSourceFacade.MenuContext( + author: viewModel.account, + statusViewModel: nil, + button: nil, + barButtonItem: self.moreMenuBarButtonItem + )) + } + case .reportUser(_), .shareUser(_): + Task { + try await DataSourceFacade.responseToMenuAction( + dependency: self, + action: action, + menuContext: DataSourceFacade.MenuContext( + author: viewModel.account, + statusViewModel: nil, + button: nil, + barButtonItem: self.moreMenuBarButtonItem + )) + } - let userRecord: ManagedObjectRecord = .init(objectID: user.objectID) - - Task { - try await DataSourceFacade.responseToMenuAction( - dependency: self, - action: action, - menuContext: DataSourceFacade.MenuContext( - author: userRecord, - authorEntity: nil, - statusViewModel: nil, - button: nil, - barButtonItem: self.moreMenuBarButtonItem - ) - ) - } // end Task + case .translateStatus(_), + .showOriginal, + .bookmarkStatus(_), + .shareStatus, + .deleteStatus, + .editStatus, + .followUser(_): + break + } } } @@ -987,3 +994,44 @@ extension ProfileViewController: DataSourceProvider { viewModel.mediaUserTimelineViewModel.dataController.update(status: status, intent: intent) } } + +//MARK: - Notifications + +extension ProfileViewController { + @objc + func relationshipChanged(_ notification: Notification) { + + guard let userInfo = notification.userInfo, let relationship = userInfo[UserInfoKey.relationship] as? Mastodon.Entity.Relationship else { + return + } + + viewModel.isUpdating = true + if viewModel.account.id == relationship.id { + // if relationship belongs to an other account + Task { + let account = viewModel.account + if let domain = account.domain, + let updatedAccount = try? await context.apiService.fetchUser(username: account.acct, domain: domain, authenticationBox: authContext.mastodonAuthenticationBox) { + viewModel.account = updatedAccount + + viewModel.relationship = relationship + self.profileHeaderViewController.viewModel.relationship = relationship + self.profileHeaderViewController.profileHeaderView.viewModel.relationship = relationship + } + + viewModel.isUpdating = false + } + } else if viewModel.account == viewModel.me { + // update my profile + Task { + if let updatedMe = try? await context.apiService.authenticatedUserInfo(authenticationBox: authContext.mastodonAuthenticationBox).value { + viewModel.me = updatedMe + viewModel.account = updatedMe + FileManager.default.store(account: updatedMe, forUserID: authContext.mastodonAuthenticationBox.authentication.userIdentifier()) + } + + viewModel.isUpdating = false + } + } + } +} diff --git a/Mastodon/Scene/Profile/ProfileViewModel.swift b/Mastodon/Scene/Profile/ProfileViewModel.swift index 8ed3cf03d..76969dedc 100644 --- a/Mastodon/Scene/Profile/ProfileViewModel.swift +++ b/Mastodon/Scene/Profile/ProfileViewModel.swift @@ -33,18 +33,17 @@ class ProfileViewModel: NSObject { // input let context: AppContext let authContext: AuthContext - @Published var me: MastodonUser? - @Published var user: MastodonUser? - + + @Published var me: Mastodon.Entity.Account + @Published var account: Mastodon.Entity.Account + @Published var relationship: Mastodon.Entity.Relationship? + let viewDidAppear = PassthroughSubject() @Published var isEditing = false @Published var isUpdating = false @Published var accountForEdit: Mastodon.Entity.Account? - // output - let relationshipViewModel = RelationshipViewModel() - @Published var userIdentifier: UserIdentifier? = nil @Published var isRelationshipActionButtonHidden: Bool = true @@ -57,10 +56,13 @@ class ProfileViewModel: NSObject { // let needsPagePinToTop = CurrentValueSubject(false) @MainActor - init(context: AppContext, authContext: AuthContext, optionalMastodonUser mastodonUser: MastodonUser?) { + init(context: AppContext, authContext: AuthContext, account: Mastodon.Entity.Account, relationship: Mastodon.Entity.Relationship?, me: Mastodon.Entity.Account) { self.context = context self.authContext = authContext - self.user = mastodonUser + self.account = account + self.relationship = relationship + self.me = me + self.postsUserTimelineViewModel = UserTimelineViewModel( context: context, authContext: authContext, @@ -79,69 +81,65 @@ class ProfileViewModel: NSObject { title: L10n.Scene.Profile.SegmentedControl.media, queryFilter: .init(onlyMedia: true) ) - self.profileAboutViewModel = ProfileAboutViewModel(context: context) + self.profileAboutViewModel = ProfileAboutViewModel(context: context, account: account) super.init() - - // bind me - self.me = authContext.mastodonAuthenticationBox.authentication.user(in: context.managedObjectContext) - $me - .assign(to: \.me, on: relationshipViewModel) - .store(in: &disposeBag) - // bind user - $user - .map { user -> UserIdentifier? in - guard let user = user else { return nil } - return MastodonUserIdentifier(domain: user.domain, userID: user.id) - } - .assign(to: &$userIdentifier) - $user - .assign(to: \.user, on: relationshipViewModel) - .store(in: &disposeBag) - + if let domain = account.domain { + userIdentifier = MastodonUserIdentifier(domain: domain, userID: account.id) + } else { + userIdentifier = nil + } + // bind userIdentifier $userIdentifier.assign(to: &postsUserTimelineViewModel.$userIdentifier) $userIdentifier.assign(to: &repliesUserTimelineViewModel.$userIdentifier) $userIdentifier.assign(to: &mediaUserTimelineViewModel.$userIdentifier) // bind bar button items - relationshipViewModel.$optionSet - .sink { [weak self] optionSet in - guard let self = self else { return } - guard let optionSet = optionSet, !optionSet.contains(.none) else { - self.isReplyBarButtonItemHidden = true - self.isMoreMenuBarButtonItemHidden = true - self.isMeBarButtonItemsHidden = true + Publishers.CombineLatest3($account, $me, $relationship) + .sink(receiveValue: { [weak self] account, me, relationship in + guard let self else { + self?.isReplyBarButtonItemHidden = true + self?.isMoreMenuBarButtonItemHidden = true + self?.isMeBarButtonItemsHidden = true return } - - let isMyself = optionSet.contains(.isMyself) + + let isMyself = (account == me) self.isReplyBarButtonItemHidden = isMyself self.isMoreMenuBarButtonItemHidden = isMyself - self.isMeBarButtonItemsHidden = !isMyself - } + self.isMeBarButtonItemsHidden = (isMyself == false) + }) .store(in: &disposeBag) + viewDidAppear + .sink { [weak self] _ in + guard let self else { return } + + self.isReplyBarButtonItemHidden = self.isReplyBarButtonItemHidden + self.isMoreMenuBarButtonItemHidden = self.isMoreMenuBarButtonItemHidden + self.isMeBarButtonItemsHidden = self.isMeBarButtonItemsHidden + } + .store(in: &disposeBag) // query relationship - let userRecord = $user.map { user -> ManagedObjectRecord? in - user.flatMap { ManagedObjectRecord(objectID: $0.objectID) } - } + let pendingRetryPublisher = CurrentValueSubject(1) // observe friendship Publishers.CombineLatest( - userRecord, + $account, pendingRetryPublisher ) - .sink { [weak self] userRecord, _ in - guard let self = self else { return } - guard let userRecord = userRecord else { return } + .sink { [weak self] account, _ in + guard let self else { return } + Task { do { - let response = try await self.updateRelationship( - record: userRecord, + let response = try await self.context.apiService.relationship( + forAccounts: [account], authenticationBox: self.authContext.mastodonAuthenticationBox ) + // there are seconds delay after request follow before requested -> following. Query again when needs guard let relationship = response.value.first else { return } if relationship.requested == true { @@ -157,11 +155,12 @@ class ProfileViewModel: NSObject { } .store(in: &disposeBag) - let isBlockingOrBlocked = Publishers.CombineLatest( - relationshipViewModel.$isBlocking, - relationshipViewModel.$isBlockingBy + let isBlockingOrBlocked = Publishers.CombineLatest3( + (relationship?.blocking ?? false).publisher, + (relationship?.blockedBy ?? false).publisher, + (relationship?.domainBlocking ?? false).publisher ) - .map { $0 || $1 } + .map { $0 || $1 || $2 } .share() Publishers.CombineLatest( @@ -172,33 +171,20 @@ class ProfileViewModel: NSObject { .assign(to: &$isPagingEnabled) } - - func viewDidLoad() { - - } - // fetch profile info before edit func fetchEditProfileInfo() -> AnyPublisher, Error> { - guard let me else { + guard let domain = me.domain else { return Fail(error: APIService.APIError.implicit(.authenticationMissing)).eraseToAnyPublisher() } let mastodonAuthentication = authContext.mastodonAuthenticationBox.authentication let authorization = Mastodon.API.OAuth.Authorization(accessToken: mastodonAuthentication.userAccessToken) - return context.apiService.accountVerifyCredentials(domain: me.domain, authorization: authorization) + return context.apiService.accountVerifyCredentials(domain: domain, authorization: authorization) + .tryMap { response in + FileManager.default.store(account: response.value, forUserID: mastodonAuthentication.userIdentifier()) + return response + }.eraseToAnyPublisher() } - - private func updateRelationship( - record: ManagedObjectRecord, - authenticationBox: MastodonAuthenticationBox - ) async throws -> Mastodon.Response.Content<[Mastodon.Entity.Relationship]> { - let response = try await context.apiService.relationship( - records: [record], - authenticationBox: authenticationBox - ) - return response - } - } extension ProfileViewModel { @@ -242,10 +228,14 @@ extension ProfileViewModel { source: nil, fieldsAttributes: fieldsAttributes ) - return try await context.apiService.accountUpdateCredentials( + let response = try await context.apiService.accountUpdateCredentials( domain: domain, query: query, authorization: authorization ) + + FileManager.default.store(account: response.value, forUserID: authenticationBox.authentication.userIdentifier()) + + return response } } diff --git a/Mastodon/Scene/Profile/RemoteProfileViewModel.swift b/Mastodon/Scene/Profile/RemoteProfileViewModel.swift deleted file mode 100644 index b0a2f9f48..000000000 --- a/Mastodon/Scene/Profile/RemoteProfileViewModel.swift +++ /dev/null @@ -1,132 +0,0 @@ -// -// RemoteProfileViewModel.swift -// Mastodon -// -// Created by MainasuK Cirno on 2021-4-2. -// - -import Foundation -import Combine -import CoreDataStack -import MastodonSDK -import MastodonCore - -final class RemoteProfileViewModel: ProfileViewModel { - - @MainActor - init(context: AppContext, authContext: AuthContext, userID: Mastodon.Entity.Account.ID) { - super.init(context: context, authContext: authContext, optionalMastodonUser: nil) - - let domain = authContext.mastodonAuthenticationBox.domain - let authorization = authContext.mastodonAuthenticationBox.userAuthorization - Just(userID) - .asyncMap { userID in - try await context.apiService.accountInfo( - domain: domain, - userID: userID, - authorization: authorization - ) - } - .retry(3) - .receive(on: DispatchQueue.main) - .sink { completion in - switch completion { - case .failure(_): - // TODO: handle error - break - case .finished: - break - } - } receiveValue: { [weak self] response in - guard let self = self else { return } - let managedObjectContext = context.managedObjectContext - let request = MastodonUser.sortedFetchRequest - request.fetchLimit = 1 - request.predicate = MastodonUser.predicate(domain: domain, id: response.value.id) - guard let mastodonUser = managedObjectContext.safeFetch(request).first else { - assertionFailure() - return - } - self.user = mastodonUser - } - .store(in: &disposeBag) - } - - @MainActor - init(context: AppContext, authContext: AuthContext, notificationID: Mastodon.Entity.Notification.ID) { - super.init(context: context, authContext: authContext, optionalMastodonUser: nil) - - Task { @MainActor in - let response = try await context.apiService.notification( - notificationID: notificationID, - authenticationBox: authContext.mastodonAuthenticationBox - ) - let userID = response.value.account.id - - let _user: MastodonUser? = try await context.managedObjectContext.perform { - let request = MastodonUser.sortedFetchRequest - request.predicate = MastodonUser.predicate(domain: authContext.mastodonAuthenticationBox.domain, id: userID) - request.fetchLimit = 1 - return context.managedObjectContext.safeFetch(request).first - } - - if let user = _user { - self.user = user - } else { - _ = try await context.apiService.accountInfo( - domain: authContext.mastodonAuthenticationBox.domain, - userID: userID, - authorization: authContext.mastodonAuthenticationBox.userAuthorization - ) - - let _user: MastodonUser? = try await context.managedObjectContext.perform { - let request = MastodonUser.sortedFetchRequest - request.predicate = MastodonUser.predicate(domain: authContext.mastodonAuthenticationBox.domain, id: userID) - request.fetchLimit = 1 - return context.managedObjectContext.safeFetch(request).first - } - - self.user = _user - } - } // end Task - } - - @MainActor - init(context: AppContext, authContext: AuthContext, acct: String){ - super.init(context: context, authContext: authContext, optionalMastodonUser: nil) - - let domain = authContext.mastodonAuthenticationBox.domain - let authenticationBox = authContext.mastodonAuthenticationBox - - Just(acct) - .asyncMap { acct -> Mastodon.Response.Content in - try await context.apiService.search( - query: .init(q: acct, type: .accounts, resolve: true), - authenticationBox: authenticationBox - ).map { $0.accounts.first } - } - .retry(3) - .receive(on: DispatchQueue.main) - .sink { completion in - switch completion { - case .failure(_): - // TODO: handle error - break - case .finished: - break - } - } receiveValue: { [weak self] response in - guard let self = self, let value = response.value else { return } - let managedObjectContext = context.managedObjectContext - let request = MastodonUser.sortedFetchRequest - request.fetchLimit = 1 - request.predicate = MastodonUser.predicate(domain: domain, id: value.id) - guard let mastodonUser = managedObjectContext.safeFetch(request).first else { - assertionFailure() - return - } - self.user = mastodonUser - } - .store(in: &disposeBag) - } -} diff --git a/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel+State.swift b/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel+State.swift index 32c9adc60..c643a1b21 100644 --- a/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel+State.swift +++ b/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel+State.swift @@ -117,7 +117,7 @@ extension UserTimelineViewModel.State { Task { let maxID = await viewModel.dataController.records.last?.id - + guard let userID = viewModel.userIdentifier?.userID, !userID.isEmpty else { stateMachine.enter(Fail.self) return diff --git a/Mastodon/Scene/Profile/UserList/RebloggedBy/RebloggedByViewController+DataSourceProvider.swift b/Mastodon/Scene/Profile/UserList/RebloggedBy/RebloggedByViewController+DataSourceProvider.swift index b0f1efc3b..5b885aab3 100644 --- a/Mastodon/Scene/Profile/UserList/RebloggedBy/RebloggedByViewController+DataSourceProvider.swift +++ b/Mastodon/Scene/Profile/UserList/RebloggedBy/RebloggedByViewController+DataSourceProvider.swift @@ -20,7 +20,7 @@ extension RebloggedByViewController: DataSourceProvider { guard let item = viewModel.diffableDataSource?.itemIdentifier(for: indexPath) else { return nil } - + switch item { case .account(let account, let relationship): return .account(account: account, relationship: relationship) diff --git a/Mastodon/Scene/Profile/UserList/UserListViewModel+Diffable.swift b/Mastodon/Scene/Profile/UserList/UserListViewModel+Diffable.swift index b172d4c4a..719e1fc72 100644 --- a/Mastodon/Scene/Profile/UserList/UserListViewModel+Diffable.swift +++ b/Mastodon/Scene/Profile/UserList/UserListViewModel+Diffable.swift @@ -41,6 +41,7 @@ extension UserListViewModel { guard let diffableDataSource = self.diffableDataSource else { return } var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([.main]) let accountsWithRelationship: [(account: Mastodon.Entity.Account, relationship: Mastodon.Entity.Relationship?)] = accounts.compactMap { account in diff --git a/Mastodon/Scene/Profile/UserList/UserListViewModel+State.swift b/Mastodon/Scene/Profile/UserList/UserListViewModel+State.swift index 6b96b3400..1b5daefba 100644 --- a/Mastodon/Scene/Profile/UserList/UserListViewModel+State.swift +++ b/Mastodon/Scene/Profile/UserList/UserListViewModel+State.swift @@ -30,7 +30,7 @@ extension UserListViewModel { extension UserListViewModel.State { class Initial: UserListViewModel.State { override func isValidNextState(_ stateClass: AnyClass) -> Bool { - guard let _ = viewModel else { return false } + guard viewModel != nil else { return false } switch stateClass { case is Reloading.Type: return true @@ -75,8 +75,8 @@ extension UserListViewModel.State { override func didEnter(from previousState: GKState?) { super.didEnter(from: previousState) - guard let _ = viewModel, let stateMachine = stateMachine else { return } - + guard viewModel != nil, let stateMachine else { return } + DispatchQueue.main.asyncAfter(deadline: .now() + 3) { stateMachine.enter(Loading.self) } @@ -118,10 +118,11 @@ extension UserListViewModel.State { maxID = nil } - guard let viewModel = viewModel else { return } + guard let viewModel else { return } let maxID = self.maxID - + let authenticationBox = viewModel.authContext.mastodonAuthenticationBox + Task { do { let accountResponse: Mastodon.Response.Content<[Mastodon.Entity.Account]> @@ -130,13 +131,13 @@ extension UserListViewModel.State { accountResponse = try await viewModel.context.apiService.favoritedBy( status: status, query: .init(maxID: maxID, limit: nil), - authenticationBox: viewModel.authContext.mastodonAuthenticationBox + authenticationBox: authenticationBox ) case .rebloggedBy(let status): accountResponse = try await viewModel.context.apiService.rebloggedBy( status: status, query: .init(maxID: maxID, limit: nil), - authenticationBox: viewModel.authContext.mastodonAuthenticationBox + authenticationBox: authenticationBox ) } diff --git a/Mastodon/Scene/Report/Report/ReportViewController.swift b/Mastodon/Scene/Report/Report/ReportViewController.swift index 468fa5ae6..67b8046a8 100644 --- a/Mastodon/Scene/Report/Report/ReportViewController.swift +++ b/Mastodon/Scene/Report/Report/ReportViewController.swift @@ -20,19 +20,22 @@ class ReportViewController: UIViewController, NeedsDependency, ReportViewControl weak var context: AppContext! { willSet { precondition(!isViewLoaded) } } weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } } - var viewModel: ReportViewModel! - + let viewModel: ReportViewModel + lazy var cancelBarButtonItem = UIBarButtonItem( barButtonSystemItem: .cancel, target: self, action: #selector(ReportViewController.cancelBarButtonItemDidPressed(_:)) ) - -} + init(viewModel: ReportViewModel) { + self.viewModel = viewModel -extension ReportViewController { + super.init(nibName: nil, bundle: nil) + } + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + override func viewDidLoad() { super.viewDidLoad() @@ -46,11 +49,10 @@ extension ReportViewController { viewModel.reportStatusViewModel.delegate = self viewModel.reportSupplementaryViewModel.delegate = self - let reportReasonViewController = ReportReasonViewController() + let reportReasonViewController = ReportReasonViewController(viewModel: viewModel.reportReasonViewModel) reportReasonViewController.context = context reportReasonViewController.coordinator = coordinator - reportReasonViewController.viewModel = viewModel.reportReasonViewModel - + addChild(reportReasonViewController) reportReasonViewController.view.translatesAutoresizingMaskIntoConstraints = false view.addSubview(reportReasonViewController.view) @@ -58,10 +60,6 @@ extension ReportViewController { reportReasonViewController.view.pinToParent() } -} - -extension ReportViewController { - @objc private func cancelBarButtonItemDidPressed(_ sender: UIBarButtonItem) { dismiss(animated: true, completion: nil) } @@ -84,7 +82,8 @@ extension ReportViewController: ReportReasonViewControllerDelegate { let reportResultViewModel = ReportResultViewModel( context: context, authContext: viewModel.authContext, - user: viewModel.user, + account: viewModel.account, + relationship: viewModel.relationship, isReported: false ) _ = coordinator.present( @@ -156,11 +155,12 @@ extension ReportViewController: ReportSupplementaryViewControllerDelegate { Task { @MainActor in do { let _ = try await viewModel.report() - + let reportResultViewModel = ReportResultViewModel( context: context, authContext: viewModel.authContext, - user: viewModel.user, + account: viewModel.account, + relationship: viewModel.relationship, isReported: true ) diff --git a/Mastodon/Scene/Report/Report/ReportViewModel.swift b/Mastodon/Scene/Report/Report/ReportViewModel.swift index ba71da66f..cff16063a 100644 --- a/Mastodon/Scene/Report/Report/ReportViewModel.swift +++ b/Mastodon/Scene/Report/Report/ReportViewModel.swift @@ -28,7 +28,8 @@ class ReportViewModel { // input let context: AppContext let authContext: AuthContext - let user: ManagedObjectRecord + let account: Mastodon.Entity.Account + let relationship: Mastodon.Entity.Relationship let status: MastodonStatus? // output @@ -39,17 +40,19 @@ class ReportViewModel { init( context: AppContext, authContext: AuthContext, - user: ManagedObjectRecord, + account: Mastodon.Entity.Account, + relationship: Mastodon.Entity.Relationship, status: MastodonStatus? ) { self.context = context self.authContext = authContext - self.user = user + self.account = account + self.relationship = relationship self.status = status self.reportReasonViewModel = ReportReasonViewModel(context: context) self.reportServerRulesViewModel = ReportServerRulesViewModel(context: context) - self.reportStatusViewModel = ReportStatusViewModel(context: context, authContext: authContext, user: user, status: status) - self.reportSupplementaryViewModel = ReportSupplementaryViewModel(context: context, authContext: authContext, user: user) + self.reportStatusViewModel = ReportStatusViewModel(context: context, authContext: authContext, account: account, status: status) + self.reportSupplementaryViewModel = ReportSupplementaryViewModel(context: context, authContext: authContext, account: account) // end init // setup reason viewModel @@ -57,17 +60,8 @@ class ReportViewModel { reportReasonViewModel.headline = L10n.Scene.Report.StepOne.whatsWrongWithThisPost } else { Task { @MainActor in - let managedObjectContext = context.managedObjectContext - let _username: String? = try? await managedObjectContext.perform { - let user = user.object(in: managedObjectContext) - return user?.acctWithDomain - } - if let username = _username { - reportReasonViewModel.headline = L10n.Scene.Report.StepOne.whatsWrongWithThisUsername(username) - } else { - reportReasonViewModel.headline = L10n.Scene.Report.StepOne.whatsWrongWithThisAccount - } - } // end Task + reportReasonViewModel.headline = L10n.Scene.Report.StepOne.whatsWrongWithThisUsername(account.username) + } } // bind server rules @@ -96,73 +90,63 @@ extension ReportViewModel { func report() async throws { guard !isReporting else { return } - let managedObjectContext = context.managedObjectContext - let _query: Mastodon.API.Reports.FileReportQuery? = try await managedObjectContext.perform { - guard let user = self.user.object(in: managedObjectContext) else { return nil } - - // the status picker is essential step in report flow - // only check isSkip or not - let statusIDs: [MastodonStatus.ID]? = { - if self.reportStatusViewModel.isSkip { - let _id: MastodonStatus.ID? = self.reportStatusViewModel.status.flatMap { record -> MastodonStatus.ID? in - return record.id - } - return _id.flatMap { [$0] } ?? [] - } else { - return self.reportStatusViewModel.selectStatuses.compactMap { record -> MastodonStatus.ID? in - return record.id - } + let account = self.account + // the status picker is essential step in report flow + // only check isSkip or not + let statusIDs: [MastodonStatus.ID]? = { + if self.reportStatusViewModel.isSkip { + let _id: MastodonStatus.ID? = self.reportStatusViewModel.status.flatMap { record -> MastodonStatus.ID? in + return record.id } - }() - - // the user comment is essential step in report flow - // only check isSkip or not - let comment: String? = { - let _comment = self.reportSupplementaryViewModel.isSkip ? nil : self.reportSupplementaryViewModel.commentContext.comment - if let comment = _comment, !comment.isEmpty { - return comment - } else { - return nil + return _id.flatMap { [$0] } ?? [] + } else { + return self.reportStatusViewModel.selectStatuses.compactMap { record -> MastodonStatus.ID? in + return record.id } - }() - return Mastodon.API.Reports.FileReportQuery( - accountID: user.id, - statusIDs: statusIDs, - comment: comment, - forward: true, - category: { - switch self.reportReasonViewModel.selectReason { + } + }() + + // the user comment is essential step in report flow + // only check isSkip or not + let comment: String? = { + let _comment = self.reportSupplementaryViewModel.isSkip ? nil : self.reportSupplementaryViewModel.commentContext.comment + if let comment = _comment, !comment.isEmpty { + return comment + } else { + return nil + } + }() + let query = Mastodon.API.Reports.FileReportQuery( + accountID: account.id, + statusIDs: statusIDs, + comment: comment, + forward: true, + category: { + switch self.reportReasonViewModel.selectReason { case .dislike: return nil case .spam: return .spam case .violateRule: return .violation case .other: return .other case .none: return nil - } - }(), - ruleIDs: { - switch self.reportReasonViewModel.selectReason { + } + }(), + ruleIDs: { + switch self.reportReasonViewModel.selectReason { case .violateRule: let ruleIDs = self.reportServerRulesViewModel.selectRules.map { $0.id }.sorted() return ruleIDs default: return nil - } - }() - ) - } - - guard let query = _query else { return } + } + }() + ) do { isReporting = true - #if DEBUG - try await Task.sleep(nanoseconds: .second * 3) - #else let _ = try await context.apiService.report( query: query, authenticationBox: authContext.mastodonAuthenticationBox ) - #endif isReportSuccess = true } catch { isReporting = false diff --git a/Mastodon/Scene/Report/ReportReason/ReportReasonViewController.swift b/Mastodon/Scene/Report/ReportReason/ReportReasonViewController.swift index 2bfb689d0..2e89229ca 100644 --- a/Mastodon/Scene/Report/ReportReason/ReportReasonViewController.swift +++ b/Mastodon/Scene/Report/ReportReason/ReportReasonViewController.swift @@ -25,9 +25,9 @@ final class ReportReasonViewController: UIViewController, NeedsDependency, Repor var disposeBag = Set() private var observations = Set() - var viewModel: ReportReasonViewModel! - private(set) lazy var reportReasonView = ReportReasonView(viewModel: viewModel) - + let viewModel: ReportReasonViewModel + let reportReasonView: ReportReasonView + let navigationActionView: NavigationActionView = { let navigationActionView = NavigationActionView() navigationActionView.backgroundColor = Asset.Scene.Onboarding.background.color @@ -35,10 +35,14 @@ final class ReportReasonViewController: UIViewController, NeedsDependency, Repor return navigationActionView }() + init(viewModel: ReportReasonViewModel) { + self.viewModel = viewModel + reportReasonView = ReportReasonView(viewModel: viewModel) + + super.init(nibName: nil, bundle: nil) + } -} - -extension ReportReasonViewController { + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func viewDidLoad() { super.viewDidLoad() diff --git a/Mastodon/Scene/Report/ReportResult/ReportResultView.swift b/Mastodon/Scene/Report/ReportResult/ReportResultView.swift index 361f5db24..0805cb315 100644 --- a/Mastodon/Scene/Report/ReportResult/ReportResultView.swift +++ b/Mastodon/Scene/Report/ReportResult/ReportResultView.swift @@ -75,7 +75,7 @@ struct ReportResultView: View { action: { viewModel.followActionPublisher.send() }, - title: viewModel.relationshipViewModel.isFollowing ? L10n.Scene.Report.StepFinal.unfollow : L10n.Scene.Report.StepFinal.unfollowed, + title: viewModel.relationship.following ? L10n.Scene.Report.StepFinal.unfollow : L10n.Scene.Report.StepFinal.unfollowed, isBusy: viewModel.isRequestFollow ) } @@ -92,7 +92,7 @@ struct ReportResultView: View { action: { viewModel.muteActionPublisher.send() }, - title: viewModel.relationshipViewModel.isMuting ? L10n.Common.Controls.Friendship.muted : L10n.Common.Controls.Friendship.mute, + title: viewModel.relationship.muting ? L10n.Common.Controls.Friendship.muted : L10n.Common.Controls.Friendship.mute, isBusy: viewModel.isRequestMute ) } @@ -109,7 +109,7 @@ struct ReportResultView: View { action: { viewModel.blockActionPublisher.send() }, - title: viewModel.relationshipViewModel.isBlocking ? L10n.Common.Controls.Friendship.blocked : L10n.Common.Controls.Friendship.block, + title: viewModel.relationship.blocking ? L10n.Common.Controls.Friendship.blocked : L10n.Common.Controls.Friendship.block, isBusy: viewModel.isRequestBlock ) } diff --git a/Mastodon/Scene/Report/ReportResult/ReportResultViewController.swift b/Mastodon/Scene/Report/ReportResult/ReportResultViewController.swift index d66eadfd7..62b82e8a1 100644 --- a/Mastodon/Scene/Report/ReportResult/ReportResultViewController.swift +++ b/Mastodon/Scene/Report/ReportResult/ReportResultViewController.swift @@ -88,10 +88,11 @@ extension ReportResultViewController { guard !self.viewModel.isRequestFollow else { return } self.viewModel.isRequestFollow = true do { - try await DataSourceFacade.responseToUserFollowAction( + let newRelationship = try await DataSourceFacade.responseToUserFollowAction( dependency: self, - user: self.viewModel.user + account: self.viewModel.account ) + self.viewModel.relationship = newRelationship } catch { // handle error } @@ -108,10 +109,11 @@ extension ReportResultViewController { guard !self.viewModel.isRequestMute else { return } self.viewModel.isRequestMute = true do { - try await DataSourceFacade.responseToUserMuteAction( + let newRelationship = try await DataSourceFacade.responseToUserMuteAction( dependency: self, - user: self.viewModel.user + account: self.viewModel.account ) + self.viewModel.relationship = newRelationship } catch { // handle error } @@ -128,10 +130,11 @@ extension ReportResultViewController { guard !self.viewModel.isRequestBlock else { return } self.viewModel.isRequestBlock = true do { - try await DataSourceFacade.responseToUserBlockAction( + let newRelationship = try await DataSourceFacade.responseToUserBlockAction( dependency: self, - user: self.viewModel.user + account: self.viewModel.account ) + self.viewModel.relationship = newRelationship } catch { // handle error } diff --git a/Mastodon/Scene/Report/ReportResult/ReportResultViewModel.swift b/Mastodon/Scene/Report/ReportResult/ReportResultViewModel.swift index de987b512..c7688963b 100644 --- a/Mastodon/Scene/Report/ReportResult/ReportResultViewModel.swift +++ b/Mastodon/Scene/Report/ReportResult/ReportResultViewModel.swift @@ -23,7 +23,8 @@ class ReportResultViewModel: ObservableObject { // input let context: AppContext let authContext: AuthContext - let user: ManagedObjectRecord + let account: Mastodon.Entity.Account + var relationship: Mastodon.Entity.Relationship let isReported: Bool var headline: String { @@ -39,8 +40,7 @@ class ReportResultViewModel: ObservableObject { // output @Published var avatarURL: URL? @Published var username: String = "" - - let relationshipViewModel = RelationshipViewModel() + let muteActionPublisher = PassthroughSubject() let followActionPublisher = PassthroughSubject() let blockActionPublisher = PassthroughSubject() @@ -48,24 +48,22 @@ class ReportResultViewModel: ObservableObject { init( context: AppContext, authContext: AuthContext, - user: ManagedObjectRecord, + account: Mastodon.Entity.Account, + relationship: Mastodon.Entity.Relationship, isReported: Bool ) { self.context = context self.authContext = authContext - self.user = user + self.account = account + self.relationship = relationship self.isReported = isReported // end init Task { @MainActor in - guard let user = user.object(in: context.managedObjectContext) else { return } - guard let me = authContext.mastodonAuthenticationBox.authentication.user(in: context.managedObjectContext) else { return } - self.relationshipViewModel.user = user - self.relationshipViewModel.me = me - - self.avatarURL = user.avatarImageURL() - self.username = user.acctWithDomain + self.avatarURL = account.avatarImageURL() + self.username = account.username + } // end Task } diff --git a/Mastodon/Scene/Report/ReportStatus/ReportStatusViewModel+State.swift b/Mastodon/Scene/Report/ReportStatus/ReportStatusViewModel+State.swift index 5238cdd70..3762d69e1 100644 --- a/Mastodon/Scene/Report/ReportStatus/ReportStatusViewModel+State.swift +++ b/Mastodon/Scene/Report/ReportStatus/ReportStatusViewModel+State.swift @@ -68,19 +68,9 @@ extension ReportStatusViewModel.State { Task { let maxID = await viewModel.dataController.records.last?.id - let managedObjectContext = viewModel.context.managedObjectContext - let _userID: MastodonUser.ID? = try await managedObjectContext.perform { - guard let user = viewModel.user.object(in: managedObjectContext) else { return nil } - return user.id - } - guard let userID = _userID else { - await enter(state: Fail.self) - return - } - do { let response = try await viewModel.context.apiService.userTimeline( - accountID: userID, + accountID: viewModel.account.id, maxID: maxID, sinceID: nil, excludeReplies: true, diff --git a/Mastodon/Scene/Report/ReportStatus/ReportStatusViewModel.swift b/Mastodon/Scene/Report/ReportStatus/ReportStatusViewModel.swift index 47aaeeb86..030b81771 100644 --- a/Mastodon/Scene/Report/ReportStatus/ReportStatusViewModel.swift +++ b/Mastodon/Scene/Report/ReportStatus/ReportStatusViewModel.swift @@ -24,7 +24,7 @@ class ReportStatusViewModel { // input let context: AppContext let authContext: AuthContext - let user: ManagedObjectRecord + let account: Mastodon.Entity.Account let status: MastodonStatus? let dataController: StatusDataController let listBatchFetchViewModel = ListBatchFetchViewModel() @@ -52,12 +52,12 @@ class ReportStatusViewModel { init( context: AppContext, authContext: AuthContext, - user: ManagedObjectRecord, + account: Mastodon.Entity.Account, status: MastodonStatus? ) { self.context = context self.authContext = authContext - self.user = user + self.account = account self.status = status self.dataController = StatusDataController() // end init diff --git a/Mastodon/Scene/Report/ReportSupplementary/ReportSupplementaryViewModel.swift b/Mastodon/Scene/Report/ReportSupplementary/ReportSupplementaryViewModel.swift index a4239bbc4..d8f9aa783 100644 --- a/Mastodon/Scene/Report/ReportSupplementary/ReportSupplementaryViewModel.swift +++ b/Mastodon/Scene/Report/ReportSupplementary/ReportSupplementaryViewModel.swift @@ -18,7 +18,7 @@ class ReportSupplementaryViewModel { // Input let context: AppContext let authContext: AuthContext - let user: ManagedObjectRecord + let account: Mastodon.Entity.Account let commentContext = ReportItem.CommentContext() @Published var isSkip = false @@ -31,11 +31,11 @@ class ReportSupplementaryViewModel { init( context: AppContext, authContext: AuthContext, - user: ManagedObjectRecord + account: Mastodon.Entity.Account ) { self.context = context self.authContext = authContext - self.user = user + self.account = account // end init Publishers.CombineLatest( diff --git a/Mastodon/Scene/Report/Share/Cell/ReportResultActionTableViewCell.swift b/Mastodon/Scene/Report/Share/Cell/ReportResultActionTableViewCell.swift deleted file mode 100644 index 1828035a6..000000000 --- a/Mastodon/Scene/Report/Share/Cell/ReportResultActionTableViewCell.swift +++ /dev/null @@ -1,140 +0,0 @@ -// -// ReportResultActionTableViewCell.swift -// Mastodon -// -// Created by MainasuK on 2022-2-8. -// - -import UIKit -import Combine -import MastodonAsset -import MastodonUI -import MastodonLocalization - -final class ReportResultActionTableViewCell: UITableViewCell { - - var disposeBag = Set() - - let containerView: UIStackView = { - let stackView = UIStackView() - stackView.axis = .vertical - return stackView - }() - - let avatarImageView: AvatarImageView = { - let imageView = AvatarImageView() - imageView.configure(cornerConfiguration: .init(corner: .fixed(radius: 27))) - return imageView - }() - - let reportBannerShadowContainer = ShadowBackgroundContainer() - let reportBannerLabel: UILabel = { - let label = UILabel() - let padding = Array(repeating: " ", count: 2).joined() - label.text = padding + L10n.Scene.Report.reported + padding - label.textColor = Asset.Scene.Report.reportBanner.color - label.font = FontFamily.Staatliches.regular.font(size: 49) - label.backgroundColor = Asset.Scene.Report.background.color - label.layer.borderColor = Asset.Scene.Report.reportBanner.color.cgColor - label.layer.borderWidth = 6 - label.layer.masksToBounds = true - label.layer.cornerRadius = 12 - return label - }() - - override func prepareForReuse() { - super.prepareForReuse() - - disposeBag.removeAll() - } - - override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { - super.init(style: style, reuseIdentifier: reuseIdentifier) - _init() - } - - required init?(coder: NSCoder) { - super.init(coder: coder) - _init() - } - -} - -extension ReportResultActionTableViewCell { - - private func _init() { - selectionStyle = .none - backgroundColor = .clear - - containerView.translatesAutoresizingMaskIntoConstraints = false - contentView.addSubview(containerView) - NSLayoutConstraint.activate([ - containerView.topAnchor.constraint(equalTo: contentView.topAnchor), - containerView.leadingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.leadingAnchor), - containerView.trailingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.trailingAnchor), - containerView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), - ]) - - let avatarContainer = UIStackView() - avatarContainer.axis = .horizontal - containerView.addArrangedSubview(avatarContainer) - - let avatarLeadingPaddingView = UIView() - let avatarTrailingPaddingView = UIView() - avatarLeadingPaddingView.translatesAutoresizingMaskIntoConstraints = false - avatarContainer.addArrangedSubview(avatarLeadingPaddingView) - avatarImageView.translatesAutoresizingMaskIntoConstraints = false - avatarContainer.addArrangedSubview(avatarImageView) - avatarTrailingPaddingView.translatesAutoresizingMaskIntoConstraints = false - avatarContainer.addArrangedSubview(avatarTrailingPaddingView) - NSLayoutConstraint.activate([ - avatarImageView.widthAnchor.constraint(equalToConstant: 106).priority(.required - 1), - avatarImageView.heightAnchor.constraint(equalToConstant: 106).priority(.required - 1), - avatarLeadingPaddingView.widthAnchor.constraint(equalTo: avatarTrailingPaddingView.widthAnchor).priority(.defaultHigh), - ]) - - reportBannerShadowContainer.translatesAutoresizingMaskIntoConstraints = false - avatarContainer.addSubview(reportBannerShadowContainer) - NSLayoutConstraint.activate([ - reportBannerShadowContainer.centerXAnchor.constraint(equalTo: avatarImageView.centerXAnchor), - reportBannerShadowContainer.centerYAnchor.constraint(equalTo: avatarImageView.centerYAnchor), - ]) - reportBannerShadowContainer.transform = CGAffineTransform(rotationAngle: -(.pi / 180 * 5)) - - reportBannerLabel.translatesAutoresizingMaskIntoConstraints = false - reportBannerShadowContainer.addSubview(reportBannerLabel) - reportBannerLabel.pinToParent() - - } - - override func layoutSubviews() { - super.layoutSubviews() - - reportBannerShadowContainer.layer.setupShadow( - color: .black, - alpha: 0.25, - x: 1, - y: 0.64, - blur: 0.64, - spread: 0, - roundedRect: reportBannerShadowContainer.bounds, - byRoundingCorners: .allCorners, - cornerRadii: CGSize(width: 12, height: 12) - ) - } - -} - -#if DEBUG -import SwiftUI -struct ReportResultActionTableViewCell_Preview: PreviewProvider { - static var previews: some View { - UIViewPreview(width: 375) { - let cell = ReportResultActionTableViewCell() - cell.avatarImageView.configure(configuration: .init(image: .placeholder(color: .blue))) - return cell - } - .previewLayout(.fixed(width: 375, height: 106)) - } -} -#endif diff --git a/Mastodon/Scene/Root/ContentSplitViewController.swift b/Mastodon/Scene/Root/ContentSplitViewController.swift index fdc5b5ba3..197988b4b 100644 --- a/Mastodon/Scene/Root/ContentSplitViewController.swift +++ b/Mastodon/Scene/Root/ContentSplitViewController.swift @@ -11,8 +11,8 @@ import CoreDataStack import MastodonCore protocol ContentSplitViewControllerDelegate: AnyObject { - func contentSplitViewController(_ contentSplitViewController: ContentSplitViewController, sidebarViewController: SidebarViewController, didSelectTab tab: MainTabBarController.Tab) - func contentSplitViewController(_ contentSplitViewController: ContentSplitViewController, sidebarViewController: SidebarViewController, didDoubleTapTab tab: MainTabBarController.Tab) + func contentSplitViewController(_ contentSplitViewController: ContentSplitViewController, sidebarViewController: SidebarViewController, didSelectTab tab: Tab) + func contentSplitViewController(_ contentSplitViewController: ContentSplitViewController, sidebarViewController: SidebarViewController, didDoubleTapTab tab: Tab) } final class ContentSplitViewController: UIViewController, NeedsDependency { @@ -37,11 +37,11 @@ final class ContentSplitViewController: UIViewController, NeedsDependency { return sidebarViewController }() - @Published var currentSupplementaryTab: MainTabBarController.Tab = .home + @Published var currentSupplementaryTab: Tab = .home private(set) lazy var mainTabBarController: MainTabBarController = { - let mainTabBarController = MainTabBarController(context: context, coordinator: coordinator, authContext: authContext) + let mainTabBarController = MainTabBarController(context: self.context, coordinator: self.coordinator, authContext: self.authContext) if let homeTimelineViewController = mainTabBarController.viewController(of: HomeTimelineViewController.self) { - homeTimelineViewController.viewModel.displaySettingBarButtonItem = false + homeTimelineViewController.viewModel?.displaySettingBarButtonItem = false } return mainTabBarController }() @@ -102,7 +102,7 @@ extension ContentSplitViewController { // MARK: - SidebarViewControllerDelegate extension ContentSplitViewController: SidebarViewControllerDelegate { - func sidebarViewController(_ sidebarViewController: SidebarViewController, didSelectTab tab: MainTabBarController.Tab) { + func sidebarViewController(_ sidebarViewController: SidebarViewController, didSelectTab tab: Tab) { delegate?.contentSplitViewController(self, sidebarViewController: sidebarViewController, didSelectTab: tab) } diff --git a/Mastodon/Scene/Root/MainTab/MainTabBarController.swift b/Mastodon/Scene/Root/MainTab/MainTabBarController.swift index bbb690541..5cd0d9d7c 100644 --- a/Mastodon/Scene/Root/MainTab/MainTabBarController.swift +++ b/Mastodon/Scene/Root/MainTab/MainTabBarController.swift @@ -34,105 +34,16 @@ class MainTabBarController: UITabBarController { ) @Published var currentTab: Tab = .home - - enum Tab: Int, CaseIterable { - case home - case search - case compose - case notifications - case me - var tag: Int { - return rawValue - } - - var title: String { - switch self { - case .home: return L10n.Common.Controls.Tabs.home - case .search: return L10n.Common.Controls.Tabs.searchAndExplore - case .compose: return L10n.Common.Controls.Actions.compose - case .notifications: return L10n.Common.Controls.Tabs.notifications - case .me: return L10n.Common.Controls.Tabs.profile - } - } + let homeTimelineViewController: HomeTimelineViewController + let searchViewController: SearchViewController + let composeViewController: UIViewController // placeholder + let notificationViewController: NotificationViewController + let meProfileViewController: ProfileViewController - var inputLabels: [String]? { - switch self { - case .home, .compose, .notifications, .me: - return nil - case .search: - return [ - L10n.Common.Controls.Tabs.A11Y.search, - L10n.Common.Controls.Tabs.A11Y.explore, - L10n.Common.Controls.Tabs.searchAndExplore - ] - } - } - - var image: UIImage { - switch self { - case .home: return UIImage(systemName: "house")! - case .search: return UIImage(systemName: "magnifyingglass")! - case .compose: return UIImage(systemName: "square.and.pencil")! - case .notifications: return UIImage(systemName: "bell")! - case .me: return UIImage(systemName: "person")! - } - } - - var selectedImage: UIImage { - return image.withTintColor(Asset.Colors.Brand.blurple.color, renderingMode: .alwaysOriginal) - } - - var largeImage: UIImage { - return image.withRenderingMode(.alwaysTemplate).resized(size: CGSize(width: 80, height: 80)) - } - - @MainActor - func viewController(context: AppContext, authContext: AuthContext?, coordinator: SceneCoordinator) -> UIViewController { - guard let authContext = authContext else { - return UITableViewController() - } - - let viewController: UIViewController - switch self { - case .home: - let _viewController = HomeTimelineViewController() - _viewController.context = context - _viewController.coordinator = coordinator - _viewController.viewModel = .init(context: context, authContext: authContext) - viewController = _viewController - case .search: - let _viewController = SearchViewController() - _viewController.context = context - _viewController.coordinator = coordinator - _viewController.viewModel = .init(context: context, authContext: authContext) - viewController = _viewController - case .compose: - viewController = UIViewController() - case .notifications: - let _viewController = NotificationViewController() - _viewController.context = context - _viewController.coordinator = coordinator - _viewController.viewModel = .init(context: context, authContext: authContext) - viewController = _viewController - case .me: - let _viewController = ProfileViewController() - _viewController.context = context - _viewController.coordinator = coordinator - _viewController.viewModel = MeProfileViewModel(context: context, authContext: authContext) - viewController = _viewController - } - viewController.title = self.title - return AdaptiveStatusBarStyleNavigationController(rootViewController: viewController) - } - } - - var _viewControllers: [UIViewController] = [] - private(set) var isReadyForWizardAvatarButton = false // output - var avatarURLObserver: AnyCancellable? @Published var avatarURL: URL? // haptic feedback @@ -146,15 +57,45 @@ class MainTabBarController: UITabBarController { self.context = context self.coordinator = coordinator self.authContext = authContext + + homeTimelineViewController = HomeTimelineViewController() + homeTimelineViewController.configureTabBarItem(with: .home) + homeTimelineViewController.context = context + homeTimelineViewController.coordinator = coordinator + + searchViewController = SearchViewController() + searchViewController.configureTabBarItem(with: .search) + searchViewController.context = context + searchViewController.coordinator = coordinator + + composeViewController = UIViewController() + composeViewController.configureTabBarItem(with: .compose) + + notificationViewController = NotificationViewController() + notificationViewController.configureTabBarItem(with: .notifications) + notificationViewController.context = context + notificationViewController.coordinator = coordinator + + meProfileViewController = ProfileViewController() + meProfileViewController.context = context + meProfileViewController.coordinator = coordinator + meProfileViewController.configureTabBarItem(with: .me) + + if let authContext { + notificationViewController.viewModel = NotificationViewModel(context: context, authContext: authContext) + homeTimelineViewController.viewModel = HomeTimelineViewModel(context: context, authContext: authContext) + searchViewController.viewModel = SearchViewModel(context: context, authContext: authContext) + } + super.init(nibName: nil, bundle: nil) + + viewControllers = [homeTimelineViewController, searchViewController, composeViewController, notificationViewController, meProfileViewController].map { AdaptiveStatusBarStyleNavigationController(rootViewController: $0) } tabBar.addInteraction(largeContentViewerInteraction) - - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") + + layoutAvatarButton() } + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } } extension MainTabBarController { @@ -171,26 +112,9 @@ extension MainTabBarController { view.backgroundColor = .systemBackground // seealso: `ThemeService.apply(theme:)` - let tabs = Tab.allCases - var viewControllers = [UIViewController]() - - for tab in tabs { - let viewController = tab.viewController(context: context, authContext: authContext, coordinator: coordinator) - viewController.tabBarItem.tag = tab.tag - viewController.tabBarItem.title = tab.title // needs for acessiblity large content label - viewController.tabBarItem.image = tab.image.imageWithoutBaseline() - viewController.tabBarItem.largeContentSizeImage = tab.largeImage.imageWithoutBaseline() - viewController.tabBarItem.accessibilityLabel = tab.title - viewController.tabBarItem.accessibilityUserInputLabels = tab.inputLabels - viewController.tabBarItem.imageInsets = UIEdgeInsets(top: 6, left: 0, bottom: -6, right: 0) - viewControllers.append(viewController) - } - - _viewControllers = viewControllers setViewControllers(viewControllers, animated: false) selectedIndex = 0 - // hacky workaround for FB11986255 (Setting accessibilityUserInputLabels on a UITabBarItem has no effect) DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(50)) { if let searchItem = self.tabBar.subviews.first(where: { $0.accessibilityLabel == Tab.search.title }) { @@ -201,7 +125,7 @@ extension MainTabBarController { context.apiService.error .receive(on: DispatchQueue.main) .sink { [weak self] error in - guard let self = self, let coordinator = self.coordinator else { return } + guard let self, let coordinator = self.coordinator else { return } switch error { case .implicit: break @@ -228,15 +152,14 @@ extension MainTabBarController { ) .receive(on: DispatchQueue.main) .sink { [weak self] authentication, currentTab in - guard let self = self else { return } - guard let notificationViewController = self.notificationViewController else { return } - + guard let self else { return } + let authentication = self.authContext?.mastodonAuthenticationBox.userAuthorization let hasUnreadPushNotification: Bool = authentication.flatMap { authentication in let count = UserDefaults.shared.getNotificationCountWithAccessToken(accessToken: authentication.accessToken) return count > 0 } ?? false - + let image: UIImage if hasUnreadPushNotification { let imageConfiguration = UIImage.SymbolConfiguration(paletteColors: [.red, SystemTheme.tabBarItemNormalIconColor]) @@ -244,17 +167,16 @@ extension MainTabBarController { } else { image = Tab.notifications.image } - + notificationViewController.tabBarItem.image = image.imageWithoutBaseline() notificationViewController.navigationController?.tabBarItem.image = image.imageWithoutBaseline() } .store(in: &disposeBag) - layoutAvatarButton() - + $avatarURL .receive(on: DispatchQueue.main) .sink { [weak self] avatarURL in - guard let self = self else { return } + guard let self else { return } self.avatarButton.avatarImageView.setImage( url: avatarURL, placeholder: .placeholder(color: .systemFill), @@ -262,33 +184,28 @@ extension MainTabBarController { ) } .store(in: &disposeBag) - + NotificationCenter.default.publisher(for: .userFetched) .receive(on: DispatchQueue.main) .sink { [weak self] _ in - guard let self = self else { return } - if let user = self.authContext?.mastodonAuthenticationBox.authentication.user(in: self.context.managedObjectContext) { - self.avatarURLObserver = user.publisher(for: \.avatar) - .sink { [weak self, weak user] _ in - guard let self = self else { return } - guard let user = user else { return } - guard user.managedObjectContext != nil else { return } - self.avatarURL = user.avatarImageURL() - } - - // a11y - let _profileTabItem = self.tabBar.items?.first { item in item.tag == Tab.me.tag } - guard let profileTabItem = _profileTabItem else { return } - profileTabItem.accessibilityHint = L10n.Scene.AccountList.tabBarHint(user.displayNameWithFallback) - - self.context.authenticationService.updateActiveUserAccountPublisher - .sink { [weak self] in - self?.updateUserAccount() - } - .store(in: &self.disposeBag) - } else { - self.avatarURLObserver = nil - } + guard let self, + let authContext = self.authContext, + let account = authContext.mastodonAuthenticationBox.authentication.account() else { return } + + self.avatarURL = account.avatarImageURL() + + // a11y + let _profileTabItem = self.tabBar.items?.first { item in item.tag == Tab.me.tag } + guard let profileTabItem = _profileTabItem else { return } + profileTabItem.accessibilityHint = L10n.Scene.AccountList.tabBarHint(account.displayNameWithFallback) + + self.context.authenticationService.updateActiveUserAccountPublisher + .sink { [weak self] in + self?.updateUserAccount() + } + .store(in: &self.disposeBag) + + self.meProfileViewController.viewModel = ProfileViewModel(context: self.context, authContext: authContext, account: account, relationship: nil, me: account) } .store(in: &disposeBag) @@ -308,11 +225,11 @@ extension MainTabBarController { $currentTab .receive(on: DispatchQueue.main) .sink { [weak self] tab in - guard let self = self else { return } + guard let self else { return } self.updateAvatarButtonAppearance() } .store(in: &disposeBag) - + updateTabBarDisplay() } @@ -366,7 +283,7 @@ extension MainTabBarController { case .search: assert(Thread.isMainThread) // double tapping search tab opens the search bar without additional taps - searchViewController?.searchBar.becomeFirstResponder() + searchViewController.searchBar.becomeFirstResponder() default: break } @@ -401,8 +318,7 @@ extension MainTabBarController { private func layoutAvatarButton() { guard avatarButton.superview == nil else { return } - let _profileTabItem = self.tabBar.items?.first { item in item.tag == Tab.me.tag } - guard let profileTabItem = _profileTabItem else { return } + guard let profileTabItem = meProfileViewController.tabBarItem else { return } guard let view = profileTabItem.value(forKey: "view") as? UIView else { return } @@ -450,36 +366,12 @@ extension MainTabBarController { guard let authContext = authContext else { return } Task { @MainActor in - let profileResponse = try await context.apiService.authenticatedUserInfo( - authenticationBox: authContext.mastodonAuthenticationBox - ) - - if let user = authContext.mastodonAuthenticationBox.authentication.user( - in: context.managedObjectContext - ) { - user.update( - property: .init( - entity: profileResponse.value, - domain: authContext.mastodonAuthenticationBox.domain - ) - ) - } + let profileResponse = try await context.apiService.authenticatedUserInfo(authenticationBox: authContext.mastodonAuthenticationBox) + FileManager.default.store(account: profileResponse.value, forUserID: authContext.mastodonAuthenticationBox.authentication.userIdentifier()) } } } -extension MainTabBarController { - - var notificationViewController: NotificationViewController? { - return viewController(of: NotificationViewController.self) - } - - var searchViewController: SearchViewController? { - return viewController(of: SearchViewController.self) - } - -} - // MARK: - UITabBarControllerDelegate extension MainTabBarController: UITabBarControllerDelegate { func tabBarController(_ tabBarController: UITabBarController, shouldSelect viewController: UIViewController) -> Bool { diff --git a/Mastodon/Scene/Root/RootSplitViewController.swift b/Mastodon/Scene/Root/RootSplitViewController.swift index ea87f2500..8a6a88dfd 100644 --- a/Mastodon/Scene/Root/RootSplitViewController.swift +++ b/Mastodon/Scene/Root/RootSplitViewController.swift @@ -127,8 +127,8 @@ extension RootSplitViewController { // MARK: - ContentSplitViewControllerDelegate extension RootSplitViewController: ContentSplitViewControllerDelegate { - func contentSplitViewController(_ contentSplitViewController: ContentSplitViewController, sidebarViewController: SidebarViewController, didSelectTab tab: MainTabBarController.Tab) { - guard let _ = MainTabBarController.Tab.allCases.firstIndex(of: tab) else { + func contentSplitViewController(_ contentSplitViewController: ContentSplitViewController, sidebarViewController: SidebarViewController, didSelectTab tab: Tab) { + guard let _ = Tab.allCases.firstIndex(of: tab) else { assertionFailure() return } @@ -158,8 +158,8 @@ extension RootSplitViewController: ContentSplitViewControllerDelegate { } } - func contentSplitViewController(_ contentSplitViewController: ContentSplitViewController, sidebarViewController: SidebarViewController, didDoubleTapTab tab: MainTabBarController.Tab) { - guard let _ = MainTabBarController.Tab.allCases.firstIndex(of: tab) else { + func contentSplitViewController(_ contentSplitViewController: ContentSplitViewController, sidebarViewController: SidebarViewController, didDoubleTapTab tab: Tab) { + guard let _ = Tab.allCases.firstIndex(of: tab) else { assertionFailure() return } @@ -170,7 +170,7 @@ extension RootSplitViewController: ContentSplitViewControllerDelegate { guard !isPrimaryDisplay else { return } - contentSplitViewController.mainTabBarController.searchViewController?.searchBar.becomeFirstResponder() + contentSplitViewController.mainTabBarController.searchViewController.searchBar.becomeFirstResponder() default: break } diff --git a/Mastodon/Scene/Root/Sidebar/SidebarViewController.swift b/Mastodon/Scene/Root/Sidebar/SidebarViewController.swift index b9a7d80f1..e13fa8443 100644 --- a/Mastodon/Scene/Root/Sidebar/SidebarViewController.swift +++ b/Mastodon/Scene/Root/Sidebar/SidebarViewController.swift @@ -12,7 +12,7 @@ import MastodonCore import MastodonUI protocol SidebarViewControllerDelegate: AnyObject { - func sidebarViewController(_ sidebarViewController: SidebarViewController, didSelectTab tab: MainTabBarController.Tab) + func sidebarViewController(_ sidebarViewController: SidebarViewController, didSelectTab tab: Tab) func sidebarViewController(_ sidebarViewController: SidebarViewController, didLongPressItem item: SidebarViewModel.Item, sourceView: UIView) func sidebarViewController(_ sidebarViewController: SidebarViewController, didDoubleTapItem item: SidebarViewModel.Item, sourceView: UIView) } diff --git a/Mastodon/Scene/Root/Sidebar/SidebarViewModel.swift b/Mastodon/Scene/Root/Sidebar/SidebarViewModel.swift index 441b8ca29..c3aa06892 100644 --- a/Mastodon/Scene/Root/Sidebar/SidebarViewModel.swift +++ b/Mastodon/Scene/Root/Sidebar/SidebarViewModel.swift @@ -23,7 +23,7 @@ final class SidebarViewModel { let authContext: AuthContext? @Published private var isSidebarDataSourceReady = false @Published private var isAvatarButtonDataReady = false - @Published var currentTab: MainTabBarController.Tab = .home + @Published var currentTab: Tab = .home // output var diffableDataSource: UICollectionViewDiffableDataSource? @@ -57,7 +57,7 @@ extension SidebarViewModel { } enum Item: Hashable { - case tab(MainTabBarController.Tab) + case tab(Tab) case setting case compose } @@ -69,18 +69,19 @@ extension SidebarViewModel { collectionView: UICollectionView, secondaryCollectionView: UICollectionView ) { - let tabCellRegistration = UICollectionView.CellRegistration { [weak self] cell, indexPath, item in - guard let self = self else { return } - - let imageURL: URL? = { - switch item { - case .me: - let user = self.authContext?.mastodonAuthenticationBox.authentication.user(in: self.context.managedObjectContext) - return user?.avatarImageURL() - default: - return nil - } - }() + let tabCellRegistration = UICollectionView.CellRegistration { [weak self] cell, indexPath, item in + guard let self else { return } + + let imageURL: URL? + switch item { + case .me: + let account = self.authContext?.mastodonAuthenticationBox.authentication.account() + imageURL = account?.avatarImageURL() + case .home, .search, .compose, .notifications: + // no custom avatar for other tabs + imageURL = nil + } + cell.item = SidebarListContentView.Item( isActive: false, accessoryImage: item == .me ? self.chevronImage : nil, @@ -104,39 +105,40 @@ extension SidebarViewModel { .store(in: &cell.disposeBag) switch item { - case .notifications: - Publishers.CombineLatest( - self.context.notificationService.unreadNotificationCountDidUpdate, - self.$currentTab - ) - .receive(on: DispatchQueue.main) - .sink { [weak cell] authentication, currentTab in - guard let cell = cell else { return } - - let hasUnreadPushNotification: Bool = { - guard let accessToken = self.authContext?.mastodonAuthenticationBox.userAuthorization.accessToken else { return false } - let count = UserDefaults.shared.getNotificationCountWithAccessToken(accessToken: accessToken) - return count > 0 - }() - - let image: UIImage - if hasUnreadPushNotification { - let imageConfiguration = UIImage.SymbolConfiguration(paletteColors: [.red, SystemTheme.tabBarItemNormalIconColor]) - image = UIImage(systemName: "bell.badge", withConfiguration: imageConfiguration)! - } else { - image = MainTabBarController.Tab.notifications.image + case .notifications: + Publishers.CombineLatest( + self.context.notificationService.unreadNotificationCountDidUpdate, + self.$currentTab + ) + .receive(on: DispatchQueue.main) + .sink { [weak cell] authentication, currentTab in + guard let cell = cell else { return } + + let hasUnreadPushNotification: Bool = { + guard let accessToken = self.authContext?.mastodonAuthenticationBox.userAuthorization.accessToken else { return false } + let count = UserDefaults.shared.getNotificationCountWithAccessToken(accessToken: accessToken) + return count > 0 + }() + + let image: UIImage + if hasUnreadPushNotification { + let imageConfiguration = UIImage.SymbolConfiguration(paletteColors: [.red, SystemTheme.tabBarItemNormalIconColor]) + image = UIImage(systemName: "bell.badge", withConfiguration: imageConfiguration)! + } else { + image = Tab.notifications.image + } + cell.item?.image = image + cell.item?.activeImage = image.withTintColor(Asset.Colors.Brand.blurple.color, renderingMode: .alwaysOriginal) + cell.setNeedsUpdateConfiguration() } - cell.item?.image = image - cell.item?.activeImage = image.withTintColor(Asset.Colors.Brand.blurple.color, renderingMode: .alwaysOriginal) - cell.setNeedsUpdateConfiguration() - } - .store(in: &cell.disposeBag) - case .me: - guard let user = self.authContext?.mastodonAuthenticationBox.authentication.user(in: self.context.managedObjectContext) else { return } - let currentUserDisplayName = user.displayNameWithFallback - cell.accessibilityHint = L10n.Scene.AccountList.tabBarHint(currentUserDisplayName) - default: - break + .store(in: &cell.disposeBag) + case .me: + guard let account = self.authContext?.mastodonAuthenticationBox.authentication.account() else { return } + + let currentUserDisplayName = account.displayNameWithFallback + cell.accessibilityHint = L10n.Scene.AccountList.tabBarHint(currentUserDisplayName) + case .compose, .home, .search: + break } } diff --git a/Mastodon/Scene/Root/Tab.swift b/Mastodon/Scene/Root/Tab.swift new file mode 100644 index 000000000..dca5e1331 --- /dev/null +++ b/Mastodon/Scene/Root/Tab.swift @@ -0,0 +1,71 @@ +// Copyright © 2024 Mastodon gGmbH. All rights reserved. + +import UIKit +import MastodonLocalization +import MastodonAsset + +enum Tab: Int, CaseIterable { + case home + case search + case compose + case notifications + case me + + var tag: Int { + return rawValue + } + + var title: String { + switch self { + case .home: return L10n.Common.Controls.Tabs.home + case .search: return L10n.Common.Controls.Tabs.searchAndExplore + case .compose: return L10n.Common.Controls.Actions.compose + case .notifications: return L10n.Common.Controls.Tabs.notifications + case .me: return L10n.Common.Controls.Tabs.profile + } + } + + var inputLabels: [String]? { + switch self { + case .home, .compose, .notifications, .me: + return nil + case .search: + return [ + L10n.Common.Controls.Tabs.A11Y.search, + L10n.Common.Controls.Tabs.A11Y.explore, + L10n.Common.Controls.Tabs.searchAndExplore + ] + } + } + + var image: UIImage { + switch self { + case .home: return UIImage(systemName: "house")! + case .search: return UIImage(systemName: "magnifyingglass")! + case .compose: return UIImage(systemName: "square.and.pencil")! + case .notifications: return UIImage(systemName: "bell")! + case .me: return UIImage(systemName: "person")! + } + } + + var selectedImage: UIImage { + return image.withTintColor(Asset.Colors.Brand.blurple.color, renderingMode: .alwaysOriginal) + } + + var largeImage: UIImage { + return image.withRenderingMode(.alwaysTemplate).resized(size: CGSize(width: 80, height: 80)) + } +} + +extension UIViewController { + func configureTabBarItem(with tab: Tab) { + title = tab.title + tabBarItem.tag = tab.tag + tabBarItem.title = tab.title // needs for acessiblity large content label + tabBarItem.image = tab.image.imageWithoutBaseline() + tabBarItem.largeContentSizeImage = tab.largeImage.imageWithoutBaseline() + tabBarItem.accessibilityLabel = tab.title + tabBarItem.accessibilityUserInputLabels = tab.inputLabels + tabBarItem.imageInsets = UIEdgeInsets(top: 6, left: 0, bottom: -6, right: 0) + } +} diff --git a/Mastodon/Scene/Search/Search/SearchViewController.swift b/Mastodon/Scene/Search/Search/SearchViewController.swift index 0f2034a17..9af36edd1 100644 --- a/Mastodon/Scene/Search/Search/SearchViewController.swift +++ b/Mastodon/Scene/Search/Search/SearchViewController.swift @@ -26,7 +26,7 @@ final class SearchViewController: UIViewController, NeedsDependency { var searchTransitionController = SearchTransitionController() var disposeBag = Set() - var viewModel: SearchViewModel! + var viewModel: SearchViewModel? // use AutoLayout could set search bar margin automatically to // layout alongside with split mode button (on iPad) @@ -37,7 +37,7 @@ final class SearchViewController: UIViewController, NeedsDependency { let searchBarTapPublisher = PassthroughSubject() private(set) lazy var discoveryViewController: DiscoveryViewController? = { - guard let authContext = viewModel.authContext else { return nil } + guard let authContext = viewModel?.authContext else { return nil } let viewController = DiscoveryViewController() viewController.context = context viewController.coordinator = coordinator @@ -70,7 +70,7 @@ extension SearchViewController { override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) - viewModel.viewDidAppeared.send() + viewModel?.viewDidAppeared.send() // note: // need set alpha because (maybe) SDK forget set alpha back @@ -110,7 +110,7 @@ extension SearchViewController { .sink { [weak self] initialText in guard let self = self else { return } // push to search detail - guard let authContext = self.viewModel.authContext else { return } + guard let authContext = self.viewModel?.authContext else { return } let searchDetailViewModel = SearchDetailViewModel(authContext: authContext, initialSearchText: initialText) searchDetailViewModel.needsBecomeFirstResponder = true self.navigationController?.delegate = self.searchTransitionController diff --git a/Mastodon/Scene/Search/SearchDetail/Search Results Overview/SearchResultOverviewCoordinator.swift b/Mastodon/Scene/Search/SearchDetail/Search Results Overview/SearchResultOverviewCoordinator.swift index 71b0e599f..7e78e3b61 100644 --- a/Mastodon/Scene/Search/SearchDetail/Search Results Overview/SearchResultOverviewCoordinator.swift +++ b/Mastodon/Scene/Search/SearchDetail/Search Results Overview/SearchResultOverviewCoordinator.swift @@ -69,7 +69,6 @@ extension SearchResultOverviewCoordinator: SearchResultsOverviewTableViewControl ) let authContext = self.authContext - let managedObjectContext = context.managedObjectContext Task { let searchResult = try await context.apiService.search( diff --git a/Mastodon/Scene/Search/SearchDetail/SearchHistory/SearchHistoryViewController.swift b/Mastodon/Scene/Search/SearchDetail/SearchHistory/SearchHistoryViewController.swift index afddfbbac..a01100be7 100644 --- a/Mastodon/Scene/Search/SearchDetail/SearchHistory/SearchHistoryViewController.swift +++ b/Mastodon/Scene/Search/SearchDetail/SearchHistory/SearchHistoryViewController.swift @@ -50,8 +50,7 @@ extension SearchHistoryViewController { } override func viewWillAppear(_ animated: Bool) { - let userID = authContext.mastodonAuthenticationBox.userID - viewModel.items = (try? FileManager.default.searchItems(forUser: userID)) ?? [] + viewModel.items = (try? FileManager.default.searchItems(for: authContext.mastodonAuthenticationBox)) ?? [] } } @@ -103,9 +102,7 @@ extension SearchHistoryViewController: SearchHistorySectionHeaderCollectionReusa _ searchHistorySectionHeaderCollectionReusableView: SearchHistorySectionHeaderCollectionReusableView, clearButtonDidPressed button: UIButton ) { - let userID = authContext.mastodonAuthenticationBox.userID - - FileManager.default.removeSearchHistory(forUser: userID) + FileManager.default.removeSearchHistory(for: authContext.mastodonAuthenticationBox) viewModel.items = [] } } @@ -113,7 +110,6 @@ extension SearchHistoryViewController: SearchHistorySectionHeaderCollectionReusa //MARK: - SearchResultOverviewCoordinatorDelegate extension SearchHistoryViewController: SearchResultOverviewCoordinatorDelegate { func newSearchHistoryItemAdded(_ coordinator: SearchResultOverviewCoordinator) { - let userID = authContext.mastodonAuthenticationBox.userID - viewModel.items = (try? FileManager.default.searchItems(forUser: userID)) ?? [] + viewModel.items = (try? FileManager.default.searchItems(for: authContext.mastodonAuthenticationBox)) ?? [] } } diff --git a/Mastodon/Scene/Search/SearchDetail/SearchHistory/SearchHistoryViewModel.swift b/Mastodon/Scene/Search/SearchDetail/SearchHistory/SearchHistoryViewModel.swift index 63f1cd0e8..1a1e38365 100644 --- a/Mastodon/Scene/Search/SearchDetail/SearchHistory/SearchHistoryViewModel.swift +++ b/Mastodon/Scene/Search/SearchDetail/SearchHistory/SearchHistoryViewModel.swift @@ -24,7 +24,7 @@ final class SearchHistoryViewModel { init(context: AppContext, authContext: AuthContext) { self.context = context self.authContext = authContext - self.items = (try? FileManager.default.searchItems(forUser: authContext.mastodonAuthenticationBox.userID)) ?? [] + self.items = (try? FileManager.default.searchItems(for: authContext.mastodonAuthenticationBox)) ?? [] } } diff --git a/Mastodon/Scene/Search/SearchDetail/SearchResult/SearchResultSection.swift b/Mastodon/Scene/Search/SearchDetail/SearchResult/SearchResultSection.swift index 42b1a1585..254d301ff 100644 --- a/Mastodon/Scene/Search/SearchDetail/SearchResult/SearchResultSection.swift +++ b/Mastodon/Scene/Search/SearchDetail/SearchResult/SearchResultSection.swift @@ -44,7 +44,7 @@ extension SearchResultSection { case .account(let account, let relationship): let cell = tableView.dequeueReusableCell(withIdentifier: UserTableViewCell.reuseIdentifier, for: indexPath) as! UserTableViewCell - guard let me = authContext.mastodonAuthenticationBox.authentication.user(in: context.managedObjectContext) else { return cell } + guard let me = authContext.mastodonAuthenticationBox.authentication.account() else { return cell } cell.userView.setButtonState(.loading) cell.configure( @@ -110,21 +110,4 @@ extension SearchResultSection { delegate: configuration.statusViewTableViewCellDelegate ) } - - static func configure( - context: AppContext, - authContext: AuthContext, - tableView: UITableView, - cell: UserTableViewCell, - viewModel: UserTableViewCell.ViewModel, - configuration: Configuration - ) { - cell.configure( - me: authContext.mastodonAuthenticationBox.authentication.user(in: context.managedObjectContext), - tableView: tableView, - viewModel: viewModel, - delegate: configuration.userTableViewCellDelegate - ) - } - } diff --git a/Mastodon/Scene/Search/SearchDetail/SearchResult/SearchResultViewController+DataSourceProvider.swift b/Mastodon/Scene/Search/SearchDetail/SearchResult/SearchResultViewController+DataSourceProvider.swift index d317b22e1..ff1eab32b 100644 --- a/Mastodon/Scene/Search/SearchDetail/SearchResult/SearchResultViewController+DataSourceProvider.swift +++ b/Mastodon/Scene/Search/SearchDetail/SearchResult/SearchResultViewController+DataSourceProvider.swift @@ -65,11 +65,6 @@ extension SearchResultViewController { target: .status, // remove reblog wrapper status: status ) - case .user(let user): - await DataSourceFacade.coordinateToProfileScene( - provider: self, - user: user - ) case .hashtag(let tag): await DataSourceFacade.coordinateToHashtagScene( provider: self, diff --git a/Mastodon/Scene/Search/SearchDetail/SearchResult/SearchResultViewModel+State.swift b/Mastodon/Scene/Search/SearchDetail/SearchResult/SearchResultViewModel+State.swift index 63835ea1e..8839242cd 100644 --- a/Mastodon/Scene/Search/SearchDetail/SearchResult/SearchResultViewModel+State.swift +++ b/Mastodon/Scene/Search/SearchDetail/SearchResult/SearchResultViewModel+State.swift @@ -152,6 +152,7 @@ extension SearchResultViewModel.State { await viewModel.dataController.reset() viewModel.hashtags = [] } + // due to combine relationships must be set first var existingRelationships = viewModel.relationships @@ -159,7 +160,7 @@ extension SearchResultViewModel.State { existingRelationships.append(hashtag) } viewModel.relationships = existingRelationships - + await viewModel.dataController.appendRecords(statuses) var existingHashtags = viewModel.hashtags diff --git a/Mastodon/Scene/Settings/Server Details/AboutInstanceViewController.swift b/Mastodon/Scene/Settings/Server Details/AboutInstanceViewController.swift index 59c1d5b01..e41dde9d0 100644 --- a/Mastodon/Scene/Settings/Server Details/AboutInstanceViewController.swift +++ b/Mastodon/Scene/Settings/Server Details/AboutInstanceViewController.swift @@ -2,13 +2,18 @@ import UIKit import MastodonSDK +import MastodonCore protocol AboutInstanceViewControllerDelegate: AnyObject { func showAdminAccount(_ viewController: AboutInstanceViewController, account: Mastodon.Entity.Account) func sendEmailToAdmin(_ viewController: AboutInstanceViewController, emailAddress: String) } -class AboutInstanceViewController: UIViewController { +class AboutInstanceViewController: UIViewController, NeedsDependency, AuthContextProvider { + + var authContext: AuthContext + var context: AppContext! + var coordinator: SceneCoordinator! weak var delegate: AboutInstanceViewControllerDelegate? var dataSource: AboutInstanceTableViewDataSource? @@ -19,7 +24,12 @@ class AboutInstanceViewController: UIViewController { var instance: Mastodon.Entity.V2.Instance? - init() { + init(context: AppContext, authContext: AuthContext, coordinator: SceneCoordinator) { + + self.context = context + self.authContext = authContext + self.coordinator = coordinator + tableView = UITableView(frame: .zero, style: .insetGrouped) tableView.translatesAutoresizingMaskIntoConstraints = false tableView.register(ContactAdminTableViewCell.self, forCellReuseIdentifier: ContactAdminTableViewCell.reuseIdentifier) diff --git a/Mastodon/Scene/Settings/Server Details/ServerDetailsViewController.swift b/Mastodon/Scene/Settings/Server Details/ServerDetailsViewController.swift index fb724f8f9..4dd19b7c0 100644 --- a/Mastodon/Scene/Settings/Server Details/ServerDetailsViewController.swift +++ b/Mastodon/Scene/Settings/Server Details/ServerDetailsViewController.swift @@ -4,6 +4,7 @@ import UIKit import MastodonSDK import MastodonLocalization import MetaTextKit +import MastodonCore enum ServerDetailsTab: Int, CaseIterable { case about = 0 @@ -36,7 +37,7 @@ class ServerDetailsViewController: UIViewController { let instanceRulesViewController: InstanceRulesViewController let containerView: UIView - init(domain: String) { + init(domain: String, appContext: AppContext, authContext: AuthContext, sceneCoordinator: SceneCoordinator) { segmentedControl = UISegmentedControl() segmentedControl.translatesAutoresizingMaskIntoConstraints = false @@ -47,7 +48,7 @@ class ServerDetailsViewController: UIViewController { containerView = UIView() containerView.translatesAutoresizingMaskIntoConstraints = false - aboutInstanceViewController = AboutInstanceViewController() + aboutInstanceViewController = AboutInstanceViewController(context: appContext, authContext: authContext, coordinator: sceneCoordinator) instanceRulesViewController = InstanceRulesViewController() pageController = UIPageViewController(transitionStyle: .scroll, navigationOrientation: .horizontal) diff --git a/Mastodon/Scene/Settings/SettingsCoordinator.swift b/Mastodon/Scene/Settings/SettingsCoordinator.swift index 2c4973a30..4be5f27d9 100644 --- a/Mastodon/Scene/Settings/SettingsCoordinator.swift +++ b/Mastodon/Scene/Settings/SettingsCoordinator.swift @@ -70,7 +70,7 @@ extension SettingsCoordinator: SettingsViewControllerDelegate { navigationController.pushViewController(notificationViewController, animated: true) case .serverDetails(let domain): - let serverDetailsViewController = ServerDetailsViewController(domain: domain) + let serverDetailsViewController = ServerDetailsViewController(domain: domain, appContext: appContext, authContext: authContext, sceneCoordinator: sceneCoordinator) serverDetailsViewController.delegate = self appContext.apiService.instanceV2(domain: domain) @@ -216,15 +216,10 @@ extension SettingsCoordinator: ServerDetailsViewControllerDelegate { } extension SettingsCoordinator: AboutInstanceViewControllerDelegate { - @MainActor func showAdminAccount(_ viewController: AboutInstanceViewController, account: Mastodon.Entity.Account) { + @MainActor + func showAdminAccount(_ viewController: AboutInstanceViewController, account: Mastodon.Entity.Account) { Task { - let user = try await appContext.apiService.fetchUser(username: account.username, domain: authContext.mastodonAuthenticationBox.domain, authenticationBox: authContext.mastodonAuthenticationBox) - - let profileViewModel = ProfileViewModel(context: appContext, authContext: authContext, optionalMastodonUser: user) - - _ = await MainActor.run { - sceneCoordinator.present(scene: .profile(viewModel: profileViewModel), transition: .show) - } + await DataSourceFacade.coordinateToProfileScene(provider: viewController, account: account) } } diff --git a/Mastodon/Scene/Share/View/Content/NotificationView+Configuration.swift b/Mastodon/Scene/Share/View/Content/NotificationView+Configuration.swift deleted file mode 100644 index 98dec5397..000000000 --- a/Mastodon/Scene/Share/View/Content/NotificationView+Configuration.swift +++ /dev/null @@ -1,226 +0,0 @@ -// -// NotificationView+Configuration.swift -// Mastodon -// -// Created by MainasuK on 2022-1-21. -// - -import UIKit -import Combine -import MastodonUI -import CoreDataStack -import MetaTextKit -import MastodonMeta -import Meta -import MastodonAsset -import MastodonCore -import MastodonLocalization -import class CoreDataStack.Notification -import MastodonSDK - -extension NotificationView { - public func configure(feed: MastodonFeed) { - guard - let notification = feed.notification, - let managedObjectContext = viewModel.context?.managedObjectContext - else { - assertionFailure() - return - } - - MastodonNotification.fromEntity( - notification, - using: managedObjectContext, - domain: viewModel.authContext?.mastodonAuthenticationBox.domain ?? "" - ).map(configure(notification:)) - } -} - -extension NotificationView { - public func configure(notification: MastodonNotification) { - viewModel.objects.insert(notification) - - configureAuthor(notification: notification) - - switch notification.entity.type { - case .follow: - setAuthorContainerBottomPaddingViewDisplay() - case .followRequest: - setFollowRequestAdaptiveMarginContainerViewDisplay() - case .mention, .status: - if let status = notification.status { - statusView.configure(status: status) - setStatusViewDisplay() - } - case .reblog, .favourite, .poll: - if let status = notification.status { - quoteStatusView.configure(status: status) - setQuoteStatusViewDisplay() - } - case ._other: - setAuthorContainerBottomPaddingViewDisplay() - assertionFailure() - } - - } -} - -extension NotificationView { - private func configureAuthor(notification: MastodonNotification) { - let author = notification.account - // author avatar - - Publishers.CombineLatest( - author.publisher(for: \.avatar), - UserDefaults.shared.publisher(for: \.preferredStaticAvatar) - ) - .map { _ in author.avatarImageURL() } - .assign(to: \.authorAvatarImageURL, on: viewModel) - .store(in: &disposeBag) - - // author name - Publishers.CombineLatest( - author.publisher(for: \.displayName), - author.publisher(for: \.emojis) - ) - .map { _, emojis in - do { - let content = MastodonContent(content: author.displayNameWithFallback, emojis: emojis.asDictionary) - let metaContent = try MastodonMetaContent.convert(document: content) - return metaContent - } catch { - assertionFailure(error.localizedDescription) - return PlaintextMetaContent(string: author.displayNameWithFallback) - } - } - .assign(to: \.authorName, on: viewModel) - .store(in: &disposeBag) - // author username - author.publisher(for: \.acct) - .map { $0 as String? } - .assign(to: \.authorUsername, on: viewModel) - .store(in: &disposeBag) - // timestamp - viewModel.timestamp = notification.entity.createdAt - - viewModel.visibility = notification.entity.status?.mastodonVisibility ?? ._other("") - - // notification type indicator - Publishers.CombineLatest( - author.publisher(for: \.displayName), - author.publisher(for: \.emojis) - ) - .sink { [weak self] _, emojis in - guard let self = self else { return } - guard let type = MastodonNotificationType(rawValue: notification.entity.type.rawValue) else { - self.viewModel.notificationIndicatorText = nil - return - } - self.viewModel.type = type - - func createMetaContent(text: String, emojis: MastodonContent.Emojis) -> MetaContent { - let content = MastodonContent(content: text, emojis: emojis) - guard let metaContent = try? MastodonMetaContent.convert(document: content) else { - return PlaintextMetaContent(string: text) - } - return metaContent - } - - // TODO: fix the i18n. The subject should assert place at the string beginning - switch type { - case .follow: - self.viewModel.notificationIndicatorText = createMetaContent( - text: L10n.Scene.Notification.NotificationDescription.followedYou, - emojis: emojis.asDictionary - ) - case .followRequest: - self.viewModel.notificationIndicatorText = createMetaContent( - text: L10n.Scene.Notification.NotificationDescription.requestToFollowYou, - emojis: emojis.asDictionary - ) - case .mention: - self.viewModel.notificationIndicatorText = createMetaContent( - text: L10n.Scene.Notification.NotificationDescription.mentionedYou, - emojis: emojis.asDictionary - ) - case .reblog: - self.viewModel.notificationIndicatorText = createMetaContent( - text: L10n.Scene.Notification.NotificationDescription.rebloggedYourPost, - emojis: emojis.asDictionary - ) - case .favourite: - self.viewModel.notificationIndicatorText = createMetaContent( - text: L10n.Scene.Notification.NotificationDescription.favoritedYourPost, - emojis: emojis.asDictionary - ) - case .poll: - self.viewModel.notificationIndicatorText = createMetaContent( - text: L10n.Scene.Notification.NotificationDescription.pollHasEnded, - emojis: emojis.asDictionary - ) - case .status: - self.viewModel.notificationIndicatorText = createMetaContent( - text: .empty, - emojis: emojis.asDictionary - ) - case ._other: - self.viewModel.notificationIndicatorText = nil - } - } - .store(in: &disposeBag) - - let authContext = viewModel.authContext - // isMuting - author.publisher(for: \.mutingBy) - .map { mutingBy in - guard let authContext = authContext else { return false } - return mutingBy.contains(where: { - $0.id == authContext.mastodonAuthenticationBox.userID - && $0.domain == authContext.mastodonAuthenticationBox.domain - }) - } - .assign(to: \.isMuting, on: viewModel) - .store(in: &disposeBag) - // isBlocking - author.publisher(for: \.blockingBy) - .map { blockingBy in - guard let authContext = authContext else { return false } - return blockingBy.contains(where: { - $0.id == authContext.mastodonAuthenticationBox.userID - && $0.domain == authContext.mastodonAuthenticationBox.domain - }) - } - .assign(to: \.isBlocking, on: viewModel) - .store(in: &disposeBag) - - // isMyself - Publishers.CombineLatest( - author.publisher(for: \.domain), - author.publisher(for: \.id) - ) - .map { domain, id in - guard let authContext = authContext else { return false } - return authContext.mastodonAuthenticationBox.domain == domain - && authContext.mastodonAuthenticationBox.userID == id - } - .assign(to: \.isMyself, on: viewModel) - .store(in: &disposeBag) - - // follow request state - viewModel.followRequestState = notification.followRequestState - viewModel.transientFollowRequestState = notification.transientFollowRequestState - - // Following - author.publisher(for: \.followingBy) - .map { [weak viewModel] followingBy in - guard let viewModel = viewModel else { return false } - guard let authContext = viewModel.authContext else { return false } - return followingBy.contains(where: { - $0.id == authContext.mastodonAuthenticationBox.userID && $0.domain == authContext.mastodonAuthenticationBox.domain - }) - } - .assign(to: \.isFollowed, on: viewModel) - .store(in: &disposeBag) - - } -} diff --git a/Mastodon/Scene/Share/View/Content/UserView+Configuration.swift b/Mastodon/Scene/Share/View/Content/UserView+Configuration.swift index e3f91f462..a2ccff02a 100644 --- a/Mastodon/Scene/Share/View/Content/UserView+Configuration.swift +++ b/Mastodon/Scene/Share/View/Content/UserView+Configuration.swift @@ -17,68 +17,16 @@ import MastodonSDK import MastodonAsset extension UserView { - public func configure(user: MastodonUser, delegate: UserViewDelegate?) { - self.delegate = delegate - viewModel.user = user - viewModel.account = nil - viewModel.relationship = nil - - Publishers.CombineLatest( - user.publisher(for: \.avatar), - UserDefaults.shared.publisher(for: \.preferredStaticAvatar) - ) - .map { _ in user.avatarImageURL() } - .assign(to: \.authorAvatarImageURL, on: viewModel) - .store(in: &disposeBag) - - // author name - Publishers.CombineLatest( - user.publisher(for: \.displayName), - user.publisher(for: \.emojis) - ) - .map { _, emojis in - do { - let content = MastodonContent(content: user.displayNameWithFallback, emojis: emojis.asDictionary) - let metaContent = try MastodonMetaContent.convert(document: content) - return metaContent - } catch { - assertionFailure(error.localizedDescription) - return PlaintextMetaContent(string: user.displayNameWithFallback) - } - } - .assign(to: \.authorName, on: viewModel) - .store(in: &disposeBag) - // author username - user.publisher(for: \.acct) - .map { $0 as String? } - .assign(to: \.authorUsername, on: viewModel) - .store(in: &disposeBag) - - user.publisher(for: \.followersCount) - .map { Int($0) } - .assign(to: \.authorFollowers, on: viewModel) - .store(in: &disposeBag) - - user.publisher(for: \.fields) - .map { fields in - let firstVerified = fields.first(where: { $0.verifiedAt != nil }) - return firstVerified?.value - } - .assign(to: \.authorVerifiedLink, on: viewModel) - .store(in: &disposeBag) - } - func configure(with account: Mastodon.Entity.Account, relationship: Mastodon.Entity.Relationship?, delegate: UserViewDelegate?) { viewModel.account = account viewModel.relationship = relationship - viewModel.user = nil self.delegate = delegate let authorUsername = PlaintextMetaContent(string: "@\(account.username)") authorUsernameLabel.configure(content: authorUsername) do { - let emojis = account.emojis?.asDictionary ?? [:] + let emojis = account.emojis.asDictionary let content = MastodonContent(content: account.displayNameWithFallback, emojis: emojis) let metaContent = try MastodonMetaContent.convert(document: content) authorNameLabel.configure(content: metaContent) diff --git a/Mastodon/Scene/Share/View/TableviewCell/UserTableViewCell+ViewModel.swift b/Mastodon/Scene/Share/View/TableviewCell/UserTableViewCell+ViewModel.swift index 6b8613292..a0c4a9bd1 100644 --- a/Mastodon/Scene/Share/View/TableviewCell/UserTableViewCell+ViewModel.swift +++ b/Mastodon/Scene/Share/View/TableviewCell/UserTableViewCell+ViewModel.swift @@ -14,14 +14,14 @@ import MastodonSDK extension UserTableViewCell { final class ViewModel { - let user: MastodonUser - + let account: Mastodon.Entity.Account + let followedUsers: AnyPublisher<[String], Never> let blockedUsers: AnyPublisher<[String], Never> let followRequestedUsers: AnyPublisher<[String], Never> - init(user: MastodonUser, followedUsers: AnyPublisher<[String], Never>, blockedUsers: AnyPublisher<[String], Never>, followRequestedUsers: AnyPublisher<[String], Never>) { - self.user = user + init(account: Mastodon.Entity.Account, followedUsers: AnyPublisher<[String], Never>, blockedUsers: AnyPublisher<[String], Never>, followRequestedUsers: AnyPublisher<[String], Never>) { + self.account = account self.followedUsers = followedUsers self.followRequestedUsers = followRequestedUsers self.blockedUsers = blockedUsers @@ -32,7 +32,7 @@ extension UserTableViewCell { extension UserTableViewCell { func configure( - me: MastodonUser, + me: Mastodon.Entity.Account, tableView: UITableView, account: Mastodon.Entity.Account, relationship: Mastodon.Entity.Relationship?, @@ -45,69 +45,16 @@ extension UserTableViewCell { self.delegate = delegate } - - func configure( - me: MastodonUser? = nil, - tableView: UITableView, - viewModel: ViewModel, - delegate: UserTableViewCellDelegate? - ) { - userView.configure(user: viewModel.user, delegate: delegate) - - guard let me = me else { - return userView.setButtonState(.none) - } - - if viewModel.user == me { - userView.setButtonState(.none) - } else { - userView.setButtonState(.loading) - } - - Publishers.CombineLatest3( - viewModel.followedUsers, - viewModel.followRequestedUsers, - viewModel.blockedUsers - ) - .receive(on: DispatchQueue.main) - .sink { [weak self] followed, requested, blocked in - if viewModel.user == me { - self?.userView.setButtonState(.none) - } else if blocked.contains(viewModel.user.id) { - self?.userView.setButtonState(.blocked) - } else if followed.contains(viewModel.user.id) { - self?.userView.setButtonState(.unfollow) - } else if requested.contains(viewModel.user.id) { - self?.userView.setButtonState(.pending) - } else if viewModel.user.locked { - self?.userView.setButtonState(.request) - } else if viewModel.user != me { - self?.userView.setButtonState(.follow) - } - } - .store(in: &disposeBag) - - self.delegate = delegate - } } extension UserTableViewCellDelegate where Self: NeedsDependency & AuthContextProvider { - func userView(_ view: UserView, didTapButtonWith state: UserView.ButtonState, for user: MastodonUser) { - Task { - try await DataSourceFacade.responseToUserViewButtonAction( - dependency: self, - user: user.asRecord, - buttonState: state - ) - } - } - func userView(_ view: UserView, didTapButtonWith state: UserView.ButtonState, for account: Mastodon.Entity.Account, me: MastodonUser?) { + func userView(_ view: UserView, didTapButtonWith state: UserView.ButtonState, for account: Mastodon.Entity.Account, me: Mastodon.Entity.Account?) { Task { await MainActor.run { view.setButtonState(.loading) } try await DataSourceFacade.responseToUserViewButtonAction( dependency: self, - user: account, + account: account, buttonState: state ) @@ -128,7 +75,6 @@ extension UserTableViewCellDelegate where Self: NeedsDependency & AuthContextPro view.viewModel.relationship = relationship view.updateButtonState(with: relationship, isMe: isMe) } - } } } diff --git a/Mastodon/Scene/SuggestionAccount/SuggestionAccountViewController.swift b/Mastodon/Scene/SuggestionAccount/SuggestionAccountViewController.swift index 84d4f9e23..ad39d4e8a 100644 --- a/Mastodon/Scene/SuggestionAccount/SuggestionAccountViewController.swift +++ b/Mastodon/Scene/SuggestionAccount/SuggestionAccountViewController.swift @@ -91,7 +91,9 @@ extension SuggestionAccountViewController: UITableViewDelegate { guard let item = tableViewDiffableDataSource.itemIdentifier(for: indexPath) else { return } switch item { case .account(let account, _): - Task { await DataSourceFacade.coordinateToProfileScene(provider: self, account: account) } + Task { + await DataSourceFacade.coordinateToProfileScene(provider: self, account: account) + } } tableView.deselectRow(at: indexPath, animated: true) diff --git a/Mastodon/Scene/SuggestionAccount/SuggestionAccountViewModel.swift b/Mastodon/Scene/SuggestionAccount/SuggestionAccountViewModel.swift index 84cc4c46d..389da8427 100644 --- a/Mastodon/Scene/SuggestionAccount/SuggestionAccountViewModel.swift +++ b/Mastodon/Scene/SuggestionAccount/SuggestionAccountViewModel.swift @@ -121,7 +121,7 @@ final class SuggestionAccountViewModel: NSObject { taskGroup.addTask { try? await DataSourceFacade.responseToUserViewButtonAction( dependency: dependency, - user: account, + account: account, buttonState: .follow ) } diff --git a/Mastodon/Scene/SuggestionAccount/TableView-Components/SuggestionAccountTableViewCell+ViewModel.swift b/Mastodon/Scene/SuggestionAccount/TableView-Components/SuggestionAccountTableViewCell+ViewModel.swift deleted file mode 100644 index 1b4fcb34c..000000000 --- a/Mastodon/Scene/SuggestionAccount/TableView-Components/SuggestionAccountTableViewCell+ViewModel.swift +++ /dev/null @@ -1,22 +0,0 @@ -// Copyright © 2023 Mastodon gGmbH. All rights reserved. - -import Combine -import MastodonUI -import CoreDataStack - -extension SuggestionAccountTableViewCell { - final class ViewModel { - let user: MastodonUser - - let followedUsers: [String] - let blockedUsers: [String] - let followRequestedUsers: [String] - - init(user: MastodonUser, followedUsers: [String], blockedUsers: [String], followRequestedUsers: [String]) { - self.user = user - self.followedUsers = followedUsers - self.followRequestedUsers = followRequestedUsers - self.blockedUsers = blockedUsers - } - } -} diff --git a/Mastodon/Scene/SuggestionAccount/TableView-Components/SuggestionAccountTableViewCell.swift b/Mastodon/Scene/SuggestionAccount/TableView-Components/SuggestionAccountTableViewCell.swift index 17a8e5b55..c556ed1e5 100644 --- a/Mastodon/Scene/SuggestionAccount/TableView-Components/SuggestionAccountTableViewCell.swift +++ b/Mastodon/Scene/SuggestionAccount/TableView-Components/SuggestionAccountTableViewCell.swift @@ -91,7 +91,7 @@ final class SuggestionAccountTableViewCell: UITableViewCell { let metaContent: MetaContent = { do { - let mastodonContent = MastodonContent(content: account.note, emojis: account.emojis?.asDictionary ?? [:]) + let mastodonContent = MastodonContent(content: account.note, emojis: account.emojis.asDictionary) return try MastodonMetaContent.convert(document: mastodonContent) } catch { assertionFailure() diff --git a/Mastodon/Scene/Thread/RemoteThreadViewModel.swift b/Mastodon/Scene/Thread/RemoteThreadViewModel.swift index 1585b524b..8c2737111 100644 --- a/Mastodon/Scene/Thread/RemoteThreadViewModel.swift +++ b/Mastodon/Scene/Thread/RemoteThreadViewModel.swift @@ -24,7 +24,6 @@ final class RemoteThreadViewModel: ThreadViewModel { ) Task { @MainActor in - let domain = authContext.mastodonAuthenticationBox.domain let response = try await context.apiService.status( statusID: statusID, authenticationBox: authContext.mastodonAuthenticationBox @@ -48,7 +47,6 @@ final class RemoteThreadViewModel: ThreadViewModel { ) Task { @MainActor in - let domain = authContext.mastodonAuthenticationBox.domain let response = try await context.apiService.notification( notificationID: notificationID, authenticationBox: authContext.mastodonAuthenticationBox diff --git a/Mastodon/Scene/Thread/ThreadViewModel.swift b/Mastodon/Scene/Thread/ThreadViewModel.swift index 2e6e69426..07c3ca8e0 100644 --- a/Mastodon/Scene/Thread/ThreadViewModel.swift +++ b/Mastodon/Scene/Thread/ThreadViewModel.swift @@ -75,7 +75,7 @@ class ThreadViewModel { // bind titleView self.navigationBarTitle = { let title = L10n.Scene.Thread.title(status.entity.account.displayNameWithFallback) - let content = MastodonContent(content: title, emojis: status.entity.account.emojis?.asDictionary ?? [:]) + let content = MastodonContent(content: title, emojis: status.entity.account.emojis.asDictionary) return try? MastodonMetaContent.convert(document: content) }() } diff --git a/Mastodon/Supporting Files/SceneDelegate.swift b/Mastodon/Supporting Files/SceneDelegate.swift index 7c6c33f8e..75aa27c6e 100644 --- a/Mastodon/Supporting Files/SceneDelegate.swift +++ b/Mastodon/Supporting Files/SceneDelegate.swift @@ -92,9 +92,6 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { // trigger authenticated user account update AppContext.shared.authenticationService.updateActiveUserAccountPublisher.send() - - // update mutes and blocks and remove related data - AppContext.shared.instanceService.updateMutesAndBlocks() if let shortcutItem = savedShortCutItem { Task { @@ -137,16 +134,34 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { switch (profile, statusID) { case (profile, nil): - let profileViewModel = RemoteProfileViewModel( - context: AppContext.shared, - authContext: authContext, - acct: incomingURL.absoluteString - ) - self.coordinator?.present( - scene: .profile(viewModel: profileViewModel), - from: nil, - transition: .show - ) + Task { + let authenticationBox = authContext.mastodonAuthenticationBox + + guard let me = authenticationBox.authentication.account() else { return } + + guard let account = try await AppContext.shared.apiService.search( + query: .init(q: incomingURL.absoluteString, type: .accounts, resolve: true), + authenticationBox: authenticationBox + ).value.accounts.first else { return } + + guard let relationship = try await AppContext.shared.apiService.relationship( + forAccounts: [account], + authenticationBox: authenticationBox + ).value.first else { return } + + let profileViewModel = ProfileViewModel( + context: AppContext.shared, + authContext: authContext, + account: account, + relationship: relationship, + me: me + ) + _ = self.coordinator?.present( + scene: .profile(viewModel: profileViewModel), + from: nil, + transition: .show + ) + } case (profile, statusID): Task { @@ -248,10 +263,10 @@ extension SceneDelegate { if !UIApplication.shared.canOpenURL(url) { return } - #if DEBUG +#if DEBUG print("source application = \(sendingAppID ?? "Unknown")") print("url = \(url)") - #endif +#endif switch url.host { case "post": @@ -264,16 +279,39 @@ extension SceneDelegate { let authContext = coordinator?.authContext else { return } - let profileViewModel = RemoteProfileViewModel( - context: AppContext.shared, - authContext: authContext, - acct: components[1] - ) - self.coordinator?.present( - scene: .profile(viewModel: profileViewModel), - from: nil, - transition: .show - ) + Task { + do { + let authenticationBox = authContext.mastodonAuthenticationBox + + guard let me = authContext.mastodonAuthenticationBox.authentication.account() else { return } + + guard let account = try await AppContext.shared.apiService.search( + query: .init(q: components[1], type: .accounts, resolve: true), + authenticationBox: authenticationBox + ).value.accounts.first else { return } + + guard let relationship = try await AppContext.shared.apiService.relationship( + forAccounts: [account], + authenticationBox: authenticationBox + ).value.first else { return } + + let profileViewModel = ProfileViewModel( + context: AppContext.shared, + authContext: authContext, + account: account, + relationship: relationship, + me: me + ) + + self.coordinator?.present( + scene: .profile(viewModel: profileViewModel), + from: nil, + transition: .show + ) + } catch { + // fail silently + } + } case "status": let components = url.pathComponents guard diff --git a/MastodonIntent/Handler/SendPostIntentHandler.swift b/MastodonIntent/Handler/SendPostIntentHandler.swift index b457b1c91..0e37d63a8 100644 --- a/MastodonIntent/Handler/SendPostIntentHandler.swift +++ b/MastodonIntent/Handler/SendPostIntentHandler.swift @@ -59,7 +59,7 @@ extension SendPostIntentHandler: SendPostIntentHandling { } mastodonAuthentications = [authentication] } else { - mastodonAuthentications = try accounts.mastodonAuthentication(in: managedObjectContext) + mastodonAuthentications = try accounts.mastodonAuthentication() } let authenticationBoxes = mastodonAuthentications.map { authentication in @@ -149,7 +149,7 @@ extension SendPostIntentHandler: SendPostIntentHandling { } func provideAccountsOptionsCollection(for intent: SendPostIntent) async throws -> INObjectCollection { - let accounts = try await Account.fetch(in: managedObjectContext) + let accounts = try await Account.fetch() return .init(items: accounts) } diff --git a/MastodonIntent/Model/Account+Fetch.swift b/MastodonIntent/Model/Account+Fetch.swift index f3d8ee344..bdd9f412a 100644 --- a/MastodonIntent/Model/Account+Fetch.swift +++ b/MastodonIntent/Model/Account+Fetch.swift @@ -14,34 +14,29 @@ import MastodonCore extension Account { @MainActor - static func fetch(in managedObjectContext: NSManagedObjectContext) async throws -> [Account] { - // get accounts - let accounts: [Account] = try await managedObjectContext.perform { - let results = AuthenticationServiceProvider.shared.authentications - let accounts = results.compactMap { mastodonAuthentication -> Account? in - guard let user = mastodonAuthentication.user(in: managedObjectContext) else { - return nil - } - let account = Account( - identifier: mastodonAuthentication.identifier.uuidString, - display: user.displayNameWithFallback, - subtitle: user.acctWithDomain, - image: user.avatarImageURL().flatMap { INImage(url: $0) } - ) - account.name = user.displayNameWithFallback - account.username = user.acctWithDomain - return account + static func fetch() async throws -> [Account] { + let accounts = AuthenticationServiceProvider.shared.authentications.compactMap { mastodonAuthentication -> Account? in + guard let authenticatedAccount = mastodonAuthentication.account() else { + return nil } - return accounts - } // end managedObjectContext.perform + let account = Account( + identifier: mastodonAuthentication.identifier.uuidString, + display: authenticatedAccount.displayNameWithFallback, + subtitle: authenticatedAccount.acctWithDomain, + image: authenticatedAccount.avatarImageURL().flatMap { INImage(url: $0) } + ) + account.name = authenticatedAccount.displayNameWithFallback + account.username = authenticatedAccount.acctWithDomain + return account + } return accounts } - + } extension Array where Element == Account { - func mastodonAuthentication(in managedObjectContext: NSManagedObjectContext) throws -> [MastodonAuthentication] { + func mastodonAuthentication() throws -> [MastodonAuthentication] { let identifiers = self .compactMap { $0.identifier } .compactMap { UUID(uuidString: $0) } diff --git a/MastodonSDK/Sources/CoreDataStack/CoreData.xcdatamodeld/CoreData 9.xcdatamodel/contents b/MastodonSDK/Sources/CoreDataStack/CoreData.xcdatamodeld/CoreData 9.xcdatamodel/contents index 062ae9d7b..9e64f9623 100644 --- a/MastodonSDK/Sources/CoreDataStack/CoreData.xcdatamodeld/CoreData 9.xcdatamodel/contents +++ b/MastodonSDK/Sources/CoreDataStack/CoreData.xcdatamodeld/CoreData 9.xcdatamodel/contents @@ -1,5 +1,5 @@ - + @@ -53,7 +53,6 @@ - @@ -111,7 +110,6 @@ - @@ -120,10 +118,7 @@ - - - @@ -131,19 +126,6 @@ - - - - - - - - - - - - - @@ -169,12 +151,6 @@ - - - - - - @@ -217,7 +193,6 @@ - @@ -249,15 +224,4 @@ - - - - - - - - - - - \ No newline at end of file diff --git a/MastodonSDK/Sources/CoreDataStack/Entity/App/Feed.swift b/MastodonSDK/Sources/CoreDataStack/Entity/App/Feed.swift index 5fca61153..2565a5e41 100644 --- a/MastodonSDK/Sources/CoreDataStack/Entity/App/Feed.swift +++ b/MastodonSDK/Sources/CoreDataStack/Entity/App/Feed.swift @@ -96,19 +96,6 @@ extension Feed { public static func hasNotificationPredicate() -> NSPredicate { return NSPredicate(format: "%K != nil", #keyPath(Feed.notification)) } - - public static func notificationTypePredicate(types: [MastodonNotificationType]) -> NSPredicate { - return NSCompoundPredicate(andPredicateWithSubpredicates: [ - hasNotificationPredicate(), - NSPredicate( - format: "%K.%K IN %@", - #keyPath(Feed.notification), - #keyPath(Notification.typeRaw), - types.map { $0.rawValue } - ) - ]) - } - } // MARK: - AutoGenerateProperty diff --git a/MastodonSDK/Sources/CoreDataStack/Entity/Mastodon/History.swift b/MastodonSDK/Sources/CoreDataStack/Entity/Mastodon/History.swift deleted file mode 100644 index 6fe703e84..000000000 --- a/MastodonSDK/Sources/CoreDataStack/Entity/Mastodon/History.swift +++ /dev/null @@ -1,81 +0,0 @@ -// -// History.swift -// CoreDataStack -// -// Created by sxiaojian on 2021/2/1. -// - -import CoreData -import Foundation - -public final class History: NSManagedObject { - public typealias ID = UUID - @NSManaged public private(set) var identifier: ID - @NSManaged public private(set) var createAt: Date - - @NSManaged public private(set) var day: Date - @NSManaged public private(set) var uses: String - @NSManaged public private(set) var accounts: String - - // many-to-one relationship - @NSManaged public private(set) var tag: Tag -} - -public extension History { - override func awakeFromInsert() { - super.awakeFromInsert() - setPrimitiveValue(UUID(), forKey: #keyPath(History.identifier)) - } - - @discardableResult - static func insert( - into context: NSManagedObjectContext, - property: Property - ) -> History { - let history: History = context.insertObject() - history.day = property.day - history.uses = property.uses - history.accounts = property.accounts - return history - } -} - -public extension History { - func update(day: Date) { - if self.day != day { - self.day = day - } - } - - func update(uses: String) { - if self.uses != uses { - self.uses = uses - } - } - - func update(accounts: String) { - if self.accounts != accounts { - self.accounts = accounts - } - } -} - -public extension History { - struct Property { - public let day: Date - public let uses: String - public let accounts: String - - public init(day: Date, uses: String, accounts: String) { - self.day = day - self.uses = uses - self.accounts = accounts - } - } -} - -extension History: Managed { - public static var defaultSortDescriptors: [NSSortDescriptor] { - return [NSSortDescriptor(keyPath: \History.createAt, ascending: false)] - } -} diff --git a/MastodonSDK/Sources/CoreDataStack/Entity/Mastodon/MastodonUser.swift b/MastodonSDK/Sources/CoreDataStack/Entity/Mastodon/MastodonUser.swift index 31ed535a9..7b9c0004e 100644 --- a/MastodonSDK/Sources/CoreDataStack/Entity/Mastodon/MastodonUser.swift +++ b/MastodonSDK/Sources/CoreDataStack/Entity/Mastodon/MastodonUser.swift @@ -9,16 +9,15 @@ import CoreData import Foundation /// See also `CoreDataStack.MastodonUser`, this extension contains several +@available(*, deprecated, message: "Replace with Mastodon.Entity.Account") final public class MastodonUser: NSManagedObject { - - public typealias ID = String - + // sourcery: autoGenerateProperty - @NSManaged public private(set) var identifier: ID + @NSManaged public private(set) var identifier: String // sourcery: autoGenerateProperty @NSManaged public private(set) var domain: String // sourcery: autoGenerateProperty - @NSManaged public private(set) var id: ID + @NSManaged public private(set) var id: String // sourcery: autoUpdatableObject, autoGenerateProperty @NSManaged public private(set) var acct: String @@ -67,7 +66,6 @@ final public class MastodonUser: NSManagedObject { // one-to-many relationship @NSManaged public private(set) var statuses: Set - @NSManaged public private(set) var notifications: Set // many-to-many relationship @NSManaged public private(set) var favourite: Set @@ -77,7 +75,6 @@ final public class MastodonUser: NSManagedObject { @NSManaged public private(set) var votePollOptions: Set @NSManaged public private(set) var votePolls: Set // relationships - @NSManaged public private(set) var followedTags: Set @NSManaged public private(set) var following: Set @NSManaged public private(set) var followingBy: Set @NSManaged public private(set) var followRequested: Set @@ -146,20 +143,6 @@ extension MastodonUser { } } -extension MastodonUser { - - @discardableResult - public static func insert( - into context: NSManagedObjectContext, - property: Property - ) -> MastodonUser { - let object: MastodonUser = context.insertObject() - object.configure(property: property) - return object - } - -} - extension MastodonUser: Managed { public static var defaultSortDescriptors: [NSSortDescriptor] { return [NSSortDescriptor(keyPath: \MastodonUser.createdAt, ascending: false)] @@ -205,331 +188,11 @@ extension MastodonUser { ]) } - public static func predicate(followingBy userID: MastodonUser.ID) -> NSPredicate { + public static func predicate(followingBy userID: String) -> NSPredicate { NSPredicate(format: "ANY %K.%K == %@", #keyPath(MastodonUser.followingBy), #keyPath(MastodonUser.id), userID) } - public static func predicate(followRequestedBy userID: MastodonUser.ID) -> NSPredicate { + public static func predicate(followRequestedBy userID: String) -> NSPredicate { NSPredicate(format: "ANY %K.%K == %@", #keyPath(MastodonUser.followRequestedBy), #keyPath(MastodonUser.id), userID) } - -} - -// MARK: - AutoGenerateProperty -extension MastodonUser: AutoGenerateProperty { - // sourcery:inline:MastodonUser.AutoGenerateProperty - - // Generated using Sourcery - // DO NOT EDIT - public struct Property { - public let identifier: ID - public let domain: String - public let id: ID - public let acct: String - public let username: String - public let displayName: String - public let avatar: String - public let avatarStatic: String? - public let header: String - public let headerStatic: String? - public let note: String? - public let url: String? - public let statusesCount: Int64 - public let followingCount: Int64 - public let followersCount: Int64 - public let locked: Bool - public let bot: Bool - public let suspended: Bool - public let createdAt: Date - public let updatedAt: Date - public let emojis: [MastodonEmoji] - public let fields: [MastodonField] - - public init( - identifier: ID, - domain: String, - id: ID, - acct: String, - username: String, - displayName: String, - avatar: String, - avatarStatic: String?, - header: String, - headerStatic: String?, - note: String?, - url: String?, - statusesCount: Int64, - followingCount: Int64, - followersCount: Int64, - locked: Bool, - bot: Bool, - suspended: Bool, - createdAt: Date, - updatedAt: Date, - emojis: [MastodonEmoji], - fields: [MastodonField] - ) { - self.identifier = identifier - self.domain = domain - self.id = id - self.acct = acct - self.username = username - self.displayName = displayName - self.avatar = avatar - self.avatarStatic = avatarStatic - self.header = header - self.headerStatic = headerStatic - self.note = note - self.url = url - self.statusesCount = statusesCount - self.followingCount = followingCount - self.followersCount = followersCount - self.locked = locked - self.bot = bot - self.suspended = suspended - self.createdAt = createdAt - self.updatedAt = updatedAt - self.emojis = emojis - self.fields = fields - } - } - - public func configure(property: Property) { - self.identifier = property.identifier - self.domain = property.domain - self.id = property.id - self.acct = property.acct - self.username = property.username - self.displayName = property.displayName - self.avatar = property.avatar - self.avatarStatic = property.avatarStatic - self.header = property.header - self.headerStatic = property.headerStatic - self.note = property.note - self.url = property.url - self.statusesCount = property.statusesCount - self.followingCount = property.followingCount - self.followersCount = property.followersCount - self.locked = property.locked - self.bot = property.bot - self.suspended = property.suspended - self.createdAt = property.createdAt - self.updatedAt = property.updatedAt - self.emojis = property.emojis - self.fields = property.fields - } - - public func update(property: Property) { - update(acct: property.acct) - update(username: property.username) - update(displayName: property.displayName) - update(avatar: property.avatar) - update(avatarStatic: property.avatarStatic) - update(header: property.header) - update(headerStatic: property.headerStatic) - update(note: property.note) - update(url: property.url) - update(statusesCount: property.statusesCount) - update(followingCount: property.followingCount) - update(followersCount: property.followersCount) - update(locked: property.locked) - update(bot: property.bot) - update(suspended: property.suspended) - update(createdAt: property.createdAt) - update(updatedAt: property.updatedAt) - update(emojis: property.emojis) - update(fields: property.fields) - } - // sourcery:end -} - -// MARK: - AutoUpdatableObject -extension MastodonUser: AutoUpdatableObject { - // sourcery:inline:MastodonUser.AutoUpdatableObject - - // Generated using Sourcery - // DO NOT EDIT - public func update(acct: String) { - if self.acct != acct { - self.acct = acct - } - } - public func update(username: String) { - if self.username != username { - self.username = username - } - } - public func update(displayName: String) { - if self.displayName != displayName { - self.displayName = displayName - } - } - public func update(avatar: String) { - if self.avatar != avatar { - self.avatar = avatar - } - } - public func update(avatarStatic: String?) { - if self.avatarStatic != avatarStatic { - self.avatarStatic = avatarStatic - } - } - public func update(header: String) { - if self.header != header { - self.header = header - } - } - public func update(headerStatic: String?) { - if self.headerStatic != headerStatic { - self.headerStatic = headerStatic - } - } - public func update(note: String?) { - if self.note != note { - self.note = note - } - } - public func update(url: String?) { - if self.url != url { - self.url = url - } - } - public func update(statusesCount: Int64) { - if self.statusesCount != statusesCount { - self.statusesCount = statusesCount - } - } - public func update(followingCount: Int64) { - if self.followingCount != followingCount { - self.followingCount = followingCount - } - } - public func update(followersCount: Int64) { - if self.followersCount != followersCount { - self.followersCount = followersCount - } - } - public func update(locked: Bool) { - if self.locked != locked { - self.locked = locked - } - } - public func update(bot: Bool) { - if self.bot != bot { - self.bot = bot - } - } - public func update(suspended: Bool) { - if self.suspended != suspended { - self.suspended = suspended - } - } - public func update(createdAt: Date) { - if self.createdAt != createdAt { - self.createdAt = createdAt - } - } - public func update(updatedAt: Date) { - if self.updatedAt != updatedAt { - self.updatedAt = updatedAt - } - } - public func update(emojis: [MastodonEmoji]) { - if self.emojis != emojis { - self.emojis = emojis - } - } - public func update(fields: [MastodonField]) { - if self.fields != fields { - self.fields = fields - } - } - // sourcery:end - - public func update(isFollowing: Bool, by mastodonUser: MastodonUser) { - if isFollowing { - if !self.followingBy.contains(mastodonUser) { - self.mutableSetValue(forKey: #keyPath(MastodonUser.followingBy)).add(mastodonUser) - } - } else { - if self.followingBy.contains(mastodonUser) { - self.mutableSetValue(forKey: #keyPath(MastodonUser.followingBy)).remove(mastodonUser) - } - } - } - public func update(isFollowRequested: Bool, by mastodonUser: MastodonUser) { - if isFollowRequested { - if !self.followRequestedBy.contains(mastodonUser) { - self.mutableSetValue(forKey: #keyPath(MastodonUser.followRequestedBy)).add(mastodonUser) - } - } else { - if self.followRequestedBy.contains(mastodonUser) { - self.mutableSetValue(forKey: #keyPath(MastodonUser.followRequestedBy)).remove(mastodonUser) - } - } - } - public func update(isMuting: Bool, by mastodonUser: MastodonUser) { - if isMuting { - if !self.mutingBy.contains(mastodonUser) { - self.mutableSetValue(forKey: #keyPath(MastodonUser.mutingBy)).add(mastodonUser) - } - } else { - if self.mutingBy.contains(mastodonUser) { - self.mutableSetValue(forKey: #keyPath(MastodonUser.mutingBy)).remove(mastodonUser) - } - } - } - public func update(isBlocking: Bool, by mastodonUser: MastodonUser) { - if isBlocking { - if !self.blockingBy.contains(mastodonUser) { - self.mutableSetValue(forKey: #keyPath(MastodonUser.blockingBy)).add(mastodonUser) - } - } else { - if self.blockingBy.contains(mastodonUser) { - self.mutableSetValue(forKey: #keyPath(MastodonUser.blockingBy)).remove(mastodonUser) - } - } - } - public func update(isEndorsed: Bool, by mastodonUser: MastodonUser) { - if isEndorsed { - if !self.endorsedBy.contains(mastodonUser) { - self.mutableSetValue(forKey: #keyPath(MastodonUser.endorsedBy)).add(mastodonUser) - } - } else { - if self.endorsedBy.contains(mastodonUser) { - self.mutableSetValue(forKey: #keyPath(MastodonUser.endorsedBy)).remove(mastodonUser) - } - } - } - - public func update(isDomainBlocking: Bool, by mastodonUser: MastodonUser) { - if isDomainBlocking { - if !self.domainBlockingBy.contains(mastodonUser) { - self.mutableSetValue(forKey: #keyPath(MastodonUser.domainBlockingBy)).add(mastodonUser) - } - } else { - if self.domainBlockingBy.contains(mastodonUser) { - self.mutableSetValue(forKey: #keyPath(MastodonUser.domainBlockingBy)).remove(mastodonUser) - } - } - } - - public func update(isShowingReblogs: Bool, by mastodonUser: MastodonUser) { - if isShowingReblogs { - if !self.showingReblogsBy.contains(mastodonUser) { - self.mutableSetValue(forKey: #keyPath(MastodonUser.showingReblogsBy)).add(mastodonUser) - } - } else { - if self.showingReblogsBy.contains(mastodonUser) { - self.mutableSetValue(forKey: #keyPath(MastodonUser.showingReblogsBy)).remove(mastodonUser) - } - } - } -} - -extension MastodonUser { - public var verifiedLink: MastodonField? { - let firstVerified = fields.first(where: { $0.verifiedAt != nil }) - return firstVerified - } } diff --git a/MastodonSDK/Sources/CoreDataStack/Entity/Mastodon/Notification.swift b/MastodonSDK/Sources/CoreDataStack/Entity/Mastodon/Notification.swift deleted file mode 100644 index 7eec86842..000000000 --- a/MastodonSDK/Sources/CoreDataStack/Entity/Mastodon/Notification.swift +++ /dev/null @@ -1,269 +0,0 @@ -// -// Notification.swift -// CoreDataStack -// -// Created by sxiaojian on 2021/4/13. -// - -import Foundation -import CoreData - -public final class Notification: NSManagedObject { - public typealias ID = String - - // sourcery: autoGenerateProperty - @NSManaged public private(set) var id: ID - // sourcery: autoGenerateProperty - @NSManaged public private(set) var typeRaw: String - // sourcery: autoGenerateProperty - @NSManaged public private(set) var domain: String - // sourcery: autoGenerateProperty - @NSManaged public private(set) var userID: String - - // sourcery: autoGenerateProperty - @NSManaged public private(set) var createAt: Date - // sourcery: autoUpdatableObject, autoGenerateProperty - @NSManaged public private(set) var updatedAt: Date - - // one-to-one relationship - // sourcery: autoGenerateRelationship - @NSManaged public private(set) var account: MastodonUser - // sourcery: autoGenerateRelationship - @NSManaged public private(set) var status: Status? - - // many-to-one relationship - @NSManaged public private(set) var feeds: Set - -} - -extension Notification { - // sourcery: autoUpdatableObject - @objc public var followRequestState: MastodonFollowRequestState { - get { - let keyPath = #keyPath(Notification.followRequestState) - willAccessValue(forKey: keyPath) - let _data = primitiveValue(forKey: keyPath) as? Data - didAccessValue(forKey: keyPath) - do { - guard let data = _data, !data.isEmpty else { return .init(state: .none) } - let state = try JSONDecoder().decode(MastodonFollowRequestState.self, from: data) - return state - } catch { - assertionFailure(error.localizedDescription) - return .init(state: .none) - } - } - set { - let keyPath = #keyPath(Notification.followRequestState) - let data = try? JSONEncoder().encode(newValue) - willChangeValue(forKey: keyPath) - setPrimitiveValue(data, forKey: keyPath) - didChangeValue(forKey: keyPath) - } - } - - // sourcery: autoUpdatableObject - @objc public var transientFollowRequestState: MastodonFollowRequestState { - get { - let keyPath = #keyPath(Notification.transientFollowRequestState) - willAccessValue(forKey: keyPath) - let _data = primitiveValue(forKey: keyPath) as? Data - didAccessValue(forKey: keyPath) - do { - guard let data = _data else { return .init(state: .none) } - let state = try JSONDecoder().decode(MastodonFollowRequestState.self, from: data) - return state - } catch { - assertionFailure(error.localizedDescription) - return .init(state: .none) - } - } - set { - let keyPath = #keyPath(Notification.transientFollowRequestState) - let data = try? JSONEncoder().encode(newValue) - willChangeValue(forKey: keyPath) - setPrimitiveValue(data, forKey: keyPath) - didChangeValue(forKey: keyPath) - } - } -} - -extension Notification: FeedIndexable { } - -extension Notification { - @discardableResult - public static func insert( - into context: NSManagedObjectContext, - property: Property, - relationship: Relationship - ) -> Notification { - let object: Notification = context.insertObject() - - object.configure(property: property) - object.configure(relationship: relationship) - - return object - } -} - -extension Notification: Managed { - public static var defaultSortDescriptors: [NSSortDescriptor] { - return [NSSortDescriptor(keyPath: \Notification.createAt, ascending: false)] - } -} - -extension Notification { - static func predicate(domain: String) -> NSPredicate { - return NSPredicate(format: "%K == %@", #keyPath(Notification.domain), domain) - } - - static func predicate(userID: String) -> NSPredicate { - return NSPredicate(format: "%K == %@", #keyPath(Notification.userID), userID) - } - - static func predicate(id: ID) -> NSPredicate { - return NSPredicate(format: "%K == %@", #keyPath(Notification.id), id) - } - - static func predicate(typeRaw: String) -> NSPredicate { - return NSPredicate(format: "%K == %@", #keyPath(Notification.typeRaw), typeRaw) - } - - public static func predicate( - domain: String, - userID: String, - id: ID - ) -> NSPredicate { - return NSCompoundPredicate(andPredicateWithSubpredicates: [ - Notification.predicate(domain: domain), - Notification.predicate(userID: userID), - Notification.predicate(id: id) - ]) - } - - public static func predicate( - domain: String, - userID: String, - typeRaw: String? = nil - ) -> NSPredicate { - if let typeRaw = typeRaw { - return NSCompoundPredicate(andPredicateWithSubpredicates: [ - Notification.predicate(domain: domain), - Notification.predicate(typeRaw: typeRaw), - Notification.predicate(userID: userID), - ]) - } else { - return NSCompoundPredicate(andPredicateWithSubpredicates: [ - Notification.predicate(domain: domain), - Notification.predicate(userID: userID) - ]) - } - } - - public static func predicate(validTypesRaws types: [String]) -> NSPredicate { - return NSPredicate(format: "%K IN %@", #keyPath(Notification.typeRaw), types) - } - -} - -// MARK: - AutoGenerateProperty -extension Notification: AutoGenerateProperty { - // sourcery:inline:Notification.AutoGenerateProperty - - // Generated using Sourcery - // DO NOT EDIT - public struct Property { - public let id: ID - public let typeRaw: String - public let domain: String - public let userID: String - public let createAt: Date - public let updatedAt: Date - - public init( - id: ID, - typeRaw: String, - domain: String, - userID: String, - createAt: Date, - updatedAt: Date - ) { - self.id = id - self.typeRaw = typeRaw - self.domain = domain - self.userID = userID - self.createAt = createAt - self.updatedAt = updatedAt - } - } - - public func configure(property: Property) { - self.id = property.id - self.typeRaw = property.typeRaw - self.domain = property.domain - self.userID = property.userID - self.createAt = property.createAt - self.updatedAt = property.updatedAt - } - - public func update(property: Property) { - update(updatedAt: property.updatedAt) - } - // sourcery:end -} - -// MARK: - AutoGenerateRelationship -extension Notification: AutoGenerateRelationship { - // sourcery:inline:Notification.AutoGenerateRelationship - - // Generated using Sourcery - // DO NOT EDIT - public struct Relationship { - public let account: MastodonUser - public let status: Status? - - public init( - account: MastodonUser, - status: Status? - ) { - self.account = account - self.status = status - } - } - - public func configure(relationship: Relationship) { - self.account = relationship.account - self.status = relationship.status - } - // sourcery:end -} - -// MARK: - AutoUpdatableObject -extension Notification: AutoUpdatableObject { - // sourcery:inline:Notification.AutoUpdatableObject - - // Generated using Sourcery - // DO NOT EDIT - public func update(updatedAt: Date) { - if self.updatedAt != updatedAt { - self.updatedAt = updatedAt - } - } - public func update(followRequestState: MastodonFollowRequestState) { - if self.followRequestState != followRequestState { - self.followRequestState = followRequestState - } - } - public func update(transientFollowRequestState: MastodonFollowRequestState) { - if self.transientFollowRequestState != transientFollowRequestState { - self.transientFollowRequestState = transientFollowRequestState - } - } - // sourcery:end -} - -extension Notification { - public func attach(feed: Feed) { - mutableSetValue(forKey: #keyPath(Notification.feeds)).add(feed) - } -} diff --git a/MastodonSDK/Sources/CoreDataStack/Entity/Mastodon/PrivateNote.swift b/MastodonSDK/Sources/CoreDataStack/Entity/Mastodon/PrivateNote.swift deleted file mode 100644 index 2e02db25c..000000000 --- a/MastodonSDK/Sources/CoreDataStack/Entity/Mastodon/PrivateNote.swift +++ /dev/null @@ -1,56 +0,0 @@ -// -// PrivateNote.swift -// CoreDataStack -// -// Created by MainasuK Cirno on 2021-4-1. -// - -import CoreData -import Foundation - -final public class PrivateNote: NSManagedObject { - - @NSManaged public private(set) var note: String? - - @NSManaged public private(set) var updatedAt: Date - - // many-to-one relationship - @NSManaged public private(set) var to: MastodonUser? - @NSManaged public private(set) var from: MastodonUser - -} - -extension PrivateNote { - public override func awakeFromInsert() { - super.awakeFromInsert() - setPrimitiveValue(Date(), forKey: #keyPath(PrivateNote.updatedAt)) - } - - @discardableResult - public static func insert( - into context: NSManagedObjectContext, - property: Property - ) -> PrivateNote { - let privateNode: PrivateNote = context.insertObject() - privateNode.note = property.note - return privateNode - } -} - -extension PrivateNote { - public struct Property { - public let note: String? - - init(note: String) { - self.note = note - } - } - -} - -extension PrivateNote: Managed { - public static var defaultSortDescriptors: [NSSortDescriptor] { - return [NSSortDescriptor(keyPath: \PrivateNote.updatedAt, ascending: false)] - } -} - diff --git a/MastodonSDK/Sources/CoreDataStack/Entity/Mastodon/Status.swift b/MastodonSDK/Sources/CoreDataStack/Entity/Mastodon/Status.swift index 1b457f085..2cfa8c0f1 100644 --- a/MastodonSDK/Sources/CoreDataStack/Entity/Mastodon/Status.swift +++ b/MastodonSDK/Sources/CoreDataStack/Entity/Mastodon/Status.swift @@ -64,7 +64,7 @@ public final class Status: NSManagedObject { // sourcery: autoUpdatableObject, autoGenerateProperty @NSManaged public private(set) var inReplyToID: Status.ID? // sourcery: autoUpdatableObject, autoGenerateProperty - @NSManaged public private(set) var inReplyToAccountID: MastodonUser.ID? + @NSManaged public private(set) var inReplyToAccountID: String? // sourcery: autoUpdatableObject, autoGenerateProperty @NSManaged public private(set) var language: String? // (ISO 639 Part 1 two-letter language code) @@ -73,20 +73,10 @@ public final class Status: NSManagedObject { // many-to-one relationship // sourcery: autoGenerateRelationship - @NSManaged public private(set) var author: MastodonUser - // sourcery: autoGenerateRelationship @NSManaged public private(set) var reblog: Status? // sourcery: autoUpdatableObject @NSManaged public private(set) var replyTo: Status? - // many-to-many relationship - @NSManaged public private(set) var favouritedBy: Set - @NSManaged public private(set) var rebloggedBy: Set - @NSManaged public private(set) var mutedBy: Set - @NSManaged public private(set) var bookmarkedBy: Set - - // one-to-one relationship - @NSManaged public private(set) var pinnedBy: MastodonUser? // sourcery: autoGenerateRelationship @NSManaged public private(set) var poll: Poll? // sourcery: autoGenerateRelationship @@ -270,7 +260,7 @@ extension Status: AutoGenerateProperty { public let repliesCount: Int64 public let url: String? public let inReplyToID: Status.ID? - public let inReplyToAccountID: MastodonUser.ID? + public let inReplyToAccountID: String? public let language: String? public let text: String? public let updatedAt: Date @@ -295,7 +285,7 @@ extension Status: AutoGenerateProperty { repliesCount: Int64, url: String?, inReplyToID: Status.ID?, - inReplyToAccountID: MastodonUser.ID?, + inReplyToAccountID: String?, language: String?, text: String?, updatedAt: Date, @@ -388,20 +378,17 @@ extension Status: AutoGenerateRelationship { // DO NOT EDIT public struct Relationship { public let application: Application? - public let author: MastodonUser public let reblog: Status? public let poll: Poll? public let card: Card? public init( application: Application?, - author: MastodonUser, reblog: Status?, poll: Poll?, card: Card? ) { self.application = application - self.author = author self.reblog = reblog self.poll = poll self.card = card @@ -410,7 +397,6 @@ extension Status: AutoGenerateRelationship { public func configure(relationship: Relationship) { self.application = relationship.application - self.author = relationship.author self.reblog = relationship.reblog self.poll = relationship.poll self.card = relationship.card @@ -484,7 +470,7 @@ extension Status: AutoUpdatableObject { self.inReplyToID = inReplyToID } } - public func update(inReplyToAccountID: MastodonUser.ID?) { + public func update(inReplyToAccountID: String?) { if self.inReplyToAccountID != inReplyToAccountID { self.inReplyToAccountID = inReplyToAccountID } @@ -536,54 +522,6 @@ extension Status: AutoUpdatableObject { } // sourcery:end - public func update(liked: Bool, by mastodonUser: MastodonUser) { - if liked { - if !self.favouritedBy.contains(mastodonUser) { - self.mutableSetValue(forKey: #keyPath(Status.favouritedBy)).add(mastodonUser) - } - } else { - if self.favouritedBy.contains(mastodonUser) { - self.mutableSetValue(forKey: #keyPath(Status.favouritedBy)).remove(mastodonUser) - } - } - } - - public func update(reblogged: Bool, by mastodonUser: MastodonUser) { - if reblogged { - if !self.rebloggedBy.contains(mastodonUser) { - self.mutableSetValue(forKey: #keyPath(Status.rebloggedBy)).add(mastodonUser) - } - } else { - if self.rebloggedBy.contains(mastodonUser) { - self.mutableSetValue(forKey: #keyPath(Status.rebloggedBy)).remove(mastodonUser) - } - } - } - - public func update(muted: Bool, by mastodonUser: MastodonUser) { - if muted { - if !self.mutedBy.contains(mastodonUser) { - self.mutableSetValue(forKey: #keyPath(Status.mutedBy)).add(mastodonUser) - } - } else { - if self.mutedBy.contains(mastodonUser) { - self.mutableSetValue(forKey: #keyPath(Status.mutedBy)).remove(mastodonUser) - } - } - } - - public func update(bookmarked: Bool, by mastodonUser: MastodonUser) { - if bookmarked { - if !self.bookmarkedBy.contains(mastodonUser) { - self.mutableSetValue(forKey: #keyPath(Status.bookmarkedBy)).add(mastodonUser) - } - } else { - if self.bookmarkedBy.contains(mastodonUser) { - self.mutableSetValue(forKey: #keyPath(Status.bookmarkedBy)).remove(mastodonUser) - } - } - } - public func update(isReveal: Bool) { revealedAt = isReveal ? Date() : nil } diff --git a/MastodonSDK/Sources/CoreDataStack/Entity/Mastodon/Tag.swift b/MastodonSDK/Sources/CoreDataStack/Entity/Mastodon/Tag.swift deleted file mode 100644 index d95c9dcb3..000000000 --- a/MastodonSDK/Sources/CoreDataStack/Entity/Mastodon/Tag.swift +++ /dev/null @@ -1,215 +0,0 @@ -// -// Tag.swift -// CoreDataStack -// -// Created by sxiaojian on 2021/2/1. -// - -import CoreData -import Foundation - -public final class Tag: NSManagedObject { - public typealias ID = UUID - - // sourcery: autoGenerateProperty - @NSManaged public private(set) var identifier: ID - // sourcery: autoGenerateProperty - @NSManaged public private(set) var domain: String - // sourcery: autoGenerateProperty - @NSManaged public private(set) var createAt: Date - // sourcery: autoUpdatableObject, autoGenerateProperty - @NSManaged public private(set) var updatedAt: Date - - // sourcery: autoGenerateProperty - @NSManaged public private(set) var name: String - // sourcery: autoUpdatableObject, autoGenerateProperty - @NSManaged public private(set) var url: String - // sourcery: autoUpdatableObject, autoGenerateProperty - @NSManaged public private(set) var following: Bool - - // one-to-one relationship - - // many-to-many relationship - @NSManaged public private(set) var followedBy: Set -} - -extension Tag { - // sourcery: autoUpdatableObject, autoGenerateProperty - @objc public var histories: [MastodonTagHistory] { - get { - let keyPath = #keyPath(Tag.histories) - willAccessValue(forKey: keyPath) - let _data = primitiveValue(forKey: keyPath) as? Data - didAccessValue(forKey: keyPath) - do { - guard let data = _data else { return [] } - let attachments = try JSONDecoder().decode([MastodonTagHistory].self, from: data) - return attachments - } catch { - assertionFailure(error.localizedDescription) - return [] - } - } - set { - let keyPath = #keyPath(Tag.histories) - let data = try? JSONEncoder().encode(newValue) - willChangeValue(forKey: keyPath) - setPrimitiveValue(data, forKey: keyPath) - didChangeValue(forKey: keyPath) - } - } -} - -extension Tag { - @discardableResult - public static func insert( - into context: NSManagedObjectContext, - property: Property - ) -> Tag { - let object: Tag = context.insertObject() - - object.configure(property: property) - - return object - } -} - - -extension Tag: Managed { - public static var defaultSortDescriptors: [NSSortDescriptor] { - [NSSortDescriptor(keyPath: \Tag.createAt, ascending: false)] - } -} - -public extension Tag { - - static func predicate(domain: String) -> NSPredicate { - NSPredicate(format: "%K == %@", #keyPath(Tag.domain), domain) - } - - static func predicate(name: String) -> NSPredicate { - // use case-insensitive query as tags #CaN #BE #speLLed #USiNG #arbITRARy #cASe - NSPredicate(format: "%K MATCHES[c] %@", #keyPath(Tag.name), name) - } - - static func predicate(domain: String, following: Bool) -> NSPredicate { - NSPredicate(format: "%K == %@ AND %K == %d", #keyPath(Tag.domain), domain, #keyPath(Tag.following), following) - } - - static func predicate(followedBy user: MastodonUser) -> NSPredicate { - NSPredicate(format: "ANY %K.%K == %@", #keyPath(Tag.followedBy), #keyPath(MastodonUser.id), user.id) - } - - static func predicate(domain: String, name: String) -> NSPredicate { - NSCompoundPredicate(andPredicateWithSubpredicates: [ - predicate(domain: domain), - predicate(name: name), - ]) - } - - static func predicate(domain: String, following: Bool, by user: MastodonUser) -> NSPredicate { - NSCompoundPredicate(andPredicateWithSubpredicates: [ - predicate(domain: domain, following: following), - predicate(followedBy: user) - ]) - } -} - -// MARK: - AutoGenerateProperty -extension Tag: AutoGenerateProperty { - // sourcery:inline:Tag.AutoGenerateProperty - - // Generated using Sourcery - // DO NOT EDIT - public struct Property { - public let identifier: ID - public let domain: String - public let createAt: Date - public let updatedAt: Date - public let name: String - public let url: String - public let following: Bool - public let histories: [MastodonTagHistory] - - public init( - identifier: ID, - domain: String, - createAt: Date, - updatedAt: Date, - name: String, - url: String, - following: Bool, - histories: [MastodonTagHistory] - ) { - self.identifier = identifier - self.domain = domain - self.createAt = createAt - self.updatedAt = updatedAt - self.name = name - self.url = url - self.following = following - self.histories = histories - } - } - - public func configure(property: Property) { - self.identifier = property.identifier - self.domain = property.domain - self.createAt = property.createAt - self.updatedAt = property.updatedAt - self.name = property.name - self.url = property.url - self.following = property.following - self.histories = property.histories - } - - public func update(property: Property) { - update(updatedAt: property.updatedAt) - update(url: property.url) - update(following: property.following) - update(histories: property.histories) - } - // sourcery:end -} - -// MARK: - AutoUpdatableObject -extension Tag: AutoUpdatableObject { - // sourcery:inline:Tag.AutoUpdatableObject - - // Generated using Sourcery - // DO NOT EDIT - public func update(updatedAt: Date) { - if self.updatedAt != updatedAt { - self.updatedAt = updatedAt - } - } - public func update(url: String) { - if self.url != url { - self.url = url - } - } - public func update(following: Bool) { - if self.following != following { - self.following = following - } - } - public func update(histories: [MastodonTagHistory]) { - if self.histories != histories { - self.histories = histories - } - } - // sourcery:end - - public func update(followed: Bool, by mastodonUser: MastodonUser) { - if following { - if !self.followedBy.contains(mastodonUser) { - self.mutableSetValue(forKey: #keyPath(Tag.followedBy)).add(mastodonUser) - } - } else { - if self.followedBy.contains(mastodonUser) { - self.mutableSetValue(forKey: #keyPath(Tag.followedBy)).remove(mastodonUser) - } - } - } - -} diff --git a/MastodonSDK/Sources/CoreDataStack/Entity/Transient/Acct.swift b/MastodonSDK/Sources/CoreDataStack/Entity/Transient/Acct.swift index fe59bb9d4..e7e071da0 100644 --- a/MastodonSDK/Sources/CoreDataStack/Entity/Transient/Acct.swift +++ b/MastodonSDK/Sources/CoreDataStack/Entity/Transient/Acct.swift @@ -11,7 +11,7 @@ import Foundation extension Feed { public enum Acct: RawRepresentable { case none - case mastodon(domain: String, userID: MastodonUser.ID) + case mastodon(domain: String, userID: String) public init?(rawValue: String) { let components = rawValue.split(separator: "@", maxSplits: 2) diff --git a/MastodonSDK/Sources/MastodonCore/AppContext.swift b/MastodonSDK/Sources/MastodonCore/AppContext.swift index c1e8933ee..1f83b73d8 100644 --- a/MastodonSDK/Sources/MastodonCore/AppContext.swift +++ b/MastodonSDK/Sources/MastodonCore/AppContext.swift @@ -122,8 +122,6 @@ public class AppContext: ObservableObject { } .store(in: &disposeBag) } - - } extension AppContext { diff --git a/MastodonSDK/Sources/MastodonCore/Authentication/MastodonAuthenticationBox.swift b/MastodonSDK/Sources/MastodonCore/Authentication/MastodonAuthenticationBox.swift index 8db326dfe..0a62d99d2 100644 --- a/MastodonSDK/Sources/MastodonCore/Authentication/MastodonAuthenticationBox.swift +++ b/MastodonSDK/Sources/MastodonCore/Authentication/MastodonAuthenticationBox.swift @@ -12,7 +12,7 @@ import MastodonSDK public struct MastodonAuthenticationBox: UserIdentifier { public let authentication: MastodonAuthentication public let domain: String - public let userID: MastodonUser.ID + public let userID: String public let appAuthorization: Mastodon.API.OAuth.Authorization public let userAuthorization: Mastodon.API.OAuth.Authorization public let inMemoryCache: MastodonAccountInMemoryCache @@ -20,7 +20,7 @@ public struct MastodonAuthenticationBox: UserIdentifier { public init( authentication: MastodonAuthentication, domain: String, - userID: MastodonUser.ID, + userID: String, appAuthorization: Mastodon.API.OAuth.Authorization, userAuthorization: Mastodon.API.OAuth.Authorization, inMemoryCache: MastodonAccountInMemoryCache diff --git a/MastodonSDK/Sources/MastodonCore/AuthenticationServiceProvider.swift b/MastodonSDK/Sources/MastodonCore/AuthenticationServiceProvider.swift index ec706cc98..544d3836d 100644 --- a/MastodonSDK/Sources/MastodonCore/AuthenticationServiceProvider.swift +++ b/MastodonSDK/Sources/MastodonCore/AuthenticationServiceProvider.swift @@ -54,21 +54,21 @@ public extension AuthenticationServiceProvider { func getAuthentication(matching userAccessToken: String) -> MastodonAuthentication? { authentications.first(where: { $0.userAccessToken == userAccessToken }) } - + func authenticationSortedByActivation() -> [MastodonAuthentication] { // fixme: why do we need this? return authentications.sorted(by: { $0.activedAt > $1.activedAt }) } - + func restore() { authentications = Self.keychain.allKeys().compactMap { guard let encoded = Self.keychain[$0], - let data = Data(base64Encoded: encoded) + let data = Data(base64Encoded: encoded) else { return nil } return try? JSONDecoder().decode(MastodonAuthentication.self, from: data) } } - + func migrateLegacyAuthentications(in context: NSManagedObjectContext) { do { let legacyAuthentications = try context.fetch(MastodonAuthenticationLegacy.sortedFetchRequest) @@ -101,10 +101,26 @@ public extension AuthenticationServiceProvider { logger.log(level: .error, "Could not migrate legacy authentications") } } - + var authenticationMigrationRequired: Bool { userDefaults.didMigrateAuthentications == false } + + func fetchAccounts(apiService: APIService) async { + // FIXME: This is a dirty hack to make the performance-stuff work. + // Problem is, that we don't persist the user on disk anymore. So we have to fetch + // it when we need it to display on the home timeline. + // We need this (also) for the Account-list, but it might be the wrong place. App Startup might be more appropriate + for authentication in authentications { + guard let account = try? await apiService.accountInfo(domain: authentication.domain, + userID: authentication.userID, + authorization: Mastodon.API.OAuth.Authorization(accessToken: authentication.userAccessToken)).value else { continue } + + FileManager.default.store(account: account, forUserID: authentication.userIdentifier()) + } + + NotificationCenter.default.post(name: .userFetched, object: nil) + } } // MARK: - Private diff --git a/MastodonSDK/Sources/MastodonCore/DataController/FeedDataController.swift b/MastodonSDK/Sources/MastodonCore/DataController/FeedDataController.swift index eb71db081..6bf2dcf05 100644 --- a/MastodonSDK/Sources/MastodonCore/DataController/FeedDataController.swift +++ b/MastodonSDK/Sources/MastodonCore/DataController/FeedDataController.swift @@ -167,14 +167,36 @@ private extension FeedDataController { func load(kind: MastodonFeed.Kind, maxID: MastodonStatus.ID?) async throws -> [MastodonFeed] { switch kind { case .home: + await context.authenticationService.authenticationServiceProvider.fetchAccounts(apiService: context.apiService) return try await context.apiService.homeTimeline(maxID: maxID, authenticationBox: authContext.mastodonAuthenticationBox) .value.map { .fromStatus(.fromEntity($0), kind: .home) } case .notificationAll: - return try await context.apiService.notifications(maxID: nil, scope: .everything, authenticationBox: authContext.mastodonAuthenticationBox) - .value.map { .fromNotification($0, kind: .notificationAll) } + return try await getFeeds(with: .everything) case .notificationMentions: - return try await context.apiService.notifications(maxID: nil, scope: .mentions, authenticationBox: authContext.mastodonAuthenticationBox) - .value.map { .fromNotification($0, kind: .notificationMentions) } + return try await getFeeds(with: .mentions) + } } + + private func getFeeds(with scope: APIService.MastodonNotificationScope) async throws -> [MastodonFeed] { + + let notifications = try await context.apiService.notifications(maxID: nil, scope: scope, authenticationBox: authContext.mastodonAuthenticationBox).value + + let accounts = notifications.map { $0.account } + let relationships = try await context.apiService.relationship(forAccounts: accounts, authenticationBox: authContext.mastodonAuthenticationBox).value + + let notificationsWithRelationship: [(notification: Mastodon.Entity.Notification, relationship: Mastodon.Entity.Relationship?)] = notifications.compactMap { notification in + guard let relationship = relationships.first(where: {$0.id == notification.account.id }) else { return (notification: notification, relationship: nil)} + + return (notification: notification, relationship: relationship) + } + + let feeds = notificationsWithRelationship.compactMap({ (notification: Mastodon.Entity.Notification, relationship: Mastodon.Entity.Relationship?) in + MastodonFeed.fromNotification(notification, relationship: relationship, kind: .notificationAll) + }) + + return feeds + } + } + diff --git a/MastodonSDK/Sources/MastodonCore/Extension/CoreDataStack/MastodonUser+Property.swift b/MastodonSDK/Sources/MastodonCore/Extension/CoreDataStack/MastodonUser+Property.swift deleted file mode 100644 index 8d2f77ba7..000000000 --- a/MastodonSDK/Sources/MastodonCore/Extension/CoreDataStack/MastodonUser+Property.swift +++ /dev/null @@ -1,43 +0,0 @@ -// -// MastodonUser+Property.swift -// Mastodon -// -// Created by MainasuK on 2022-1-11. -// - -import Foundation -import CoreDataStack -import MastodonSDK - -extension MastodonUser.Property { - public init(entity: Mastodon.Entity.Account, domain: String) { - self.init(entity: entity, domain: domain, networkDate: Date()) - } - - init(entity: Mastodon.Entity.Account, domain: String, networkDate: Date) { - self.init( - identifier: entity.id + "@" + domain, - domain: domain, - id: entity.id, - acct: entity.acct, - username: entity.username, - displayName: entity.displayName, - avatar: entity.avatar, - avatarStatic: entity.avatarStatic, - header: entity.header, - headerStatic: entity.headerStatic, - note: entity.note, - url: entity.url, - statusesCount: Int64(entity.statusesCount), - followingCount: Int64(entity.followingCount), - followersCount: Int64(entity.followersCount), - locked: entity.locked, - bot: entity.bot ?? false, - suspended: entity.suspended ?? false, - createdAt: entity.createdAt, - updatedAt: networkDate, - emojis: entity.mastodonEmojis, - fields: entity.mastodonFields - ) - } -} diff --git a/MastodonSDK/Sources/MastodonCore/Extension/CoreDataStack/MastodonUser.swift b/MastodonSDK/Sources/MastodonCore/Extension/CoreDataStack/MastodonUser.swift deleted file mode 100644 index 6d952726c..000000000 --- a/MastodonSDK/Sources/MastodonCore/Extension/CoreDataStack/MastodonUser.swift +++ /dev/null @@ -1,102 +0,0 @@ -// -// MastodonUser.swift -// Mastodon -// -// Created by MainasuK Cirno on 2021/2/3. -// - -import Foundation -import CoreDataStack -import MastodonSDK -import MastodonMeta - -extension MastodonUser { - - public var displayNameWithFallback: String { - return !displayName.isEmpty ? displayName : username - } - - public var acctWithDomain: String { - if !acct.contains("@") { - // Safe concat due to username cannot contains "@" - return username + "@" + domain - } else { - return acct - } - } - - public var domainFromAcct: String { - if !acct.contains("@") { - return domain - } else { - let domain = acct.split(separator: "@").last - return String(domain!) - } - } - -} - -extension MastodonUser { - - public func headerImageURL() -> URL? { - return URL(string: header) - } - - public func headerImageURLWithFallback(domain: String) -> URL { - return URL(string: header) ?? URL(string: "https://\(domain)/headers/original/missing.png")! - } - - public func avatarImageURL() -> URL? { - let string = UserDefaults.shared.preferredStaticAvatar ? avatarStatic ?? avatar : avatar - return URL(string: string) - } - - public func avatarImageURLWithFallback(domain: String) -> URL { - return avatarImageURL() ?? URL(string: "https://\(domain)/avatars/original/missing.png")! - } - -} - -extension MastodonUser { - - public var profileURL: URL { - if let urlString = self.url, - let url = URL(string: urlString) { - return url - } else { - return URL(string: "https://\(self.domain)/@\(username)")! - } - } - - public var activityItems: [Any] { - var items: [Any] = [] - items.append(profileURL) - return items - } - -} - -extension MastodonUser { - public var nameMetaContent: MastodonMetaContent? { - do { - let content = MastodonContent(content: displayNameWithFallback, emojis: emojis.asDictionary) - let metaContent = try MastodonMetaContent.convert(document: content) - return metaContent - } catch { - assertionFailure() - return nil - } - } - - public var bioMetaContent: MastodonMetaContent? { - guard let note = note else { return nil } - do { - let content = MastodonContent(content: note, emojis: emojis.asDictionary) - let metaContent = try MastodonMetaContent.convert(document: content) - return metaContent - } catch { - assertionFailure() - return nil - } - } -} diff --git a/MastodonSDK/Sources/MastodonCore/Extension/CoreDataStack/Notification+Property.swift b/MastodonSDK/Sources/MastodonCore/Extension/CoreDataStack/Notification+Property.swift deleted file mode 100644 index 4d125bd52..000000000 --- a/MastodonSDK/Sources/MastodonCore/Extension/CoreDataStack/Notification+Property.swift +++ /dev/null @@ -1,29 +0,0 @@ -// -// Notification+Property.swift -// Mastodon -// -// Created by MainasuK on 2022-1-21. -// - -import Foundation -import CoreDataStack -import MastodonSDK -import class CoreDataStack.Notification - -extension Notification.Property { - public init( - entity: Mastodon.Entity.Notification, - domain: String, - userID: MastodonUser.ID, - networkDate: Date - ) { - self.init( - id: entity.id, - typeRaw: entity.type.rawValue, - domain: domain, - userID: userID, - createAt: entity.createdAt, - updatedAt: networkDate - ) - } -} diff --git a/MastodonSDK/Sources/MastodonCore/Extension/CoreDataStack/Tag+Property.swift b/MastodonSDK/Sources/MastodonCore/Extension/CoreDataStack/Tag+Property.swift deleted file mode 100644 index 7411fd960..000000000 --- a/MastodonSDK/Sources/MastodonCore/Extension/CoreDataStack/Tag+Property.swift +++ /dev/null @@ -1,45 +0,0 @@ -// -// Tag+Property.swift -// Mastodon -// -// Created by MainasuK on 2022-1-20. -// - -import Foundation -import CoreDataStack -import MastodonSDK - -extension Tag.Property { - public init( - entity: Mastodon.Entity.Tag, - domain: String, - networkDate: Date - ) { - self.init( - identifier: UUID(), - domain: domain, - createAt: networkDate, - updatedAt: networkDate, - name: entity.name, - url: entity.url, - following: entity.following ?? false, - histories: { - guard let histories = entity.history else { return [] } - let result: [MastodonTagHistory] = histories.map { history in - return MastodonTagHistory(entity: history) - } - return result - }() - ) - } -} - -extension MastodonTagHistory { - public convenience init(entity: Mastodon.Entity.History) { - self.init( - day: entity.day, - uses: entity.uses, - accounts: entity.accounts - ) - } -} diff --git a/MastodonSDK/Sources/MastodonCore/Extension/MastodonSDK/Mastodon+Entity+Account.swift b/MastodonSDK/Sources/MastodonCore/Extension/MastodonSDK/Mastodon+Entity+Account.swift index 56c00e31d..0e4ba9966 100644 --- a/MastodonSDK/Sources/MastodonCore/Extension/MastodonSDK/Mastodon+Entity+Account.swift +++ b/MastodonSDK/Sources/MastodonCore/Extension/MastodonSDK/Mastodon+Entity+Account.swift @@ -14,7 +14,7 @@ extension Mastodon.Entity.Account { let isAnimated = !UserDefaults.shared.preferredStaticEmoji var dict = MastodonContent.Emojis() - for emoji in emojis ?? [] { + for emoji in emojis { dict[emoji.shortcode] = isAnimated ? emoji.url : emoji.staticURL } return dict diff --git a/MastodonSDK/Sources/MastodonCore/FetchedResultsController/FollowedTagsFetchedResultController.swift b/MastodonSDK/Sources/MastodonCore/FetchedResultsController/FollowedTagsFetchedResultController.swift deleted file mode 100644 index 4c3670283..000000000 --- a/MastodonSDK/Sources/MastodonCore/FetchedResultsController/FollowedTagsFetchedResultController.swift +++ /dev/null @@ -1,75 +0,0 @@ -// -// FollowedTagsFetchedResultController.swift -// -// -// Created by Marcus Kida on 23.11.22. -// - -import UIKit -import Combine -import CoreData -import CoreDataStack -import MastodonSDK - -public final class FollowedTagsFetchedResultController: NSObject { - - var disposeBag = Set() - - let fetchedResultsController: NSFetchedResultsController - - // input - @Published public var domain: String? = nil - @Published public var user: MastodonUser? = nil - - // output - @Published public private(set) var records: [Tag] = [] - - public init(managedObjectContext: NSManagedObjectContext, domain: String, user: MastodonUser) { - self.domain = domain - self.fetchedResultsController = { - let fetchRequest = Tag.sortedFetchRequest - fetchRequest.predicate = Tag.predicate(domain: domain, following: true, by: user) - fetchRequest.sortDescriptors = Tag.defaultSortDescriptors - fetchRequest.returnsObjectsAsFaults = false - fetchRequest.fetchBatchSize = 20 - let controller = NSFetchedResultsController( - fetchRequest: fetchRequest, - managedObjectContext: managedObjectContext, - sectionNameKeyPath: nil, - cacheName: nil - ) - - return controller - }() - super.init() - - fetchedResultsController.delegate = self - try? fetchedResultsController.performFetch() - - Publishers.CombineLatest( - self.$domain, - self.$user - ) - .receive(on: DispatchQueue.main) - .sink { [weak self] domain, user in - guard let self = self, let domain = domain, let user = user else { return } - self.fetchedResultsController.fetchRequest.predicate = Tag.predicate(domain: domain, following: true, by: user) - do { - try self.fetchedResultsController.performFetch() - } catch { - assertionFailure(error.localizedDescription) - } - } - .store(in: &disposeBag) - } - -} - -// MARK: - NSFetchedResultsControllerDelegate -extension FollowedTagsFetchedResultController: NSFetchedResultsControllerDelegate { - public func controller(_ controller: NSFetchedResultsController, didChangeContentWith snapshot: NSDiffableDataSourceSnapshotReference) { - - let objects = fetchedResultsController.fetchedObjects ?? [] - self.records = objects - } -} diff --git a/MastodonSDK/Sources/MastodonCore/MastodonAuthentication.swift b/MastodonSDK/Sources/MastodonCore/MastodonAuthentication.swift index 0b1a9db49..2ed7542df 100644 --- a/MastodonSDK/Sources/MastodonCore/MastodonAuthentication.swift +++ b/MastodonSDK/Sources/MastodonCore/MastodonAuthentication.swift @@ -98,7 +98,21 @@ public struct MastodonAuthentication: Codable, Hashable { let userPredicate = MastodonUser.predicate(domain: domain, id: userID) return MastodonUser.findOrFetch(in: context, matching: userPredicate) } - + + public func account() -> Mastodon.Entity.Account? { + + let account = FileManager + .default + .accounts(for: self.userIdentifier()) + .first(where: { $0.id == userID }) + + return account + } + + public func userIdentifier() -> MastodonUserIdentifier { + MastodonUserIdentifier(domain: domain, userID: userID) + } + func updating(instance: Instance) -> Self { copy(instanceObjectIdURI: instance.objectID.uriRepresentation()) } diff --git a/MastodonSDK/Sources/MastodonCore/Model/Compose/ComposeStatusItem.swift b/MastodonSDK/Sources/MastodonCore/Model/Compose/ComposeStatusItem.swift deleted file mode 100644 index 65650dcdc..000000000 --- a/MastodonSDK/Sources/MastodonCore/Model/Compose/ComposeStatusItem.swift +++ /dev/null @@ -1,66 +0,0 @@ -// -// ComposeStatusItem.swift -// Mastodon -// -// Created by MainasuK Cirno on 2021-3-11. -// - -import Foundation -import Combine -import CoreData -import MastodonMeta -import CoreDataStack - -/// Note: update Equatable when change case -enum ComposeStatusItem { - case replyTo(record: ManagedObjectRecord) - case input(replyTo: ManagedObjectRecord?, attribute: ComposeStatusAttribute) - case attachment(attachmentAttribute: ComposeStatusAttachmentAttribute) - case pollOption(pollOptionAttributes: [ComposeStatusPollItem.PollOptionAttribute], pollExpiresOptionAttribute: ComposeStatusPollItem.PollExpiresOptionAttribute) -} - -extension ComposeStatusItem: Hashable { } - -extension ComposeStatusItem { - final class ComposeStatusAttribute: Hashable { - private let id = UUID() - - @Published var author: ManagedObjectRecord? - - @Published var composeContent: String? - - @Published var isContentWarningComposing = false - @Published var contentWarningContent = "" - - static func == (lhs: ComposeStatusAttribute, rhs: ComposeStatusAttribute) -> Bool { - return lhs.author == rhs.author - && lhs.composeContent == rhs.composeContent - && lhs.isContentWarningComposing == rhs.isContentWarningComposing - && lhs.contentWarningContent == rhs.contentWarningContent - } - - func hash(into hasher: inout Hasher) { - hasher.combine(id) - } - } -} - -extension ComposeStatusItem { - final class ComposeStatusAttachmentAttribute: Hashable { - private let id = UUID() - - var attachmentServices: [MastodonAttachmentService] - - init(attachmentServices: [MastodonAttachmentService]) { - self.attachmentServices = attachmentServices - } - - static func == (lhs: ComposeStatusAttachmentAttribute, rhs: ComposeStatusAttachmentAttribute) -> Bool { - return lhs.attachmentServices == rhs.attachmentServices - } - - func hash(into hasher: inout Hasher) { - hasher.combine(id) - } - } -} diff --git a/MastodonSDK/Sources/MastodonCore/Persistence/FileManager+Account.swift b/MastodonSDK/Sources/MastodonCore/Persistence/FileManager+Account.swift new file mode 100644 index 000000000..cf72c53af --- /dev/null +++ b/MastodonSDK/Sources/MastodonCore/Persistence/FileManager+Account.swift @@ -0,0 +1,56 @@ +// Copyright © 2023 Mastodon gGmbH. All rights reserved. + +import Foundation +import MastodonSDK + +public extension FileManager { + func store(account: Mastodon.Entity.Account, forUserID userID: UserIdentifier) { + var accounts = accounts(for: userID) + + if let index = accounts.firstIndex(of: account) { + accounts.remove(at: index) + } + + accounts.append(account) + + storeJSON(accounts, userID: userID) + } + + func accounts(for userId: UserIdentifier) -> [Mastodon.Entity.Account] { + guard let sharedDirectory else { return [] } + + let accountPath = Persistence.accounts(userId).filepath(baseURL: sharedDirectory) + + guard let data = try? Data(contentsOf: accountPath) else { return [] } + + let jsonDecoder = JSONDecoder() + jsonDecoder.dateDecodingStrategy = .iso8601 + + do { + let accounts = try jsonDecoder.decode([Mastodon.Entity.Account].self, from: data) + return accounts + } catch { + return [] + } + + } +} + +private extension FileManager { + private func storeJSON(_ encodable: Encodable, userID: UserIdentifier) { + guard let sharedDirectory else { return } + + let jsonEncoder = JSONEncoder() + jsonEncoder.dateEncodingStrategy = .iso8601 + do { + let data = try jsonEncoder.encode(encodable) + + let accountsPath = Persistence.accounts( userID).filepath(baseURL: sharedDirectory) + try data.write(to: accountsPath) + } catch { + debugPrint(error.localizedDescription) + } + + } + +} diff --git a/Mastodon/Persistence/FileManager+SearchHistory.swift b/MastodonSDK/Sources/MastodonCore/Persistence/FileManager+SearchHistory.swift similarity index 59% rename from Mastodon/Persistence/FileManager+SearchHistory.swift rename to MastodonSDK/Sources/MastodonCore/Persistence/FileManager+SearchHistory.swift index 95dd29a52..a63515cb1 100644 --- a/Mastodon/Persistence/FileManager+SearchHistory.swift +++ b/MastodonSDK/Sources/MastodonCore/Persistence/FileManager+SearchHistory.swift @@ -1,17 +1,12 @@ // Copyright © 2023 Mastodon gGmbH. All rights reserved. import Foundation -import MastodonCore -extension FileManager { - func searchItems(forUser userID: String) throws -> [Persistence.SearchHistory.Item] { - return try searchItems().filter { $0.userID == userID } - } - - func searchItems() throws -> [Persistence.SearchHistory.Item] { +public extension FileManager { + func searchItems(for userId: UserIdentifier) throws -> [Persistence.SearchHistory.Item] { guard let documentsDirectory else { return [] } - let searchHistoryPath = Persistence.searchHistory.filepath(baseURL: documentsDirectory) + let searchHistoryPath = Persistence.searchHistory(userId).filepath(baseURL: documentsDirectory) guard let data = try? Data(contentsOf: searchHistoryPath) else { return [] } @@ -28,18 +23,23 @@ extension FileManager { } } - func addSearchItem(_ newSearchItem: Persistence.SearchHistory.Item) throws { - guard let documentsDirectory else { return } - - var searchItems = (try? searchItems()) ?? [] + func addSearchItem(_ newSearchItem: Persistence.SearchHistory.Item, for userId: UserIdentifier) throws { + var searchItems = (try? searchItems(for: userId)) ?? [] if let index = searchItems.firstIndex(of: newSearchItem) { searchItems.remove(at: index) } - + searchItems.append(newSearchItem) - storeJSON(searchItems, .searchHistory) + storeJSON(searchItems, .searchHistory(userId)) + } + + func removeSearchHistory(for userId: UserIdentifier) { + let searchItems = (try? searchItems(for: userId)) ?? [] + let newSearchItems = searchItems.filter { $0.userID != userId.userID } + + storeJSON(newSearchItems, .searchHistory(userId)) } private func storeJSON(_ encodable: Encodable, _ persistence: Persistence) { @@ -55,25 +55,5 @@ extension FileManager { } catch { debugPrint(error.localizedDescription) } - - } - - func removeSearchHistory(forUser userID: String) { - guard let documentsDirectory else { return } - - var searchItems = (try? searchItems()) ?? [] - let newSearchItems = searchItems.filter { $0.userID != userID } - - storeJSON(newSearchItems, .searchHistory) - } -} - -public extension FileManager { - var documentsDirectory: URL? { - urls(for: .documentDirectory, in: .userDomainMask).first - } - - var cachesDirectory: URL? { - urls(for: .cachesDirectory, in: .userDomainMask).first } } diff --git a/MastodonSDK/Sources/MastodonCore/Persistence/FileManager+Shared.swift b/MastodonSDK/Sources/MastodonCore/Persistence/FileManager+Shared.swift new file mode 100644 index 000000000..0afd30f2f --- /dev/null +++ b/MastodonSDK/Sources/MastodonCore/Persistence/FileManager+Shared.swift @@ -0,0 +1,18 @@ +// Copyright © 2023 Mastodon gGmbH. All rights reserved. + +import Foundation +import MastodonCommon + +public extension FileManager { + var documentsDirectory: URL? { + urls(for: .documentDirectory, in: .userDomainMask).first + } + + var cachesDirectory: URL? { + urls(for: .cachesDirectory, in: .userDomainMask).first + } + + var sharedDirectory: URL? { + containerURL(forSecurityApplicationGroupIdentifier: AppName.groupID) + } +} diff --git a/Mastodon/Persistence/FileManager+Timeline.swift b/MastodonSDK/Sources/MastodonCore/Persistence/FileManager+Timeline.swift similarity index 87% rename from Mastodon/Persistence/FileManager+Timeline.swift rename to MastodonSDK/Sources/MastodonCore/Persistence/FileManager+Timeline.swift index 0a7046eaa..0dee5bf0c 100644 --- a/Mastodon/Persistence/FileManager+Timeline.swift +++ b/MastodonSDK/Sources/MastodonCore/Persistence/FileManager+Timeline.swift @@ -1,27 +1,54 @@ // Copyright © 2023 Mastodon gGmbH. All rights reserved. import Foundation -import MastodonCore import MastodonSDK -extension FileManager { - private static let cacheItemsLimit: Int = 100 // max number of items to cache - +public extension FileManager { + // Retrieve func cachedHomeTimeline(for userId: UserIdentifier) throws -> [MastodonStatus] { try cached(timeline: .homeTimeline(userId)).map(MastodonStatus.fromEntity) } - + func cachedNotificationsAll(for userId: UserIdentifier) throws -> [Mastodon.Entity.Notification] { try cached(timeline: .notificationsAll(userId)) } - + func cachedNotificationsMentions(for userId: UserIdentifier) throws -> [Mastodon.Entity.Notification] { try cached(timeline: .notificationsMentions(userId)) } - - private func cached(timeline: Persistence) throws -> [T] { + // Create + func cacheHomeTimeline(items: [MastodonStatus], for userIdentifier: UserIdentifier) { + cache(items.map { $0.entity }, timeline: .homeTimeline(userIdentifier)) + } + + func cacheNotificationsAll(items: [Mastodon.Entity.Notification], for userIdentifier: UserIdentifier) { + cache(items, timeline: .notificationsAll(userIdentifier)) + } + + func cacheNotificationsMentions(items: [Mastodon.Entity.Notification], for userIdentifier: UserIdentifier) { + cache(items, timeline: .notificationsMentions(userIdentifier)) + } + + // Delete + func invalidateHomeTimelineCache(for userId: UserIdentifier) { + invalidate(timeline: .homeTimeline(userId)) + } + + func invalidateNotificationsAll(for userId: UserIdentifier) { + invalidate(timeline: .notificationsAll(userId)) + } + + func invalidateNotificationsMentions(for userId: UserIdentifier) { + invalidate(timeline: .notificationsMentions(userId)) + } +} + +private extension FileManager { + static let cacheItemsLimit: Int = 100 // max number of items to cache + + func cached(timeline: Persistence) throws -> [T] { guard let cachesDirectory else { return [] } let filePath = timeline.filepath(baseURL: cachesDirectory) @@ -37,20 +64,8 @@ extension FileManager { } } - // Create - func cacheHomeTimeline(items: [MastodonStatus], for userIdentifier: UserIdentifier) { - cache(items.map { $0.entity }, timeline: .homeTimeline(userIdentifier)) - } - - func cacheNotificationsAll(items: [Mastodon.Entity.Notification], for userIdentifier: UserIdentifier) { - cache(items, timeline: .notificationsAll(userIdentifier)) - } - - func cacheNotificationsMentions(items: [Mastodon.Entity.Notification], for userIdentifier: UserIdentifier) { - cache(items, timeline: .notificationsMentions(userIdentifier)) - } - - private func cache(_ items: [T], timeline: Persistence) { + + func cache(_ items: [T], timeline: Persistence) { guard let cachesDirectory else { return } let processableItems: [T] @@ -69,21 +84,8 @@ extension FileManager { debugPrint(error.localizedDescription) } } - - // Delete - func invalidateHomeTimelineCache(for userId: UserIdentifier) { - invalidate(timeline: .homeTimeline(userId)) - } - - func invalidateNotificationsAll(for userId: UserIdentifier) { - invalidate(timeline: .notificationsAll(userId)) - } - - func invalidateNotificationsMentions(for userId: UserIdentifier) { - invalidate(timeline: .notificationsMentions(userId)) - } - - private func invalidate(timeline: Persistence) { + + func invalidate(timeline: Persistence) { guard let cachesDirectory else { return } let filePath = timeline.filepath(baseURL: cachesDirectory) diff --git a/MastodonSDK/Sources/MastodonCore/Persistence/Model/SearchHistory.swift b/MastodonSDK/Sources/MastodonCore/Persistence/Model/SearchHistory.swift new file mode 100644 index 000000000..9ad5b30be --- /dev/null +++ b/MastodonSDK/Sources/MastodonCore/Persistence/Model/SearchHistory.swift @@ -0,0 +1,31 @@ +// Copyright © 2023 Mastodon gGmbH. All rights reserved. + +import Foundation +import MastodonSDK + +extension Persistence.SearchHistory { + public struct Item: Codable, Hashable, Equatable { + public let updatedAt: Date + public let userID: Mastodon.Entity.Account.ID + + public let account: Mastodon.Entity.Account? + public let hashtag: Mastodon.Entity.Tag? + + public init(updatedAt: Date, userID: Mastodon.Entity.Account.ID, account: Mastodon.Entity.Account?, hashtag: Mastodon.Entity.Tag?) { + self.updatedAt = updatedAt + self.userID = userID + self.account = account + self.hashtag = hashtag + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(userID) + hasher.combine(account) + hasher.combine(hashtag) + } + + public static func == (lhs: Persistence.SearchHistory.Item, rhs: Persistence.SearchHistory.Item) -> Bool { + return lhs.account == rhs.account && lhs.hashtag == rhs.hashtag && lhs.userID == rhs.userID + } + } +} diff --git a/MastodonSDK/Sources/MastodonCore/Persistence/Persistence+MastodonUser.swift b/MastodonSDK/Sources/MastodonCore/Persistence/Persistence+MastodonUser.swift deleted file mode 100644 index b8b5f3089..000000000 --- a/MastodonSDK/Sources/MastodonCore/Persistence/Persistence+MastodonUser.swift +++ /dev/null @@ -1,159 +0,0 @@ -// -// Persistence+MastodonUser.swift -// Persistence+MastodonUser -// -// Created by Cirno MainasuK on 2021-8-18. -// Copyright © 2021 Twidere. All rights reserved. -// - -import CoreData -import CoreDataStack -import Foundation -import MastodonSDK - -extension Persistence.MastodonUser { - - public struct PersistContext { - public let domain: String - public let entity: Mastodon.Entity.Account - public let cache: Persistence.PersistCache? - public let networkDate: Date - - public init( - domain: String, - entity: Mastodon.Entity.Account, - cache: Persistence.PersistCache?, - networkDate: Date - ) { - self.domain = domain - self.entity = entity - self.cache = cache - self.networkDate = networkDate - } - } - - public struct PersistResult { - public let user: MastodonUser - public let isNewInsertion: Bool - - public init( - user: MastodonUser, - isNewInsertion: Bool - ) { - self.user = user - self.isNewInsertion = isNewInsertion - } - } - - public static func createOrMerge( - in managedObjectContext: NSManagedObjectContext, - context: PersistContext - ) -> PersistResult { - if let oldMastodonUser = fetch(in: managedObjectContext, context: context) { - merge(mastodonUser: oldMastodonUser, context: context) - return PersistResult(user: oldMastodonUser, isNewInsertion: false) - } else { - let user = create(in: managedObjectContext, context: context) - return PersistResult(user: user, isNewInsertion: true) - } - } - -} - -extension Persistence.MastodonUser { - - public static func fetch( - in managedObjectContext: NSManagedObjectContext, - context: PersistContext - ) -> MastodonUser? { - if let cache = context.cache { - return cache.dictionary[context.entity.id] - } else { - let request = MastodonUser.sortedFetchRequest - request.predicate = MastodonUser.predicate( - domain: context.domain, - id: context.entity.id - ) - request.fetchLimit = 1 - do { - return try managedObjectContext.fetch(request).first - } catch { - assertionFailure(error.localizedDescription) - return nil - } - } - } - - @discardableResult - public static func create( - in managedObjectContext: NSManagedObjectContext, - context: PersistContext - ) -> MastodonUser { - let property = MastodonUser.Property( - entity: context.entity, - domain: context.domain, - networkDate: context.networkDate - ) - let user = MastodonUser.insert(into: managedObjectContext, property: property) - return user - } - - public static func merge( - mastodonUser user: MastodonUser, - context: PersistContext - ) { - guard context.networkDate > user.updatedAt else { return } - let property = MastodonUser.Property( - entity: context.entity, - domain: context.domain, - networkDate: context.networkDate - ) - user.update(property: property) - } - - private static func update( - mastodonUser user: MastodonUser, - context: PersistContext - ) { - // TODO: - } // end func update - -} - -extension Persistence.MastodonUser { - public struct RelationshipContext { - public let entity: Mastodon.Entity.Relationship - public let me: MastodonUser - public let networkDate: Date - - public init( - entity: Mastodon.Entity.Relationship, - me: MastodonUser, - networkDate: Date - ) { - self.entity = entity - self.me = me - self.networkDate = networkDate - } - } - - public static func update( - mastodonUser user: MastodonUser, - context: RelationshipContext - ) { - guard context.entity.id != context.me.id else { return } // not update relationship for self - - let relationship = context.entity - let me = context.me - - user.update(isFollowing: relationship.following, by: me) - relationship.requested.flatMap { user.update(isFollowRequested: $0, by: me) } - // relationship.endorsed.flatMap { user.update(isEndorsed: $0, by: me) } - me.update(isFollowing: relationship.followedBy, by: user) - relationship.muting.flatMap { user.update(isMuting: $0, by: me) } - user.update(isBlocking: relationship.blocking, by: me) - relationship.domainBlocking.flatMap { user.update(isDomainBlocking: $0, by: me) } - relationship.blockedBy.flatMap { me.update(isBlocking: $0, by: user) } - relationship.showingReblogs.flatMap { me.update(isShowingReblogs: $0, by: user) } - } -} diff --git a/MastodonSDK/Sources/MastodonCore/Persistence/Persistence+Notification.swift b/MastodonSDK/Sources/MastodonCore/Persistence/Persistence+Notification.swift deleted file mode 100644 index cfe715503..000000000 --- a/MastodonSDK/Sources/MastodonCore/Persistence/Persistence+Notification.swift +++ /dev/null @@ -1,197 +0,0 @@ -// -// Persistence+Notification.swift -// Mastodon -// -// Created by MainasuK on 2022-1-21. -// - -import CoreData -import CoreDataStack -import Foundation -import MastodonSDK -import class CoreDataStack.Notification - -extension Persistence.Notification { - - public struct PersistContext { - public let domain: String - public let entity: Mastodon.Entity.Notification - public let me: MastodonUser - public let networkDate: Date - - public init( - domain: String, - entity: Mastodon.Entity.Notification, - me: MastodonUser, - networkDate: Date - ) { - self.domain = domain - self.entity = entity - self.me = me - self.networkDate = networkDate - } - } - - public struct PersistResult { - public let notification: Notification - public let isNewInsertion: Bool - - public init( - notification: Notification, - isNewInsertion: Bool - ) { - self.notification = notification - self.isNewInsertion = isNewInsertion - } - } - - public static func createOrMerge( - in managedObjectContext: NSManagedObjectContext, - context: PersistContext - ) -> PersistResult { - - if let old = fetch(in: managedObjectContext, context: context) { - merge(object: old, context: context) - return PersistResult( - notification: old, - isNewInsertion: false - ) - } else { - let accountResult = Persistence.MastodonUser.createOrMerge( - in: managedObjectContext, - context: Persistence.MastodonUser.PersistContext( - domain: context.domain, - entity: context.entity.account, - cache: nil, - networkDate: context.networkDate - ) - ) - let account = accountResult.user - - let status: Status? = { - guard let entity = context.entity.status else { return nil } - let result = Persistence.Status.createOrMerge( - in: managedObjectContext, - context: Persistence.Status.PersistContext( - domain: context.domain, - entity: entity, - me: context.me, - statusCache: nil, - userCache: nil, - networkDate: context.networkDate - ) - ) - return result.status - }() - - let relationship = Notification.Relationship( - account: account, - status: status - ) - - let object = create( - in: managedObjectContext, - context: context, - relationship: relationship - ) - - return PersistResult( - notification: object, - isNewInsertion: true - ) - } - } - -} - -extension Persistence.Notification { - - public static func fetch( - in managedObjectContext: NSManagedObjectContext, - context: PersistContext - ) -> Notification? { - let request = Notification.sortedFetchRequest - request.predicate = Notification.predicate( - domain: context.me.domain, - userID: context.me.id, - id: context.entity.id - ) - request.fetchLimit = 1 - do { - return try managedObjectContext.fetch(request).first - } catch { - assertionFailure(error.localizedDescription) - return nil - } - } - - @discardableResult - public static func create( - in managedObjectContext: NSManagedObjectContext, - context: PersistContext, - relationship: Notification.Relationship - ) -> Notification { - let property = Notification.Property( - entity: context.entity, - domain: context.me.domain, - userID: context.me.id, - networkDate: context.networkDate - ) - let object = Notification.insert( - into: managedObjectContext, - property: property, - relationship: relationship - ) - update(object: object, context: context) - return object - } - - public static func merge( - object: Notification, - context: PersistContext - ) { - guard context.networkDate > object.updatedAt else { return } - let property = Notification.Property( - entity: context.entity, - domain: context.me.domain, - userID: context.me.id, - networkDate: context.networkDate - ) - object.update(property: property) - - if let status = object.status, let entity = context.entity.status { - let property = Status.Property( - entity: entity, - domain: context.domain, - networkDate: context.networkDate - ) - status.update(property: property) - } - - let accountProperty = MastodonUser.Property( - entity: context.entity.account, - domain: context.domain, - networkDate: context.networkDate - ) - object.account.update(property: accountProperty) - - if let author = object.status, let entity = context.entity.status { - let property = Status.Property( - entity: entity, - domain: context.domain, - networkDate: context.networkDate - ) - author.update(property: property) - } - - update(object: object, context: context) - } - - private static func update( - object: Notification, - context: PersistContext - ) { - // do nothing - } - -} diff --git a/MastodonSDK/Sources/MastodonCore/Persistence/Persistence+Status.swift b/MastodonSDK/Sources/MastodonCore/Persistence/Persistence+Status.swift index c7548db82..79efbf78e 100644 --- a/MastodonSDK/Sources/MastodonCore/Persistence/Persistence+Status.swift +++ b/MastodonSDK/Sources/MastodonCore/Persistence/Persistence+Status.swift @@ -41,16 +41,13 @@ extension Persistence.Status { public struct PersistResult { public let status: Status public let isNewInsertion: Bool - public let isNewInsertionAuthor: Bool public init( status: Status, - isNewInsertion: Bool, - isNewInsertionAuthor: Bool + isNewInsertion: Bool ) { self.status = status self.isNewInsertion = isNewInsertion - self.isNewInsertionAuthor = isNewInsertionAuthor } } @@ -78,8 +75,7 @@ extension Persistence.Status { merge(in: managedObjectContext, mastodonStatus: oldStatus, context: context) return PersistResult( status: oldStatus, - isNewInsertion: false, - isNewInsertionAuthor: false + isNewInsertion: false ) } else { let poll: Poll? = { @@ -98,21 +94,10 @@ extension Persistence.Status { let card = createCard(in: managedObjectContext, context: context) - let authorResult = Persistence.MastodonUser.createOrMerge( - in: managedObjectContext, - context: Persistence.MastodonUser.PersistContext( - domain: context.domain, - entity: context.entity.account, - cache: context.userCache, - networkDate: context.networkDate - ) - ) - let author = authorResult.user let application: Application? = createApplication(in: managedObjectContext, context: .init(entity: context.entity)) let relationship = Status.Relationship( application: application, - author: author, reblog: reblog, poll: poll, card: card @@ -125,8 +110,7 @@ extension Persistence.Status { return PersistResult( status: status, - isNewInsertion: true, - isNewInsertionAuthor: authorResult.isNewInsertion + isNewInsertion: true ) } } @@ -170,7 +154,6 @@ extension Persistence.Status { property: property, relationship: relationship ) - update(status: status, context: context) return status } @@ -214,7 +197,6 @@ extension Persistence.Status { relationship: Status.Relationship( application: status.application, - author: status.author, reblog: status.reblog, poll: result.poll, card: status.card @@ -226,7 +208,6 @@ extension Persistence.Status { relationship: Status.Relationship( application: status.application, - author: status.author, reblog: status.reblog, poll: nil, card: status.card @@ -239,8 +220,6 @@ extension Persistence.Status { let relationship = Card.Relationship(status: status) card?.configure(relationship: relationship) } - - update(status: status, context: context) } private static func createCard( @@ -258,17 +237,6 @@ extension Persistence.Status { ) return result.card } - - private static func update( - status: Status, - context: PersistContext - ) { - // update friendships - if let user = context.me { - context.entity.reblogged.flatMap { status.update(reblogged: $0, by: user) } - context.entity.favourited.flatMap { status.update(liked: $0, by: user) } - } - } private static func createApplication( in managedObjectContext: NSManagedObjectContext, diff --git a/MastodonSDK/Sources/MastodonCore/Persistence/Persistence+Tag.swift b/MastodonSDK/Sources/MastodonCore/Persistence/Persistence+Tag.swift deleted file mode 100644 index 163019505..000000000 --- a/MastodonSDK/Sources/MastodonCore/Persistence/Persistence+Tag.swift +++ /dev/null @@ -1,135 +0,0 @@ -// -// Persistence+Tag.swift -// Mastodon -// -// Created by MainasuK on 2022-1-20. -// - -import CoreData -import CoreDataStack -import Foundation -import MastodonSDK - -extension Persistence.Tag { - - public struct PersistContext { - public let domain: String - public let entity: Mastodon.Entity.Tag - public let me: MastodonUser? - public let networkDate: Date - - public init( - domain: String, - entity: Mastodon.Entity.Tag, - me: MastodonUser?, - networkDate: Date - ) { - self.domain = domain - self.entity = entity - self.me = me - self.networkDate = networkDate - } - } - - public struct PersistResult { - public let tag: Tag - public let isNewInsertion: Bool - - public init( - tag: Tag, - isNewInsertion: Bool - ) { - self.tag = tag - self.isNewInsertion = isNewInsertion - } - } - - public static func createOrMerge( - in managedObjectContext: NSManagedObjectContext, - context: PersistContext - ) -> PersistResult { - if let old = fetch(in: managedObjectContext, context: context) { - merge(tag: old, context: context) - return PersistResult( - tag: old, - isNewInsertion: false - ) - } else { - let object = create( - in: managedObjectContext, - context: context - ) - - return PersistResult( - tag: object, - isNewInsertion: false - ) - } - } - -} - -extension Persistence.Tag { - - public static func fetch( - in managedObjectContext: NSManagedObjectContext, - context: PersistContext - ) -> Tag? { - let request = Tag.sortedFetchRequest - request.predicate = Tag.predicate(domain: context.domain, name: context.entity.name) - request.fetchLimit = 1 - do { - return try managedObjectContext.fetch(request).first - } catch { - assertionFailure(error.localizedDescription) - return nil - } - } - - @discardableResult - public static func create( - in managedObjectContext: NSManagedObjectContext, - context: PersistContext - ) -> Tag { - let property = Tag.Property( - entity: context.entity, - domain: context.domain, - networkDate: context.networkDate - ) - let object = Tag.insert( - into: managedObjectContext, - property: property - ) - update(tag: object, context: context) - if let followingUser = context.me { - object.update(followed: property.following, by: followingUser) - } - return object - } - - public static func merge( - tag: Tag, - context: PersistContext - ) { - guard context.networkDate > tag.updatedAt else { return } - let property = Tag.Property( - entity: context.entity, - domain: context.domain, - networkDate: context.networkDate - ) - - tag.update(property: property) - if let followingUser = context.me { - tag.update(followed: property.following, by: followingUser) - } - update(tag: tag, context: context) - } - - private static func update( - tag: Tag, - context: PersistContext - ) { - tag.update(updatedAt: context.networkDate) - } - -} diff --git a/MastodonSDK/Sources/MastodonCore/Persistence/Persistence.swift b/MastodonSDK/Sources/MastodonCore/Persistence/Persistence.swift index f11ff61e1..48ce11f07 100644 --- a/MastodonSDK/Sources/MastodonCore/Persistence/Persistence.swift +++ b/MastodonSDK/Sources/MastodonCore/Persistence/Persistence.swift @@ -9,21 +9,28 @@ import Foundation public enum Persistence { - case searchHistory + case searchHistory(UserIdentifier) case homeTimeline(UserIdentifier) case notificationsMentions(UserIdentifier) case notificationsAll(UserIdentifier) - + case accounts(UserIdentifier) + + private func uniqueUserDomainIdentifier(for userIdentifier: UserIdentifier) -> String { + "\(userIdentifier.userID)@\(userIdentifier.domain)" + } + private var filename: String { switch self { - case .searchHistory: - return "search_history" // todo: @zeitschlag should this be user-scoped as well? + case .searchHistory(let userIdentifier): + return "search_history_\(uniqueUserDomainIdentifier(for: userIdentifier))" case let .homeTimeline(userIdentifier): - return "home_timeline_\(userIdentifier.uniqueUserDomainIdentifier)" + return "home_timeline_\(uniqueUserDomainIdentifier(for: userIdentifier))" case let .notificationsMentions(userIdentifier): return "notifications_mentions_\(userIdentifier.uniqueUserDomainIdentifier)" case let .notificationsAll(userIdentifier): - return "notifications_all_\(userIdentifier.uniqueUserDomainIdentifier)" + return "notifications_all_\(uniqueUserDomainIdentifier(for: userIdentifier))" + case .accounts(let userIdentifier): + return "account_\(uniqueUserDomainIdentifier(for: userIdentifier))" } } @@ -41,7 +48,6 @@ extension Persistence { public enum Poll { } public enum Card { } public enum PollOption { } - public enum Tag { } public enum SearchHistory { } public enum Notification { } } diff --git a/MastodonSDK/Sources/MastodonCore/Persistence/Protocol/MastodonEmojiContainer.swift b/MastodonSDK/Sources/MastodonCore/Persistence/Protocol/MastodonEmojiContainer.swift index 7273d507f..c017ac08e 100644 --- a/MastodonSDK/Sources/MastodonCore/Persistence/Protocol/MastodonEmojiContainer.swift +++ b/MastodonSDK/Sources/MastodonCore/Persistence/Protocol/MastodonEmojiContainer.swift @@ -11,14 +11,12 @@ import MastodonSDK import CoreDataStack public protocol MastodonEmojiContainer { - var emojis: [Mastodon.Entity.Emoji]? { get } + var emojis: [Mastodon.Entity.Emoji] { get } } extension MastodonEmojiContainer { public var mastodonEmojis: [MastodonEmoji] { - return emojis.flatMap { emojis in - emojis.map { MastodonEmoji(emoji: $0) } - } ?? [] + return emojis.map { MastodonEmoji(emoji: $0) } } } diff --git a/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Account.swift b/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Account.swift index 5e058c258..d9d120b2a 100644 --- a/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Account.swift +++ b/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Account.swift @@ -34,19 +34,6 @@ extension APIService { authorization: authorization ).singleOutput() - let managedObjectContext = self.backgroundManagedObjectContext - try await managedObjectContext.performChanges { - _ = Persistence.MastodonUser.createOrMerge( - in: managedObjectContext, - context: Persistence.MastodonUser.PersistContext( - domain: domain, - entity: response.value, - cache: nil, - networkDate: response.networkDate - ) - ) - } - return response } @@ -63,33 +50,6 @@ extension APIService { domain: domain, authorization: authorization ) - .flatMap { response -> AnyPublisher, Error> in - let account = response.value - - let managedObjectContext = self.backgroundManagedObjectContext - return managedObjectContext.performChanges { - _ = Persistence.MastodonUser.createOrMerge( - in: managedObjectContext, - context: Persistence.MastodonUser.PersistContext( - domain: domain, - entity: account, - cache: nil, - networkDate: response.networkDate - ) - ) - } - .setFailureType(to: Error.self) - .tryMap { result -> Mastodon.Response.Content in - switch result { - case .success: - return response - case .failure(let error): - throw error - } - } - .eraseToAnyPublisher() - } - .eraseToAnyPublisher() } public func accountUpdateCredentials( @@ -104,19 +64,6 @@ extension APIService { authorization: authorization ).singleOutput() - let managedObjectContext = self.backgroundManagedObjectContext - try await managedObjectContext.performChanges { - _ = Persistence.MastodonUser.createOrMerge( - in: managedObjectContext, - context: Persistence.MastodonUser.PersistContext( - domain: domain, - entity: response.value, - cache: nil, - networkDate: response.networkDate - ) - ) - } - return response } @@ -171,7 +118,7 @@ extension APIService { extension APIService { public func fetchUser(username: String, domain: String, authenticationBox: MastodonAuthenticationBox) - async throws -> MastodonUser? { + async throws -> Mastodon.Entity.Account? { let query = Mastodon.API.Account.AccountLookupQuery(acct: "\(username)@\(domain)") let authorization = authenticationBox.userAuthorization @@ -182,21 +129,6 @@ extension APIService { authorization: authorization ).singleOutput() - // user - let managedObjectContext = self.backgroundManagedObjectContext - var result: MastodonUser? - try await managedObjectContext.performChanges { - result = Persistence.MastodonUser.createOrMerge( - in: managedObjectContext, - context: Persistence.MastodonUser.PersistContext( - domain: domain, - entity: response.value, - cache: nil, - networkDate: response.networkDate - ) - ).user - } - - return result + return response.value } } diff --git a/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Block.swift b/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Block.swift index c1650e9b5..9b0a1a465 100644 --- a/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Block.swift +++ b/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Block.swift @@ -14,8 +14,8 @@ import MastodonSDK extension APIService { private struct MastodonBlockContext { - let sourceUserID: MastodonUser.ID - let targetUserID: MastodonUser.ID + let sourceUserID: String + let targetUserID: String let targetUsername: String let isBlocking: Bool let isFollowing: Bool @@ -23,17 +23,10 @@ extension APIService { @discardableResult public func getBlocked( + sinceID: Mastodon.Entity.Status.ID? = nil, + limit: Int? = nil, authenticationBox: MastodonAuthenticationBox ) async throws -> Mastodon.Response.Content<[Mastodon.Entity.Account]> { - try await _getBlocked(sinceID: nil, limit: nil, authenticationBox: authenticationBox) - } - - private func _getBlocked( - sinceID: Mastodon.Entity.Status.ID?, - limit: Int?, - authenticationBox: MastodonAuthenticationBox - ) async throws -> Mastodon.Response.Content<[Mastodon.Entity.Account]> { - let managedObjectContext = backgroundManagedObjectContext let response = try await Mastodon.API.Account.blocks( session: session, domain: authenticationBox.domain, @@ -42,118 +35,14 @@ extension APIService { authorization: authenticationBox.userAuthorization ).singleOutput() - let userIDs = response.value.map { $0.id } - let predicate = MastodonUser.predicate(domain: authenticationBox.domain, ids: userIDs) - - let fetchRequest = MastodonUser.fetchRequest() - fetchRequest.predicate = predicate - fetchRequest.includesPropertyValues = false - - try await managedObjectContext.performChanges { - let users = try managedObjectContext.fetch(fetchRequest) as! [MastodonUser] - - for user in users { - user.deleteStatusAndNotificationFeeds(in: managedObjectContext) - } - } - return response } public func toggleBlock( - user: ManagedObjectRecord, + account: Mastodon.Entity.Account, authenticationBox: MastodonAuthenticationBox ) async throws -> Mastodon.Response.Content { - - let managedObjectContext = backgroundManagedObjectContext - let blockContext: MastodonBlockContext = try await managedObjectContext.performChanges { - let authentication = authenticationBox.authentication - - guard - let user = user.object(in: managedObjectContext), - let me = authentication.user(in: managedObjectContext) - else { - throw APIError.implicit(.badRequest) - } - - let isBlocking = user.blockingBy.contains(me) - let isFollowing = user.followingBy.contains(me) - // toggle block state - user.update(isBlocking: !isBlocking, by: me) - // update follow state implicitly - if !isBlocking { - // will do block action. set to unfollow - user.update(isFollowing: false, by: me) - } - - return MastodonBlockContext( - sourceUserID: me.id, - targetUserID: user.id, - targetUsername: user.username, - isBlocking: isBlocking, - isFollowing: isFollowing - ) - } - - let result: Result, Error> - do { - if blockContext.isBlocking { - let response = try await Mastodon.API.Account.unblock( - session: session, - domain: authenticationBox.domain, - accountID: blockContext.targetUserID, - authorization: authenticationBox.userAuthorization - ).singleOutput() - result = .success(response) - } else { - let response = try await Mastodon.API.Account.block( - session: session, - domain: authenticationBox.domain, - accountID: blockContext.targetUserID, - authorization: authenticationBox.userAuthorization - ).singleOutput() - result = .success(response) - } - } catch { - result = .failure(error) - } - - try await managedObjectContext.performChanges { - let authentication = authenticationBox.authentication - - guard - let user = user.object(in: managedObjectContext), - let me = authentication.user(in: managedObjectContext) - else { return } - - - switch result { - case .success(let response): - let relationship = response.value - Persistence.MastodonUser.update( - mastodonUser: user, - context: Persistence.MastodonUser.RelationshipContext( - entity: relationship, - me: me, - networkDate: response.networkDate - ) - ) - case .failure: - // rollback - user.update(isBlocking: blockContext.isBlocking, by: me) - user.update(isFollowing: blockContext.isFollowing, by: me) - } - } - - let response = try result.get() - return response - } - - public func toggleBlock( - user: Mastodon.Entity.Account, - authenticationBox: MastodonAuthenticationBox - ) async throws -> Mastodon.Response.Content { - guard let relationship = try await relationship(forAccounts: [user], authenticationBox: authenticationBox).value.first else { + guard let relationship = try await relationship(forAccounts: [account], authenticationBox: authenticationBox).value.first else { throw APIError.implicit(.badRequest) } @@ -163,14 +52,14 @@ extension APIService { response = try await Mastodon.API.Account.unblock( session: session, domain: authenticationBox.domain, - accountID: user.id, + accountID: account.id, authorization: authenticationBox.userAuthorization ).singleOutput() } else { response = try await Mastodon.API.Account.block( session: session, domain: authenticationBox.domain, - accountID: user.id, + accountID: account.id, authorization: authenticationBox.userAuthorization ).singleOutput() } @@ -178,21 +67,3 @@ extension APIService { return response } } - -extension MastodonUser { - func deleteStatusAndNotificationFeeds(in context: NSManagedObjectContext) { - statuses.map { - $0.feeds - .union($0.reblogFrom.map { $0.feeds }.flatMap { $0 }) - .union($0.notifications.map { $0.feeds }.flatMap { $0 }) - } - .flatMap { $0 } - .forEach(context.delete) - - notifications.map { - $0.feeds - } - .flatMap { $0 } - .forEach(context.delete) - } -} diff --git a/MastodonSDK/Sources/MastodonCore/Service/API/APIService+DomainBlock.swift b/MastodonSDK/Sources/MastodonCore/Service/API/APIService+DomainBlock.swift index 5f1427ef6..33d1476b8 100644 --- a/MastodonSDK/Sources/MastodonCore/Service/API/APIService+DomainBlock.swift +++ b/MastodonSDK/Sources/MastodonCore/Service/API/APIService+DomainBlock.swift @@ -68,94 +68,62 @@ extension APIService { } public func toggleDomainBlock( - user: ManagedObjectRecord, + account: Mastodon.Entity.Account, authenticationBox: MastodonAuthenticationBox ) async throws -> Mastodon.Response.Content { - guard let originalRelationship = try await relationship(records: [user], authenticationBox: authenticationBox).value.first else { + guard let originalRelationship = try await relationship(forAccounts: [account], authenticationBox: authenticationBox).value.first else { throw APIError.implicit(.badRequest) } let response: Mastodon.Response.Content - let domainBlocking = originalRelationship.domainBlocking ?? false - - let managedObjectContext = backgroundManagedObjectContext - - guard let _user = user.object(in: managedObjectContext) else { throw APIError.implicit(.badRequest) } + let domainBlocking = originalRelationship.domainBlocking if domainBlocking { - response = try await unblockDomain(user: _user, authorizationBox: authenticationBox).singleOutput() + response = try await unblockDomain(account: account, authorizationBox: authenticationBox) } else { - response = try await blockDomain(user: _user, authorizationBox: authenticationBox).singleOutput() + response = try await blockDomain(account: account, authorizationBox: authenticationBox) } return response } func blockDomain( - user: MastodonUser, + account: Mastodon.Entity.Account, authorizationBox: MastodonAuthenticationBox - ) -> AnyPublisher, Error> { + ) async throws -> Mastodon.Response.Content { let authorization = authorizationBox.userAuthorization - return Mastodon.API.DomainBlock.blockDomain( + guard let domain = account.domainFromAcct else { + throw APIError.implicit(.badRequest) + } + + let result = try await Mastodon.API.DomainBlock.blockDomain( domain: authorizationBox.domain, - blockDomain: user.domainFromAcct, + blockDomain: domain, session: session, authorization: authorization - ) - .flatMap { response -> AnyPublisher, Error> in - self.backgroundManagedObjectContext.performChanges { - let requestMastodonUserRequest = MastodonUser.sortedFetchRequest - requestMastodonUserRequest.predicate = MastodonUser.predicate(domain: authorizationBox.domain, id: authorizationBox.userID) - requestMastodonUserRequest.fetchLimit = 1 - guard let requestMastodonUser = self.backgroundManagedObjectContext.safeFetch(requestMastodonUserRequest).first else { return } - user.update(isDomainBlocking: true, by: requestMastodonUser) - } - .setFailureType(to: Error.self) - .tryMap { result -> Mastodon.Response.Content in - switch result { - case .success: - return response - case .failure(let error): - throw error - } - } - .eraseToAnyPublisher() - } - .eraseToAnyPublisher() + ).singleOutput() + + return result } func unblockDomain( - user: MastodonUser, + account: Mastodon.Entity.Account, authorizationBox: MastodonAuthenticationBox - ) -> AnyPublisher, Error> { + ) async throws -> Mastodon.Response.Content { let authorization = authorizationBox.userAuthorization - - return Mastodon.API.DomainBlock.unblockDomain( + + guard let domain = account.domainFromAcct else { + throw APIError.implicit(.badRequest) + } + + let result = try await Mastodon.API.DomainBlock.unblockDomain( domain: authorizationBox.domain, - blockDomain: user.domainFromAcct, + blockDomain: domain, session: session, authorization: authorization - ) - .flatMap { response -> AnyPublisher, Error> in - self.backgroundManagedObjectContext.performChanges { - let requestMastodonUserRequest = MastodonUser.sortedFetchRequest - requestMastodonUserRequest.predicate = MastodonUser.predicate(domain: authorizationBox.domain, id: authorizationBox.userID) - requestMastodonUserRequest.fetchLimit = 1 - guard let requestMastodonUser = self.backgroundManagedObjectContext.safeFetch(requestMastodonUserRequest).first else { return } - user.update(isDomainBlocking: false, by: requestMastodonUser) - } - .setFailureType(to: Error.self) - .tryMap { result -> Mastodon.Response.Content in - switch result { - case .success: - return response - case .failure(let error): - throw error - } - } - .eraseToAnyPublisher() - } - .eraseToAnyPublisher() + ).singleOutput() + + return result } } diff --git a/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Follow.swift b/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Follow.swift index b3a046bad..cae26cf3f 100644 --- a/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Follow.swift +++ b/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Follow.swift @@ -14,8 +14,8 @@ import MastodonSDK extension APIService { private struct MastodonFollowContext { - let sourceUserID: MastodonUser.ID - let targetUserID: MastodonUser.ID + let sourceUserID: String + let targetUserID: String let isFollowing: Bool let isPending: Bool let needsUnfollow: Bool @@ -30,113 +30,29 @@ extension APIService { /// - activeMastodonAuthenticationBox: `AuthenticationService.MastodonAuthenticationBox` /// - Returns: publisher for `Relationship` public func toggleFollow( - user: ManagedObjectRecord, + account: Mastodon.Entity.Account, authenticationBox: MastodonAuthenticationBox ) async throws -> Mastodon.Response.Content { - let managedObjectContext = backgroundManagedObjectContext - let _followContext: MastodonFollowContext? = try await managedObjectContext.performChanges { - guard let me = authenticationBox.authentication.user(in: managedObjectContext) else { return nil } - guard let user = user.object(in: managedObjectContext) else { return nil } - - let isFollowing = user.followingBy.contains(me) - let isPending = user.followRequestedBy.contains(me) - let needsUnfollow = isFollowing || isPending - - if needsUnfollow { - // unfollow - user.update(isFollowing: false, by: me) - user.update(isFollowRequested: false, by: me) - } else { - // follow - if user.locked { - user.update(isFollowing: false, by: me) - user.update(isFollowRequested: true, by: me) - } else { - user.update(isFollowing: true, by: me) - user.update(isFollowRequested: false, by: me) - } - } - let context = MastodonFollowContext( - sourceUserID: me.id, - targetUserID: user.id, - isFollowing: isFollowing, - isPending: isPending, - needsUnfollow: needsUnfollow - ) - return context - } - - guard let followContext = _followContext else { - throw APIError.implicit(.badRequest) - } - - // request follow or unfollow - let result: Result, Error> - do { - let response = try await Mastodon.API.Account.follow( - session: session, - domain: authenticationBox.domain, - accountID: followContext.targetUserID, - followQueryType: followContext.needsUnfollow ? .unfollow : .follow(query: .init()), - authorization: authenticationBox.userAuthorization - ).singleOutput() - result = .success(response) - } catch { - result = .failure(error) - } - - // update friendship state - try await managedObjectContext.performChanges { - guard let me = authenticationBox.authentication.user(in: managedObjectContext), - let user = user.object(in: managedObjectContext) - else { return } - - switch result { - case .success(let response): - Persistence.MastodonUser.update( - mastodonUser: user, - context: Persistence.MastodonUser.RelationshipContext( - entity: response.value, - me: me, - networkDate: response.networkDate - ) - ) - case .failure: - // rollback - user.update(isFollowing: followContext.isFollowing, by: me) - user.update(isFollowRequested: followContext.isPending, by: me) - } - } - - let response = try result.get() - return response - } - - public func toggleFollow( - user: Mastodon.Entity.Account, - authenticationBox: MastodonAuthenticationBox - ) async throws -> Mastodon.Response.Content { - - guard let relationship = try await relationship(forAccounts: [user], authenticationBox: authenticationBox).value.first else { + guard let relationship = try await relationship(forAccounts: [account], authenticationBox: authenticationBox).value.first else { throw APIError.implicit(.badRequest) } let response: Mastodon.Response.Content - if relationship.following || (relationship.requested ?? false) { + if relationship.following || relationship.requested { // unfollow response = try await Mastodon.API.Account.unfollow( session: session, domain: authenticationBox.domain, - accountID: user.id, + accountID: account.id, authorization: authenticationBox.userAuthorization ).singleOutput() } else { response = try await Mastodon.API.Account.follow( session: session, domain: authenticationBox.domain, - accountID: user.id, + accountID: account.id, followQueryType: .follow(query: .init()), authorization: authenticationBox.userAuthorization ).singleOutput() @@ -145,64 +61,10 @@ extension APIService { return response } - public func toggleShowReblogs( - for user: ManagedObjectRecord, - authenticationBox: MastodonAuthenticationBox - ) async throws -> Mastodon.Response.Content { - - let managedObjectContext = backgroundManagedObjectContext - guard let user = user.object(in: managedObjectContext), - let me = authenticationBox.authentication.user(in: managedObjectContext) - else { throw APIError.implicit(.badRequest) } - - let result: Result, Error> - - let oldShowReblogs = me.showingReblogsBy.contains(user) - let newShowReblogs = (oldShowReblogs == false) - - do { - let response = try await Mastodon.API.Account.follow( - session: session, - domain: authenticationBox.domain, - accountID: user.id, - followQueryType: .follow(query: .init(reblogs: newShowReblogs)), - authorization: authenticationBox.userAuthorization - ).singleOutput() - - result = .success(response) - } catch { - result = .failure(error) - } - - try await managedObjectContext.performChanges { - guard let me = authenticationBox.authentication.user(in: managedObjectContext) else { return } - - switch result { - case .success(let response): - Persistence.MastodonUser.update( - mastodonUser: user, - context: Persistence.MastodonUser.RelationshipContext( - entity: response.value, - me: me, - networkDate: response.networkDate - ) - ) - case .failure: - // rollback - user.update(isShowingReblogs: oldShowReblogs, by: me) - } - } - - return try result.get() - } - public func toggleShowReblogs( for user: Mastodon.Entity.Account, authenticationBox: MastodonAuthenticationBox ) async throws -> Mastodon.Response.Content { - - let result: Result, Error> - let relationship = try await Mastodon.API.Account.relationships( session: session, domain: authenticationBox.domain, @@ -210,23 +72,17 @@ extension APIService { authorization: authenticationBox.userAuthorization ).singleOutput().value.first - let oldShowReblogs = relationship?.showingReblogs == true + let oldShowReblogs = relationship?.showingReblogs ?? true let newShowReblogs = (oldShowReblogs == false) - do { - let response = try await Mastodon.API.Account.follow( - session: session, - domain: authenticationBox.domain, - accountID: user.id, - followQueryType: .follow(query: .init(reblogs: newShowReblogs)), - authorization: authenticationBox.userAuthorization - ).singleOutput() + let response = try await Mastodon.API.Account.follow( + session: session, + domain: authenticationBox.domain, + accountID: user.id, + followQueryType: .follow(query: .init(reblogs: newShowReblogs)), + authorization: authenticationBox.userAuthorization + ).singleOutput() - result = .success(response) - } catch { - result = .failure(error) - } - - return try result.get() + return response } } diff --git a/MastodonSDK/Sources/MastodonCore/Service/API/APIService+FollowRequest.swift b/MastodonSDK/Sources/MastodonCore/Service/API/APIService+FollowRequest.swift index f90aab5d3..60d5d1837 100644 --- a/MastodonSDK/Sources/MastodonCore/Service/API/APIService+FollowRequest.swift +++ b/MastodonSDK/Sources/MastodonCore/Service/API/APIService+FollowRequest.swift @@ -26,27 +26,6 @@ extension APIService { authorization: authenticationBox.userAuthorization ).singleOutput() - let managedObjectContext = self.backgroundManagedObjectContext - try await managedObjectContext.performChanges { - let request = MastodonUser.sortedFetchRequest - request.predicate = MastodonUser.predicate( - domain: authenticationBox.domain, - id: authenticationBox.userID - ) - request.fetchLimit = 1 - guard let user = managedObjectContext.safeFetch(request).first else { return } - guard let me = authenticationBox.authentication.user(in: managedObjectContext) else { return } - - Persistence.MastodonUser.update( - mastodonUser: user, - context: Persistence.MastodonUser.RelationshipContext( - entity: response.value, - me: me, - networkDate: response.networkDate - ) - ) - } - return response } diff --git a/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Follower.swift b/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Follower.swift index f463501f6..b55287cd3 100644 --- a/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Follower.swift +++ b/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Follower.swift @@ -32,28 +32,7 @@ extension APIService { query: query, authorization: authorization ).singleOutput() - - let managedObjectContext = self.backgroundManagedObjectContext - try await managedObjectContext.performChanges { - let me = authenticationBox.authentication.user(in: managedObjectContext) - - for entity in response.value { - let result = Persistence.MastodonUser.createOrMerge( - in: managedObjectContext, - context: Persistence.MastodonUser.PersistContext( - domain: domain, - entity: entity, - cache: nil, - networkDate: response.networkDate - ) - ) - - let user = result.user - me?.update(isFollowing: true, by: user) - } - } - + return response } - } diff --git a/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Following.swift b/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Following.swift index 683a98166..749d09110 100644 --- a/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Following.swift +++ b/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Following.swift @@ -34,29 +34,6 @@ extension APIService { authorization: authorization ).singleOutput() - let managedObjectContext = self.backgroundManagedObjectContext - try await managedObjectContext.performChanges { - let me = authenticationBox.authentication.user(in: managedObjectContext) - - for entity in response.value { - let result = Persistence.MastodonUser.createOrMerge( - in: managedObjectContext, - context: Persistence.MastodonUser.PersistContext( - domain: domain, - entity: entity, - cache: nil, - networkDate: response.networkDate - ) - ) - - if let me = me { - let user = result.user - user.update(isFollowing: true, by: me) - } - } - - } - return response } diff --git a/MastodonSDK/Sources/MastodonCore/Service/API/APIService+HomeTimeline.swift b/MastodonSDK/Sources/MastodonCore/Service/API/APIService+HomeTimeline.swift index 272d81fa2..17544b4fe 100644 --- a/MastodonSDK/Sources/MastodonCore/Service/API/APIService+HomeTimeline.swift +++ b/MastodonSDK/Sources/MastodonCore/Service/API/APIService+HomeTimeline.swift @@ -11,7 +11,7 @@ import CoreData import CoreDataStack import MastodonSDK -public extension Foundation.Notification.Name { +public extension Notification.Name { static let userFetched = Notification.Name(rawValue: "org.joinmastodon.app.user-fetched") } @@ -33,7 +33,7 @@ extension APIService { limit: limit, local: local ) - + let response = try await Mastodon.API.Timeline.home( session: session, domain: domain, @@ -54,18 +54,6 @@ extension APIService { ) } } - - // FIXME: This is a dirty hack to make the performance-stuff work. - // Problem is, that we don't persist the user on disk anymore. So we have to fetch - // it when we need it to display on the home timeline. - // We need this (also) for the Account-list, but it might be the wrong place. App Startup might be more appropriate - for authentication in AuthenticationServiceProvider.shared.authentications { - _ = try? await accountInfo(domain: authentication.domain, - userID: authentication.userID, - authorization: Mastodon.API.OAuth.Authorization(accessToken: authentication.userAccessToken)).value - } - - NotificationCenter.default.post(name: .userFetched, object: nil) return response } diff --git a/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Mute.swift b/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Mute.swift index 65f0eb811..f973412ab 100644 --- a/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Mute.swift +++ b/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Mute.swift @@ -14,8 +14,7 @@ import MastodonSDK extension APIService { private struct MastodonMuteContext { - let sourceUserID: MastodonUser.ID - let targetUserID: MastodonUser.ID + let targetUserID: String let targetUsername: String let isMuting: Bool } @@ -32,7 +31,6 @@ extension APIService { limit: Int?, authenticationBox: MastodonAuthenticationBox ) async throws -> Mastodon.Response.Content<[Mastodon.Entity.Account]> { - let managedObjectContext = backgroundManagedObjectContext let response = try await Mastodon.API.Account.mutes( session: session, domain: authenticationBox.domain, @@ -41,52 +39,27 @@ extension APIService { authorization: authenticationBox.userAuthorization ).singleOutput() - let userIDs = response.value.map { $0.id } - let predicate = MastodonUser.predicate(domain: authenticationBox.domain, ids: userIDs) - - let fetchRequest = MastodonUser.fetchRequest() - fetchRequest.predicate = predicate - fetchRequest.includesPropertyValues = false - - try await managedObjectContext.performChanges { - let users = try managedObjectContext.fetch(fetchRequest) as! [MastodonUser] - - for user in users { - user.deleteStatusAndNotificationFeeds(in: managedObjectContext) - } - } - return response } public func toggleMute( - user: ManagedObjectRecord, - authenticationBox: MastodonAuthenticationBox + authenticationBox: MastodonAuthenticationBox, + account: Mastodon.Entity.Account ) async throws -> Mastodon.Response.Content { - let managedObjectContext = backgroundManagedObjectContext - let muteContext: MastodonMuteContext = try await managedObjectContext.performChanges { - let authentication = authenticationBox.authentication + guard let relationship = try await Mastodon.API.Account.relationships( + session: session, + domain: authenticationBox.domain, + query: .init(ids: [account.id]), + authorization: authenticationBox.userAuthorization + ).singleOutput().value.first else { throw APIError.implicit(.badRequest) } + + let muteContext = MastodonMuteContext( + targetUserID: account.id, + targetUsername: account.username, + isMuting: relationship.muting + ) - guard - let user = user.object(in: managedObjectContext), - let me = authentication.user(in: managedObjectContext) - else { - throw APIError.implicit(.badRequest) - } - - let isMuting = user.mutingBy.contains(me) - - // toggle mute state - user.update(isMuting: !isMuting, by: me) - return MastodonMuteContext( - sourceUserID: me.id, - targetUserID: user.id, - targetUsername: user.username, - isMuting: isMuting - ) - } - let result: Result, Error> do { if muteContext.isMuting { @@ -96,7 +69,7 @@ extension APIService { accountID: muteContext.targetUserID, authorization: authenticationBox.userAuthorization ).singleOutput() - try await getMutes(authenticationBox: authenticationBox) + result = .success(response) } else { let response = try await Mastodon.API.Account.mute( @@ -105,38 +78,16 @@ extension APIService { accountID: muteContext.targetUserID, authorization: authenticationBox.userAuthorization ).singleOutput() - try await getMutes(authenticationBox: authenticationBox) + result = .success(response) } } catch { result = .failure(error) } - - try await managedObjectContext.performChanges { - guard let user = user.object(in: managedObjectContext), - let me = authenticationBox.authentication.user(in: managedObjectContext) - else { return } - - switch result { - case .success(let response): - let relationship = response.value - Persistence.MastodonUser.update( - mastodonUser: user, - context: Persistence.MastodonUser.RelationshipContext( - entity: relationship, - me: me, - networkDate: response.networkDate - ) - ) - case .failure: - // rollback - user.update(isMuting: muteContext.isMuting, by: me) - } - } - + let response = try result.get() return response } - + } diff --git a/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Notification.swift b/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Notification.swift index 296a43d2b..e7889f4aa 100644 --- a/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Notification.swift +++ b/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Notification.swift @@ -11,7 +11,6 @@ import CoreDataStack import Foundation import MastodonSDK import OSLog -import class CoreDataStack.Notification extension APIService { @@ -86,74 +85,6 @@ extension APIService { authorization: authorization ).singleOutput() - let managedObjectContext = self.backgroundManagedObjectContext - try await managedObjectContext.performChanges { - guard let me = authenticationBox.authentication.user(in: managedObjectContext) else { - assertionFailure() - return - } - - var notifications: [Notification] = [] - for entity in response.value { - let result = Persistence.Notification.createOrMerge( - in: managedObjectContext, - context: Persistence.Notification.PersistContext( - domain: authenticationBox.domain, - entity: entity, - me: me, - networkDate: response.networkDate - ) - ) - notifications.append(result.notification) - } - - // locate anchor notification - let anchorNotification: Notification? = { - guard let maxID = query.maxID else { return nil } - let request = Notification.sortedFetchRequest - request.predicate = Notification.predicate( - domain: authenticationBox.domain, - userID: authenticationBox.userID, - id: maxID - ) - request.fetchLimit = 1 - return try? managedObjectContext.fetch(request).first - }() - - // update hasMore flag for anchor status - let acct = Feed.Acct.mastodon(domain: authenticationBox.domain, userID: authenticationBox.userID) - let kind: Feed.Kind = scope == .everything ? .notificationAll : .notificationMentions - if let anchorNotification = anchorNotification, - let feed = anchorNotification.feed(kind: kind, acct: acct) { - feed.update(hasMore: false) - } - - // persist Feed relationship - let sortedNotifications = notifications.sorted(by: { $0.createAt < $1.createAt }) - let oldestNotification = sortedNotifications.first - for notification in notifications { - let _feed = notification.feed(kind: kind, acct: acct) - if let feed = _feed { - feed.update(updatedAt: response.networkDate) - } else { - let feedProperty = Feed.Property( - acct: acct, - kind: kind, - hasMore: false, - createdAt: notification.createAt, - updatedAt: response.networkDate - ) - let feed = Feed.insert(into: managedObjectContext, property: feedProperty) - notification.attach(feed: feed) - - // set hasMore on oldest notification if is new feed - if notification === oldestNotification { - feed.update(hasMore: true) - } - } - } - } - return response } } @@ -174,20 +105,6 @@ extension APIService { authorization: authorization ).singleOutput() - let managedObjectContext = self.backgroundManagedObjectContext - try await managedObjectContext.performChanges { - guard let me = authenticationBox.authentication.user(in: managedObjectContext) else { return } - _ = Persistence.Notification.createOrMerge( - in: managedObjectContext, - context: Persistence.Notification.PersistContext( - domain: domain, - entity: response.value, - me: me, - networkDate: response.networkDate - ) - ) - } - return response } diff --git a/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Reblog.swift b/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Reblog.swift index 09ca59a16..55a1828c8 100644 --- a/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Reblog.swift +++ b/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Reblog.swift @@ -62,7 +62,6 @@ extension APIService { query: Mastodon.API.Statuses.RebloggedByQuery, authenticationBox: MastodonAuthenticationBox ) async throws -> Mastodon.Response.Content<[Mastodon.Entity.Account]> { - let managedObjectContext = backgroundManagedObjectContext let statusID: Status.ID = status.reblog?.id ?? status.id let response = try await Mastodon.API.Statuses.rebloggedBy( @@ -72,21 +71,7 @@ extension APIService { query: query, authorization: authenticationBox.userAuthorization ).singleOutput() - - try await managedObjectContext.performChanges { - for entity in response.value { - _ = Persistence.MastodonUser.createOrMerge( - in: managedObjectContext, - context: .init( - domain: authenticationBox.domain, - entity: entity, - cache: nil, - networkDate: response.networkDate - ) - ) - } // end for … in - } - + return response - } // end func + } } diff --git a/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Recommend.swift b/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Recommend.swift index 14255fc82..cd133b89b 100644 --- a/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Recommend.swift +++ b/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Recommend.swift @@ -26,21 +26,6 @@ extension APIService { authorization: authenticationBox.userAuthorization ).singleOutput() - let managedObjectContext = backgroundManagedObjectContext - try await managedObjectContext.performChanges { - for entity in response.value { - _ = Persistence.MastodonUser.createOrMerge( - in: managedObjectContext, - context: Persistence.MastodonUser.PersistContext( - domain: authenticationBox.domain, - entity: entity, - cache: nil, - networkDate: response.networkDate - ) - ) - } // end for … in - } - return response } @@ -55,24 +40,8 @@ extension APIService { authorization: authenticationBox.userAuthorization ).singleOutput() - let managedObjectContext = backgroundManagedObjectContext - try await managedObjectContext.performChanges { - for entity in response.value { - _ = Persistence.MastodonUser.createOrMerge( - in: managedObjectContext, - context: Persistence.MastodonUser.PersistContext( - domain: authenticationBox.domain, - entity: entity.account, - cache: nil, - networkDate: response.networkDate - ) - ) - } // end for … in - } - return response } - } extension APIService { @@ -88,24 +57,6 @@ extension APIService { authorization: authenticationBox.userAuthorization ).singleOutput() - let managedObjectContext = backgroundManagedObjectContext - try await managedObjectContext.performChanges { - for entity in response.value { - for account in entity.accounts { - _ = Persistence.MastodonUser.createOrMerge( - in: managedObjectContext, - context: Persistence.MastodonUser.PersistContext( - domain: authenticationBox.domain, - entity: account, - cache: nil, - networkDate: response.networkDate - ) - ) - - } // end for account in - } // end for entity in - } - return response } diff --git a/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Relationship.swift b/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Relationship.swift index 2df898977..4336aa0a1 100644 --- a/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Relationship.swift +++ b/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Relationship.swift @@ -11,67 +11,21 @@ import CoreData import CoreDataStack import MastodonSDK +extension Notification.Name { + public static let relationshipChanged = Notification.Name(rawValue: "org.joinmastodon.app.relationship-changed") +} + +public enum UserInfoKey { + public static let relationship = "relationship" +} + extension APIService { - - public func relationship( - records: [ManagedObjectRecord], - authenticationBox: MastodonAuthenticationBox - ) async throws -> Mastodon.Response.Content<[Mastodon.Entity.Relationship]> { - let managedObjectContext = backgroundManagedObjectContext - - let _query: Mastodon.API.Account.RelationshipQuery? = try? await managedObjectContext.perform { - var ids: [MastodonUser.ID] = [] - for record in records { - guard let user = record.object(in: managedObjectContext) else { continue } - guard user.id != authenticationBox.userID else { continue } - ids.append(user.id) - } - guard !ids.isEmpty else { return nil } - return Mastodon.API.Account.RelationshipQuery(ids: ids) - } - guard let query = _query else { - throw APIError.implicit(.badRequest) - } - - let response = try await Mastodon.API.Account.relationships( - session: session, - domain: authenticationBox.domain, - query: query, - authorization: authenticationBox.userAuthorization - ).singleOutput() - - try await managedObjectContext.performChanges { - guard let me = authenticationBox.authentication.user(in: managedObjectContext) else { - // assertionFailure() - return - } - - let relationships = response.value - for record in records { - guard let user = record.object(in: managedObjectContext) else { continue } - guard let relationship = relationships.first(where: { $0.id == user.id }) else { continue } - - Persistence.MastodonUser.update( - mastodonUser: user, - context: Persistence.MastodonUser.RelationshipContext( - entity: relationship, - me: me, - networkDate: response.networkDate - ) - ) - } // end for in - } - - return response - } - - public func relationship( forAccounts accounts: [Mastodon.Entity.Account], authenticationBox: MastodonAuthenticationBox ) async throws -> Mastodon.Response.Content<[Mastodon.Entity.Relationship]> { - let ids: [MastodonUser.ID] = accounts.compactMap { $0.id } + let ids: [String] = accounts.compactMap { $0.id } guard ids.isEmpty == false else { throw APIError.implicit(.badRequest) } diff --git a/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Search.swift b/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Search.swift index 123c01d17..3b3624344 100644 --- a/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Search.swift +++ b/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Search.swift @@ -24,25 +24,6 @@ extension APIService { query: query, authorization: authorization ).singleOutput() - - let managedObjectContext = self.backgroundManagedObjectContext - try await managedObjectContext.performChanges { - let me = authenticationBox.authentication.user(in: managedObjectContext) - - // user - for entity in response.value.accounts { - _ = Persistence.MastodonUser.createOrMerge( - in: managedObjectContext, - context: Persistence.MastodonUser.PersistContext( - domain: domain, - entity: entity, - cache: nil, - networkDate: response.networkDate - ) - ) - } - - } // ent try await managedObjectContext.performChanges { … } return response } diff --git a/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Tags.swift b/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Tags.swift index 008a7e44e..bb9961f18 100644 --- a/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Tags.swift +++ b/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Tags.swift @@ -27,8 +27,8 @@ extension APIService { authorization: authorization ).singleOutput() - return try await persistTag(from: response, domain: domain, authenticationBox: authenticationBox) - } // end func + return response + } public func followTag( for tag: String, @@ -44,8 +44,8 @@ extension APIService { authorization: authorization ).singleOutput() - return try await persistTag(from: response, domain: domain, authenticationBox: authenticationBox) - } // end func + return response + } public func unfollowTag( for tag: String, @@ -61,32 +61,6 @@ extension APIService { authorization: authorization ).singleOutput() - return try await persistTag(from: response, domain: domain, authenticationBox: authenticationBox) - } // end func -} - -fileprivate extension APIService { - @available(*, deprecated, message: "We don't persist tags anymore") - func persistTag( - from response: Mastodon.Response.Content, - domain: String, - authenticationBox: MastodonAuthenticationBox - ) async throws -> Mastodon.Response.Content { - let managedObjectContext = self.backgroundManagedObjectContext - try await managedObjectContext.performChanges { - let me = authenticationBox.authentication.user(in: managedObjectContext) - - _ = Persistence.Tag.createOrMerge( - in: managedObjectContext, - context: Persistence.Tag.PersistContext( - domain: domain, - entity: response.value, - me: me, - networkDate: response.networkDate - ) - ) - } - return response } } diff --git a/MastodonSDK/Sources/MastodonCore/Service/AuthenticationService.swift b/MastodonSDK/Sources/MastodonCore/Service/AuthenticationService.swift index 8a92244b5..dde021705 100644 --- a/MastodonSDK/Sources/MastodonCore/Service/AuthenticationService.swift +++ b/MastodonSDK/Sources/MastodonCore/Service/AuthenticationService.swift @@ -25,13 +25,13 @@ public final class AuthenticationService: NSObject { // output @Published public var mastodonAuthenticationBoxes: [MastodonAuthenticationBox] = [] - + private func fetchFollowedBlockedUserIds( _ authBox: MastodonAuthenticationBox, _ previousFollowingIDs: [String]? = nil, _ maxID: String? = nil ) async throws { - guard let apiService = apiService else { return } + guard let apiService else { return } let followingResponse = try await fetchFollowing(maxID, apiService, authBox) let followingIds = (previousFollowingIDs ?? []) + followingResponse.ids @@ -126,7 +126,7 @@ public final class AuthenticationService: NSObject { extension AuthenticationService { - public func activeMastodonUser(domain: String, userID: MastodonUser.ID) async throws -> Bool { + public func activeMastodonUser(domain: String, userID: String) async throws -> Bool { var isActive = false AuthenticationServiceProvider.shared.activateAuthentication(in: domain, for: userID) diff --git a/MastodonSDK/Sources/MastodonCore/Service/InstanceService.swift b/MastodonSDK/Sources/MastodonCore/Service/InstanceService.swift index 0d1509af8..fb4335cc3 100644 --- a/MastodonSDK/Sources/MastodonCore/Service/InstanceService.swift +++ b/MastodonSDK/Sources/MastodonCore/Service/InstanceService.swift @@ -121,23 +121,3 @@ extension InstanceService { .eraseToAnyPublisher() } } - -public extension InstanceService { - func updateMutesAndBlocks() { - Task { - for authBox in authenticationService?.mastodonAuthenticationBoxes ?? [] { - do { - try await apiService?.getMutes( - authenticationBox: authBox - ) - - try await apiService?.getBlocked( - authenticationBox: authBox - ) - - } catch { - } - } - } - } -} diff --git a/MastodonSDK/Sources/MastodonCore/Service/Notification/NotificationService.swift b/MastodonSDK/Sources/MastodonCore/Service/Notification/NotificationService.swift index 0c60ad8e7..727cb7d18 100644 --- a/MastodonSDK/Sources/MastodonCore/Service/Notification/NotificationService.swift +++ b/MastodonSDK/Sources/MastodonCore/Service/Notification/NotificationService.swift @@ -97,33 +97,30 @@ extension NotificationService { extension NotificationService { public func unreadApplicationShortcutItems() async throws -> [UIApplicationShortcutItem] { guard let authenticationService = self.authenticationService else { return [] } - let managedObjectContext = authenticationService.managedObjectContext - return try await managedObjectContext.perform { - var items: [UIApplicationShortcutItem] = [] - for authentication in AuthenticationServiceProvider.shared.authentications { - guard let user = authentication.user(in: managedObjectContext) else { continue } - let accessToken = authentication.userAccessToken - let count = UserDefaults.shared.getNotificationCountWithAccessToken(accessToken: accessToken) - guard count > 0 else { continue } - - let title = "@\(user.acctWithDomain)" - let subtitle = L10n.A11y.Plural.Count.Unread.notification(count) - - let item = UIApplicationShortcutItem( - type: NotificationService.unreadShortcutItemIdentifier, - localizedTitle: title, - localizedSubtitle: subtitle, - icon: nil, - userInfo: [ - "accessToken": accessToken as NSSecureCoding - ] - ) - items.append(item) - } - return items + + var items: [UIApplicationShortcutItem] = [] + for authentication in AuthenticationServiceProvider.shared.authentications { + guard let account = authentication.account() else { continue } + let accessToken = authentication.userAccessToken + let count = UserDefaults.shared.getNotificationCountWithAccessToken(accessToken: accessToken) + guard count > 0 else { continue } + + let title = "@\(account.acctWithDomain)" + let subtitle = L10n.A11y.Plural.Count.Unread.notification(count) + + let item = UIApplicationShortcutItem( + type: NotificationService.unreadShortcutItemIdentifier, + localizedTitle: title, + localizedSubtitle: subtitle, + icon: nil, + userInfo: [ + "accessToken": accessToken as NSSecureCoding + ] + ) + items.append(item) } - } -} + return items + }} extension NotificationService { diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift index 4175e8444..8005ebfa1 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift @@ -211,7 +211,7 @@ extension Mastodon.API { return try Mastodon.API.decoder.decode(type, from: data) } catch let decodeError { #if DEBUG - debugPrint(decodeError) + debugPrint("\(response.url), Data: \(String(data: data, encoding: .utf8)), \(decodeError)") #endif guard let httpURLResponse = response as? HTTPURLResponse else { diff --git a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Account.swift b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Account.swift index f189a131e..29b73763c 100644 --- a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Account.swift +++ b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Account.swift @@ -9,7 +9,7 @@ import Foundation import MastodonCommon extension Mastodon.Entity { - + /// Account /// /// - Since: 0.1.0 @@ -18,8 +18,7 @@ extension Mastodon.Entity { /// 2021/1/28 /// # Reference /// [Document](https://docs.joinmastodon.org/entities/account/) - public final class Account: Codable, Sendable { - + public final class Account: Sendable { public typealias ID = String // Base @@ -27,7 +26,7 @@ extension Mastodon.Entity { public let username: String public let acct: String public let url: String - + // Display public let displayName: String public let note: String @@ -36,52 +35,55 @@ extension Mastodon.Entity { public let header: String public let headerStatic: String? public let locked: Bool - public let emojis: [Emoji]? + public let emojis: [Emoji] public let discoverable: Bool? - + // Statistical public let createdAt: Date public let lastStatusAt: Date? public let statusesCount: Int public let followersCount: Int public let followingCount: Int - + public let moved: Account? public let fields: [Field]? public let bot: Bool? public let source: Source? public let suspended: Bool? public let muteExpiresAt: Date? + } +} + +//MARK: - Codable +extension Mastodon.Entity.Account: Codable { + enum CodingKeys: String, CodingKey { + case id + case username + case acct + case url - enum CodingKeys: String, CodingKey { - case id - case username - case acct - case url - - case displayName = "display_name" - case note - case avatar - case avatarStatic = "avatar_static" - case header - case headerStatic = "header_static" - case locked - case emojis - case discoverable - - case createdAt = "created_at" - case lastStatusAt = "last_status_at" - case statusesCount = "statuses_count" - case followersCount = "followers_count" - case followingCount = "following_count" - case moved - - case fields - case bot - case source - case suspended - case muteExpiresAt = "mute_expires_at" - } + case displayName = "display_name" + case note + case avatar + case avatarStatic = "avatar_static" + case header + case headerStatic = "header_static" + case locked + case emojis + case discoverable + + case createdAt = "created_at" + case lastStatusAt = "last_status_at" + case statusesCount = "statuses_count" + case followersCount = "followers_count" + case followingCount = "following_count" + case moved + + case fields + case bot + case source + case suspended + case muteExpiresAt = "mute_expires_at" } } @@ -131,17 +133,37 @@ extension Mastodon.Entity.Account { return components.host } + public func headerImageURL() -> URL? { + let string = UserDefaults.shared.preferredStaticAvatar ? headerStatic ?? header : header + return URL(string: string) + } + public func avatarImageURL() -> URL? { let string = UserDefaults.shared.preferredStaticAvatar ? avatarStatic ?? avatar : avatar return URL(string: string) } public func avatarImageURLWithFallback(domain: String) -> URL { - return avatarImageURL() ?? URL(string: "https://\(domain)/avatars/original/missing.png")! + return avatarImageURL() ?? URL(string: "https://\(domain)/avatars/original/\(Self.missingImageName)")! } public var displayNameWithFallback: String { return !displayName.isEmpty ? displayName : username } + + public var domainFromAcct: String? { + if acct.contains("@") == false { + return domain + } else if let domain = acct.split(separator: "@").last { + return String(domain) + } else { + return nil + } + } + +} + +extension Mastodon.Entity.Account { + public static let missingImageName = "missing.png" } diff --git a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Mention.swift b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Mention.swift index 182cc0138..2cfcd9d7c 100644 --- a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Mention.swift +++ b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Mention.swift @@ -15,7 +15,7 @@ extension Mastodon.Entity { /// # Last Update /// 2021/1/28 /// # Reference - /// [Document](https://docs.joinmastodon.org/entities/mention/) + /// [Document](https://docs.joinmastodon.org/entities/Status/#Mention) public struct Mention: Codable, Sendable { public typealias ID = String diff --git a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Relationship.swift b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Relationship.swift index f5d2200e7..ebda38749 100644 --- a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Relationship.swift +++ b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Relationship.swift @@ -17,22 +17,33 @@ extension Mastodon.Entity { /// # Reference /// [Document](https://docs.joinmastodon.org/entities/relationship/) public struct Relationship: Codable, Sendable, Equatable, Hashable { - public typealias ID = String - - public let id: ID + /// The account ID + public let id: String + /// Are you following this user? public let following: Bool - public let requested: Bool? - public let endorsed: Bool? + /// Do you have a pending follow request for this user? + public let requested: Bool + /// Are you featuring this user on your profile? + public let endorsed: Bool + /// Are you followed by this user? public let followedBy: Bool - public let muting: Bool? - public let mutingNotifications: Bool? - public let showingReblogs: Bool? - public let notifying: Bool? + /// Are you muting this user? + public let muting: Bool + /// Are you muting notifications from this user? + public let mutingNotifications: Bool + /// Are you receiving this user’s boosts in your home timeline? + public let showingReblogs: Bool + /// Have you enabled notifications for this user? + public let notifying: Bool + /// Are you blocking this user? public let blocking: Bool - public let domainBlocking: Bool? - public let blockedBy: Bool? + /// Are you blocking this user’s domain? + public let domainBlocking: Bool + /// Is this user blocking you? + public let blockedBy: Bool + /// This user’s profile bio public let note: String? - + enum CodingKeys: String, CodingKey { case id case following diff --git a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Status.swift b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Status.swift index 8949d8dc4..5262bfef0 100644 --- a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Status.swift +++ b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Status.swift @@ -37,8 +37,8 @@ extension Mastodon.Entity { // Rendering public let mentions: [Mention]? - public let tags: [Tag]? - public let emojis: [Emoji]? + public let tags: [Tag] + public let emojis: [Emoji] // Informational public let reblogsCount: Int diff --git a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+StatusEdit.swift b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+StatusEdit.swift index fd61ff060..f8f50b7f5 100644 --- a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+StatusEdit.swift +++ b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+StatusEdit.swift @@ -36,7 +36,7 @@ extension Mastodon.Entity { public let account: Account public let poll: Poll? public let mediaAttachments: [Attachment]? - public let emojis: [Emoji]? + public let emojis: [Emoji] enum CodingKeys: String, CodingKey { case content diff --git a/MastodonSDK/Sources/MastodonSDK/MastodonFeed.swift b/MastodonSDK/Sources/MastodonSDK/MastodonFeed.swift index 707c76bbb..3bd5c940d 100644 --- a/MastodonSDK/Sources/MastodonSDK/MastodonFeed.swift +++ b/MastodonSDK/Sources/MastodonSDK/MastodonFeed.swift @@ -16,16 +16,18 @@ public final class MastodonFeed { public var isLoadingMore: Bool = false public let status: MastodonStatus? + public let relationship: Mastodon.Entity.Relationship? public let notification: Mastodon.Entity.Notification? public let kind: Feed.Kind - init(hasMore: Bool, isLoadingMore: Bool, status: MastodonStatus?, notification: Mastodon.Entity.Notification?, kind: Feed.Kind) { + init(hasMore: Bool, isLoadingMore: Bool, status: MastodonStatus?, notification: Mastodon.Entity.Notification?, relationship: Mastodon.Entity.Relationship?, kind: Feed.Kind) { self.id = notification?.id ?? status?.id ?? UUID().uuidString self.hasMore = hasMore self.isLoadingMore = isLoadingMore self.status = status self.notification = notification + self.relationship = relationship self.kind = kind } } @@ -37,11 +39,12 @@ public extension MastodonFeed { isLoadingMore: false, status: status, notification: nil, + relationship: nil, kind: kind ) } - static func fromNotification(_ notification: Mastodon.Entity.Notification, kind: Feed.Kind) -> MastodonFeed { + static func fromNotification(_ notification: Mastodon.Entity.Notification, relationship: Mastodon.Entity.Relationship?, kind: Feed.Kind) -> MastodonFeed { MastodonFeed( hasMore: false, isLoadingMore: false, @@ -52,6 +55,7 @@ public extension MastodonFeed { return .fromEntity(status) }(), notification: notification, + relationship: relationship, kind: kind ) } diff --git a/MastodonSDK/Sources/MastodonSDK/MastodonNotification.swift b/MastodonSDK/Sources/MastodonSDK/MastodonNotification.swift index b32d59c29..a92a33a14 100644 --- a/MastodonSDK/Sources/MastodonSDK/MastodonNotification.swift +++ b/MastodonSDK/Sources/MastodonSDK/MastodonNotification.swift @@ -10,30 +10,26 @@ public final class MastodonNotification { entity.id } - public let account: MastodonUser + public let account: Mastodon.Entity.Account + public var relationship: Mastodon.Entity.Relationship? public let status: MastodonStatus? public let feeds: [MastodonFeed] public var followRequestState: MastodonFollowRequestState = .init(state: .none) public var transientFollowRequestState: MastodonFollowRequestState = .init(state: .none) - public init(entity: Mastodon.Entity.Notification, account: MastodonUser, status: MastodonStatus?, feeds: [MastodonFeed]) { + public init(entity: Mastodon.Entity.Notification, account: Mastodon.Entity.Account, relationship: Mastodon.Entity.Relationship?, status: MastodonStatus?, feeds: [MastodonFeed]) { self.entity = entity self.account = account + self.relationship = relationship self.status = status self.feeds = feeds } } public extension MastodonNotification { - static func fromEntity(_ entity: Mastodon.Entity.Notification, using managedObjectContext: NSManagedObjectContext, domain: String) -> MastodonNotification? { - guard let user = MastodonUser.fetch(in: managedObjectContext, configurationBlock: { request in - request.predicate = MastodonUser.predicate(domain: domain, id: entity.account.id) - }).first else { - assertionFailure() - return nil - } - return MastodonNotification(entity: entity, account: user, status: entity.status.map(MastodonStatus.fromEntity), feeds: []) + static func fromEntity(_ entity: Mastodon.Entity.Notification, relationship: Mastodon.Entity.Relationship?) -> MastodonNotification { + return MastodonNotification(entity: entity, account: entity.account, relationship: relationship, status: entity.status.map(MastodonStatus.fromEntity), feeds: []) } } diff --git a/MastodonSDK/Sources/MastodonUI/Extension/Date.swift b/MastodonSDK/Sources/MastodonUI/Extension/Date.swift index 2b0cb9098..771d4c11a 100644 --- a/MastodonSDK/Sources/MastodonUI/Extension/Date.swift +++ b/MastodonSDK/Sources/MastodonUI/Extension/Date.swift @@ -32,10 +32,10 @@ extension Date { } public var localizedTimeAgoSinceNow: String { - return self.localizedTimeAgo(since: Date(), isSlowed: false, isAbbreviated: false) + return self.localizedTimeAgo(since: Date()) } - public func localizedTimeAgo(since date: Date, isSlowed: Bool, isAbbreviated: Bool) -> String { + public func localizedTimeAgo(since date: Date, isSlowed: Bool = false, isAbbreviated: Bool = false) -> String { let earlierDate = date < self ? date : self let latestDate = earlierDate == date ? self : date diff --git a/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/ComposeContentViewModel.swift b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/ComposeContentViewModel.swift index cdd0e88a4..cf70443f5 100644 --- a/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/ComposeContentViewModel.swift +++ b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/ComposeContentViewModel.swift @@ -7,7 +7,6 @@ import UIKit import Combine -import CoreDataStack import Meta import MetaTextKit import MastodonMeta @@ -156,7 +155,7 @@ public final class ComposeContentViewModel: NSObject, ObservableObject { self.visibility = { // default private when user locked var visibility: Mastodon.Entity.Status.Visibility = { - guard let author = authContext.mastodonAuthenticationBox.authentication.user(in: context.managedObjectContext) else { + guard let author = authContext.mastodonAuthenticationBox.authentication.account() else { return .public } return author.locked ? .private : .public @@ -196,7 +195,7 @@ public final class ComposeContentViewModel: NSObject, ObservableObject { case .reply(let record): context.managedObjectContext.performAndWait { let status = record.entity - let author = authContext.mastodonAuthenticationBox.authentication.user(in: context.managedObjectContext) + let author = authContext.mastodonAuthenticationBox.authentication.account() var mentionAccts: [String] = [] if author?.id != status.account.id { @@ -311,11 +310,19 @@ extension ComposeContentViewModel { // bind author $authContext .sink { [weak self] authContext in - guard let self = self else { return } - guard let user = authContext.mastodonAuthenticationBox.authentication.user(in: self.context.managedObjectContext) else { return } - self.avatarURL = user.avatarImageURL() - self.name = user.nameMetaContent ?? PlaintextMetaContent(string: user.displayNameWithFallback) - self.username = user.acctWithDomain + guard let self, let account = authContext.mastodonAuthenticationBox.authentication.account() else { return } + + self.avatarURL = account.avatarImageURL() + + do { + let content = MastodonContent(content: account.displayNameWithFallback, emojis: account.emojis.asDictionary) + let metaContent = try MastodonMetaContent.convert(document: content) + self.name = metaContent + } catch { + self.name = PlaintextMetaContent(string: account.displayNameWithFallback) + } + + self.username = account.acctWithDomain } .store(in: &disposeBag) @@ -553,14 +560,8 @@ extension ComposeContentViewModel { public func statusPublisher() throws -> StatusPublisher { let authContext = self.authContext - - // author - let managedObjectContext = self.context.managedObjectContext - var _author: ManagedObjectRecord? - managedObjectContext.performAndWait { - _author = authContext.mastodonAuthenticationBox.authentication.user(in: managedObjectContext)?.asRecord - } - guard let author = _author else { + + guard authContext.mastodonAuthenticationBox.authentication.account() != nil else { throw AppError.badAuthentication } @@ -583,7 +584,6 @@ extension ComposeContentViewModel { } return MastodonStatusPublisher( - author: author, replyTo: { if case .reply(let status) = destination { return status @@ -611,12 +611,7 @@ extension ComposeContentViewModel { guard case let .editStatus(status, _) = composeContext else { return nil } // author - let managedObjectContext = self.context.managedObjectContext - var _author: ManagedObjectRecord? - managedObjectContext.performAndWait { - _author = authContext.mastodonAuthenticationBox.authentication.user(in: managedObjectContext)?.asRecord - } - guard let author = _author else { + guard let author = authContext.mastodonAuthenticationBox.authentication.account() else { throw AppError.badAuthentication } diff --git a/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Publisher/MastodonStatusEditPublisher.swift b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Publisher/MastodonStatusEditPublisher.swift index 127598bb5..d28968bca 100644 --- a/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Publisher/MastodonStatusEditPublisher.swift +++ b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Publisher/MastodonStatusEditPublisher.swift @@ -11,7 +11,7 @@ public final class MastodonEditStatusPublisher: NSObject, ProgressReporting { // Input public let statusID: Status.ID - public let author: ManagedObjectRecord + public let author: Mastodon.Entity.Account // content warning public let isContentWarningComposing: Bool @@ -41,7 +41,7 @@ public final class MastodonEditStatusPublisher: NSObject, ProgressReporting { public init( statusID: Status.ID, - author: ManagedObjectRecord, + author: Mastodon.Entity.Account, isContentWarningComposing: Bool, contentWarning: String, content: String, diff --git a/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Publisher/MastodonStatusPublisher.swift b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Publisher/MastodonStatusPublisher.swift index 42826976f..9e9901b08 100644 --- a/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Publisher/MastodonStatusPublisher.swift +++ b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Publisher/MastodonStatusPublisher.swift @@ -13,11 +13,6 @@ import MastodonCore import MastodonSDK public final class MastodonStatusPublisher: NSObject, ProgressReporting { - - // Input - - // author - public let author: ManagedObjectRecord // refer public let replyTo: MastodonStatus? // content warning @@ -47,7 +42,6 @@ public final class MastodonStatusPublisher: NSObject, ProgressReporting { public var reactor: StatusPublisherReactor? public init( - author: ManagedObjectRecord, replyTo: MastodonStatus?, isContentWarningComposing: Bool, contentWarning: String, @@ -61,7 +55,6 @@ public final class MastodonStatusPublisher: NSObject, ProgressReporting { visibility: Mastodon.Entity.Status.Visibility, language: String ) { - self.author = author self.replyTo = replyTo self.isContentWarningComposing = isContentWarningComposing self.contentWarning = contentWarning diff --git a/MastodonSDK/Sources/MastodonUI/View/Content/CondensedUserView.swift b/MastodonSDK/Sources/MastodonUI/View/Content/CondensedUserView.swift index c19d8f2ed..c549ad5a5 100644 --- a/MastodonSDK/Sources/MastodonUI/View/Content/CondensedUserView.swift +++ b/MastodonSDK/Sources/MastodonUI/View/Content/CondensedUserView.swift @@ -127,7 +127,7 @@ public class CondensedUserView: UIView { public func configure(with account: Mastodon.Entity.Account, showFollowers: Bool = true) { let displayNameMetaContent: MetaContent do { - let content = MastodonContent(content: account.displayNameWithFallback, emojis: account.emojis?.asDictionary ?? [:]) + let content = MastodonContent(content: account.displayNameWithFallback, emojis: account.emojis.asDictionary) displayNameMetaContent = try MastodonMetaContent.convert(document: content) } catch { displayNameMetaContent = PlaintextMetaContent(string: account.displayNameWithFallback) diff --git a/MastodonSDK/Sources/MastodonUI/View/Content/FamiliarFollowersDashboardView+Configuration.swift b/MastodonSDK/Sources/MastodonUI/View/Content/FamiliarFollowersDashboardView+Configuration.swift index d8fbbf6ca..12aadb040 100644 --- a/MastodonSDK/Sources/MastodonUI/View/Content/FamiliarFollowersDashboardView+Configuration.swift +++ b/MastodonSDK/Sources/MastodonUI/View/Content/FamiliarFollowersDashboardView+Configuration.swift @@ -20,7 +20,7 @@ extension FamiliarFollowersDashboardView { viewModel.emojis = { var array: [Mastodon.Entity.Emoji] = [] for account in accounts { - array.append(contentsOf: account.emojis ?? []) + array.append(contentsOf: account.emojis) } return array.asDictionary }() diff --git a/MastodonSDK/Sources/MastodonUI/View/Content/NotificationView+ViewModel.swift b/MastodonSDK/Sources/MastodonUI/View/Content/NotificationView+ViewModel.swift deleted file mode 100644 index ad59df742..000000000 --- a/MastodonSDK/Sources/MastodonUI/View/Content/NotificationView+ViewModel.swift +++ /dev/null @@ -1,309 +0,0 @@ -// -// NotificationView+ViewModel.swift -// -// -// Created by MainasuK on 2022-1-21. -// - -import UIKit -import Combine -import Meta -import MastodonSDK -import MastodonAsset -import MastodonLocalization -import MastodonExtension -import MastodonCore -import CoreData -import CoreDataStack - -extension NotificationView { - public final class ViewModel: ObservableObject { - public var disposeBag = Set() - public var objects = Set() - - @Published public var context: AppContext? - @Published public var authContext: AuthContext? - - @Published public var type: MastodonNotificationType? - @Published public var notificationIndicatorText: MetaContent? - - @Published public var authorAvatarImage: UIImage? - @Published public var authorAvatarImageURL: URL? - @Published public var authorName: MetaContent? - @Published public var authorUsername: String? - - @Published public var isMyself = false - @Published public var isMuting = false - @Published public var isBlocking = false - @Published public var isTranslated = false - @Published public var isFollowed = false - - @Published public var timestamp: Date? - - @Published public var visibility: MastodonVisibility = .public - - @Published public var followRequestState = MastodonFollowRequestState(state: .none) - @Published public var transientFollowRequestState = MastodonFollowRequestState(state: .none) - - let timestampUpdatePublisher = Timer.publish(every: 1.0, on: .main, in: .common) - .autoconnect() - .share() - .eraseToAnyPublisher() - } -} - -extension NotificationView.ViewModel { - func bind(notificationView: NotificationView) { - bindAuthor(notificationView: notificationView) - bindAuthorMenu(notificationView: notificationView) - bindFollowRequest(notificationView: notificationView) - - $context - .assign(to: \.context, on: notificationView.statusView.viewModel) - .store(in: &disposeBag) - $authContext - .assign(to: \.authContext, on: notificationView.statusView.viewModel) - .store(in: &disposeBag) - $authContext - .assign(to: \.authContext, on: notificationView.quoteStatusView.viewModel) - .store(in: &disposeBag) - } - - private func bindAuthor(notificationView: NotificationView) { - // avatar - Publishers.CombineLatest( - $authorAvatarImage, - $authorAvatarImageURL - ) - .sink { image, url in - let configuration: AvatarImageView.Configuration = { - if let image = image { - return AvatarImageView.Configuration(image: image) - } else { - return AvatarImageView.Configuration(url: url) - } - }() - notificationView.avatarButton.avatarImageView.configure(configuration: configuration) - notificationView.avatarButton.avatarImageView.configure(cornerConfiguration: .init(corner: .fixed(radius: 12))) - } - .store(in: &disposeBag) - // name - $authorName - .sink { metaContent in - let metaContent = metaContent ?? PlaintextMetaContent(string: " ") - notificationView.authorNameLabel.configure(content: metaContent) - } - .store(in: &disposeBag) - // username - $authorUsername - .map { text -> String in - guard let text = text else { return "" } - return "@\(text)" - } - .sink { username in - let metaContent = PlaintextMetaContent(string: username) - notificationView.authorUsernameLabel.configure(content: metaContent) - } - .store(in: &disposeBag) - // timestamp - let formattedTimestamp = Publishers.CombineLatest( - $timestamp, - timestampUpdatePublisher.prepend(Date()).eraseToAnyPublisher() - ) - .map { timestamp, _ in - timestamp?.localizedTimeAgoSinceNow ?? "" - } - .removeDuplicates() - - formattedTimestamp - .sink { timestamp in - notificationView.dateLabel.configure(content: PlaintextMetaContent(string: timestamp)) - } - .store(in: &disposeBag) - - $visibility - .sink { visibility in - notificationView.visibilityIconImageView.image = visibility.image - } - .store(in: &disposeBag) - - // notification type indicator - $notificationIndicatorText - .sink { text in - if let text = text { - notificationView.notificationTypeIndicatorLabel.configure(content: text) - } else { - notificationView.notificationTypeIndicatorLabel.reset() - } - } - .store(in: &disposeBag) - - Publishers.CombineLatest4( - $authorName, - $authorUsername, - $notificationIndicatorText, - formattedTimestamp - ) - .sink { name, username, type, timestamp in - notificationView.accessibilityLabel = [ - "\(name?.string ?? "") \(type?.string ?? "")", - username.map { "@\($0)" } ?? "", - timestamp - ].joined(separator: ", ") - if !notificationView.statusView.isHidden { - notificationView.accessibilityLabel! += ", " + (notificationView.statusView.accessibilityLabel ?? "") - } - if !notificationView.quoteStatusViewContainerView.isHidden { - notificationView.accessibilityLabel! += ", " + (notificationView.quoteStatusView.accessibilityLabel ?? "") - } - } - .store(in: &disposeBag) - - Publishers.CombineLatest( - $authorAvatarImage, - $type - ) - .sink { avatarImage, type in - var actions = [UIAccessibilityCustomAction]() - - // these notifications can be directly actioned to view the profile - if type != .follow, type != .followRequest { - actions.append( - UIAccessibilityCustomAction( - name: L10n.Common.Controls.Status.showUserProfile, - image: avatarImage - ) { [weak notificationView] _ in - guard let notificationView = notificationView, let delegate = notificationView.delegate else { return false } - delegate.notificationView(notificationView, authorAvatarButtonDidPressed: notificationView.avatarButton) - return true - } - ) - } - - if type == .followRequest { - actions.append( - UIAccessibilityCustomAction( - name: L10n.Common.Controls.Actions.confirm, - image: Asset.Editing.checkmark20.image - ) { [weak notificationView] _ in - guard let notificationView = notificationView, let delegate = notificationView.delegate else { return false } - delegate.notificationView(notificationView, acceptFollowRequestButtonDidPressed: notificationView.acceptFollowRequestButton) - return true - } - ) - - actions.append( - UIAccessibilityCustomAction( - name: L10n.Common.Controls.Actions.delete, - image: Asset.Circles.forbidden20.image - ) { [weak notificationView] _ in - guard let notificationView = notificationView, let delegate = notificationView.delegate else { return false } - delegate.notificationView(notificationView, rejectFollowRequestButtonDidPressed: notificationView.rejectFollowRequestButton) - return true - } - ) - } - - notificationView.notificationActions = actions - } - .store(in: &disposeBag) - } - - private func bindAuthorMenu(notificationView: NotificationView) { - Publishers.CombineLatest4( - $authorName, - $isMuting, - $isBlocking, - Publishers.CombineLatest3( - $isMyself, - $isTranslated, - $isFollowed - ) - ) - .sink { [weak self] authorName, isMuting, isBlocking, isMyselfIsTranslatedIsFollowed in - guard let name = authorName?.string, let self, let context = self.context, let authContext = self.authContext else { - notificationView.menuButton.menu = nil - return - } - - let (isMyself, isTranslated, isFollowed) = isMyselfIsTranslatedIsFollowed - - let authentication = authContext.mastodonAuthenticationBox.authentication - let instance = authentication.instance(in: context.managedObjectContext) - let isTranslationEnabled = instance?.isTranslationEnabled ?? false - - let menuContext = NotificationView.AuthorMenuContext( - name: name, - isMuting: isMuting, - isBlocking: isBlocking, - isMyself: isMyself, - isBookmarking: false, // no bookmark action display for notification item - isFollowed: isFollowed, - isTranslationEnabled: isTranslationEnabled, - isTranslated: isTranslated, - statusLanguage: nil - ) - let (menu, actions) = notificationView.setupAuthorMenu(menuContext: menuContext) - notificationView.menuButton.menu = menu - notificationView.authorActions = actions - notificationView.menuButton.showsMenuAsPrimaryAction = true - - notificationView.menuButton.isHidden = menuContext.isMyself - } - .store(in: &disposeBag) - } - - private func bindFollowRequest(notificationView: NotificationView) { - Publishers.CombineLatest( - $followRequestState, - $transientFollowRequestState - ) - .sink { followRequestState, transientFollowRequestState in - switch followRequestState.state { - case .isAccept: - notificationView.rejectFollowRequestButtonShadowBackgroundContainer.isHidden = true - notificationView.acceptFollowRequestButton.isUserInteractionEnabled = false - notificationView.acceptFollowRequestButton.setImage(nil, for: .normal) - notificationView.acceptFollowRequestButton.setTitle(L10n.Scene.Notification.FollowRequest.accepted, for: .normal) - case .isReject: - notificationView.acceptFollowRequestButtonShadowBackgroundContainer.isHidden = true - notificationView.rejectFollowRequestButton.isUserInteractionEnabled = false - notificationView.rejectFollowRequestButton.setImage(nil, for: .normal) - notificationView.rejectFollowRequestButton.setTitle(L10n.Scene.Notification.FollowRequest.rejected, for: .normal) - default: - break - } - - let state = transientFollowRequestState.state - if state == .isAccepting { - notificationView.acceptFollowRequestActivityIndicatorView.startAnimating() - notificationView.acceptFollowRequestButton.tintColor = .clear - notificationView.acceptFollowRequestButton.setTitleColor(.clear, for: .normal) - } else { - notificationView.acceptFollowRequestActivityIndicatorView.stopAnimating() - notificationView.acceptFollowRequestButton.tintColor = .white - notificationView.acceptFollowRequestButton.setTitleColor(.white, for: .normal) - } - if state == .isRejecting { - notificationView.rejectFollowRequestActivityIndicatorView.startAnimating() - notificationView.rejectFollowRequestButton.tintColor = .clear - notificationView.rejectFollowRequestButton.setTitleColor(.clear, for: .normal) - } else { - notificationView.rejectFollowRequestActivityIndicatorView.stopAnimating() - notificationView.rejectFollowRequestButton.tintColor = .black - notificationView.rejectFollowRequestButton.setTitleColor(.black, for: .normal) - } - - UIView.animate(withDuration: 0.3) { - if state == .isAccept { - notificationView.rejectFollowRequestButtonShadowBackgroundContainer.isHidden = true - } - if state == .isReject { - notificationView.acceptFollowRequestButtonShadowBackgroundContainer.isHidden = true - } - } - } - .store(in: &disposeBag) - } - -} diff --git a/MastodonSDK/Sources/MastodonUI/View/Content/StatusView+Configuration.swift b/MastodonSDK/Sources/MastodonUI/View/Content/StatusView+Configuration.swift index 2c3ced43c..2ba820071 100644 --- a/MastodonSDK/Sources/MastodonUI/View/Content/StatusView+Configuration.swift +++ b/MastodonSDK/Sources/MastodonUI/View/Content/StatusView+Configuration.swift @@ -88,16 +88,11 @@ extension StatusView { private func configureHeader(status: MastodonStatus) { if status.entity.reblogged == true, let authenticationBox = viewModel.authContext?.mastodonAuthenticationBox, - let managedObjectContext = viewModel.context?.managedObjectContext { - - let user = MastodonUser.findOrFetch( - in: managedObjectContext, - matching: MastodonUser.predicate(domain: authenticationBox.domain, id: authenticationBox.userID) - ) + let account = authenticationBox.authentication.account() { + + let name = account.displayNameWithFallback + let emojis = account.emojis - let name = user?.displayNameWithFallback ?? authenticationBox.authentication.username - let emojis = user?.emojis ?? [] - viewModel.header = { let text = L10n.Common.Controls.Status.userReblogged(name) let content = MastodonContent(content: text, emojis: emojis.asDictionary) @@ -111,8 +106,8 @@ extension StatusView { }() } else if status.reblog != nil { let name = status.entity.account.displayNameWithFallback - let emojis = status.entity.account.emojis ?? [] - + let emojis = status.entity.account.emojis + viewModel.header = { let text = L10n.Common.Controls.Status.userReblogged(name) let content = MastodonContent(content: text, emojis: emojis.asDictionary) @@ -167,7 +162,7 @@ extension StatusView { }, receiveValue: { [weak self] response in guard let self else { return } let replyTo = response.value - let header = createHeader(name: replyTo.account.displayNameWithFallback, emojis: replyTo.account.emojis?.asDictionary ?? [:]) + let header = createHeader(name: replyTo.account.displayNameWithFallback, emojis: replyTo.account.emojis.asDictionary) self.viewModel.header = header }) .store(in: &disposeBag) @@ -210,8 +205,8 @@ extension StatusView { // author avatar viewModel.authorAvatarImageURL = author.avatarImageURL() - let emojis = author.emojis?.asDictionary ?? [:] - + let emojis = author.emojis.asDictionary + // author name viewModel.authorName = { do { @@ -280,7 +275,7 @@ extension StatusView { // content do { - let content = MastodonContent(content: translatedContent, emojis: status.entity.emojis?.asDictionary ?? [:]) + let content = MastodonContent(content: translatedContent, emojis: status.entity.emojis.asDictionary) let metaContent = try MastodonMetaContent.convert(document: content) viewModel.content = metaContent viewModel.isCurrentlyTranslating = false @@ -299,7 +294,7 @@ extension StatusView { viewModel.language = (status.reblog ?? status).entity.language // content do { - let content = MastodonContent(content: statusEdit.content, emojis: statusEdit.emojis?.asDictionary ?? [:]) + let content = MastodonContent(content: statusEdit.content, emojis: statusEdit.emojis.asDictionary) let metaContent = try MastodonMetaContent.convert(document: content) viewModel.content = metaContent viewModel.isCurrentlyTranslating = false @@ -319,7 +314,7 @@ extension StatusView { // spoilerText if let spoilerText = status.entity.spoilerText, !spoilerText.isEmpty { do { - let content = MastodonContent(content: spoilerText, emojis: status.entity.emojis?.asDictionary ?? [:]) + let content = MastodonContent(content: spoilerText, emojis: status.entity.emojis.asDictionary) let metaContent = try MastodonMetaContent.convert(document: content) viewModel.spoilerContent = metaContent } catch { @@ -333,7 +328,7 @@ extension StatusView { viewModel.language = (status.reblog ?? status).entity.language // content do { - let content = MastodonContent(content: status.entity.content ?? "", emojis: status.entity.emojis?.asDictionary ?? [:]) + let content = MastodonContent(content: status.entity.content ?? "", emojis: status.entity.emojis.asDictionary) let metaContent = try MastodonMetaContent.convert(document: content) viewModel.content = metaContent viewModel.isCurrentlyTranslating = false diff --git a/MastodonSDK/Sources/MastodonUI/View/Content/StatusView+ViewModel.swift b/MastodonSDK/Sources/MastodonUI/View/Content/StatusView+ViewModel.swift index eef476a38..074ef6736 100644 --- a/MastodonSDK/Sources/MastodonUI/View/Content/StatusView+ViewModel.swift +++ b/MastodonSDK/Sources/MastodonUI/View/Content/StatusView+ViewModel.swift @@ -701,7 +701,7 @@ extension StatusView.ViewModel { let menuContext = StatusAuthorView.AuthorMenuContext( name: name, - isMuting: rel.muting ?? false, + isMuting: rel.muting, isBlocking: rel.blocking, isMyself: isMyself, isBookmarking: isBookmark, diff --git a/MastodonSDK/Sources/MastodonUI/View/Content/UserView+ViewModel.swift b/MastodonSDK/Sources/MastodonUI/View/Content/UserView+ViewModel.swift index f35afb97b..c29bdd88d 100644 --- a/MastodonSDK/Sources/MastodonUI/View/Content/UserView+ViewModel.swift +++ b/MastodonSDK/Sources/MastodonUI/View/Content/UserView+ViewModel.swift @@ -26,7 +26,6 @@ extension UserView { @Published public var authorUsername: String? @Published public var authorFollowers: Int? @Published public var authorVerifiedLink: String? - @Published public var user: MastodonUser? @Published public var account: Mastodon.Entity.Account? @Published public var relationship: Mastodon.Entity.Relationship? } diff --git a/MastodonSDK/Sources/MastodonUI/View/Content/UserView.swift b/MastodonSDK/Sources/MastodonUI/View/Content/UserView.swift index 6ad31bc75..59f6458a8 100644 --- a/MastodonSDK/Sources/MastodonUI/View/Content/UserView.swift +++ b/MastodonSDK/Sources/MastodonUI/View/Content/UserView.swift @@ -15,8 +15,7 @@ import CoreDataStack import MastodonSDK public protocol UserViewDelegate: AnyObject { - func userView(_ view: UserView, didTapButtonWith state: UserView.ButtonState, for user: MastodonUser) - func userView(_ view: UserView, didTapButtonWith state: UserView.ButtonState, for user: Mastodon.Entity.Account, me: MastodonUser?) + func userView(_ view: UserView, didTapButtonWith state: UserView.ButtonState, for user: Mastodon.Entity.Account, me: Mastodon.Entity.Account?) } public final class UserView: UIView { @@ -255,9 +254,7 @@ public extension UserView { } @objc private func didTapFollowButton() { - if let user = viewModel.user { - delegate?.userView(self, didTapButtonWith: currentButtonState, for: user) - } else if let account = viewModel.account { + if let account = viewModel.account { delegate?.userView(self, didTapButtonWith: currentButtonState, for: account, me: nil) } } @@ -270,9 +267,9 @@ public extension UserView { buttonState = .none } else if relationship.following { buttonState = .unfollow - } else if relationship.blocking || (relationship.domainBlocking ?? false) { + } else if relationship.blocking || relationship.domainBlocking { buttonState = .blocked - } else if relationship.requested ?? false { + } else if relationship.requested { buttonState = .pending } else { buttonState = .follow diff --git a/MastodonSDK/Sources/MastodonUI/View/Control/ProfileRelationshipActionButton.swift b/MastodonSDK/Sources/MastodonUI/View/Control/ProfileRelationshipActionButton.swift index 1a3637b49..e7408f5f7 100644 --- a/MastodonSDK/Sources/MastodonUI/View/Control/ProfileRelationshipActionButton.swift +++ b/MastodonSDK/Sources/MastodonUI/View/Control/ProfileRelationshipActionButton.swift @@ -7,10 +7,13 @@ import UIKit import MastodonAsset +import MastodonSDK import MastodonLocalization public final class ProfileRelationshipActionButton: UIButton { - public func configure(actionOptionSet: RelationshipActionOptionSet) { + public func configure(relationship: Mastodon.Entity.Relationship, between account: Mastodon.Entity.Account, and me: Mastodon.Entity.Account, isEditing: Bool = false, isUpdating: Bool = false) { + + let isMyself = (account == me) var configuration = UIButton.Configuration.filled() @@ -19,19 +22,41 @@ public final class ProfileRelationshipActionButton: UIButton { configuration.activityIndicatorColorTransformer = UIConfigurationColorTransformer({ _ in return Asset.Colors.Label.primaryReverse.color }) configuration.background.cornerRadius = 10 - let title: String - if let option = actionOptionSet.highPriorityAction(except: .editOptions), option == .blocked || option == .suspended { - isEnabled = false - configuration.showsActivityIndicator = false - title = actionOptionSet.title - } else if actionOptionSet.contains(.updating) { + var title: String + + if isMyself { + if isEditing { + title = L10n.Common.Controls.Actions.save + } else { + title = L10n.Common.Controls.Friendship.editInfo + } + } else if relationship.blocking { + title = L10n.Common.Controls.Friendship.blocked + } else if relationship.domainBlocking { + title = L10n.Common.Controls.Friendship.domainBlocked + } else if relationship.requested { + title = L10n.Common.Controls.Friendship.pending + } else if relationship.muting { + title = L10n.Common.Controls.Friendship.muted + } else if relationship.following { + title = L10n.Common.Controls.Friendship.following + } else if account.locked { + title = L10n.Common.Controls.Friendship.request + } else { + title = L10n.Common.Controls.Friendship.follow + } + + if relationship.blockedBy || account.suspended ?? false { isEnabled = false + } else { + isEnabled = true + } + + if isUpdating { configuration.showsActivityIndicator = true title = "" } else { - isEnabled = true configuration.showsActivityIndicator = false - title = actionOptionSet.title } configuration.attributedTitle = AttributedString( diff --git a/MastodonSDK/Sources/MastodonUI/ViewModel/RelationshipViewModel.swift b/MastodonSDK/Sources/MastodonUI/ViewModel/RelationshipViewModel.swift deleted file mode 100644 index b8a579199..000000000 --- a/MastodonSDK/Sources/MastodonUI/ViewModel/RelationshipViewModel.swift +++ /dev/null @@ -1,277 +0,0 @@ -// -// RelationshipViewModel.swift -// -// -// Created by MainasuK on 2022-4-14. -// - -import UIKit -import Combine -import MastodonAsset -import MastodonLocalization -import CoreDataStack - -public enum RelationshipAction: Int, CaseIterable { - case showReblogs - case isMyself - case followingBy - case blockingBy - case none // set hide from UI - case follow - case request - case pending - case following - case muting - case blocked - case blocking - case suspended - case edit - case editing - case updating - case domainBlocking - - public var option: RelationshipActionOptionSet { - return RelationshipActionOptionSet(rawValue: 1 << rawValue) - } -} - -// construct option set on the enum for safe iterator -public struct RelationshipActionOptionSet: OptionSet { - - public let rawValue: Int - - public init(rawValue: Int) { - self.rawValue = rawValue - } - - public static let isMyself = RelationshipAction.isMyself.option - public static let followingBy = RelationshipAction.followingBy.option - public static let blockingBy = RelationshipAction.blockingBy.option - public static let none = RelationshipAction.none.option - public static let follow = RelationshipAction.follow.option - public static let request = RelationshipAction.request.option - public static let pending = RelationshipAction.pending.option - public static let following = RelationshipAction.following.option - public static let muting = RelationshipAction.muting.option - public static let blocked = RelationshipAction.blocked.option - public static let blocking = RelationshipAction.blocking.option - public static let suspended = RelationshipAction.suspended.option - public static let edit = RelationshipAction.edit.option - public static let editing = RelationshipAction.editing.option - public static let updating = RelationshipAction.updating.option - public static let showReblogs = RelationshipAction.showReblogs.option - public static let editOptions: RelationshipActionOptionSet = [.edit, .editing, .updating] - public static let domainBlocking = RelationshipAction.domainBlocking.option - - public func highPriorityAction(except: RelationshipActionOptionSet) -> RelationshipAction? { - let set = subtracting(except) - for action in RelationshipAction.allCases.reversed() where set.contains(action.option) { - return action - } - - return nil - } - - public var title: String { - guard let highPriorityAction = self.highPriorityAction(except: []) else { - assertionFailure() - return " " - } - switch highPriorityAction { - case .isMyself: return "" - case .followingBy: return " " - case .blockingBy: return " " - case .none: return " " - case .follow: return L10n.Common.Controls.Friendship.follow - case .request: return L10n.Common.Controls.Friendship.request - case .pending: return L10n.Common.Controls.Friendship.pending - case .following: return L10n.Common.Controls.Friendship.following - case .muting: return L10n.Common.Controls.Friendship.muted - case .blocked: return L10n.Common.Controls.Friendship.follow // blocked by user (deprecated) - case .blocking: return L10n.Common.Controls.Friendship.blocked - case .suspended: return L10n.Common.Controls.Friendship.follow - case .edit: return L10n.Common.Controls.Friendship.editInfo - case .editing: return L10n.Common.Controls.Actions.done - case .updating: return " " - case .showReblogs: return " " - case .domainBlocking: return L10n.Common.Controls.Friendship.domainBlocked - } - } -} - -public final class RelationshipViewModel { - - var disposeBag = Set() - - public var userObserver: AnyCancellable? - public var meObserver: AnyCancellable? - - // input - @Published public var user: MastodonUser? - @Published public var me: MastodonUser? - public let relationshipUpdatePublisher = CurrentValueSubject(Void()) // needs initial event - - // output - @Published public var isMyself = false - @Published public var optionSet: RelationshipActionOptionSet? - - @Published public var isFollowing = false - @Published public var isFollowingBy = false - @Published public var isMuting = false - @Published public var showReblogs = false - @Published public var isBlocking = false - @Published public var isBlockingBy = false - @Published public var isSuspended = false - @Published public var isDomainBlocking = false - - public init() { - Publishers.CombineLatest3( - $user, - $me, - relationshipUpdatePublisher - ) - .receive(on: DispatchQueue.main) - .sink { [weak self] user, me, _ in - guard let self = self else { return } - self.update(user: user, me: me) - - guard let user = user, let me = me else { - self.userObserver = nil - self.meObserver = nil - return - } - - // do not modify object to prevent infinity loop - self.userObserver = RelationshipViewModel.createObjectChangePublisher(user: user) - .sink { [weak self] _ in - guard let self = self else { return } - self.relationshipUpdatePublisher.send() - } - - self.meObserver = RelationshipViewModel.createObjectChangePublisher(user: me) - .sink { [weak self] _ in - guard let self = self else { return } - self.relationshipUpdatePublisher.send() - } - } - .store(in: &disposeBag) - } - -} - -extension RelationshipViewModel { - - public static func createObjectChangePublisher(user: MastodonUser) -> AnyPublisher { - return ManagedObjectObserver - .observe(object: user) - .map { _ in Void() } - .catch { error in - return Just(Void()) - } - .eraseToAnyPublisher() - } - -} - -extension RelationshipViewModel { - private func update(user: MastodonUser?, me: MastodonUser?) { - guard let user, let me else { - reset() - return - } - - let optionSet = RelationshipViewModel.optionSet(user: user, me: me) - - self.isMyself = optionSet.contains(.isMyself) - self.isFollowingBy = optionSet.contains(.followingBy) - self.isFollowing = optionSet.contains(.following) - self.isMuting = optionSet.contains(.muting) - self.isBlockingBy = optionSet.contains(.blockingBy) - self.isBlocking = optionSet.contains(.blocking) - self.isSuspended = optionSet.contains(.suspended) - self.showReblogs = optionSet.contains(.showReblogs) - self.isDomainBlocking = optionSet.contains(.domainBlocking) - - self.optionSet = optionSet - } - - private func reset() { - isMyself = false - isFollowingBy = false - isFollowing = false - isMuting = false - isBlockingBy = false - isBlocking = false - optionSet = nil - showReblogs = false - isDomainBlocking = false - } -} - -extension RelationshipViewModel { - - public static func optionSet(user: MastodonUser, me: MastodonUser) -> RelationshipActionOptionSet { - let isMyself = user.id == me.id && user.domain == me.domain - guard !isMyself else { - return [.isMyself, .edit] - } - - let isProtected = user.locked - let isFollowingBy = me.followingBy.contains(user) - let isFollowing = user.followingBy.contains(me) - let isPending = user.followRequestedBy.contains(me) - let isMuting = user.mutingBy.contains(me) - let isBlockingBy = me.blockingBy.contains(user) - let isBlocking = user.blockingBy.contains(me) - let isShowingReblogs = me.showingReblogsBy.contains(user) - let isDomainBlocking = user.domainBlockingBy.contains(me) - - var optionSet: RelationshipActionOptionSet = [.follow] - - if isMyself { - optionSet.insert(.isMyself) - } - - if isProtected { - optionSet.insert(.request) - } - - if isFollowingBy { - optionSet.insert(.followingBy) - } - - if isFollowing { - optionSet.insert(.following) - } - - if isPending { - optionSet.insert(.pending) - } - - if isMuting { - optionSet.insert(.muting) - } - - if isBlockingBy { - optionSet.insert(.blockingBy) - } - - if isBlocking { - optionSet.insert(.blocking) - } - - if user.suspended { - optionSet.insert(.suspended) - } - - if isShowingReblogs { - optionSet.insert(.showReblogs) - } - - if isDomainBlocking { - optionSet.insert(.domainBlocking) - } - - return optionSet - } -} diff --git a/WidgetExtension/Variants/FollowersCount/FollowersCountWidget.swift b/WidgetExtension/Variants/FollowersCount/FollowersCountWidget.swift index 0882823f3..b7bcf1942 100644 --- a/WidgetExtension/Variants/FollowersCount/FollowersCountWidget.swift +++ b/WidgetExtension/Variants/FollowersCount/FollowersCountWidget.swift @@ -88,9 +88,7 @@ private extension FollowersCountWidgetProvider { } guard - let desiredAccount = configuration.account ?? authBox.authentication.user( - in: WidgetExtension.appContext.managedObjectContext - )?.acctWithDomain + let desiredAccount = configuration.account ?? authBox.authentication.account()?.acctWithDomain else { return completion(.unconfigured) } diff --git a/WidgetExtension/Variants/MultiFollowersCount/MultiFollowersCountWidget.swift b/WidgetExtension/Variants/MultiFollowersCount/MultiFollowersCountWidget.swift index 441403ddb..59cd257bd 100644 --- a/WidgetExtension/Variants/MultiFollowersCount/MultiFollowersCountWidget.swift +++ b/WidgetExtension/Variants/MultiFollowersCount/MultiFollowersCountWidget.swift @@ -91,9 +91,7 @@ private extension MultiFollowersCountWidgetProvider { if let configuredAccounts = configuration.accounts?.compactMap({ $0 }) { desiredAccounts = configuredAccounts - } else if let currentlyLoggedInAccount = authBox.authentication.user( - in: WidgetExtension.appContext.managedObjectContext - )?.acctWithDomain { + } else if let currentlyLoggedInAccount = authBox.authentication.account()?.acctWithDomain { desiredAccounts = [currentlyLoggedInAccount] } else { return completion(.unconfigured)