Make Profile-screen use Mastodon.Entity.Account instead of MastodonUser (#1192)

- Make Profile-screen use Mastodon.Entity.Account instead of
MastodonUser
- Fix things left and right
- Just like with the Status-refactoring I'm expecting more bugs to show
up over time.
This commit is contained in:
Nathan Mattes 2024-02-21 18:31:27 +01:00 committed by GitHub
commit db12ea4aa9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
173 changed files with 2389 additions and 6349 deletions

View File

@ -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 = "<group>"; };
2AE202AB297FE19100F66E55 /* FollowersCountIntentHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FollowersCountIntentHandler.swift; sourceTree = "<group>"; };
2AE244472927831100BDBF7C /* UIImage+SFSymbols.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIImage+SFSymbols.swift"; sourceTree = "<group>"; };
2AF2E7BE2B19DC6E00D98917 /* FileManager+Timeline.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FileManager+Timeline.swift"; sourceTree = "<group>"; };
2D198642261BF09500F0B013 /* SearchResultItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultItem.swift; sourceTree = "<group>"; };
2D198648261C0B8500F0B013 /* SearchResultSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultSection.swift; sourceTree = "<group>"; };
2D206B8525F5FB0900143C56 /* Double.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Double.swift; sourceTree = "<group>"; };
@ -794,6 +788,7 @@
D8099077294BC8A30050219F /* PrivacyTableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrivacyTableViewController.swift; sourceTree = "<group>"; };
D8099079294BC9390050219F /* PrivacyTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrivacyTableViewCell.swift; sourceTree = "<group>"; };
D809907B294D25510050219F /* PrivacyViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrivacyViewModel.swift; sourceTree = "<group>"; };
D80F627A2B5C32C500877059 /* NotificationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationView.swift; sourceTree = "<group>"; };
D81439852AD415DE0071A88F /* AboutInstance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutInstance.swift; sourceTree = "<group>"; };
D81439872AD450A40071A88F /* AboutInstanceTableViewDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutInstanceTableViewDataSource.swift; sourceTree = "<group>"; };
D81A22742AB4643200905D71 /* SearchResultsOverviewTableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultsOverviewTableViewController.swift; sourceTree = "<group>"; };
@ -839,14 +834,12 @@
D8A6FE6429325F5900666A47 /* StringsConvertor */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = StringsConvertor; sourceTree = "<group>"; };
D8A6FE6529325F5900666A47 /* ios-infoPlist.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "ios-infoPlist.json"; sourceTree = "<group>"; };
D8A6FE6629325F5900666A47 /* Localizable.stringsdict */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; path = Localizable.stringsdict; sourceTree = "<group>"; };
D8AC98752B0F61680045EC2B /* FileManager+SearchHistory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FileManager+SearchHistory.swift"; sourceTree = "<group>"; };
D8AC98782B0F622B0045EC2B /* SearchHistory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchHistory.swift; sourceTree = "<group>"; };
D8B5E4ED2A4EB8920008970C /* NotificationSettingTableViewToggleCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NotificationSettingTableViewToggleCell.swift; sourceTree = "<group>"; };
D8B5E4EF2A4EB8A00008970C /* NotificationSettingTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationSettingTableViewCell.swift; sourceTree = "<group>"; };
D8B5E4F12A4EBCF90008970C /* NotificationSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationSettings.swift; sourceTree = "<group>"; };
D8B5E4F32A4ED0240008970C /* NotificationSettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationSettingsViewModel.swift; sourceTree = "<group>"; };
D8BE30B22A179E26006B8270 /* SuggestionAccountTableViewFooter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionAccountTableViewFooter.swift; sourceTree = "<group>"; };
D8BEBCB52A1B7FFD0004F475 /* SuggestionAccountTableViewCell+ViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SuggestionAccountTableViewCell+ViewModel.swift"; sourceTree = "<group>"; };
D8CF45822B50893900C84D70 /* Tab.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tab.swift; sourceTree = "<group>"; };
D8D688F52AB869CB000F651A /* SearchResultsProfileTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultsProfileTableViewCell.swift; sourceTree = "<group>"; };
D8D688F82AB8B970000F651A /* SearchResultOverviewCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultOverviewCoordinator.swift; sourceTree = "<group>"; };
D8E5C345296DAB84007E76A7 /* DataSourceFacade+Status+History.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DataSourceFacade+Status+History.swift"; sourceTree = "<group>"; };
@ -1140,7 +1133,6 @@
DB98EB5F27B10E150082E365 /* ReportCommentTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportCommentTableViewCell.swift; sourceTree = "<group>"; };
DB98EB6127B215EB0082E365 /* ReportResultViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportResultViewController.swift; sourceTree = "<group>"; };
DB98EB6427B216500082E365 /* ReportResultViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportResultViewModel.swift; sourceTree = "<group>"; };
DB98EB6827B21A7C0082E365 /* ReportResultActionTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportResultActionTableViewCell.swift; sourceTree = "<group>"; };
DB9D6BE825E4F5340051B173 /* SearchViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchViewController.swift; sourceTree = "<group>"; };
DB9D6BF725E4F5690051B173 /* NotificationViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationViewController.swift; sourceTree = "<group>"; };
DB9D6BFE25E4F5940051B173 /* ProfileViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileViewController.swift; sourceTree = "<group>"; };
@ -1171,7 +1163,6 @@
DBA94435265CBB7400C537E1 /* ProfileFieldItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileFieldItem.swift; sourceTree = "<group>"; };
DBA9443D265CFA6400C537E1 /* ProfileFieldCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileFieldCollectionViewCell.swift; sourceTree = "<group>"; };
DBABE3EB25ECAC4B00879EE5 /* WelcomeIllustrationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WelcomeIllustrationView.swift; sourceTree = "<group>"; };
DBAE3FAE26172FC0004B8251 /* RemoteProfileViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteProfileViewModel.swift; sourceTree = "<group>"; };
DBB3BA2926A81C020004F2D4 /* FLAnimatedImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FLAnimatedImageView.swift; sourceTree = "<group>"; };
DBB45B5527B39FC9002DC5A7 /* MediaPreviewVideoViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPreviewVideoViewController.swift; sourceTree = "<group>"; };
DBB45B5827B39FE4002DC5A7 /* MediaPreviewVideoViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPreviewVideoViewModel.swift; sourceTree = "<group>"; };
@ -1184,7 +1175,6 @@
DBB5254F2611ED6D002F1F29 /* ProfileHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileHeaderView.swift; sourceTree = "<group>"; };
DBB525552611EDCA002F1F29 /* UserTimelineViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserTimelineViewModel.swift; sourceTree = "<group>"; };
DBB5255D2611F07A002F1F29 /* ProfileViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileViewModel.swift; sourceTree = "<group>"; };
DBB525632612C988002F1F29 /* MeProfileViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeProfileViewModel.swift; sourceTree = "<group>"; };
DBB8AB4526AECDE200F6D281 /* SendPostIntentHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SendPostIntentHandler.swift; sourceTree = "<group>"; };
DBB9759B262462E1004620BD /* ThreadMetaView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadMetaView.swift; sourceTree = "<group>"; };
DBBE1B4425F3474B0081417A /* MastodonPickServerAppearance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonPickServerAppearance.swift; sourceTree = "<group>"; };
@ -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 = "<group>";
};
D80F627E2B5C32E400877059 /* NotificationView */ = {
isa = PBXGroup;
children = (
D80F627A2B5C32C500877059 /* NotificationView.swift */,
DB63F776279A9A2A00455B82 /* NotificationView+Configuration.swift */,
);
path = NotificationView;
sourceTree = "<group>";
};
D81A22732AB4641F00905D71 /* Search Results Overview */ = {
isa = PBXGroup;
children = (
@ -1898,24 +1895,6 @@
path = Localization;
sourceTree = "<group>";
};
D8AC98742B0F615E0045EC2B /* Persistence */ = {
isa = PBXGroup;
children = (
D8AC98772B0F62230045EC2B /* Model */,
D8AC98752B0F61680045EC2B /* FileManager+SearchHistory.swift */,
2AF2E7BE2B19DC6E00D98917 /* FileManager+Timeline.swift */,
);
path = Persistence;
sourceTree = "<group>";
};
D8AC98772B0F62230045EC2B /* Model */ = {
isa = PBXGroup;
children = (
D8AC98782B0F622B0045EC2B /* SearchHistory.swift */,
);
path = Model;
sourceTree = "<group>";
};
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 = "<group>";
@ -2707,7 +2686,6 @@
DB98EB4827B0F0CD0082E365 /* ReportStatusTableViewCell.swift */,
DB98EB4B27B0F2BC0082E365 /* ReportStatusTableViewCell+ViewModel.swift */,
DB98EB5F27B10E150082E365 /* ReportCommentTableViewCell.swift */,
DB98EB6827B21A7C0082E365 /* ReportResultActionTableViewCell.swift */,
);
path = Cell;
sourceTree = "<group>";
@ -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 = "<group>";
@ -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 */,

View File

@ -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

View File

@ -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

View File

@ -13,7 +13,6 @@ enum ReportItem: Hashable {
case header(context: HeaderContext)
case status(record: MastodonStatus)
case comment(context: CommentContext)
case result(record: ManagedObjectRecord<MastodonUser>)
case bottomLoader
}

View File

@ -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()

View File

@ -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(

View File

@ -5,8 +5,6 @@
// Created by Marcus Kida on 17.11.22.
//
import CoreData
import CoreDataStack
import MastodonCore
import MastodonSDK

View File

@ -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
}
}
}

View File

@ -13,56 +13,40 @@ import MastodonSDK
extension DataSourceFacade {
static func responseToUserBlockAction(
dependency: NeedsDependency & AuthContextProvider,
user: ManagedObjectRecord<MastodonUser>
) 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<MastodonUser>
) 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
}
}

View File

@ -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<MastodonUser>
) 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<MastodonUser>
) 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)
}
}

View File

@ -28,30 +28,4 @@ extension DataSourceFacade {
transition: .show
)
}
@MainActor
static func coordinateToHashtagScene(
provider: DataSourceProvider & AuthContextProvider,
tag: ManagedObjectRecord<Tag>
) 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
)
}
}

View File

@ -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<MastodonUser>,
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
}

View File

@ -6,20 +6,28 @@
//
import UIKit
import CoreDataStack
import MastodonSDK
import MastodonCore
extension DataSourceFacade {
static func responseToUserMuteAction(
dependency: NeedsDependency & AuthContextProvider,
user: ManagedObjectRecord<MastodonUser>
) 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
}
}

View File

@ -41,58 +41,88 @@ extension DataSourceFacade {
assertionFailure()
return
}
await coordinateToProfileScene(
provider: provider,
account: redirectRecord
)
}
@MainActor
static func coordinateToProfileScene(
provider: ViewControllerWithDependencies & AuthContextProvider,
user: ManagedObjectRecord<MastodonUser>
) 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<MastodonUser>
) 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

View File

@ -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

View File

@ -137,18 +137,18 @@ extension DataSourceFacade {
extension DataSourceFacade {
struct MenuContext {
let author: ManagedObjectRecord<MastodonUser>? // 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<T>(
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<MastodonUser>? = try? await managedObjectContext.perform {
guard let user = menuContext.author?.object(in: managedObjectContext) else { return nil }
return ManagedObjectRecord<MastodonUser>(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<MastodonUser>? = try? await managedObjectContext.perform {
guard let user = menuContext.author?.object(in: managedObjectContext) else { return nil }
return ManagedObjectRecord<MastodonUser>(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<MastodonUser>? = try? await managedObjectContext.perform {
guard let user = menuContext.author?.object(in: managedObjectContext) else { return nil }
return ManagedObjectRecord<MastodonUser>(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<MastodonUser>? = try? await managedObjectContext.perform {
guard let user = menuContext.author?.object(in: managedObjectContext) else { return nil }
return ManagedObjectRecord<MastodonUser>(objectID: user.objectID)
}
guard let user = _user else { return }
try await DataSourceFacade.responseToDomainBlockAction(
dependency: dependency,
user: user
account: menuContext.author
)
}
}

View File

@ -9,105 +9,15 @@ import MastodonSDK
extension DataSourceFacade {
static func responseToUserViewButtonAction(
dependency: NeedsDependency & AuthContextProvider,
user: ManagedObjectRecord<MastodonUser>,
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
}

View File

@ -30,27 +30,42 @@ extension NotificationTableViewCellDelegate where Self: DataSourceProvider & Aut
assertionFailure("only works for status data provider")
return
}
let _author: ManagedObjectRecord<MastodonUser>? = 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<MastodonUser>? = 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

View File

@ -35,37 +35,23 @@ extension StatusTableViewCellDelegate where Self: DataSourceProvider & AuthConte
}
switch await statusView.viewModel.header {
case .none:
break
case .reply:
let _replyToAuthor: ManagedObjectRecord<MastodonUser>? = 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<MastodonUser>? = 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")
}
}

View File

@ -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<MastodonUser>? = 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 {

View File

@ -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<MastodonUser>)
case hashtag(tag: Mastodon.Entity.Tag)
case notification(record: MastodonNotification)
case account(account: Mastodon.Entity.Account, relationship: Mastodon.Entity.Relationship?)

View File

@ -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

View File

@ -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)

View File

@ -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)

View File

@ -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
}

View File

@ -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

View File

@ -46,7 +46,6 @@ final class DiscoveryPostsViewModel {
self.context = context
self.authContext = authContext
self.dataController = StatusDataController()
// end init
Task {
await checkServerEndpoint()

View File

@ -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()

View File

@ -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))
}
}

View File

@ -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

View File

@ -25,8 +25,8 @@ final class HomeTimelineViewController: UIViewController, NeedsDependency, Media
weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } }
var disposeBag = Set<AnyCancellable>()
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 }

View File

@ -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 }) }

View File

@ -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

View File

@ -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(

View File

@ -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
}
}
}

View File

@ -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)

View File

@ -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)

View File

@ -38,8 +38,6 @@ final class NotificationTimelineViewController: UIViewController, NeedsDependenc
}()
let cellFrameCache = NSCache<NSNumber, NSValue>()
}
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

View File

@ -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 {

View File

@ -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)

View File

@ -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
}
}
}

View File

@ -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] = []

View File

@ -21,7 +21,7 @@ final class NotificationViewController: TabmanViewController, NeedsDependency {
var disposeBag = Set<AnyCancellable>()
var observations = Set<NSKeyValueObservation>()
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

View File

@ -22,7 +22,6 @@ final class NotificationViewModel {
let viewDidLoad = PassthroughSubject<Void, Never>()
// output
let scopes = NotificationTimelineViewModel.Scope.allCases
@Published var viewControllers: [UIViewController] = []
@Published var currentPageIndex = 0 {
didSet {

View File

@ -139,30 +139,22 @@ class MastodonLoginViewController: UIViewController, NeedsDependency {
@objc func login() {
guard let server = viewModel.selectedServer else { return }
authenticationViewModel
.authenticated
.asyncMap { domain, user -> Result<Bool, Error> 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

View File

@ -187,7 +187,6 @@ extension AuthenticationViewModel {
userToken: Mastodon.Entity.Token
) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Account>, 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<Mastodon.Entity.Account> 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()

View File

@ -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,

View File

@ -58,7 +58,7 @@ extension FavoriteViewModel.State {
Task {
// reset
await viewModel.dataController.reset()
stateMachine.enter(Loading.self)
}
}

View File

@ -7,8 +7,6 @@
import Foundation
import Combine
import CoreData
import CoreDataStack
import GameplayKit
import MastodonSDK
import MastodonCore

View File

@ -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<AnyCancellable>()
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<MastodonUser> = .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<MastodonUser> = .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
)
}
}

View File

@ -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

View File

@ -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
}
}

View File

@ -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)
}

View File

@ -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

View File

@ -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?
}
}
}
}

View File

@ -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<MastodonUser>(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<MastodonUser> = .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<MastodonUser>(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<MastodonUser>(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<MastodonUser>(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<MastodonUser>(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<MastodonUser> = .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
}
}
}
}

View File

@ -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<Void, Never>()
@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<Bool, Never>(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<MastodonUser>? in
user.flatMap { ManagedObjectRecord<MastodonUser>(objectID: $0.objectID) }
}
let pendingRetryPublisher = CurrentValueSubject<TimeInterval, Never>(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<Mastodon.Response.Content<Mastodon.Entity.Account>, 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<MastodonUser>,
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
}
}

View File

@ -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<Mastodon.Entity.Account?> 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)
}
}

View File

@ -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

View File

@ -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)

View File

@ -41,6 +41,7 @@ extension UserListViewModel {
guard let diffableDataSource = self.diffableDataSource else { return }
var snapshot = NSDiffableDataSourceSnapshot<UserSection, UserItem>()
snapshot.appendSections([.main])
let accountsWithRelationship: [(account: Mastodon.Entity.Account, relationship: Mastodon.Entity.Relationship?)] = accounts.compactMap { account in

View File

@ -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
)
}

View File

@ -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
)

View File

@ -28,7 +28,8 @@ class ReportViewModel {
// input
let context: AppContext
let authContext: AuthContext
let user: ManagedObjectRecord<MastodonUser>
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<MastodonUser>,
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

View File

@ -25,9 +25,9 @@ final class ReportReasonViewController: UIViewController, NeedsDependency, Repor
var disposeBag = Set<AnyCancellable>()
private var observations = Set<NSKeyValueObservation>()
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()

View File

@ -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
)
}

View File

@ -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
}

View File

@ -23,7 +23,8 @@ class ReportResultViewModel: ObservableObject {
// input
let context: AppContext
let authContext: AuthContext
let user: ManagedObjectRecord<MastodonUser>
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<Void, Never>()
let followActionPublisher = PassthroughSubject<Void, Never>()
let blockActionPublisher = PassthroughSubject<Void, Never>()
@ -48,24 +48,22 @@ class ReportResultViewModel: ObservableObject {
init(
context: AppContext,
authContext: AuthContext,
user: ManagedObjectRecord<MastodonUser>,
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
}

View File

@ -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,

View File

@ -24,7 +24,7 @@ class ReportStatusViewModel {
// input
let context: AppContext
let authContext: AuthContext
let user: ManagedObjectRecord<MastodonUser>
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<MastodonUser>,
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

View File

@ -18,7 +18,7 @@ class ReportSupplementaryViewModel {
// Input
let context: AppContext
let authContext: AuthContext
let user: ManagedObjectRecord<MastodonUser>
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<MastodonUser>
account: Mastodon.Entity.Account
) {
self.context = context
self.authContext = authContext
self.user = user
self.account = account
// end init
Publishers.CombineLatest(

View File

@ -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<AnyCancellable>()
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

View File

@ -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)
}

View File

@ -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 {

View File

@ -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
}

View File

@ -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)
}

View File

@ -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<Section, Item>?
@ -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<SidebarListCollectionViewCell, MainTabBarController.Tab> { [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<SidebarListCollectionViewCell, Tab> { [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
}
}

View File

@ -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)
}
}

View File

@ -26,7 +26,7 @@ final class SearchViewController: UIViewController, NeedsDependency {
var searchTransitionController = SearchTransitionController()
var disposeBag = Set<AnyCancellable>()
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<String, Never>()
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

View File

@ -69,7 +69,6 @@ extension SearchResultOverviewCoordinator: SearchResultsOverviewTableViewControl
)
let authContext = self.authContext
let managedObjectContext = context.managedObjectContext
Task {
let searchResult = try await context.apiService.search(

View File

@ -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)) ?? []
}
}

View File

@ -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)) ?? []
}
}

View File

@ -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
)
}
}

View File

@ -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,

View File

@ -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

View File

@ -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)

View File

@ -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)

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -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)

View File

@ -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)
}
}
}
}

View File

@ -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)

View File

@ -121,7 +121,7 @@ final class SuggestionAccountViewModel: NSObject {
taskGroup.addTask {
try? await DataSourceFacade.responseToUserViewButtonAction(
dependency: dependency,
user: account,
account: account,
buttonState: .follow
)
}

View File

@ -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
}
}
}

View File

@ -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()

View File

@ -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

View File

@ -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)
}()
}

View File

@ -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

View File

@ -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<Account> {
let accounts = try await Account.fetch(in: managedObjectContext)
let accounts = try await Account.fetch()
return .init(items: accounts)
}

View File

@ -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) }

Some files were not shown because too many files have changed in this diff Show More