Merge pull request #1115 from mastodon/141-improve-search-flow

Better Search Workflow (IOS-141)
This commit is contained in:
Nathan Mattes 2023-09-20 19:07:27 +02:00 committed by GitHub
commit 40f20641bc
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
62 changed files with 1286 additions and 1570 deletions

View File

@ -651,16 +651,15 @@
}
},
"searching": {
"segment": {
"all": "All",
"people": "People",
"hashtags": "Hashtags",
"posts": "Posts"
},
"posts": "Posts with \"%@\"",
"people": "People with \"%@\"",
"profile": "Go to @%@@%@",
"url": "Open Link",
"empty_state": {
"no_results": "No results"
},
"recent_search": "Recent searches",
"clear_all": "Clear all",
"clear": "Clear"
}
},

View File

@ -651,15 +651,18 @@
}
},
"searching": {
"segment": {
"all": "All",
"people": "People",
"hashtags": "Hashtags",
"posts": "Posts"
},
"posts": "Posts matching \"%@\"",
"people": "People matching \"%@\"",
"hashtag": "Go to #%@",
"profile": "Go to @%@@%@",
"url": "Open Link",
"empty_state": {
"no_results": "No results"
},
"no_user": {
"title": "No User Account Found",
"message": "There's no Useraccount \"%@\" on %@"
}
"recent_search": "Recent searches",
"clear": "Clear"
}

View File

@ -139,6 +139,9 @@
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 */; };
D81A22752AB4643200905D71 /* SearchResultsOverviewTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D81A22742AB4643200905D71 /* SearchResultsOverviewTableViewController.swift */; };
D81A22782AB4782400905D71 /* SearchResultOverviewSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = D81A22772AB4782400905D71 /* SearchResultOverviewSection.swift */; };
D81A227B2AB47B9A00905D71 /* SearchResultDefaultSectionTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D81A227A2AB47B9A00905D71 /* SearchResultDefaultSectionTableViewCell.swift */; };
D8363B1629469CE200A74079 /* OnboardingNextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8363B1529469CE200A74079 /* OnboardingNextView.swift */; };
D87BFC8B291D5C6B00FEE264 /* MastodonLoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D87BFC8A291D5C6B00FEE264 /* MastodonLoginView.swift */; };
D87BFC8D291EB81200FEE264 /* MastodonLoginViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D87BFC8C291EB81200FEE264 /* MastodonLoginViewModel.swift */; };
@ -148,6 +151,8 @@
D8A6AB6C291C5136003AB663 /* MastodonLoginViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8A6AB6B291C5136003AB663 /* MastodonLoginViewController.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 */; };
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 */; };
D8E5C349296DB8A3007E76A7 /* StatusEditHistoryViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8E5C348296DB8A3007E76A7 /* StatusEditHistoryViewController.swift */; };
D8F0372C29D232730027DE2E /* HashtagIntentHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8F0372B29D232730027DE2E /* HashtagIntentHandler.swift */; };
@ -194,7 +199,6 @@
DB0FCB842796B2A2006C02E2 /* FavoriteViewController+DataSourceProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0FCB832796B2A2006C02E2 /* FavoriteViewController+DataSourceProvider.swift */; };
DB0FCB862796BDA1006C02E2 /* SearchSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0FCB852796BDA1006C02E2 /* SearchSection.swift */; };
DB0FCB882796BDA9006C02E2 /* SearchItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0FCB872796BDA9006C02E2 /* SearchItem.swift */; };
DB0FCB8C2796BF8D006C02E2 /* SearchViewModel+Diffable.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0FCB8B2796BF8D006C02E2 /* SearchViewModel+Diffable.swift */; };
DB0FCB8E2796C0B7006C02E2 /* TrendCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0FCB8D2796C0B7006C02E2 /* TrendCollectionViewCell.swift */; };
DB0FCB922796DE19006C02E2 /* TrendSectionHeaderCollectionReusableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0FCB912796DE19006C02E2 /* TrendSectionHeaderCollectionReusableView.swift */; };
DB0FCB942797E2B0006C02E2 /* SearchResultViewModel+Diffable.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0FCB932797E2B0006C02E2 /* SearchResultViewModel+Diffable.swift */; };
@ -253,7 +257,6 @@
DB4AA6B327BA34B6009EC082 /* CellFrameCacheContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB4AA6B227BA34B6009EC082 /* CellFrameCacheContainer.swift */; };
DB4F0963269ED06300D62E92 /* SearchResultViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB4F0962269ED06300D62E92 /* SearchResultViewController.swift */; };
DB4F0966269ED52200D62E92 /* SearchResultViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB4F0965269ED52200D62E92 /* SearchResultViewModel.swift */; };
DB4F0968269ED8AD00D62E92 /* SearchHistoryTableHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB4F0967269ED8AD00D62E92 /* SearchHistoryTableHeaderView.swift */; };
DB4F096A269EDAD200D62E92 /* SearchResultViewModel+State.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB4F0969269EDAD200D62E92 /* SearchResultViewModel+State.swift */; };
DB4F097526A037F500D62E92 /* SearchHistoryViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB4F097426A037F500D62E92 /* SearchHistoryViewModel.swift */; };
DB4F097B26A039FF00D62E92 /* SearchHistorySection.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB4F097A26A039FF00D62E92 /* SearchHistorySection.swift */; };
@ -298,7 +301,6 @@
DB63F74F2799405600455B82 /* SearchHistoryViewModel+Diffable.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB63F74E2799405600455B82 /* SearchHistoryViewModel+Diffable.swift */; };
DB63F752279944AA00455B82 /* SearchHistorySectionHeaderCollectionReusableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB63F751279944AA00455B82 /* SearchHistorySectionHeaderCollectionReusableView.swift */; };
DB63F7542799491600455B82 /* DataSourceFacade+SearchHistory.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB63F7532799491600455B82 /* DataSourceFacade+SearchHistory.swift */; };
DB63F75A279953F200455B82 /* SearchHistoryUserCollectionViewCell+ViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB63F759279953F200455B82 /* SearchHistoryUserCollectionViewCell+ViewModel.swift */; };
DB63F76227996B6600455B82 /* SearchHistoryViewController+DataSourceProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB63F76127996B6600455B82 /* SearchHistoryViewController+DataSourceProvider.swift */; };
DB63F764279A5E3C00455B82 /* NotificationTimelineViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB63F763279A5E3C00455B82 /* NotificationTimelineViewController.swift */; };
DB63F767279A5EB300455B82 /* NotificationTimelineViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB63F766279A5EB300455B82 /* NotificationTimelineViewModel.swift */; };
@ -776,6 +778,9 @@
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>"; };
D81A22742AB4643200905D71 /* SearchResultsOverviewTableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultsOverviewTableViewController.swift; sourceTree = "<group>"; };
D81A22772AB4782400905D71 /* SearchResultOverviewSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultOverviewSection.swift; sourceTree = "<group>"; };
D81A227A2AB47B9A00905D71 /* SearchResultDefaultSectionTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultDefaultSectionTableViewCell.swift; sourceTree = "<group>"; };
D82463522A52B47B00A3DBDD /* be */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = be; path = be.lproj/Intents.strings; sourceTree = "<group>"; };
D82463532A52B47B00A3DBDD /* be */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = be; path = be.lproj/WidgetExtension.strings; sourceTree = "<group>"; };
D82463542A52B47B00A3DBDD /* be */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = be; path = be.lproj/InfoPlist.strings; sourceTree = "<group>"; };
@ -799,6 +804,8 @@
D8A6FE6629325F5900666A47 /* Localizable.stringsdict */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; path = Localizable.stringsdict; 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>"; };
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>"; };
D8E5C348296DB8A3007E76A7 /* StatusEditHistoryViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusEditHistoryViewController.swift; sourceTree = "<group>"; };
D8F0372B29D232730027DE2E /* HashtagIntentHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HashtagIntentHandler.swift; sourceTree = "<group>"; };
@ -847,7 +854,6 @@
DB0FCB832796B2A2006C02E2 /* FavoriteViewController+DataSourceProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FavoriteViewController+DataSourceProvider.swift"; sourceTree = "<group>"; };
DB0FCB852796BDA1006C02E2 /* SearchSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchSection.swift; sourceTree = "<group>"; };
DB0FCB872796BDA9006C02E2 /* SearchItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchItem.swift; sourceTree = "<group>"; };
DB0FCB8B2796BF8D006C02E2 /* SearchViewModel+Diffable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SearchViewModel+Diffable.swift"; sourceTree = "<group>"; };
DB0FCB8D2796C0B7006C02E2 /* TrendCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrendCollectionViewCell.swift; sourceTree = "<group>"; };
DB0FCB912796DE19006C02E2 /* TrendSectionHeaderCollectionReusableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrendSectionHeaderCollectionReusableView.swift; sourceTree = "<group>"; };
DB0FCB932797E2B0006C02E2 /* SearchResultViewModel+Diffable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SearchResultViewModel+Diffable.swift"; sourceTree = "<group>"; };
@ -929,7 +935,6 @@
DB4B779626CA50BA00B087B3 /* th */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = th; path = th.lproj/Intents.stringsdict; sourceTree = "<group>"; };
DB4F0962269ED06300D62E92 /* SearchResultViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultViewController.swift; sourceTree = "<group>"; };
DB4F0965269ED52200D62E92 /* SearchResultViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultViewModel.swift; sourceTree = "<group>"; };
DB4F0967269ED8AD00D62E92 /* SearchHistoryTableHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchHistoryTableHeaderView.swift; sourceTree = "<group>"; };
DB4F0969269EDAD200D62E92 /* SearchResultViewModel+State.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SearchResultViewModel+State.swift"; sourceTree = "<group>"; };
DB4F097426A037F500D62E92 /* SearchHistoryViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchHistoryViewModel.swift; sourceTree = "<group>"; };
DB4F097A26A039FF00D62E92 /* SearchHistorySection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchHistorySection.swift; sourceTree = "<group>"; };
@ -989,7 +994,6 @@
DB63F74E2799405600455B82 /* SearchHistoryViewModel+Diffable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SearchHistoryViewModel+Diffable.swift"; sourceTree = "<group>"; };
DB63F751279944AA00455B82 /* SearchHistorySectionHeaderCollectionReusableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchHistorySectionHeaderCollectionReusableView.swift; sourceTree = "<group>"; };
DB63F7532799491600455B82 /* DataSourceFacade+SearchHistory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DataSourceFacade+SearchHistory.swift"; sourceTree = "<group>"; };
DB63F759279953F200455B82 /* SearchHistoryUserCollectionViewCell+ViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SearchHistoryUserCollectionViewCell+ViewModel.swift"; sourceTree = "<group>"; };
DB63F76127996B6600455B82 /* SearchHistoryViewController+DataSourceProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SearchHistoryViewController+DataSourceProvider.swift"; sourceTree = "<group>"; };
DB63F763279A5E3C00455B82 /* NotificationTimelineViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationTimelineViewController.swift; sourceTree = "<group>"; };
DB63F766279A5EB300455B82 /* NotificationTimelineViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationTimelineViewModel.swift; sourceTree = "<group>"; };
@ -1795,6 +1799,26 @@
path = Privacy;
sourceTree = "<group>";
};
D81A22732AB4641F00905D71 /* Search Results Overview */ = {
isa = PBXGroup;
children = (
D81A22792AB47B8400905D71 /* Cells */,
D81A22742AB4643200905D71 /* SearchResultsOverviewTableViewController.swift */,
D81A22772AB4782400905D71 /* SearchResultOverviewSection.swift */,
D8D688F82AB8B970000F651A /* SearchResultOverviewCoordinator.swift */,
);
path = "Search Results Overview";
sourceTree = "<group>";
};
D81A22792AB47B8400905D71 /* Cells */ = {
isa = PBXGroup;
children = (
D81A227A2AB47B9A00905D71 /* SearchResultDefaultSectionTableViewCell.swift */,
D8D688F52AB869CB000F651A /* SearchResultsProfileTableViewCell.swift */,
);
path = Cells;
sourceTree = "<group>";
};
D8A6AB68291C50F3003AB663 /* Login */ = {
isa = PBXGroup;
children = (
@ -2101,6 +2125,8 @@
DB4F0965269ED52200D62E92 /* SearchResultViewModel.swift */,
DB0FCB932797E2B0006C02E2 /* SearchResultViewModel+Diffable.swift */,
DB4F0969269EDAD200D62E92 /* SearchResultViewModel+State.swift */,
2D198648261C0B8500F0B013 /* SearchResultSection.swift */,
2D198642261BF09500F0B013 /* SearchResultItem.swift */,
);
path = SearchResult;
sourceTree = "<group>";
@ -2110,10 +2136,6 @@
children = (
DB0FCB852796BDA1006C02E2 /* SearchSection.swift */,
DB0FCB872796BDA9006C02E2 /* SearchItem.swift */,
2D198648261C0B8500F0B013 /* SearchResultSection.swift */,
2D198642261BF09500F0B013 /* SearchResultItem.swift */,
DB4F097A26A039FF00D62E92 /* SearchHistorySection.swift */,
DB4F097C26A03A5B00D62E92 /* SearchHistoryItem.swift */,
);
path = Search;
sourceTree = "<group>";
@ -2136,14 +2158,6 @@
path = Status;
sourceTree = "<group>";
};
DB4F098026A0475500D62E92 /* View */ = {
isa = PBXGroup;
children = (
DB4F0967269ED8AD00D62E92 /* SearchHistoryTableHeaderView.swift */,
);
path = View;
sourceTree = "<group>";
};
DB4FFC2D269EC39C00D62E92 /* Search */ = {
isa = PBXGroup;
children = (
@ -2282,7 +2296,6 @@
isa = PBXGroup;
children = (
DB63F74C27993F5B00455B82 /* SearchHistoryUserCollectionViewCell.swift */,
DB63F759279953F200455B82 /* SearchHistoryUserCollectionViewCell+ViewModel.swift */,
DB63F751279944AA00455B82 /* SearchHistorySectionHeaderCollectionReusableView.swift */,
);
path = Cell;
@ -2905,6 +2918,7 @@
DBF1D24F269DAF6100C1C08A /* SearchDetail */ = {
isa = PBXGroup;
children = (
D81A22732AB4641F00905D71 /* Search Results Overview */,
DB4F0964269ED06700D62E92 /* SearchResult */,
DBF1D252269DB01700C1C08A /* SearchHistory */,
DBF1D24D269DAF5D00C1C08A /* SearchDetailViewController.swift */,
@ -2917,11 +2931,12 @@
isa = PBXGroup;
children = (
DB63F7502799449300455B82 /* Cell */,
DB4F098026A0475500D62E92 /* View */,
DBF1D250269DB01200C1C08A /* SearchHistoryViewController.swift */,
DB63F76127996B6600455B82 /* SearchHistoryViewController+DataSourceProvider.swift */,
DB4F097426A037F500D62E92 /* SearchHistoryViewModel.swift */,
DB63F74E2799405600455B82 /* SearchHistoryViewModel+Diffable.swift */,
DB4F097A26A039FF00D62E92 /* SearchHistorySection.swift */,
DB4F097C26A03A5B00D62E92 /* SearchHistoryItem.swift */,
);
path = SearchHistory;
sourceTree = "<group>";
@ -2933,7 +2948,6 @@
2DE0FAC62615F5D200CDF649 /* View */,
DB9D6BE825E4F5340051B173 /* SearchViewController.swift */,
2D6DE3FF26141DF600A63F6A /* SearchViewModel.swift */,
DB0FCB8B2796BF8D006C02E2 /* SearchViewModel+Diffable.swift */,
);
path = Search;
sourceTree = "<group>";
@ -3544,8 +3558,8 @@
DB3E6FE02806A4ED00B035AE /* DiscoveryHashtagsViewModel.swift in Sources */,
2D38F1F725CD47AC00561493 /* HomeTimelineViewModel+LoadOldestState.swift in Sources */,
DB0FCB7427956939006C02E2 /* DataSourceFacade+Status.swift in Sources */,
D81A22782AB4782400905D71 /* SearchResultOverviewSection.swift in Sources */,
DBB525502611ED6D002F1F29 /* ProfileHeaderView.swift in Sources */,
DB63F75A279953F200455B82 /* SearchHistoryUserCollectionViewCell+ViewModel.swift in Sources */,
DB023D26279FFB0A005AC798 /* ShareActivityProvider.swift in Sources */,
5D0393962612D266007FE196 /* WebViewModel.swift in Sources */,
5B24BBDA262DB14800A9381B /* ReportViewModel.swift in Sources */,
@ -3593,7 +3607,6 @@
DB0FCB9C27980AB6006C02E2 /* HashtagTimelineViewController+DataSourceProvider.swift in Sources */,
DB63F76F279A7D1100455B82 /* NotificationTableViewCell.swift in Sources */,
2A1BF99529F7E68400FA1BA5 /* DataSourceFacade+UserView.swift in Sources */,
DB0FCB8C2796BF8D006C02E2 /* SearchViewModel+Diffable.swift in Sources */,
DBEFCD76282A143F00C0ABEA /* ReportStatusViewController.swift in Sources */,
DBDFF1952805561700557A48 /* DiscoveryPostsViewModel+Diffable.swift in Sources */,
DB03A795272A981400EE37C5 /* ContentSplitViewController.swift in Sources */,
@ -3698,8 +3711,8 @@
2D84350525FF858100EECE90 /* UIScrollView.swift in Sources */,
6213AF5A28939C8400BCADB6 /* BookmarkViewModel.swift in Sources */,
5B24BBDB262DB14800A9381B /* ReportStatusViewModel+Diffable.swift in Sources */,
DB4F0968269ED8AD00D62E92 /* SearchHistoryTableHeaderView.swift in Sources */,
5DA732CC2629CEF500A92342 /* UIView+Remove.swift in Sources */,
D81A22752AB4643200905D71 /* SearchResultsOverviewTableViewController.swift in Sources */,
2A506CF4292CD85800059C37 /* FollowedTagsViewController.swift in Sources */,
DB1D843026566512000346B3 /* KeyboardPreference.swift in Sources */,
DB852D1926FAEB6B00FC9D81 /* SidebarViewController.swift in Sources */,
@ -3796,8 +3809,10 @@
DB63F77B279ACAE500455B82 /* DataSourceFacade+Favorite.swift in Sources */,
DB9D6BF825E4F5690051B173 /* NotificationViewController.swift in Sources */,
2DAC9E46262FC9FD0062E1A6 /* SuggestionAccountTableViewCell.swift in Sources */,
D8D688F92AB8B970000F651A /* SearchResultOverviewCoordinator.swift in Sources */,
DB4FFC2C269EC39600D62E92 /* SearchTransitionController.swift in Sources */,
2A3D9B7E29A8F33A00F30313 /* StatusHistoryView.swift in Sources */,
D81A227B2AB47B9A00905D71 /* SearchResultDefaultSectionTableViewCell.swift in Sources */,
6213AF5E2893A8B200BCADB6 /* DataSourceFacade+Bookmark.swift in Sources */,
DBA5E7A9263BD3A4004598BB /* ContextMenuImagePreviewViewController.swift in Sources */,
DBF156E22702DA6900EC00B7 /* UIStatusBarManager+HandleTapAction.m in Sources */,
@ -3878,6 +3893,7 @@
DBFEEC99279BDCDE004F81DD /* ProfileAboutViewModel.swift in Sources */,
2D198649261C0B8500F0B013 /* SearchResultSection.swift in Sources */,
DB4F097B26A039FF00D62E92 /* SearchHistorySection.swift in Sources */,
D8D688F62AB869CB000F651A /* SearchResultsProfileTableViewCell.swift in Sources */,
2A82294F29262EE000D2A1F7 /* AppContext+NextAccount.swift in Sources */,
DBB525302611EBF3002F1F29 /* ProfilePagingViewModel.swift in Sources */,
DB9F58EC26EF435000E7BBE9 /* AccountViewController.swift in Sources */,

View File

@ -9,10 +9,13 @@ import UIKit
import MastodonCore
protocol NeedsDependency: AnyObject {
//FIXME: Get rid of ! ~@zeitschlag
var context: AppContext! { get set }
var coordinator: SceneCoordinator! { get set }
}
typealias ViewControllerWithDependencies = NeedsDependency & UIViewController
extension UISceneSession {
private struct AssociatedKeys {
static var sceneCoordinator = "SceneCoordinator"

View File

@ -153,6 +153,7 @@ extension SceneCoordinator {
// search
case searchDetail(viewModel: SearchDetailViewModel)
case searchResult(viewModel: SearchResultViewModel)
// compose
case compose(viewModel: ComposeViewModel)
@ -376,159 +377,169 @@ private extension SceneCoordinator {
let viewController: UIViewController?
switch scene {
case .welcome:
let _viewController = WelcomeViewController()
viewController = _viewController
case .mastodonPickServer(let viewModel):
let _viewController = MastodonPickServerViewController()
_viewController.viewModel = viewModel
viewController = _viewController
case .mastodonRegister(let viewModel):
let _viewController = MastodonRegisterViewController()
_viewController.viewModel = viewModel
viewController = _viewController
case .mastodonServerRules(let viewModel):
let _viewController = MastodonServerRulesViewController()
_viewController.viewModel = viewModel
viewController = _viewController
case .mastodonConfirmEmail(let viewModel):
let _viewController = MastodonConfirmEmailViewController()
_viewController.viewModel = viewModel
viewController = _viewController
case .mastodonLogin:
let loginViewController = MastodonLoginViewController(appContext: appContext,
authenticationViewModel: AuthenticationViewModel(context: appContext, coordinator: self, isAuthenticationExist: false),
sceneCoordinator: self)
loginViewController.delegate = self
case .welcome:
let _viewController = WelcomeViewController()
viewController = _viewController
case .mastodonPickServer(let viewModel):
let _viewController = MastodonPickServerViewController()
_viewController.viewModel = viewModel
viewController = _viewController
case .mastodonRegister(let viewModel):
let _viewController = MastodonRegisterViewController()
_viewController.viewModel = viewModel
viewController = _viewController
case .mastodonServerRules(let viewModel):
let _viewController = MastodonServerRulesViewController()
_viewController.viewModel = viewModel
viewController = _viewController
case .mastodonConfirmEmail(let viewModel):
let _viewController = MastodonConfirmEmailViewController()
_viewController.viewModel = viewModel
viewController = _viewController
case .mastodonLogin:
let loginViewController = MastodonLoginViewController(appContext: appContext,
authenticationViewModel: AuthenticationViewModel(context: appContext, coordinator: self, isAuthenticationExist: false),
sceneCoordinator: self)
loginViewController.delegate = self
viewController = loginViewController
case .mastodonPrivacyPolicies(let viewModel):
let privacyViewController = PrivacyTableViewController(context: appContext, coordinator: self, viewModel: viewModel)
viewController = privacyViewController
case .mastodonResendEmail(let viewModel):
let _viewController = MastodonResendEmailViewController()
_viewController.viewModel = viewModel
viewController = _viewController
case .mastodonWebView(let viewModel):
let _viewController = WebViewController()
_viewController.viewModel = viewModel
viewController = _viewController
case .searchDetail(let viewModel):
let _viewController = SearchDetailViewController()
_viewController.viewModel = viewModel
viewController = _viewController
case .compose(let viewModel):
let _viewController = ComposeViewController(viewModel: viewModel)
viewController = _viewController
case .thread(let viewModel):
let _viewController = ThreadViewController()
_viewController.viewModel = viewModel
viewController = _viewController
case .editHistory(let viewModel):
let editHistoryViewController = StatusEditHistoryViewController(viewModel: viewModel)
viewController = editHistoryViewController
case .hashtagTimeline(let viewModel):
let _viewController = HashtagTimelineViewController()
_viewController.viewModel = viewModel
viewController = _viewController
case .accountList(let viewModel):
let _viewController = AccountListViewController()
_viewController.viewModel = viewModel
viewController = _viewController
case .profile(let viewModel):
let _viewController = ProfileViewController()
_viewController.viewModel = viewModel
viewController = _viewController
case .bookmark(let viewModel):
let _viewController = BookmarkViewController()
_viewController.viewModel = viewModel
viewController = _viewController
case .followedTags(let viewModel):
let _viewController = FollowedTagsViewController()
_viewController.viewModel = viewModel
viewController = _viewController
case .favorite(let viewModel):
let _viewController = FavoriteViewController()
_viewController.viewModel = viewModel
viewController = _viewController
case .follower(let viewModel):
let _viewController = FollowerListViewController()
_viewController.viewModel = viewModel
viewController = _viewController
case .following(let viewModel):
let _viewController = FollowingListViewController()
_viewController.viewModel = viewModel
viewController = _viewController
case .familiarFollowers(let viewModel):
let _viewController = FamiliarFollowersViewController()
_viewController.viewModel = viewModel
viewController = _viewController
case .rebloggedBy(let viewModel):
let _viewController = RebloggedByViewController()
_viewController.viewModel = viewModel
viewController = _viewController
case .favoritedBy(let viewModel):
let _viewController = FavoritedByViewController()
_viewController.viewModel = viewModel
viewController = _viewController
case .report(let viewModel):
let _viewController = ReportViewController()
_viewController.viewModel = viewModel
viewController = _viewController
case .reportServerRules(let viewModel):
let _viewController = ReportServerRulesViewController()
_viewController.viewModel = viewModel
viewController = _viewController
case .reportStatus(let viewModel):
let _viewController = ReportStatusViewController()
_viewController.viewModel = viewModel
viewController = _viewController
case .reportSupplementary(let viewModel):
let _viewController = ReportSupplementaryViewController()
_viewController.viewModel = viewModel
viewController = _viewController
case .reportResult(let viewModel):
let _viewController = ReportResultViewController()
_viewController.viewModel = viewModel
viewController = _viewController
case .suggestionAccount(let viewModel):
let _viewController = SuggestionAccountViewController()
_viewController.viewModel = viewModel
viewController = _viewController
case .mediaPreview(let viewModel):
let _viewController = MediaPreviewViewController()
_viewController.viewModel = viewModel
viewController = _viewController
case .safari(let url):
guard let scheme = url.scheme?.lowercased(),
scheme == "http" || scheme == "https" else {
return nil
}
let _viewController = SFSafariViewController(url: url)
_viewController.preferredBarTintColor = ThemeService.shared.currentTheme.value.navigationBarBackgroundColor
_viewController.preferredControlTintColor = Asset.Colors.Brand.blurple.color
viewController = _viewController
viewController = loginViewController
case .mastodonPrivacyPolicies(let viewModel):
let privacyViewController = PrivacyTableViewController(context: appContext, coordinator: self, viewModel: viewModel)
viewController = privacyViewController
case .mastodonResendEmail(let viewModel):
let _viewController = MastodonResendEmailViewController()
_viewController.viewModel = viewModel
viewController = _viewController
case .mastodonWebView(let viewModel):
let _viewController = WebViewController()
_viewController.viewModel = viewModel
viewController = _viewController
case .alertController(let alertController):
if let popoverPresentationController = alertController.popoverPresentationController {
assert(
popoverPresentationController.sourceView != nil ||
popoverPresentationController.sourceRect != .zero ||
popoverPresentationController.barButtonItem != nil
)
}
viewController = alertController
case .activityViewController(let activityViewController, let sourceView, let barButtonItem):
activityViewController.popoverPresentationController?.sourceView = sourceView
activityViewController.popoverPresentationController?.barButtonItem = barButtonItem
viewController = activityViewController
case .settings(let viewModel):
let _viewController = SettingsViewController()
_viewController.viewModel = viewModel
viewController = _viewController
case .editStatus(let viewModel):
let composeViewController = ComposeViewController(viewModel: viewModel)
viewController = composeViewController
case .searchDetail(let viewModel):
let _viewController = SearchDetailViewController(appContext: appContext, sceneCoordinator: self, authContext: viewModel.authContext)
_viewController.viewModel = viewModel
viewController = _viewController
case .searchResult(let viewModel):
let searchResultViewController = SearchResultViewController()
searchResultViewController.context = appContext
searchResultViewController.coordinator = self
searchResultViewController.viewModel = viewModel
viewController = searchResultViewController
case .compose(let viewModel):
let _viewController = ComposeViewController(viewModel: viewModel)
viewController = _viewController
case .thread(let viewModel):
let _viewController = ThreadViewController()
_viewController.viewModel = viewModel
viewController = _viewController
case .editHistory(let viewModel):
let editHistoryViewController = StatusEditHistoryViewController(viewModel: viewModel)
viewController = editHistoryViewController
case .hashtagTimeline(let viewModel):
let _viewController = HashtagTimelineViewController()
_viewController.viewModel = viewModel
viewController = _viewController
case .accountList(let viewModel):
let _viewController = AccountListViewController()
_viewController.viewModel = viewModel
viewController = _viewController
case .profile(let viewModel):
let _viewController = ProfileViewController()
_viewController.viewModel = viewModel
viewController = _viewController
case .bookmark(let viewModel):
let _viewController = BookmarkViewController()
_viewController.viewModel = viewModel
viewController = _viewController
case .followedTags(let viewModel):
let _viewController = FollowedTagsViewController()
_viewController.viewModel = viewModel
viewController = _viewController
case .favorite(let viewModel):
let _viewController = FavoriteViewController()
_viewController.viewModel = viewModel
viewController = _viewController
case .follower(let viewModel):
let _viewController = FollowerListViewController()
_viewController.viewModel = viewModel
viewController = _viewController
case .following(let viewModel):
let _viewController = FollowingListViewController()
_viewController.viewModel = viewModel
viewController = _viewController
case .familiarFollowers(let viewModel):
let _viewController = FamiliarFollowersViewController()
_viewController.viewModel = viewModel
viewController = _viewController
case .rebloggedBy(let viewModel):
let _viewController = RebloggedByViewController()
_viewController.viewModel = viewModel
viewController = _viewController
case .favoritedBy(let viewModel):
let _viewController = FavoritedByViewController()
_viewController.viewModel = viewModel
viewController = _viewController
case .report(let viewModel):
let _viewController = ReportViewController()
_viewController.viewModel = viewModel
viewController = _viewController
case .reportServerRules(let viewModel):
let _viewController = ReportServerRulesViewController()
_viewController.viewModel = viewModel
viewController = _viewController
case .reportStatus(let viewModel):
let _viewController = ReportStatusViewController()
_viewController.viewModel = viewModel
viewController = _viewController
case .reportSupplementary(let viewModel):
let _viewController = ReportSupplementaryViewController()
_viewController.viewModel = viewModel
viewController = _viewController
case .reportResult(let viewModel):
let _viewController = ReportResultViewController()
_viewController.viewModel = viewModel
viewController = _viewController
case .suggestionAccount(let viewModel):
let _viewController = SuggestionAccountViewController()
_viewController.viewModel = viewModel
viewController = _viewController
case .mediaPreview(let viewModel):
let _viewController = MediaPreviewViewController()
_viewController.viewModel = viewModel
viewController = _viewController
case .safari(let url):
guard let scheme = url.scheme?.lowercased(),
scheme == "http" || scheme == "https" else {
return nil
}
let _viewController = SFSafariViewController(url: url)
_viewController.preferredBarTintColor = ThemeService.shared.currentTheme.value.navigationBarBackgroundColor
_viewController.preferredControlTintColor = Asset.Colors.Brand.blurple.color
viewController = _viewController
case .alertController(let alertController):
if let popoverPresentationController = alertController.popoverPresentationController {
assert(
popoverPresentationController.sourceView != nil ||
popoverPresentationController.sourceRect != .zero ||
popoverPresentationController.barButtonItem != nil
)
}
viewController = alertController
case .activityViewController(let activityViewController, let sourceView, let barButtonItem):
activityViewController.popoverPresentationController?.sourceView = sourceView
activityViewController.popoverPresentationController?.barButtonItem = barButtonItem
viewController = activityViewController
case .settings(let viewModel):
let _viewController = SettingsViewController()
_viewController.viewModel = viewModel
viewController = _viewController
case .editStatus(let viewModel):
let composeViewController = ComposeViewController(viewModel: viewModel)
viewController = composeViewController
}
setupDependency(for: viewController as? NeedsDependency)

View File

@ -39,30 +39,31 @@ extension UserSection {
return UITableViewDiffableDataSource(tableView: tableView) { tableView, indexPath, item -> UITableViewCell? in
switch item {
case .user(let record):
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: UserTableViewCell.self), for: indexPath) as! UserTableViewCell
context.managedObjectContext.performAndWait {
guard let user = record.object(in: context.managedObjectContext) else { return }
configure(
context: context,
authContext: authContext,
tableView: tableView,
cell: cell,
viewModel: UserTableViewCell.ViewModel(value: .user(user),
followedUsers: authContext.mastodonAuthenticationBox.inMemoryCache.$followingUserIds.eraseToAnyPublisher(),
blockedUsers: authContext.mastodonAuthenticationBox.inMemoryCache.$blockedUserIds.eraseToAnyPublisher(),
followRequestedUsers: authContext.mastodonAuthenticationBox.inMemoryCache.$followRequestedUserIDs.eraseToAnyPublisher()
),
configuration: configuration
)
}
return cell
case .bottomLoader:
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self), for: indexPath) as! TimelineBottomLoaderTableViewCell
cell.startAnimating()
return cell
case .bottomHeader(let text):
case .user(let record):
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: UserTableViewCell.self), for: indexPath) as! UserTableViewCell
context.managedObjectContext.performAndWait {
guard let user = record.object(in: context.managedObjectContext) else { return }
configure(
context: context,
authContext: authContext,
tableView: tableView,
cell: cell,
viewModel: UserTableViewCell.ViewModel(
user: user,
followedUsers: authContext.mastodonAuthenticationBox.inMemoryCache.$followingUserIds.eraseToAnyPublisher(),
blockedUsers: authContext.mastodonAuthenticationBox.inMemoryCache.$blockedUserIds.eraseToAnyPublisher(),
followRequestedUsers: authContext.mastodonAuthenticationBox.inMemoryCache.$followRequestedUserIDs.eraseToAnyPublisher()
),
configuration: configuration
)
}
return cell
case .bottomLoader:
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self), for: indexPath) as! TimelineBottomLoaderTableViewCell
cell.startAnimating()
return cell
case .bottomHeader(let text):
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: TimelineFooterTableViewCell.self), for: indexPath) as! TimelineFooterTableViewCell
cell.messageLabel.text = text
return cell

View File

@ -26,7 +26,7 @@ extension DataSourceFacade {
@MainActor
static func coordinateToHashtagScene(
provider: DataSourceProvider & AuthContextProvider,
provider: ViewControllerWithDependencies & AuthContextProvider,
tag: Mastodon.Entity.Tag
) async {
let hashtagTimelineViewModel = HashtagTimelineViewModel(

View File

@ -33,7 +33,7 @@ extension DataSourceFacade {
@MainActor
static func coordinateToProfileScene(
provider: DataSourceProvider & AuthContextProvider,
provider: ViewControllerWithDependencies & AuthContextProvider,
user: ManagedObjectRecord<MastodonUser>
) async {
guard let user = user.object(in: provider.context.managedObjectContext) else {
@ -127,227 +127,6 @@ extension DataSourceFacade {
let barButtonItem: UIBarButtonItem?
}
// @MainActor
// static func createProfileActionMenu(
// dependency: NeedsDependency,
// user: ManagedObjectRecord<MastodonUser>
// ) -> UIMenu {
// var children: [UIMenuElement] = []
// let name = mastodonUser.displayNameWithFallback
//
// if let shareUser = shareUser {
// let shareAction = UIAction(
// title: L10n.Common.Controls.Actions.shareUser(name),
// image: UIImage(systemName: "square.and.arrow.up"),
// identifier: nil,
// discoverabilityTitle: nil,
// attributes: [],
// state: .off
// ) { [weak provider, weak sourceView, weak barButtonItem] _ in
// guard let provider = provider else { return }
// let activityViewController = createActivityViewControllerForMastodonUser(mastodonUser: shareUser, dependency: provider)
// provider.coordinator.present(
// scene: .activityViewController(
// activityViewController: activityViewController,
// sourceView: sourceView,
// barButtonItem: barButtonItem
// ),
// from: provider,
// transition: .activityViewControllerPresent(animated: true, completion: nil)
// )
// }
// children.append(shareAction)
// }
//
// if let shareStatus = shareStatus {
// let shareAction = UIAction(
// title: L10n.Common.Controls.Actions.sharePost,
// image: UIImage(systemName: "square.and.arrow.up"),
// identifier: nil,
// discoverabilityTitle: nil,
// attributes: [],
// state: .off
// ) { [weak provider, weak sourceView, weak barButtonItem] _ in
// guard let provider = provider else { return }
// let activityViewController = createActivityViewControllerForMastodonUser(status: shareStatus, dependency: provider)
// provider.coordinator.present(
// scene: .activityViewController(
// activityViewController: activityViewController,
// sourceView: sourceView,
// barButtonItem: barButtonItem
// ),
// from: provider,
// transition: .activityViewControllerPresent(animated: true, completion: nil)
// )
// }
// children.append(shareAction)
// }
//
// if !isMyself {
// // mute
// let muteAction = UIAction(
// title: isMuting ? L10n.Common.Controls.Friendship.unmuteUser(name) : L10n.Common.Controls.Friendship.mute,
// image: isMuting ? UIImage(systemName: "speaker") : UIImage(systemName: "speaker.slash"),
// discoverabilityTitle: isMuting ? nil : L10n.Common.Controls.Friendship.muteUser(name),
// attributes: isMuting ? [] : .destructive,
// state: .off
// ) { [weak provider, weak cell] _ in
// guard let provider = provider else { return }
//
// UserProviderFacade.toggleUserMuteRelationship(
// provider: provider,
// cell: cell
// )
// .sink { _ in
// // do nothing
// } receiveValue: { _ in
// // do nothing
// }
// .store(in: &provider.context.disposeBag)
// }
// if isMuting {
// children.append(muteAction)
// } else {
// let muteMenu = UIMenu(title: L10n.Common.Controls.Friendship.muteUser(name), image: UIImage(systemName: "speaker.slash"), options: [], children: [muteAction])
// children.append(muteMenu)
// }
// }
//
// if !isMyself {
// // block
// let blockAction = UIAction(
// title: isBlocking ? L10n.Common.Controls.Friendship.unblockUser(name) : L10n.Common.Controls.Friendship.block,
// image: isBlocking ? UIImage(systemName: "hand.raised.slash") : UIImage(systemName: "hand.raised"),
// discoverabilityTitle: isBlocking ? nil : L10n.Common.Controls.Friendship.blockUser(name),
// attributes: isBlocking ? [] : .destructive,
// state: .off
// ) { [weak provider, weak cell] _ in
// guard let provider = provider else { return }
//
// UserProviderFacade.toggleUserBlockRelationship(
// provider: provider,
// cell: cell
// )
// .sink { _ in
// // do nothing
// } receiveValue: { _ in
// // do nothing
// }
// .store(in: &provider.context.disposeBag)
// }
// if isBlocking {
// children.append(blockAction)
// } else {
// let blockMenu = UIMenu(title: L10n.Common.Controls.Friendship.blockUser(name), image: UIImage(systemName: "hand.raised"), options: [], children: [blockAction])
// children.append(blockMenu)
// }
// }
//
// if !isMyself {
// let reportAction = UIAction(
// title: L10n.Common.Controls.Actions.reportUser(name),
// image: UIImage(systemName: "flag"),
// identifier: nil,
// discoverabilityTitle: nil,
// attributes: [],
// state: .off
// ) { [weak provider] _ in
// guard let provider = provider else { return }
// guard let authenticationBox = provider.context.authenticationService.activeMastodonAuthenticationBox.value else {
// return
// }
// let viewModel = ReportViewModel(
// context: provider.context,
// domain: authenticationBox.domain,
// user: mastodonUser,
// status: nil
// )
// provider.coordinator.present(
// scene: .report(viewModel: viewModel),
// from: provider,
// transition: .modal(animated: true, completion: nil)
// )
// }
// children.append(reportAction)
// }
//
// if !isInSameDomain {
// if isDomainBlocking {
// let unblockDomainAction = UIAction(
// title: L10n.Common.Controls.Actions.unblockDomain(mastodonUser.domainFromAcct),
// image: UIImage(systemName: "nosign"),
// identifier: nil,
// discoverabilityTitle: nil,
// attributes: [],
// state: .off
// ) { [weak provider, weak cell] _ in
// guard let provider = provider else { return }
// provider.context.blockDomainService.unblockDomain(userProvider: provider, cell: cell)
// }
// children.append(unblockDomainAction)
// } else {
// let blockDomainAction = UIAction(
// title: L10n.Common.Controls.Actions.blockDomain(mastodonUser.domainFromAcct),
// image: UIImage(systemName: "nosign"),
// identifier: nil,
// discoverabilityTitle: nil,
// attributes: [],
// state: .off
// ) { [weak provider, weak cell] _ in
// guard let provider = provider else { return }
//
// let alertController = UIAlertController(title: L10n.Common.Alerts.BlockDomain.title(mastodonUser.domainFromAcct), message: nil, preferredStyle: .alert)
// let cancelAction = UIAlertAction(title: L10n.Common.Controls.Actions.cancel, style: .default) { _ in }
// alertController.addAction(cancelAction)
// let blockDomainAction = UIAlertAction(title: L10n.Common.Alerts.BlockDomain.blockEntireDomain, style: .destructive) { [weak provider, weak cell] _ in
// guard let provider = provider else { return }
// provider.context.blockDomainService.blockDomain(userProvider: provider, cell: cell)
// }
// alertController.addAction(blockDomainAction)
// provider.present(alertController, animated: true, completion: nil)
// }
// children.append(blockDomainAction)
// }
// }
//
// if let status = shareStatus, isMyself {
// let deleteAction = UIAction(
// title: L10n.Common.Controls.Actions.delete,
// image: UIImage(systemName: "delete.left"),
// identifier: nil,
// discoverabilityTitle: nil,
// attributes: [.destructive],
// state: .off
// ) { [weak provider] _ in
// guard let provider = provider else { return }
//
// let alertController = UIAlertController(title: L10n.Common.Alerts.DeletePost.title, message: nil, preferredStyle: .alert)
// let cancelAction = UIAlertAction(title: L10n.Common.Controls.Actions.cancel, style: .default) { _ in }
// alertController.addAction(cancelAction)
// let deleteAction = UIAlertAction(title: L10n.Common.Alerts.DeletePost.delete, style: .destructive) { [weak provider] _ in
// guard let provider = provider else { return }
// guard let activeMastodonAuthenticationBox = provider.context.authenticationService.activeMastodonAuthenticationBox.value else { return }
// provider.context.apiService.deleteStatus(
// domain: activeMastodonAuthenticationBox.domain,
// statusID: status.id,
// authorizationBox: activeMastodonAuthenticationBox
// )
// .sink { _ in
// // do nothing
// } receiveValue: { _ in
// // do nothing
// }
// .store(in: &provider.context.disposeBag)
// }
// alertController.addAction(deleteAction)
// provider.present(alertController, animated: true, completion: nil)
// }
// children.append(deleteAction)
// }
//
// return UIMenu(title: "", options: [], children: children)
// }
static func createActivityViewController(
dependency: NeedsDependency,
user: ManagedObjectRecord<MastodonUser>

View File

@ -8,11 +8,12 @@
import Foundation
import CoreDataStack
import MastodonCore
import UIKit
extension DataSourceFacade {
static func responseToCreateSearchHistory(
provider: DataSourceProvider & AuthContextProvider,
provider: ViewControllerWithDependencies & AuthContextProvider,
item: DataSourceItem
) async {
switch item {

View File

@ -5,14 +5,14 @@
// Created by MainasuK on 2022-1-17.
//
import Foundation
import UIKit
import CoreData
import CoreDataStack
import MastodonCore
extension DataSourceFacade {
static func coordinateToStatusThreadScene(
provider: DataSourceProvider & AuthContextProvider,
provider: ViewControllerWithDependencies & AuthContextProvider,
target: StatusTarget,
status: ManagedObjectRecord<Status>
) async {
@ -40,7 +40,7 @@ extension DataSourceFacade {
@MainActor
static func coordinateToStatusThreadScene(
provider: DataSourceProvider & AuthContextProvider,
provider: ViewControllerWithDependencies & AuthContextProvider,
root: StatusItem.Thread
) async {
let threadViewModel = ThreadViewModel(

View File

@ -352,10 +352,7 @@ extension StatusTableViewCellDelegate where Self: DataSourceProvider & AuthConte
choices: [choice],
authenticationBox: authContext.mastodonAuthenticationBox
)
logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): vote poll for \(choice) success")
} catch {
logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): vote poll fail: \(error.localizedDescription)")
// restore voting state
try await managedObjectContext.performChanges {
guard
@ -411,10 +408,7 @@ extension StatusTableViewCellDelegate where Self: DataSourceProvider & AuthConte
choices: choices,
authenticationBox: authContext.mastodonAuthenticationBox
)
logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): vote poll for \(choices) success")
} catch {
logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): vote poll fail: \(error.localizedDescription)")
// restore voting state
try await managedObjectContext.performChanges {
guard let poll = poll.object(in: managedObjectContext) else { return }

View File

@ -15,7 +15,6 @@ import MastodonLocalization
extension UITableViewDelegate where Self: DataSourceProvider & AuthContextProvider {
func aspectTableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): indexPath: \(indexPath.debugDescription)")
Task {
let source = DataSourceItem.Source(tableViewCell: nil, indexPath: indexPath)
guard let item = await item(from: source) else {
@ -77,7 +76,6 @@ extension UITableViewDelegate where Self: DataSourceProvider & MediaPreviewableV
contextMenuConfigurationForRowAt
indexPath: IndexPath, point: CGPoint
) -> UIContextMenuConfiguration? {
logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public)")
guard let cell = tableView.cellForRow(at: indexPath) as? StatusViewContainerTableViewCell else { return nil }
@ -238,7 +236,6 @@ extension UITableViewDelegate where Self: DataSourceProvider & MediaPreviewableV
willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration,
animator: UIContextMenuInteractionCommitAnimating
) {
logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public)")
guard let configuration = configuration as? TimelineTableViewCellContextMenuConfiguration else { return }
guard let indexPath = configuration.indexPath, let index = configuration.index else { return }

View File

@ -44,7 +44,6 @@ extension DataSourceItem {
}
}
protocol DataSourceProvider: NeedsDependency & UIViewController {
var logger: Logger { get }
protocol DataSourceProvider: ViewControllerWithDependencies {
func item(from source: DataSourceItem.Source) async -> DataSourceItem?
}

View File

@ -41,13 +41,8 @@ extension HomeTimelineViewModel {
.sink { [weak self] records in
guard let self = self else { return }
guard let diffableDataSource = self.diffableDataSource else { return }
self.logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): incoming \(records.count) objects")
Task { @MainActor in
let start = CACurrentMediaTime()
defer {
let end = CACurrentMediaTime()
self.logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): cost \(end - start, format: .fixed(precision: 4))s to process \(records.count) feeds")
}
let oldSnapshot = diffableDataSource.snapshot()
var newSnapshot: NSDiffableDataSourceSnapshot<StatusSection, StatusItem> = {
let newItems = records.map { record in
@ -92,11 +87,8 @@ extension HomeTimelineViewModel {
let hasChanges = newSnapshot.itemIdentifiers != oldSnapshot.itemIdentifiers
if !hasChanges && !self.hasPendingStatusEditReload {
self.logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): snapshot not changes")
self.didLoadLatest.send()
return
} else {
self.logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): snapshot has changes")
}
guard let difference = self.calculateReloadSnapshotDifference(
@ -106,7 +98,6 @@ extension HomeTimelineViewModel {
) else {
self.updateSnapshotUsingReloadData(snapshot: newSnapshot)
self.didLoadLatest.send()
self.logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): applied new snapshot")
return
}
@ -116,7 +107,6 @@ extension HomeTimelineViewModel {
contentOffset.y = tableView.contentOffset.y - difference.sourceDistanceToTableViewTopEdge
tableView.setContentOffset(contentOffset, animated: false)
self.didLoadLatest.send()
self.logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): applied new snapshot")
self.hasPendingStatusEditReload = false
} // end Task
}

View File

@ -21,9 +21,6 @@ final class HeightFixedSearchBar: UISearchBar {
}
final class SearchViewController: UIViewController, NeedsDependency {
let logger = Logger(subsystem: "SearchViewController", category: "ViewController")
weak var context: AppContext! { willSet { precondition(!isViewLoaded) } }
weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } }
@ -37,16 +34,6 @@ final class SearchViewController: UIViewController, NeedsDependency {
let titleViewContainer = UIView()
let searchBar = HeightFixedSearchBar()
// let collectionView: UICollectionView = {
// var configuration = UICollectionLayoutListConfiguration(appearance: .insetGrouped)
// configuration.backgroundColor = .clear
// configuration.headerMode = .supplementary
// let layout = UICollectionViewCompositionalLayout.list(using: configuration)
// let collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout)
// collectionView.backgroundColor = .clear
// return collectionView
// }()
// value is the initial search text to set
let searchBarTapPublisher = PassthroughSubject<String, Never>()
@ -62,11 +49,6 @@ final class SearchViewController: UIViewController, NeedsDependency {
)
return viewController
}()
deinit {
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
}
}
extension SearchViewController {
@ -85,30 +67,12 @@ extension SearchViewController {
title = L10n.Scene.Search.title
setupSearchBar()
// collectionView.translatesAutoresizingMaskIntoConstraints = false
// view.addSubview(collectionView)
// NSLayoutConstraint.activate([
// collectionView.topAnchor.constraint(equalTo: view.topAnchor),
// collectionView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
// collectionView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
// collectionView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
// ])
//
// collectionView.delegate = self
// viewModel.setupDiffableDataSource(
// collectionView: collectionView
// )
guard let discoveryViewController = self.discoveryViewController else { return }
addChild(discoveryViewController)
discoveryViewController.view.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(discoveryViewController.view)
discoveryViewController.view.pinToParent()
// discoveryViewController.view.isHidden = true
}
override func viewDidAppear(_ animated: Bool) {
@ -171,7 +135,6 @@ extension SearchViewController {
// MARK: - UISearchBarDelegate
extension SearchViewController: UISearchBarDelegate {
func searchBarShouldBeginEditing(_ searchBar: UISearchBar) -> Bool {
os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
searchBarTapPublisher.send("")
return false
}
@ -184,12 +147,8 @@ extension SearchViewController: UISearchBarDelegate {
// MARK: - UISearchControllerDelegate
extension SearchViewController: UISearchControllerDelegate {
func willDismissSearchController(_ searchController: UISearchController) {
logger.debug("\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public)")
searchController.isActive = true
}
func didPresentSearchController(_ searchController: UISearchController) {
logger.debug("\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public)")
}
}
// MARK: - ScrollViewContainer
@ -201,23 +160,3 @@ extension SearchViewController: ScrollViewContainer {
discoveryViewController?.scrollToTop(animated: animated)
}
}
// MARK: - UICollectionViewDelegate
//extension SearchViewController: UICollectionViewDelegate {
// func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
// logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): select item at: \(indexPath.debugDescription)")
//
// defer {
// collectionView.deselectItem(at: indexPath, animated: true)
// }
//
// guard let diffableDataSource = viewModel.diffableDataSource else { return }
// guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return }
//
// switch item {
// case .trend(let hashtag):
// let viewModel = HashtagTimelineViewModel(context: context, hashtag: hashtag.name)
// coordinator.present(scene: .hashtagTimeline(viewModel: viewModel), from: self, transition: .show)
// }
// }
//}

View File

@ -1,42 +0,0 @@
//
// SearchViewModel+Diffable.swift
// Mastodon
//
// Created by MainasuK on 2022-1-18.
//
import UIKit
import MastodonSDK
//extension SearchViewModel {
//
// func setupDiffableDataSource(
// collectionView: UICollectionView
// ) {
// diffableDataSource = SearchSection.diffableDataSource(
// collectionView: collectionView,
// context: context
// )
//
// var snapshot = NSDiffableDataSourceSnapshot<SearchSection, SearchItem>()
// snapshot.appendSections([.trend])
// diffableDataSource?.apply(snapshot)
//
// $hashtags
// .receive(on: DispatchQueue.main)
// .sink { [weak self] hashtags in
// guard let self = self else { return }
// guard let diffableDataSource = self.diffableDataSource else { return }
//
// var snapshot = NSDiffableDataSourceSnapshot<SearchSection, SearchItem>()
// snapshot.appendSections([.trend])
//
// let trendItems = hashtags.map { SearchItem.trend($0) }
// snapshot.appendItems(trendItems, toSection: .trend)
//
// diffableDataSource.apply(snapshot)
// }
// .store(in: &disposeBag)
// }
//
//}

View File

@ -31,32 +31,5 @@ final class SearchViewModel: NSObject {
self.context = context
self.authContext = authContext
super.init()
// Publishers.CombineLatest(
// context.authenticationService.activeMastodonAuthenticationBox,
// viewDidAppeared
// )
// .compactMap { authenticationBox, _ -> MastodonAuthenticationBox? in
// return authenticationBox
// }
// .throttle(for: 3, scheduler: DispatchQueue.main, latest: true)
// .asyncMap { authenticationBox in
// try await context.apiService.trendHashtags(domain: authenticationBox.domain, query: nil)
// }
// .retry(3)
// .map { response in Result<Mastodon.Response.Content<[Mastodon.Entity.Tag]>, Error> { response } }
// .catch { error in Just(Result<Mastodon.Response.Content<[Mastodon.Entity.Tag]>, Error> { throw error }) }
// .receive(on: DispatchQueue.main)
// .sink { [weak self] result in
// guard let self = self else { return }
// switch result {
// case .success(let response):
// self.hashtags = response.value
// case .failure:
// break
// }
// }
// .store(in: &disposeBag)
}
}

View File

@ -0,0 +1,34 @@
// Copyright © 2023 Mastodon gGmbH. All rights reserved.
import UIKit
import MastodonAsset
class SearchResultDefaultSectionTableViewCell: UITableViewCell {
static let reuseIdentifier = "SearchResultDefaultSectionTableViewCell"
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
backgroundColor = .secondarySystemGroupedBackground
}
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
func configure(item: SearchResultOverviewItem.DefaultSectionEntry) {
var content = UIListContentConfiguration.cell()
content.image = item.icon
content.text = item.title
content.imageProperties.tintColor = Asset.Colors.Brand.blurple.color
contentConfiguration = content
}
func configure(item: SearchResultOverviewItem.SuggestionSectionEntry) {
var content = UIListContentConfiguration.cell()
content.image = item.icon
content.text = item.title
content.imageProperties.tintColor = Asset.Colors.Brand.blurple.color
contentConfiguration = content
}
}

View File

@ -0,0 +1,30 @@
// Copyright © 2023 Mastodon gGmbH. All rights reserved.
import UIKit
import MastodonUI
class SearchResultsProfileTableViewCell: UITableViewCell {
static let reuseIdentifier = "SearchResultsProfileTableViewCell"
let condensedUserView: CondensedUserView
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
condensedUserView = CondensedUserView(frame: .zero)
condensedUserView.translatesAutoresizingMaskIntoConstraints = false
super.init(style: style, reuseIdentifier: reuseIdentifier)
contentView.addSubview(condensedUserView)
condensedUserView.pinToParent()
backgroundColor = .secondarySystemGroupedBackground
}
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
override func prepareForReuse() {
super.prepareForReuse()
condensedUserView.prepareForReuse()
}
}

View File

@ -0,0 +1,171 @@
// Copyright © 2023 Mastodon gGmbH. All rights reserved.
import UIKit
import MastodonCore
import MastodonSDK
import MastodonLocalization
protocol Coordinator {
func start()
}
class SearchResultOverviewCoordinator: Coordinator {
let overviewViewController: SearchResultsOverviewTableViewController
let sceneCoordinator: SceneCoordinator
let context: AppContext
let authContext: AuthContext
var activeTask: Task<Void, Never>?
init(appContext: AppContext, authContext: AuthContext, sceneCoordinator: SceneCoordinator) {
self.sceneCoordinator = sceneCoordinator
self.context = appContext
self.authContext = authContext
overviewViewController = SearchResultsOverviewTableViewController(appContext: appContext, authContext: authContext, sceneCoordinator: sceneCoordinator)
}
func start() {
overviewViewController.delegate = self
}
}
extension SearchResultOverviewCoordinator: SearchResultsOverviewTableViewControllerDelegate {
@MainActor
func searchForPosts(_ viewController: SearchResultsOverviewTableViewController, withSearchText searchText: String) {
let searchResultViewModel = SearchResultViewModel(context: context, authContext: authContext, searchScope: .posts, searchText: searchText)
sceneCoordinator.present(scene: .searchResult(viewModel: searchResultViewModel), transition: .show)
}
func showPosts(_ viewController: SearchResultsOverviewTableViewController, tag: Mastodon.Entity.Tag) {
Task {
await DataSourceFacade.coordinateToHashtagScene(
provider: viewController,
tag: tag
)
await DataSourceFacade.responseToCreateSearchHistory(provider: viewController,
item: .hashtag(tag: .entity(tag)))
}
}
@MainActor
func searchForPeople(_ viewController: SearchResultsOverviewTableViewController, withName searchText: String) {
let searchResultViewModel = SearchResultViewModel(context: context, authContext: authContext, searchScope: .people, searchText: searchText)
sceneCoordinator.present(scene: .searchResult(viewModel: searchResultViewModel), transition: .show)
}
func goTo(_ viewController: SearchResultsOverviewTableViewController, urlString: String) {
let query = Mastodon.API.V2.Search.Query(
q: urlString,
type: .default,
resolve: true
)
let authContext = self.authContext
let managedObjectContext = context.managedObjectContext
Task {
let searchResult = try await context.apiService.search(
query: query,
authenticationBox: authContext.mastodonAuthenticationBox
).value
if let account = searchResult.accounts.first {
showProfile(viewController, for: account)
} else if let status = searchResult.statuses.first {
let status = try await managedObjectContext.perform {
return Persistence.Status.fetch(in: managedObjectContext, context: Persistence.Status.PersistContext(
domain: authContext.mastodonAuthenticationBox.domain,
entity: status,
me: authContext.mastodonAuthenticationBox.authenticationRecord.object(in: managedObjectContext)?.user,
statusCache: nil,
userCache: nil,
networkDate: Date()))
}
guard let status else { return }
await DataSourceFacade.coordinateToStatusThreadScene(
provider: viewController,
target: .status, // remove reblog wrapper
status: status.asRecord
)
} else if let url = URL(string: urlString) {
let prefixedURL: URL?
if var components = URLComponents(url: url, resolvingAgainstBaseURL: false) {
if components.scheme == nil {
components.scheme = "https"
}
prefixedURL = components.url
} else {
prefixedURL = url
}
guard let prefixedURL else { return }
await sceneCoordinator.present(scene: .safari(url: prefixedURL), transition: .safariPresent(animated: true))
}
}
}
func showProfile(_ viewController: SearchResultsOverviewTableViewController, for account: Mastodon.Entity.Account) {
let managedObjectContext = context.managedObjectContext
let domain = authContext.mastodonAuthenticationBox.domain
Task {
let user = try await managedObjectContext.perform {
return Persistence.MastodonUser.fetch(in: managedObjectContext,
context: Persistence.MastodonUser.PersistContext(
domain: domain,
entity: account,
cache: nil,
networkDate: Date()
))
}
if let user {
await DataSourceFacade.coordinateToProfileScene(provider: viewController,
user: user.asRecord)
await DataSourceFacade.responseToCreateSearchHistory(provider: viewController,
item: .user(record: user.asRecord))
}
}
}
func searchForPerson(_ viewController: SearchResultsOverviewTableViewController, username: String, domain: String) {
let acct = "\(username)@\(domain)"
let query = Mastodon.API.V2.Search.Query(
q: acct,
type: .accounts,
resolve: true
)
Task {
let searchResult = try await context.apiService.search(
query: query,
authenticationBox: authContext.mastodonAuthenticationBox
).value
if let account = searchResult.accounts.first(where: { $0.acctWithDomainIfMissing(domain).lowercased() == acct.lowercased() }) {
showProfile(viewController, for: account)
} else {
await MainActor.run {
let alertTitle = L10n.Scene.Search.Searching.NoUser.title
let alertMessage = L10n.Scene.Search.Searching.NoUser.message(username, domain)
let alertController = UIAlertController(title: alertTitle, message: alertMessage, preferredStyle: .alert)
let okAction = UIAlertAction(title: L10n.Common.Controls.Actions.ok, style: .default)
alertController.addAction(okAction)
sceneCoordinator.present(scene: .alertController(alertController: alertController), transition: .alertController(animated: true))
}
}
}
}
}

View File

@ -0,0 +1,75 @@
// Copyright © 2023 Mastodon gGmbH. All rights reserved.
import UIKit
import MastodonSDK
import MastodonLocalization
import CoreDataStack
enum SearchResultOverviewSection: Hashable {
case `default`
case suggestions
}
enum SearchResultOverviewItem: Hashable {
case `default`(DefaultSectionEntry)
case suggestion(SuggestionSectionEntry)
enum DefaultSectionEntry: Hashable {
case showHashtag(hashtag: String)
case posts(matching: String)
case people(matching: String)
case showProfile(username: String, domain: String)
case openLink(String)
var title: String {
switch self {
case .posts(let text):
return L10n.Scene.Search.Searching.posts(text)
case .people(let username):
return L10n.Scene.Search.Searching.people(username)
case .showProfile(let username, let instanceName):
return L10n.Scene.Search.Searching.profile(username, instanceName)
case .openLink(_):
return L10n.Scene.Search.Searching.url
case .showHashtag(let hashtag):
return L10n.Scene.Search.Searching.hashtag(hashtag)
}
}
var icon: UIImage? {
switch self {
case .posts(_):
return UIImage(systemName: "magnifyingglass")
case .people(_):
return UIImage(systemName: "person.2")
case .showProfile(_, _):
return UIImage(systemName: "person.crop.circle")
case .openLink(_):
return UIImage(systemName: "link")
case .showHashtag(_):
return UIImage(systemName: "number")
}
}
}
enum SuggestionSectionEntry: Hashable {
case hashtag(tag: Mastodon.Entity.Tag)
case profile(user: Mastodon.Entity.Account)
var title: String? {
if case let .hashtag(tag) = self {
return tag.name
} else {
return nil
}
}
var icon: UIImage? {
if case .hashtag(_) = self {
return UIImage(systemName: "number")
} else {
return nil
}
}
}
}

View File

@ -0,0 +1,229 @@
// Copyright © 2023 Mastodon gGmbH. All rights reserved.
import UIKit
import MastodonCore
import MastodonSDK
import MastodonLocalization
import MastodonUI
protocol SearchResultsOverviewTableViewControllerDelegate: AnyObject {
func goTo(_ viewController: SearchResultsOverviewTableViewController, urlString: String)
func showPosts(_ viewController: SearchResultsOverviewTableViewController, tag: Mastodon.Entity.Tag)
func searchForPosts(_ viewController: SearchResultsOverviewTableViewController, withSearchText searchText: String)
func searchForPeople(_ viewController: SearchResultsOverviewTableViewController, withName searchText: String)
func showProfile(_ viewController: SearchResultsOverviewTableViewController, for account: Mastodon.Entity.Account)
func searchForPerson(_ viewController: SearchResultsOverviewTableViewController, username: String, domain: String)
}
class SearchResultsOverviewTableViewController: UIViewController, NeedsDependency, AuthContextProvider {
let authContext: AuthContext
var context: AppContext!
var coordinator: SceneCoordinator!
private let tableView: UITableView
var dataSource: UITableViewDiffableDataSource<SearchResultOverviewSection, SearchResultOverviewItem>?
weak var delegate: SearchResultsOverviewTableViewControllerDelegate?
var activeTask: Task<Void, Never>?
init(appContext: AppContext, authContext: AuthContext, sceneCoordinator: SceneCoordinator) {
self.authContext = authContext
self.context = appContext
self.coordinator = sceneCoordinator
tableView = UITableView(frame: .zero, style: .insetGrouped)
tableView.translatesAutoresizingMaskIntoConstraints = false
tableView.backgroundColor = .systemGroupedBackground
tableView.separatorInset.left = 62
tableView.register(SearchResultDefaultSectionTableViewCell.self, forCellReuseIdentifier: SearchResultDefaultSectionTableViewCell.reuseIdentifier)
tableView.register(StatusTableViewCell.self, forCellReuseIdentifier: StatusTableViewCell.reuseIdentifier)
tableView.register(HashtagTableViewCell.self, forCellReuseIdentifier: HashtagTableViewCell.reuseIdentifier)
tableView.register(SearchResultsProfileTableViewCell.self, forCellReuseIdentifier: SearchResultsProfileTableViewCell.reuseIdentifier)
super.init(nibName: nil, bundle: nil)
let dataSource = UITableViewDiffableDataSource<SearchResultOverviewSection, SearchResultOverviewItem>(tableView: tableView) { tableView, indexPath, itemIdentifier in
switch itemIdentifier {
case .default(let item):
guard let cell = tableView.dequeueReusableCell(withIdentifier: SearchResultDefaultSectionTableViewCell.reuseIdentifier, for: indexPath) as? SearchResultDefaultSectionTableViewCell else { fatalError() }
cell.configure(item: item)
return cell
case .suggestion(let suggestion):
switch suggestion {
case .hashtag(let hashtag):
guard let cell = tableView.dequeueReusableCell(withIdentifier: SearchResultDefaultSectionTableViewCell.reuseIdentifier, for: indexPath) as? SearchResultDefaultSectionTableViewCell else { fatalError() }
cell.configure(item: .hashtag(tag: hashtag))
return cell
case .profile(let profile):
guard let cell = tableView.dequeueReusableCell(withIdentifier: SearchResultsProfileTableViewCell.reuseIdentifier, for: indexPath) as? SearchResultsProfileTableViewCell else { fatalError() }
cell.condensedUserView.configure(with: profile)
return cell
}
}
}
tableView.dataSource = dataSource
tableView.delegate = self
self.dataSource = dataSource
view.addSubview(tableView)
tableView.pinToParent()
}
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
override func viewDidLoad() {
super.viewDidLoad()
var snapshot = NSDiffableDataSourceSnapshot<SearchResultOverviewSection, SearchResultOverviewItem>()
snapshot.appendSections([.default, .suggestions])
dataSource?.apply(snapshot, animatingDifferences: false)
}
func showStandardSearch(for searchText: String) {
guard let dataSource else { return }
var snapshot = dataSource.snapshot()
snapshot.deleteItems(snapshot.itemIdentifiers(inSection: .default))
if searchText.lowercased().starts(with: "https://") && (searchText.contains(" ") == false) {
if URL(string: searchText)?.isValidURL() ?? false {
snapshot.appendItems([.default(.openLink(searchText))], toSection: .default)
}
}
if searchText.starts(with: "#") && searchText.length > 1 {
snapshot.appendItems([.default(.showHashtag(hashtag: searchText.replacingOccurrences(of: "#", with: "")))],
toSection: .default)
} else if searchText.length > 1, let hashtagRegex = try? NSRegularExpression(pattern: MastodonRegex.Search.hashtag, options: .caseInsensitive), hashtagRegex.firstMatch(in: searchText, range: NSRange(location: 0, length: searchText.length-1)) != nil {
snapshot.appendItems([.default(.showHashtag(hashtag: searchText.replacingOccurrences(of: "#", with: "")))],
toSection: .default)
}
if searchText.length > 1,
let usernameRegex = try? NSRegularExpression(pattern: MastodonRegex.Search.username, options: .caseInsensitive),
usernameRegex.firstMatch(in: searchText, range: NSRange(location: 0, length: searchText.length-1)) != nil {
let components = searchText.split(separator: "@")
if components.count == 2 {
let username = String(components[0]).replacingOccurrences(of: "@", with: "")
let domain = String(components[1])
if domain.split(separator: ".").count >= 2 {
snapshot.appendItems([.default(.showProfile(username: username, domain: domain))], toSection: .default)
} else {
snapshot.appendItems([.default(.showProfile(username: username, domain: authContext.mastodonAuthenticationBox.domain))], toSection: .default)
}
} else {
snapshot.appendItems([.default(.showProfile(username: searchText, domain: authContext.mastodonAuthenticationBox.domain))], toSection: .default)
}
}
snapshot.appendItems([.default(.posts(matching: searchText)),
.default(.people(matching: searchText))], toSection: .default)
dataSource.apply(snapshot, animatingDifferences: false)
}
func searchForSuggestions(for searchText: String) {
activeTask?.cancel()
guard let dataSource else { return }
var snapshot = dataSource.snapshot()
snapshot.deleteItems(snapshot.itemIdentifiers(inSection: .suggestions))
dataSource.apply(snapshot, animatingDifferences: false)
guard searchText.isNotEmpty else { return }
let query = Mastodon.API.V2.Search.Query(
q: searchText,
type: .default,
resolve: true
)
let searchTask = Task {
do {
let searchResult = try await context.apiService.search(
query: query,
authenticationBox: authContext.mastodonAuthenticationBox
).value
let firstThreeHashtags = searchResult.hashtags.prefix(3)
let firstThreeUsers = searchResult.accounts.prefix(3)
var snapshot = dataSource.snapshot()
if firstThreeHashtags.isNotEmpty {
snapshot.appendItems(firstThreeHashtags.map { .suggestion(.hashtag(tag: $0)) }, toSection: .suggestions )
}
if firstThreeUsers.isNotEmpty {
snapshot.appendItems(firstThreeUsers.map { .suggestion(.profile(user: $0)) }, toSection: .suggestions )
}
guard Task.isCancelled == false else { return }
await MainActor.run {
dataSource.apply(snapshot, animatingDifferences: false)
}
} catch {
// do nothing
print(error.localizedDescription)
}
}
activeTask = searchTask
}
}
//MARK: UITableViewDelegate
extension SearchResultsOverviewTableViewController: UITableViewDelegate {
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
guard let snapshot = dataSource?.snapshot() else { return }
let section = snapshot.sectionIdentifiers[indexPath.section]
let item = snapshot.itemIdentifiers(inSection: section)[indexPath.row]
switch item {
case .default(let defaultSectionEntry):
switch defaultSectionEntry {
case .posts(let searchText):
delegate?.searchForPosts(self, withSearchText: searchText)
case .people(let searchText):
delegate?.searchForPeople(self, withName: searchText)
case .showProfile(let username, let domain):
delegate?.searchForPerson(self, username: username, domain: domain)
case .openLink(let urlString):
delegate?.goTo(self, urlString: urlString)
case .showHashtag(let hashtagText):
let tag = Mastodon.Entity.Tag(name: hashtagText, url: "")
delegate?.showPosts(self, tag: tag)
}
case .suggestion(let suggestionSectionEntry):
switch suggestionSectionEntry {
case .hashtag(let tag):
delegate?.showPosts(self, tag: tag)
case .profile(let account):
delegate?.showProfile(self, for: account)
}
}
tableView.deselectRow(at: indexPath, animated: true)
}
}
extension SearchResultsOverviewTableViewController: UserTableViewCellDelegate {}

View File

@ -8,7 +8,6 @@
import os.log
import UIKit
import Combine
import Pageboy
import MastodonAsset
import MastodonCore
import MastodonLocalization
@ -23,22 +22,20 @@ final class CustomSearchController: UISearchController {
// Fake search bar not works on iPad with UISplitViewController
// check device and fallback to standard UISearchController
final class SearchDetailViewController: PageboyViewController, NeedsDependency {
let logger = Logger(subsystem: "SearchDetail", category: "UI")
final class SearchDetailViewController: UIViewController, NeedsDependency {
var disposeBag = Set<AnyCancellable>()
var observations = Set<NSKeyValueObservation>()
let searchResultOverviewCoordinator: SearchResultOverviewCoordinator
weak var context: AppContext! { willSet { precondition(!isViewLoaded) } }
weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } }
let isPhoneDevice: Bool = {
return UIDevice.current.userInterfaceIdiom == .phone
}()
var viewModel: SearchDetailViewModel!
var viewControllers: [SearchResultViewController]!
let navigationBarVisualEffectBackgroundView = UIVisualEffectView(effect: UIBlurEffect(style: .systemMaterial))
let navigationBarBackgroundView = UIView()
@ -73,9 +70,7 @@ final class SearchDetailViewController: PageboyViewController, NeedsDependency {
searchController.searchBar.setShowsScope(true, animated: false)
}
searchBar.placeholder = L10n.Scene.Search.SearchBar.placeholder
searchBar.scopeButtonTitles = SearchDetailViewModel.SearchScope.allCases.map { $0.segmentedControlTitle }
searchBar.sizeToFit()
searchBar.scopeBarBackgroundImage = UIImage()
return searchBar
}()
@ -86,11 +81,30 @@ final class SearchDetailViewController: PageboyViewController, NeedsDependency {
searchHistoryViewController.viewModel = SearchHistoryViewModel(context: context, authContext: viewModel.authContext)
return searchHistoryViewController
}()
}
extension SearchDetailViewController {
private(set) lazy var searchResultsOverviewViewController: SearchResultsOverviewTableViewController = {
return searchResultOverviewCoordinator.overviewViewController
}()
//MARK: - init
init(appContext: AppContext, sceneCoordinator: SceneCoordinator, authContext: AuthContext) {
self.context = appContext
self.coordinator = sceneCoordinator
self.searchResultOverviewCoordinator = SearchResultOverviewCoordinator(appContext: appContext, authContext: authContext, sceneCoordinator: sceneCoordinator)
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
//MARK: - UIViewController
override func viewDidLoad() {
searchResultOverviewCoordinator.start()
super.viewDidLoad()
setupBackgroundColor(theme: ThemeService.shared.currentTheme.value)
@ -119,91 +133,30 @@ extension SearchDetailViewController {
searchHistoryViewController.view.pinToParent()
}
transition = Transition(style: .fade, duration: 0.1)
isScrollEnabled = false
viewControllers = viewModel.searchScopes.map { scope in
let searchResultViewController = SearchResultViewController()
searchResultViewController.context = context
searchResultViewController.coordinator = coordinator
searchResultViewController.viewModel = SearchResultViewModel(context: context, authContext: viewModel.authContext, searchScope: scope)
// bind searchText
viewModel.searchText
.assign(to: \.value, on: searchResultViewController.viewModel.searchText)
.store(in: &searchResultViewController.disposeBag)
// bind navigationBarFrame
viewModel.navigationBarFrame
.receive(on: DispatchQueue.main)
.assign(to: \.value, on: searchResultViewController.viewModel.navigationBarFrame)
.store(in: &searchResultViewController.disposeBag)
return searchResultViewController
addChild(searchResultsOverviewViewController)
searchResultsOverviewViewController.view.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(searchResultsOverviewViewController.view)
searchResultsOverviewViewController.didMove(toParent: self)
if isPhoneDevice {
NSLayoutConstraint.activate([
searchResultsOverviewViewController.view.topAnchor.constraint(equalTo: navigationBarBackgroundView.bottomAnchor),
searchResultsOverviewViewController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
searchResultsOverviewViewController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
searchResultsOverviewViewController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
])
} else {
searchResultsOverviewViewController.view.pinToParent()
}
// set initial items from "all" search scope for non-appeared lists
if let allSearchScopeViewController = viewControllers.first(where: { $0.viewModel.searchScope == .all }) {
allSearchScopeViewController.viewModel.$items
.receive(on: DispatchQueue.main)
.sink { [weak self] items in
guard let self = self else { return }
guard self.currentViewController === allSearchScopeViewController else { return }
for viewController in self.viewControllers where viewController != allSearchScopeViewController {
// do not change appeared list
guard !viewController.viewModel.viewDidAppear.value else { continue }
// set initial items
switch viewController.viewModel.searchScope {
case .all:
assertionFailure()
break
case .people:
viewController.viewModel.userFetchedResultsController.userIDs = allSearchScopeViewController.viewModel.userFetchedResultsController.userIDs
case .hashtags:
viewController.viewModel.hashtags = allSearchScopeViewController.viewModel.hashtags
case .posts:
viewController.viewModel.statusFetchedResultsController.statusIDs = allSearchScopeViewController.viewModel.statusFetchedResultsController.statusIDs
}
}
}
.store(in: &allSearchScopeViewController.disposeBag)
}
dataSource = self
delegate = self
// bind search bar scope
viewModel.selectedSearchScope
.receive(on: DispatchQueue.main)
.sink { [weak self] searchScope in
guard let self = self else { return }
if let index = self.viewModel.searchScopes.firstIndex(of: searchScope) {
self.searchBar.selectedScopeButtonIndex = index
self.scrollToPage(.at(index: index), animated: true)
}
}
.store(in: &disposeBag)
// bind search trigger
viewModel.searchText
.removeDuplicates()
.throttle(for: 0.5, scheduler: DispatchQueue.main, latest: true)
.sink { [weak self] searchText in
guard let self = self else { return }
guard let searchResultViewController = self.currentViewController as? SearchResultViewController else {
return
}
self.logger.debug("\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): trigger search \(searchText)")
searchResultViewController.viewModel.stateMachine.enter(SearchResultViewModel.State.Loading.self)
}
.store(in: &disposeBag)
// bind search history display
viewModel.searchText
.removeDuplicates()
.receive(on: DispatchQueue.main)
.sink { [weak self] searchText in
guard let self = self else { return }
self.searchHistoryViewController.view.isHidden = !searchText.isEmpty
self.searchResultsOverviewViewController.view.isHidden = searchText.isEmpty
}
.store(in: &disposeBag)
}
@ -253,7 +206,6 @@ extension SearchDetailViewController {
}
}
}
}
extension SearchDetailViewController {
@ -292,7 +244,6 @@ extension SearchDetailViewController {
searchController.searchBar.sizeToFit()
}
searchBar.text = viewModel.searchText.value
searchBar.delegate = self
}
@ -305,18 +256,19 @@ extension SearchDetailViewController {
// MARK: - UISearchBarDelegate
extension SearchDetailViewController: UISearchBarDelegate {
func searchBar(_ searchBar: UISearchBar, selectedScopeButtonIndexDidChange selectedScope: Int) {
viewModel.selectedSearchScope.value = viewModel.searchScopes[selectedScope]
func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) {
let trimmedSearchText = searchText.trimmingCharacters(in: .whitespacesAndNewlines)
viewModel.searchText.value = trimmedSearchText
searchResultsOverviewViewController.showStandardSearch(for: trimmedSearchText)
searchResultsOverviewViewController.searchForSuggestions(for: trimmedSearchText)
}
func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) {
logger.debug("\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): searchTest \(searchText)")
viewModel.searchText.value = searchText.trimmingCharacters(in: .whitespacesAndNewlines)
func searchBarSearchButtonClicked(_ searchBar: UISearchBar) {
searchBar.resignFirstResponder()
}
func searchBarCancelButtonClicked(_ searchBar: UISearchBar) {
logger.debug("\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public)")
// dismiss or pop
if isModal {
dismiss(animated: true, completion: nil)
@ -324,77 +276,4 @@ extension SearchDetailViewController: UISearchBarDelegate {
navigationController?.popViewController(animated: false)
}
}
}
// MARK: - PageboyViewControllerDataSource
extension SearchDetailViewController: PageboyViewControllerDataSource {
func numberOfViewControllers(in pageboyViewController: PageboyViewController) -> Int {
return viewControllers.count
}
func viewController(for pageboyViewController: PageboyViewController, at index: PageboyViewController.PageIndex) -> UIViewController? {
guard index < viewControllers.count else { return nil }
return viewControllers[index]
}
func defaultPage(for pageboyViewController: PageboyViewController) -> PageboyViewController.Page? {
return .first
}
}
// MARK: - PageboyViewControllerDelegate
extension SearchDetailViewController: PageboyViewControllerDelegate {
func pageboyViewController(
_ pageboyViewController: PageboyViewController,
willScrollToPageAt index: PageboyViewController.PageIndex,
direction: PageboyViewController.NavigationDirection,
animated: Bool
) {
// do nothing
}
func pageboyViewController(
_ pageboyViewController: PageboyViewController,
didScrollTo position: CGPoint,
direction: PageboyViewController.NavigationDirection,
animated: Bool
) {
// do nothing
}
func pageboyViewController(
_ pageboyViewController: PageboyViewController,
didCancelScrollToPageAt index: PageboyViewController.PageIndex,
returnToPageAt previousIndex: PageboyViewController.PageIndex
) {
// do nothing
}
func pageboyViewController(
_ pageboyViewController: PageboyViewController,
didScrollToPageAt index: PageboyViewController.PageIndex,
direction: PageboyViewController.NavigationDirection,
animated: Bool
) {
logger.debug("\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): index \(index)")
let searchResultViewController = viewControllers[index]
viewModel.selectedSearchScope.value = searchResultViewController.viewModel.searchScope
// trigger fetch
searchResultViewController.viewModel.stateMachine.enter(SearchResultViewModel.State.Loading.self)
}
func pageboyViewController(
_ pageboyViewController: PageboyViewController,
didReloadWith currentViewController: UIViewController,
currentPageIndex: PageboyViewController.PageIndex
) {
// do nothing
}
}

View File

@ -5,7 +5,6 @@
// Created by MainasuK Cirno on 2021-7-13.
//
import os.log
import Foundation
import CoreGraphics
import Combine
@ -15,53 +14,37 @@ import MastodonAsset
import MastodonLocalization
final class SearchDetailViewModel {
// input
let authContext: AuthContext
var needsBecomeFirstResponder = false
let viewDidAppear = PassthroughSubject<Void, Never>()
let navigationBarFrame = CurrentValueSubject<CGRect, Never>(.zero)
// output
let searchScopes = SearchScope.allCases
let selectedSearchScope = CurrentValueSubject<SearchScope, Never>(.all)
let searchText: CurrentValueSubject<String, Never>
let searchActionPublisher = PassthroughSubject<Void, Never>()
init(authContext: AuthContext, initialSearchText: String = "") {
self.authContext = authContext
self.searchText = CurrentValueSubject(initialSearchText)
}
deinit {
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
}
}
extension SearchDetailViewModel {
enum SearchScope: CaseIterable {
case all
case people
case hashtags
case posts
var segmentedControlTitle: String {
switch self {
case .all: return L10n.Scene.Search.Searching.Segment.all
case .people: return L10n.Scene.Search.Searching.Segment.people
case .hashtags: return L10n.Scene.Search.Searching.Segment.hashtags
case .posts: return L10n.Scene.Search.Searching.Segment.posts
}
}
var searchType: Mastodon.API.V2.Search.SearchType {
switch self {
enum SearchScope: CaseIterable {
case all
case people
case hashtags
case posts
var searchType: Mastodon.API.V2.Search.SearchType {
switch self {
case .all: return .default
case .people: return .accounts
case .hashtags: return .hashtags
case .posts: return .statuses
}
}
}
}

View File

@ -11,14 +11,12 @@ import MastodonAsset
import MastodonLocalization
import MastodonUI
protocol SearchHistorySectionHeaderCollectionReusableViewDelegate: AnyObject, UserViewDelegate {
protocol SearchHistorySectionHeaderCollectionReusableViewDelegate: AnyObject {
func searchHistorySectionHeaderCollectionReusableView(_ searchHistorySectionHeaderCollectionReusableView: SearchHistorySectionHeaderCollectionReusableView, clearButtonDidPressed button: UIButton)
}
final class SearchHistorySectionHeaderCollectionReusableView: UICollectionReusableView {
let logger = Logger(subsystem: "SearchHistorySectionHeaderCollectionReusableView", category: "View")
weak var delegate: SearchHistorySectionHeaderCollectionReusableViewDelegate?
let primaryLabel: UILabel = {
@ -32,8 +30,9 @@ final class SearchHistorySectionHeaderCollectionReusableView: UICollectionReusab
let clearButton: UIButton = {
let button = UIButton(type: .system)
button.setImage(UIImage(systemName: "xmark.circle.fill"), for: .normal)
button.tintColor = Asset.Colors.Label.secondary.color
button.setTitle(L10n.Scene.Search.Searching.clearAll, for: .normal)
button.tintColor = Asset.Colors.Brand.blurple.color
button.accessibilityLabel = L10n.Scene.Search.Searching.clear
return button
@ -49,9 +48,6 @@ final class SearchHistorySectionHeaderCollectionReusableView: UICollectionReusab
_init()
}
}
extension SearchHistorySectionHeaderCollectionReusableView {
private func _init() {
primaryLabel.translatesAutoresizingMaskIntoConstraints = false
addSubview(primaryLabel)
@ -74,11 +70,8 @@ extension SearchHistorySectionHeaderCollectionReusableView {
clearButton.addTarget(self, action: #selector(SearchHistorySectionHeaderCollectionReusableView.clearButtonDidPressed(_:)), for: .touchUpInside)
}
}
extension SearchHistorySectionHeaderCollectionReusableView {
@objc private func clearButtonDidPressed(_ sender: UIButton) {
logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public)")
delegate?.searchHistorySectionHeaderCollectionReusableView(self, clearButtonDidPressed: sender)
}
}

View File

@ -1,72 +0,0 @@
//
// SearchHistoryUserCollectionViewCell+ViewModel.swift
// Mastodon
//
// Created by MainasuK on 2022-1-20.
//
import Foundation
import CoreDataStack
import MastodonUI
import Combine
extension SearchHistoryUserCollectionViewCell {
final class ViewModel {
let value: MastodonUser
let followedUsers: AnyPublisher<[String], Never>
let blockedUsers: AnyPublisher<[String], Never>
let followRequestedUsers: AnyPublisher<[String], Never>
init(value: MastodonUser, followedUsers: AnyPublisher<[String], Never>, blockedUsers: AnyPublisher<[String], Never>, followRequestedUsers: AnyPublisher<[String], Never>) {
self.value = value
self.followedUsers = followedUsers
self.followRequestedUsers = followRequestedUsers
self.blockedUsers = blockedUsers
}
}
}
extension SearchHistoryUserCollectionViewCell {
func configure(
me: MastodonUser?,
viewModel: ViewModel,
delegate: UserViewDelegate?
) {
let user = viewModel.value
userView.configure(user: user, delegate: delegate)
guard let me = me else {
return userView.setButtonState(.none)
}
if 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 blocked.contains(user.id) {
self?.userView.setButtonState(.blocked)
} else if followed.contains(user.id) {
self?.userView.setButtonState(.unfollow)
} else if requested.contains(user.id) {
self?.userView.setButtonState(.pending)
} else if user.locked {
self?.userView.setButtonState(.request)
} else if user != me {
self?.userView.setButtonState(.follow)
}
}
.store(in: &_disposeBag)
}
}

View File

@ -1,74 +1,45 @@
//
// SearchHistoryUserCollectionViewCell.swift
// Mastodon
//
// Created by MainasuK on 2022-1-20.
//
// Copyright © 2023 Mastodon gGmbH. All rights reserved.
import UIKit
import Combine
import MastodonCore
import MastodonUI
import MastodonCore
class SearchHistoryUserCollectionViewCell: UICollectionViewCell {
static let reuseIdentifier = "SearchHistoryUserCollectionViewCell"
let condensedUserView: CondensedUserView
override init(frame: CGRect) {
condensedUserView = CondensedUserView(frame: .zero)
condensedUserView.translatesAutoresizingMaskIntoConstraints = false
super.init(frame: frame)
contentView.addSubview(condensedUserView)
condensedUserView.pinToParent()
}
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
final class SearchHistoryUserCollectionViewCell: UICollectionViewCell {
var _disposeBag = Set<AnyCancellable>()
let userView = UserView()
override func prepareForReuse() {
super.prepareForReuse()
userView.prepareForReuse()
}
override init(frame: CGRect) {
super.init(frame: frame)
_init()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
_init()
}
}
extension SearchHistoryUserCollectionViewCell {
private func _init() {
ThemeService.shared.currentTheme
.map { $0.secondarySystemGroupedBackgroundColor }
.sink { [weak self] backgroundColor in
guard let self = self else { return }
self.backgroundColor = backgroundColor
self.setNeedsUpdateConfiguration()
}
.store(in: &_disposeBag)
userView.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(userView)
NSLayoutConstraint.activate([
userView.topAnchor.constraint(equalTo: contentView.topAnchor),
userView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16),
contentView.trailingAnchor.constraint(equalTo: userView.trailingAnchor, constant: 16),
userView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor),
])
userView.accessibilityTraits.insert(.button)
condensedUserView.prepareForReuse()
}
override func updateConfiguration(using state: UICellConfigurationState) {
super.updateConfiguration(using: state)
var backgroundConfiguration = UIBackgroundConfiguration.listGroupedCell()
backgroundConfiguration.backgroundColorTransformer = .init { _ in
if state.isHighlighted || state.isSelected {
return ThemeService.shared.currentTheme.value.tableViewCellSelectionBackgroundColor
} else {
return .secondarySystemGroupedBackground
}
return ThemeService.shared.currentTheme.value.secondarySystemGroupedBackgroundColor
}
self.backgroundConfiguration = backgroundConfiguration
}
}

View File

@ -8,6 +8,7 @@
import UIKit
import CoreDataStack
import MastodonCore
import MastodonAsset
enum SearchHistorySection: Hashable {
case main
@ -30,16 +31,7 @@ extension SearchHistorySection {
let userCellRegister = UICollectionView.CellRegistration<SearchHistoryUserCollectionViewCell, ManagedObjectRecord<MastodonUser>> { cell, indexPath, item in
context.managedObjectContext.performAndWait {
guard let user = item.object(in: context.managedObjectContext) else { return }
cell.configure(
me: authContext.mastodonAuthenticationBox.authenticationRecord.object(in: context.managedObjectContext)?.user,
viewModel: SearchHistoryUserCollectionViewCell.ViewModel(
value: user,
followedUsers: authContext.mastodonAuthenticationBox.inMemoryCache.$followingUserIds.eraseToAnyPublisher(),
blockedUsers: authContext.mastodonAuthenticationBox.inMemoryCache.$blockedUserIds.eraseToAnyPublisher(),
followRequestedUsers: authContext.mastodonAuthenticationBox.inMemoryCache.$followRequestedUserIDs.eraseToAnyPublisher()
),
delegate: configuration.searchHistorySectionHeaderCollectionReusableViewDelegate
)
cell.condensedUserView.configure(with: user)
}
}
@ -47,6 +39,8 @@ extension SearchHistorySection {
context.managedObjectContext.performAndWait {
guard let hashtag = item.object(in: context.managedObjectContext) else { return }
var contentConfiguration = cell.defaultContentConfiguration()
contentConfiguration.image = UIImage(systemName: "magnifyingglass")
contentConfiguration.imageProperties.tintColor = Asset.Colors.Brand.blurple.color
contentConfiguration.text = "#" + hashtag.name
cell.contentConfiguration = contentConfiguration
}
@ -54,13 +48,13 @@ extension SearchHistorySection {
var backgroundConfiguration = UIBackgroundConfiguration.listGroupedCell()
backgroundConfiguration.backgroundColorTransformer = .init { [weak cell] _ in
guard let state = cell?.configurationState else {
return ThemeService.shared.currentTheme.value.secondarySystemGroupedBackgroundColor
return .secondarySystemGroupedBackground
}
if state.isHighlighted || state.isSelected {
return ThemeService.shared.currentTheme.value.tableViewCellSelectionBackgroundColor
}
return ThemeService.shared.currentTheme.value.secondarySystemGroupedBackgroundColor
return .secondarySystemGroupedBackground
}
cell.backgroundConfiguration = backgroundConfiguration
}
@ -78,13 +72,8 @@ extension SearchHistorySection {
}
}
let trendHeaderRegister = UICollectionView.SupplementaryRegistration<SearchHistorySectionHeaderCollectionReusableView>(elementKind: UICollectionView.elementKindSectionHeader) { [weak dataSource] supplementaryView, elementKind, indexPath in
let trendHeaderRegister = UICollectionView.SupplementaryRegistration<SearchHistorySectionHeaderCollectionReusableView>(elementKind: UICollectionView.elementKindSectionHeader) { supplementaryView, elementKind, indexPath in
supplementaryView.delegate = configuration.searchHistorySectionHeaderCollectionReusableViewDelegate
guard let _ = dataSource else { return }
// let sections = dataSource.snapshot().sectionIdentifiers
// guard indexPath.section < sections.count else { return }
// let section = sections[indexPath.section]
}
dataSource.supplementaryViewProvider = { (collectionView: UICollectionView, elementKind: String, indexPath: IndexPath) in

View File

@ -14,8 +14,6 @@ import MastodonUI
final class SearchHistoryViewController: UIViewController, NeedsDependency {
let logger = Logger(subsystem: "SearchHistoryViewController", category: "ViewController")
weak var context: AppContext! { willSet { precondition(!isViewLoaded) } }
weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } }
@ -24,6 +22,8 @@ final class SearchHistoryViewController: UIViewController, NeedsDependency {
let collectionView: UICollectionView = {
var configuration = UICollectionLayoutListConfiguration(appearance: .insetGrouped)
configuration.separatorConfiguration.bottomSeparatorInsets.leading = 62
configuration.separatorConfiguration.topSeparatorInsets.leading = 62
configuration.backgroundColor = .clear
configuration.headerMode = .supplementary
let layout = UICollectionViewCompositionalLayout.list(using: configuration)
@ -68,8 +68,6 @@ extension SearchHistoryViewController {
extension SearchHistoryViewController: UICollectionViewDelegate {
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): select item at: \(indexPath.debugDescription)")
defer {
collectionView.deselectItem(at: indexPath, animated: true)
}
@ -116,14 +114,14 @@ extension SearchHistoryViewController: SearchHistorySectionHeaderCollectionReusa
_ searchHistorySectionHeaderCollectionReusableView: SearchHistorySectionHeaderCollectionReusableView,
clearButtonDidPressed button: UIButton
) {
logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public)")
Task {
try await DataSourceFacade.responseToDeleteSearchHistory(
provider: self
)
await MainActor.run {
button.isEnabled = false
}
}
}
}
extension SearchHistoryViewController: UserTableViewCellDelegate {}

View File

@ -37,26 +37,25 @@ extension SearchHistoryViewModel {
do {
let managedObjectContext = self.context.managedObjectContext
let items: [SearchHistoryItem] = try await managedObjectContext.perform {
var users: [SearchHistoryItem] = []
var hashtags: [SearchHistoryItem] = []
var items: [SearchHistoryItem] = []
for record in records {
guard let searchHistory = record.object(in: managedObjectContext) else { continue }
if let user = searchHistory.account {
users.append(.user(.init(objectID: user.objectID)))
items.append(.user(.init(objectID: user.objectID)))
} else if let hashtag = searchHistory.hashtag {
hashtags.append(.hashtag(.init(objectID: hashtag.objectID)))
} else {
continue
items.append(.hashtag(.init(objectID: hashtag.objectID)))
}
}
return users + hashtags
return items
}
let mostRecentItems = Array(items.prefix(10))
var snapshot = NSDiffableDataSourceSnapshot<SearchHistorySection, SearchHistoryItem>()
snapshot.appendSections([.main])
snapshot.appendItems(items, toSection: .main)
await diffableDataSource.apply(snapshot, animatingDifferences: false)
snapshot.appendItems(mostRecentItems, toSection: .main)
await diffableDataSource.apply(snapshot, animatingDifferences: true)
} catch {
// do nothing
}

View File

@ -32,71 +32,3 @@ final class SearchHistoryViewModel {
}
}
//extension SearchHistoryViewModel {
// func persistSearchHistory(for item: SearchHistoryItem) {
// guard let box = context.authenticationService.activeMastodonAuthenticationBox.value else { return }
// let property = SearchHistory.Property(domain: box.domain, userID: box.userID)
//
// switch item {
// case .account(let objectID):
// let managedObjectContext = context.backgroundManagedObjectContext
// managedObjectContext.performChanges {
// guard let user = try? managedObjectContext.existingObject(with: objectID) as? MastodonUser else { return }
// if let searchHistory = user.findSearchHistory(domain: box.domain, userID: box.userID) {
// searchHistory.update(updatedAt: Date())
// } else {
// SearchHistory.insert(into: managedObjectContext, property: property, account: user)
// }
// }
// .sink { result in
// switch result {
// case .failure(let error):
// assertionFailure(error.localizedDescription)
// case .success:
// break
// }
// }
// .store(in: &context.disposeBag)
//
// case .hashtag(let objectID):
// let managedObjectContext = context.backgroundManagedObjectContext
// managedObjectContext.performChanges {
// guard let hashtag = try? managedObjectContext.existingObject(with: objectID) as? Tag else { return }
// if let searchHistory = hashtag.findSearchHistory(domain: box.domain, userID: box.userID) {
// searchHistory.update(updatedAt: Date())
// } else {
// _ = SearchHistory.insert(into: managedObjectContext, property: property, hashtag: hashtag)
// }
// }
// .sink { result in
// switch result {
// case .failure(let error):
// assertionFailure(error.localizedDescription)
// case .success:
// break
// }
// }
// .store(in: &context.disposeBag)
//
// case .status:
// // FIXME:
// break
// }
// }
//
// func clearSearchHistory() {
// let managedObjectContext = context.backgroundManagedObjectContext
// managedObjectContext.performChanges {
// let request = SearchHistory.sortedFetchRequest
// let searchHistories = managedObjectContext.safeFetch(request)
// for searchHistory in searchHistories {
// managedObjectContext.delete(searchHistory)
// }
// }
// .sink { result in
// // do nothing
// }
// .store(in: &context.disposeBag)
// }
//}

View File

@ -1,100 +0,0 @@
//
// SearchHistoryTableHeaderView.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021-7-14.
//
import os.log
import UIKit
import Combine
import MastodonAsset
import MastodonCore
import MastodonLocalization
import MastodonUI
protocol SearchHistoryTableHeaderViewDelegate: AnyObject {
func searchHistoryTableHeaderView(_ searchHistoryTableHeaderView: SearchHistoryTableHeaderView, clearSearchHistoryButtonDidPressed button: UIButton)
}
final class SearchHistoryTableHeaderView: UIView {
let logger = Logger(subsystem: "SearchHistory", category: "UI")
weak var delegate: SearchHistoryTableHeaderViewDelegate?
var disposeBag = Set<AnyCancellable>()
let recentSearchesLabel: UILabel = {
let label = UILabel()
label.font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 20, weight: .semibold))
label.textColor = Asset.Colors.Label.primary.color
label.text = L10n.Scene.Search.Searching.recentSearch
return label
}()
let clearSearchHistoryButton: HighlightDimmableButton = {
let button = HighlightDimmableButton(type: .custom)
button.expandEdgeInsets = UIEdgeInsets(top: -10, left: -10, bottom: -10, right: -10)
button.setTitleColor(Asset.Colors.Brand.blurple.color, for: .normal)
button.setTitle(L10n.Scene.Search.Searching.clear, for: .normal)
return button
}()
override init(frame: CGRect) {
super.init(frame: frame)
_init()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
_init()
}
}
extension SearchHistoryTableHeaderView {
private func _init() {
preservesSuperviewLayoutMargins = true
recentSearchesLabel.translatesAutoresizingMaskIntoConstraints = false
addSubview(recentSearchesLabel)
NSLayoutConstraint.activate([
recentSearchesLabel.topAnchor.constraint(equalTo: topAnchor, constant: 16),
recentSearchesLabel.leadingAnchor.constraint(equalTo: readableContentGuide.leadingAnchor),
bottomAnchor.constraint(equalTo: recentSearchesLabel.bottomAnchor, constant: 16),
])
clearSearchHistoryButton.translatesAutoresizingMaskIntoConstraints = false
addSubview(clearSearchHistoryButton)
NSLayoutConstraint.activate([
clearSearchHistoryButton.centerYAnchor.constraint(equalTo: recentSearchesLabel.centerYAnchor),
clearSearchHistoryButton.leadingAnchor.constraint(equalTo: recentSearchesLabel.trailingAnchor),
clearSearchHistoryButton.trailingAnchor.constraint(equalTo: readableContentGuide.trailingAnchor),
])
clearSearchHistoryButton.setContentHuggingPriority(.defaultHigh + 10, for: .horizontal)
clearSearchHistoryButton.addTarget(self, action: #selector(SearchHistoryTableHeaderView.clearSearchHistoryButtonDidPressed(_:)), for: .touchUpInside)
setupBackgroundColor(theme: ThemeService.shared.currentTheme.value)
ThemeService.shared.currentTheme
.receive(on: RunLoop.main)
.sink { [weak self] theme in
guard let self = self else { return }
self.setupBackgroundColor(theme: theme)
}
.store(in: &disposeBag)
}
}
extension SearchHistoryTableHeaderView {
@objc private func clearSearchHistoryButtonDidPressed(_ sender: UIButton) {
logger.debug("\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public)")
delegate?.searchHistoryTableHeaderView(self, clearSearchHistoryButtonDidPressed: sender)
}
}
extension SearchHistoryTableHeaderView {
private func setupBackgroundColor(theme: Theme) {
backgroundColor = theme.systemGroupedBackgroundColor
}
}

View File

@ -9,6 +9,8 @@ import UIKit
import MetaTextKit
final class HashtagTableViewCell: UITableViewCell {
static let reuseIdentifier = "HashtagTableViewCell"
let primaryLabel = MetaLabel(style: .statusName)

View File

@ -23,8 +23,6 @@ enum SearchResultSection: Hashable {
extension SearchResultSection {
static let logger = Logger(subsystem: "SearchResultSection", category: "logic")
struct Configuration {
let authContext: AuthContext
weak var statusViewTableViewCellDelegate: StatusTableViewCellDelegate?
@ -45,7 +43,7 @@ extension SearchResultSection {
return UITableViewDiffableDataSource(tableView: tableView) { tableView, indexPath, item -> UITableViewCell? in
switch item {
case .user(let record):
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: UserTableViewCell.self), for: indexPath) as! UserTableViewCell
let cell = tableView.dequeueReusableCell(withIdentifier: UserTableViewCell.reuseIdentifier, for: indexPath) as! UserTableViewCell
context.managedObjectContext.performAndWait {
guard let user = record.object(in: context.managedObjectContext) else { return }
configure(
@ -53,7 +51,7 @@ extension SearchResultSection {
authContext: authContext,
tableView: tableView,
cell: cell,
viewModel: UserTableViewCell.ViewModel(value: .user(user),
viewModel: UserTableViewCell.ViewModel(user: user,
followedUsers: authContext.mastodonAuthenticationBox.inMemoryCache.$followingUserIds.eraseToAnyPublisher(),
blockedUsers: authContext.mastodonAuthenticationBox.inMemoryCache.$blockedUserIds.eraseToAnyPublisher(),
followRequestedUsers: authContext.mastodonAuthenticationBox.inMemoryCache.$followRequestedUserIDs.eraseToAnyPublisher()),

View File

@ -40,7 +40,6 @@ extension SearchResultViewController: DataSourceProvider {
extension SearchResultViewController {
func aspectTableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): indexPath: \(indexPath.debugDescription)")
Task {
let source = DataSourceItem.Source(tableViewCell: nil, indexPath: indexPath)
guard let item = await item(from: source) else {
@ -72,6 +71,8 @@ extension SearchResultViewController {
case .notification:
assertionFailure()
} // end switch
tableView.deselectRow(at: indexPath, animated: true)
} // end Task
} // end func
}

View File

@ -11,11 +11,10 @@ import Combine
import CoreDataStack
import MastodonCore
import MastodonUI
import MastodonAsset
final class SearchResultViewController: UIViewController, NeedsDependency, MediaPreviewableViewController {
let logger = Logger(subsystem: "SearchResultViewController", category: "ViewController")
weak var context: AppContext! { willSet { precondition(!isViewLoaded) } }
weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } }
@ -39,21 +38,13 @@ extension SearchResultViewController {
override func viewDidLoad() {
super.viewDidLoad()
setupBackgroundColor(theme: ThemeService.shared.currentTheme.value)
ThemeService.shared.currentTheme
.receive(on: DispatchQueue.main)
.sink { [weak self] theme in
guard let self = self else { return }
self.setupBackgroundColor(theme: theme)
}
.store(in: &disposeBag)
view.backgroundColor = Asset.Theme.System.systemGroupedBackground.color
tableView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(tableView)
tableView.pinToParent()
tableView.delegate = self
// tableView.prefetchDataSource = self
viewModel.setupDiffableDataSource(
tableView: tableView,
statusTableViewCellDelegate: self,
@ -71,83 +62,14 @@ extension SearchResultViewController {
}
.store(in: &disposeBag)
// listen keyboard events and set content inset
let keyboardEventPublishers = Publishers.CombineLatest3(
KeyboardResponderService.shared.isShow,
KeyboardResponderService.shared.state,
KeyboardResponderService.shared.endFrame
)
Publishers.CombineLatest3(
keyboardEventPublishers,
viewModel.viewDidAppear,
viewModel.didDataSourceUpdate
)
.sink(receiveValue: { [weak self] keyboardEvents, _, _ in
guard let self = self else { return }
let (isShow, state, endFrame) = keyboardEvents
// update keyboard background color
guard isShow, state == .dock else {
self.tableView.contentInset.bottom = 0
self.tableView.verticalScrollIndicatorInsets.bottom = 0
return
}
// isShow AND dock state
// adjust inset for tableView
let contentFrame = self.view.convert(self.tableView.frame, to: nil)
let padding = contentFrame.maxY - endFrame.minY
guard padding > 0 else {
self.tableView.contentInset.bottom = self.view.safeAreaInsets.bottom
self.tableView.verticalScrollIndicatorInsets.bottom = self.view.safeAreaInsets.bottom
return
}
self.tableView.contentInset.bottom = padding - self.view.safeAreaInsets.bottom
self.tableView.verticalScrollIndicatorInsets.bottom = padding - self.view.safeAreaInsets.bottom
})
.store(in: &disposeBag)
//
// works for already onscreen page
viewModel.navigationBarFrame
.removeDuplicates()
.receive(on: DispatchQueue.main)
.sink { [weak self] frame in
guard let self = self else { return }
guard self.viewModel.viewDidAppear.value else { return }
self.tableView.contentInset.top = frame.height
self.tableView.verticalScrollIndicatorInsets.top = frame.height
}
.store(in: &disposeBag)
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
// works for appearing page
if !viewModel.viewDidAppear.value {
tableView.contentInset.top = viewModel.navigationBarFrame.value.height
tableView.contentOffset.y = -viewModel.navigationBarFrame.value.height
}
tableView.deselectRow(with: transitionCoordinator, animated: animated)
title = viewModel.searchText
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
viewModel.viewDidAppear.value = true
viewModel.stateMachine.enter(SearchResultViewModel.State.Initial.self)
}
}
extension SearchResultViewController {
private func setupBackgroundColor(theme: Theme) {
view.backgroundColor = theme.systemGroupedBackgroundColor
// tableView.backgroundColor = theme.systemBackgroundColor
// searchHeader.backgroundColor = theme.systemGroupedBackgroundColor
}
}
// MARK: - StatusTableViewCellDelegate
@ -180,81 +102,9 @@ extension SearchResultViewController: UITableViewDelegate, AutoGenerateTableView
func tableView(_ tableView: UITableView, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) {
aspectTableView(tableView, willPerformPreviewActionForMenuWith: configuration, animator: animator)
}
// sourcery:end
// func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat {
// aspectTableView(tableView, estimatedHeightForRowAt: indexPath)
// }
//
// func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
// aspectTableView(tableView, willDisplay: cell, forRowAt: indexPath)
// }
//
// func tableView(_ tableView: UITableView, didEndDisplaying cell: UITableViewCell, forRowAt indexPath: IndexPath) {
// aspectTableView(tableView, didEndDisplaying: cell, forRowAt: indexPath)
// }
//
// func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
// guard let diffableDataSource = viewModel.diffableDataSource else { return }
// guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return }
//
// viewModel.persistSearchHistory(for: item)
//
// switch item {
// case .account(let account):
// let profileViewModel = RemoteProfileViewModel(context: context, userID: account.id)
// coordinator.present(scene: .profile(viewModel: profileViewModel), from: self, transition: .show)
// case .hashtag(let hashtag):
// let hashtagViewModel = HashtagTimelineViewModel(context: context, hashtag: hashtag.name)
// coordinator.present(scene: .hashtagTimeline(viewModel: hashtagViewModel), from: self, transition: .show)
// case .status:
// aspectTableView(tableView, didSelectRowAt: indexPath)
// case .bottomLoader:
// break
// }
// }
//
// func tableView(_ tableView: UITableView, contextMenuConfigurationForRowAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? {
// aspectTableView(tableView, contextMenuConfigurationForRowAt: indexPath, point: point)
// }
//
// func tableView(_ tableView: UITableView, previewForHighlightingContextMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? {
// aspectTableView(tableView, previewForHighlightingContextMenuWithConfiguration: configuration)
// }
//
// func tableView(_ tableView: UITableView, previewForDismissingContextMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? {
// aspectTableView(tableView, previewForDismissingContextMenuWithConfiguration: configuration)
// }
//
// func tableView(_ tableView: UITableView, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) {
// aspectTableView(tableView, willPerformPreviewActionForMenuWith: configuration, animator: animator)
// }
}
// MARK: - UITableViewDataSourcePrefetching
//extension SearchResultViewController: UITableViewDataSourcePrefetching {
// func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) {
// aspectTableView(tableView, cancelPrefetchingForRowsAt: indexPaths)
// }
//
// func tableView(_ tableView: UITableView, cancelPrefetchingForRowsAt indexPaths: [IndexPath]) {
// aspectTableView(tableView, cancelPrefetchingForRowsAt: indexPaths)
// }
//}
// MARK: - AVPlayerViewControllerDelegate
//extension SearchResultViewController: AVPlayerViewControllerDelegate {
// func playerViewController(_ playerViewController: AVPlayerViewController, willBeginFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator) {
// handlePlayerViewController(playerViewController, willBeginFullScreenPresentationWithAnimationCoordinator: coordinator)
// }
//
// func playerViewController(_ playerViewController: AVPlayerViewController, willEndFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator) {
// handlePlayerViewController(playerViewController, willEndFullScreenPresentationWithAnimationCoordinator: coordinator)
// }
//}
// MARK: - StatusTableViewCellDelegate
extension SearchResultViewController: StatusTableViewCellDelegate { }

View File

@ -65,8 +65,7 @@ extension SearchResultViewModel {
if let currentState = self.stateMachine.currentState {
switch currentState {
case is State.Loading,
is State.Fail,
is State.Idle:
is State.Fail:
let attribute = SearchResultItem.BottomLoaderAttribute(isEmptyResult: false)
snapshot.appendItems([.bottomLoader(attribute: attribute)], toSection: .main)
case is State.NoMore:
@ -74,6 +73,9 @@ extension SearchResultViewModel {
let attribute = SearchResultItem.BottomLoaderAttribute(isEmptyResult: true)
snapshot.appendItems([.bottomLoader(attribute: attribute)], toSection: .main)
}
case is State.Idle:
// do nothing
break
default:
break
}

View File

@ -5,7 +5,6 @@
// Created by MainasuK Cirno on 2021-7-14.
//
import os.log
import Foundation
import GameplayKit
import MastodonSDK
@ -14,8 +13,6 @@ import MastodonCore
extension SearchResultViewModel {
class State: GKState {
let logger = Logger(subsystem: "SearchResultViewModel.State", category: "StateMachine")
let id = UUID()
weak var viewModel: SearchResultViewModel?
@ -24,46 +21,37 @@ extension SearchResultViewModel {
self.viewModel = viewModel
}
override func didEnter(from previousState: GKState?) {
super.didEnter(from: previousState)
let from = previousState.flatMap { String(describing: $0) } ?? "nil"
let to = String(describing: self)
logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): \(from) -> \(to)")
}
@MainActor
func enter(state: State.Type) {
public func enter(state: State.Type) {
stateMachine?.enter(state)
}
deinit {
logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): [\(self.id.uuidString)] \(String(describing: self))")
}
}
}
extension SearchResultViewModel.State {
class Initial: SearchResultViewModel.State {
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
guard let viewModel = viewModel else { return false }
return stateClass == Loading.self && !viewModel.searchText.value.isEmpty
return stateClass == Loading.self
}
override func didEnter(from previousState: GKState?) {
super.didEnter(from: previousState)
guard let viewModel else { return }
viewModel.items = [.bottomLoader(attribute: .init(isEmptyResult: false))]
}
}
class Loading: SearchResultViewModel.State {
var previousSearchText = ""
var offset: Int? = nil
var latestLoadingToken = UUID()
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
guard let viewModel = self.viewModel else { return false }
switch stateClass {
case is Fail.Type, is Idle.Type, is NoMore.Type:
return true
case is Loading.Type:
return viewModel.searchText.value != previousSearchText
default:
return false
}
@ -71,12 +59,11 @@ extension SearchResultViewModel.State {
override func didEnter(from previousState: GKState?) {
super.didEnter(from: previousState)
guard let viewModel = viewModel, let stateMachine = stateMachine else { return }
guard let viewModel, let stateMachine = stateMachine else { return }
let searchText = viewModel.searchText.value
let searchType = viewModel.searchScope.searchType
if previousState is NoMore && previousSearchText == searchText {
if previousState is NoMore {
// same searchText from NoMore
// break the loading and resume NoMore state
stateMachine.enter(NoMore.self)
@ -86,17 +73,12 @@ extension SearchResultViewModel.State {
// viewModel.items.value = viewModel.items.value
}
guard !searchText.isEmpty else {
guard viewModel.searchText.isEmpty == false else {
stateMachine.enter(Fail.self)
return
}
if searchText != previousSearchText {
previousSearchText = searchText
offset = nil
} else {
offset = viewModel.items.count
}
offset = viewModel.items.count
// not set offset for all case
// and assert other cases the items are all the same type elements
@ -108,7 +90,7 @@ extension SearchResultViewModel.State {
}()
let query = Mastodon.API.V2.Search.Query(
q: searchText,
q: viewModel.searchText,
type: searchType,
accountID: nil,
maxID: nil,
@ -130,8 +112,6 @@ extension SearchResultViewModel.State {
authenticationBox: viewModel.authContext.mastodonAuthenticationBox
)
// discard result when search text is outdated
guard searchText == self.previousSearchText else { return }
// discard result when request not the latest one
guard id == self.latestLoadingToken else { return }
// discard result when state is not Loading
@ -165,7 +145,6 @@ extension SearchResultViewModel.State {
viewModel.hashtags = hashtags
} catch {
logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): search \(searchText) fail: \(error.localizedDescription)")
await enter(state: Fail.self)
}
} // end Task

View File

@ -20,14 +20,13 @@ final class SearchResultViewModel {
// input
let context: AppContext
let authContext: AuthContext
let searchScope: SearchDetailViewModel.SearchScope
let searchText = CurrentValueSubject<String, Never>("")
let searchScope: SearchScope
let searchText: String
@Published var hashtags: [Mastodon.Entity.Tag] = []
let userFetchedResultsController: UserFetchedResultsController
let statusFetchedResultsController: StatusFetchedResultsController
let listBatchFetchViewModel = ListBatchFetchViewModel()
let viewDidAppear = CurrentValueSubject<Bool, Never>(false)
var cellFrameCache = NSCache<NSNumber, NSValue>()
var navigationBarFrame = CurrentValueSubject<CGRect, Never>(.zero)
@ -43,15 +42,16 @@ final class SearchResultViewModel {
State.Idle(viewModel: self),
State.NoMore(viewModel: self),
])
stateMachine.enter(State.Initial.self)
return stateMachine
}()
let didDataSourceUpdate = PassthroughSubject<Void, Never>()
init(context: AppContext, authContext: AuthContext, searchScope: SearchDetailViewModel.SearchScope) {
init(context: AppContext, authContext: AuthContext, searchScope: SearchScope = .all, searchText: String) {
self.context = context
self.authContext = authContext
self.searchScope = searchScope
self.searchText = searchText
self.userFetchedResultsController = UserFetchedResultsController(
managedObjectContext: context.managedObjectContext,
domain: authContext.mastodonAuthenticationBox.domain,
@ -62,139 +62,5 @@ final class SearchResultViewModel {
domain: authContext.mastodonAuthenticationBox.domain,
additionalTweetPredicate: nil
)
// Publishers.CombineLatest(
// items,
// statusFetchedResultsController.objectIDs.removeDuplicates()
// )
// .receive(on: DispatchQueue.main)
// .sink { [weak self] items, statusObjectIDs in
// guard let self = self else { return }
// guard let diffableDataSource = self.diffableDataSource else { return }
//
// var snapshot = NSDiffableDataSourceSnapshot<SearchResultSection, SearchResultItem>()
// snapshot.appendSections([.main])
//
// // append account & hashtag items
//
// var items = items
// if self.searchScope == .all {
// // all search scope not paging. it's safe sort on whole dataset
// items.sort(by: { ($0.sortKey ?? "") < ($1.sortKey ?? "")})
// }
// snapshot.appendItems(items, toSection: .main)
//
// var oldSnapshotAttributeDict: [NSManagedObjectID : Item.StatusAttribute] = [:]
// let oldSnapshot = diffableDataSource.snapshot()
// for item in oldSnapshot.itemIdentifiers {
// guard case let .status(objectID, attribute) = item else { continue }
// oldSnapshotAttributeDict[objectID] = attribute
// }
//
// // append statuses
// var statusItems: [SearchResultItem] = []
// for objectID in statusObjectIDs {
// let attribute = oldSnapshotAttributeDict[objectID] ?? Item.StatusAttribute()
// statusItems.append(.status(statusObjectID: objectID, attribute: attribute))
// }
// snapshot.appendItems(statusItems, toSection: .main)
//
// if let currentState = self.stateMachine.currentState {
// switch currentState {
// case is State.Loading, is State.Fail, is State.Idle:
// let attribute = SearchResultItem.BottomLoaderAttribute(isEmptyResult: false)
// snapshot.appendItems([.bottomLoader(attribute: attribute)], toSection: .main)
// case is State.Fail:
// break
// case is State.NoMore:
// if snapshot.itemIdentifiers.isEmpty {
// let attribute = SearchResultItem.BottomLoaderAttribute(isEmptyResult: true)
// snapshot.appendItems([.bottomLoader(attribute: attribute)], toSection: .main)
// }
// default:
// break
// }
// }
//
// diffableDataSource.defaultRowAnimation = .fade
// diffableDataSource.apply(snapshot, animatingDifferences: true) { [weak self] in
// guard let self = self else { return }
// self.didDataSourceUpdate.send()
// }
//
// }
// .store(in: &disposeBag)
}
}
extension SearchResultViewModel {
func persistSearchHistory(for item: SearchResultItem) {
fatalError()
// guard let box = context.authenticationService.activeMastodonAuthenticationBox.value else { return }
// let property = SearchHistory.Property(domain: box.domain, userID: box.userID)
// let domain = box.domain
//
// switch item {
// case .account(let entity):
// let managedObjectContext = context.backgroundManagedObjectContext
// managedObjectContext.performChanges {
// let (user, _) = APIService.CoreData.createOrMergeMastodonUser(
// into: managedObjectContext,
// for: nil,
// in: domain,
// entity: entity,
// userCache: nil,
// networkDate: Date(),
// log: OSLog.api
// )
// if let searchHistory = user.findSearchHistory(domain: box.domain, userID: box.userID) {
// searchHistory.update(updatedAt: Date())
// } else {
// SearchHistory.insert(into: managedObjectContext, property: property, account: user)
// }
// }
// .sink { result in
// switch result {
// case .failure(let error):
// assertionFailure(error.localizedDescription)
// case .success:
// break
// }
// }
// .store(in: &context.disposeBag)
//
// case .hashtag(let entity):
// let managedObjectContext = context.backgroundManagedObjectContext
// var tag: Tag?
// managedObjectContext.performChanges {
// let (hashtag, _) = APIService.CoreData.createOrMergeTag(
// into: managedObjectContext,
// entity: entity
// )
// tag = hashtag
// if let searchHistory = hashtag.findSearchHistory(domain: box.domain, userID: box.userID) {
// searchHistory.update(updatedAt: Date())
// } else {
// _ = SearchHistory.insert(into: managedObjectContext, property: property, hashtag: hashtag)
// }
// }
// .sink { result in
// switch result {
// case .failure(let error):
// assertionFailure(error.localizedDescription)
// case .success:
// print(tag?.searchHistories)
// break
// }
// }
// .store(in: &context.disposeBag)
//
// case .status:
// // FIXME:
// break
// case .bottomLoader:
// break
// }
}
}

View File

@ -13,6 +13,8 @@ import MastodonLocalization
import MastodonUI
final class StatusTableViewCell: UITableViewCell {
static let reuseIdentifier = "StatusTableViewCell"
static let marginForRegularHorizontalSizeClass: CGFloat = 64

View File

@ -12,72 +12,65 @@ import Combine
extension UserTableViewCell {
final class ViewModel {
let value: Value
let user: MastodonUser
let followedUsers: AnyPublisher<[String], Never>
let blockedUsers: AnyPublisher<[String], Never>
let followRequestedUsers: AnyPublisher<[String], Never>
init(value: Value, followedUsers: AnyPublisher<[String], Never>, blockedUsers: AnyPublisher<[String], Never>, followRequestedUsers: AnyPublisher<[String], Never>) {
self.value = value
init(user: MastodonUser, followedUsers: AnyPublisher<[String], Never>, blockedUsers: AnyPublisher<[String], Never>, followRequestedUsers: AnyPublisher<[String], Never>) {
self.user = user
self.followedUsers = followedUsers
self.followRequestedUsers = followRequestedUsers
self.blockedUsers = blockedUsers
}
enum Value {
case user(MastodonUser)
// case status(Status)
}
}
}
extension UserTableViewCell {
func configure(
me: MastodonUser?,
me: MastodonUser? = nil,
tableView: UITableView,
viewModel: ViewModel,
delegate: UserTableViewCellDelegate?
) {
switch viewModel.value {
case .user(let user):
userView.configure(user: user, delegate: delegate)
guard let me = me else {
return userView.setButtonState(.none)
}
if 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 blocked.contains(user.id) {
self?.userView.setButtonState(.blocked)
} else if followed.contains(user.id) {
self?.userView.setButtonState(.unfollow)
} else if requested.contains(user.id) {
self?.userView.setButtonState(.pending)
} else if user.locked {
self?.userView.setButtonState(.request)
} else if user != me {
self?.userView.setButtonState(.follow)
}
}
.store(in: &disposeBag)
userView.configure(user: viewModel.user, delegate: delegate)
guard let me = me else {
return userView.setButtonState(.none)
}
self.delegate = delegate
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
}
}

View File

@ -16,7 +16,8 @@ import MastodonSDK
protocol UserTableViewCellDelegate: UserViewDelegate, AnyObject { }
final class UserTableViewCell: UITableViewCell {
static let reuseIdentifier = "UserTableViewCell"
weak var delegate: UserTableViewCellDelegate?
let userView = UserView()

View File

@ -548,3 +548,10 @@ extension MastodonUser: AutoUpdatableObject {
}
}
}
extension MastodonUser {
public var verifiedLink: MastodonField? {
let firstVerified = fields.first(where: { $0.verifiedAt != nil })
return firstVerified
}
}

View File

@ -90,8 +90,6 @@ extension StatusFetchedResultsController {
// MARK: - NSFetchedResultsControllerDelegate
extension StatusFetchedResultsController: NSFetchedResultsControllerDelegate {
public func controller(_ controller: NSFetchedResultsController<NSFetchRequestResult>, didChangeContentWith snapshot: NSDiffableDataSourceSnapshotReference) {
os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
let indexes = statusIDs
let objects = fetchedResultsController.fetchedObjects ?? []

View File

@ -97,7 +97,6 @@ extension UserFetchedResultsController {
// MARK: - NSFetchedResultsControllerDelegate
extension UserFetchedResultsController: NSFetchedResultsControllerDelegate {
public func controller(_ controller: NSFetchedResultsController<NSFetchRequestResult>, didChangeContentWith snapshot: NSDiffableDataSourceSnapshotReference) {
os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
let indexes = userIDs
let objects = fetchedResultsController.fetchedObjects ?? []

View File

@ -230,12 +230,12 @@ extension APIService {
var result: MastodonUser?
try await managedObjectContext.perform {
result = Persistence.MastodonUser.fetch(in: managedObjectContext,
context: Persistence.MastodonUser.PersistContext(
domain: domain,
entity: response.value,
cache: nil,
networkDate: response.networkDate
))
context: Persistence.MastodonUser.PersistContext(
domain: domain,
entity: response.value,
cache: nil,
networkDate: response.networkDate
))
}
return result
}

View File

@ -1277,21 +1277,39 @@ public enum L10n {
public enum Searching {
/// Clear
public static let clear = L10n.tr("Localizable", "Scene.Search.Searching.Clear", fallback: "Clear")
/// Clear all
public static let clearAll = L10n.tr("Localizable", "Scene.Search.Searching.ClearAll", fallback: "Clear all")
/// Go to #%@
public static func hashtag(_ p1: Any) -> String {
return L10n.tr("Localizable", "Scene.Search.Searching.Hashtag", String(describing: p1), fallback: "Go to #%@")
}
/// People matching "%@"
public static func people(_ p1: Any) -> String {
return L10n.tr("Localizable", "Scene.Search.Searching.People", String(describing: p1), fallback: "People matching \"%@\"")
}
/// Posts matching "%@"
public static func posts(_ p1: Any) -> String {
return L10n.tr("Localizable", "Scene.Search.Searching.Posts", String(describing: p1), fallback: "Posts matching \"%@\"")
}
/// Go to @%@@%@
public static func profile(_ p1: Any, _ p2: Any) -> String {
return L10n.tr("Localizable", "Scene.Search.Searching.Profile", String(describing: p1), String(describing: p2), fallback: "Go to @%@@%@")
}
/// Recent searches
public static let recentSearch = L10n.tr("Localizable", "Scene.Search.Searching.RecentSearch", fallback: "Recent searches")
/// Open URL in Mastodon
public static let url = L10n.tr("Localizable", "Scene.Search.Searching.Url", fallback: "Open URL in Mastodon")
public enum EmptyState {
/// No results
public static let noResults = L10n.tr("Localizable", "Scene.Search.Searching.EmptyState.NoResults", fallback: "No results")
}
public enum Segment {
/// All
public static let all = L10n.tr("Localizable", "Scene.Search.Searching.Segment.All", fallback: "All")
/// Hashtags
public static let hashtags = L10n.tr("Localizable", "Scene.Search.Searching.Segment.Hashtags", fallback: "Hashtags")
/// People
public static let people = L10n.tr("Localizable", "Scene.Search.Searching.Segment.People", fallback: "People")
/// Posts
public static let posts = L10n.tr("Localizable", "Scene.Search.Searching.Segment.Posts", fallback: "Posts")
public enum NoUser {
/// There's no Useraccount "%@" on %@
public static func message(_ p1: Any, _ p2: Any) -> String {
return L10n.tr("Localizable", "Scene.Search.Searching.NoUser.Message", String(describing: p1), String(describing: p2), fallback: "There's no Useraccount \"%@\" on %@")
}
/// No User Account Found
public static let title = L10n.tr("Localizable", "Scene.Search.Searching.NoUser.Title", fallback: "No User Account Found")
}
}
}

View File

@ -440,12 +440,18 @@ uploaded to Mastodon.";
"Scene.Search.SearchBar.Cancel" = "Cancel";
"Scene.Search.SearchBar.Placeholder" = "Search hashtags and users";
"Scene.Search.Searching.Clear" = "Clear";
"Scene.Search.Searching.ClearAll" = "Clear all";
"Scene.Search.Searching.EmptyState.NoResults" = "No results";
"Scene.Search.Searching.RecentSearch" = "Recent searches";
"Scene.Search.Searching.Segment.All" = "All";
"Scene.Search.Searching.Segment.Hashtags" = "Hashtags";
"Scene.Search.Searching.Segment.People" = "People";
"Scene.Search.Searching.Segment.Posts" = "Posts";
"Scene.Search.Searching.Posts" = "Posts matching \"%@\"";
"Scene.Search.Searching.People" = "People matching \"%@\"";
"Scene.Search.Searching.Profile" = "Go to @%@@%@";
"Scene.Search.Searching.Hashtag" = "Go to #%@";
"Scene.Search.Searching.Url" = "Open URL in Mastodon";
"Scene.Search.Searching.NoUser.Title" = "No User Account Found";
"Scene.Search.Searching.NoUser.Message" = "There's no Useraccount \"%@\" on %@";
"Scene.Search.Title" = "Search";
"Scene.ServerPicker.Button.Category.Academia" = "academia";
"Scene.ServerPicker.Button.Category.Activism" = "activism";
@ -554,4 +560,4 @@ uploaded to Mastodon.";
"Widget.MultipleFollowers.ConfigurationDescription" = "Show number of followers for multiple accounts.";
"Widget.MultipleFollowers.ConfigurationDisplayName" = "Multiple followers";
"Widget.MultipleFollowers.MockUser.AccountName" = "another@follower.social";
"Widget.MultipleFollowers.MockUser.DisplayName" = "Another follower";
"Widget.MultipleFollowers.MockUser.DisplayName" = "Another follower";

View File

@ -208,7 +208,6 @@ extension Mastodon.API {
return try Mastodon.API.decoder.decode(type, from: data)
} catch let decodeError {
#if DEBUG
os_log(.info, "%{public}s[%{public}ld], %{public}s: decode fail. content %s", ((#file as NSString).lastPathComponent), #line, #function, String(data: data, encoding: .utf8) ?? "<nil>")
debugPrint(decodeError)
#endif

View File

@ -92,3 +92,10 @@ extension Mastodon.Entity.Account {
return acct
}
}
extension Mastodon.Entity.Account {
public var verifiedLink: Mastodon.Entity.Field? {
let firstVerified = fields?.first(where: { $0.verifiedAt != nil })
return firstVerified
}
}

View File

@ -16,7 +16,7 @@ extension Mastodon.Entity {
/// 2021/1/28
/// # Reference
/// [Document](https://docs.joinmastodon.org/entities/history/)
public struct History: Codable, Sendable {
public struct History: Hashable, Codable, Sendable {
/// UNIX timestamp on midnight of the given day
public let day: Date
public let uses: String

View File

@ -25,6 +25,13 @@ extension Mastodon.Entity {
public let history: [History]?
public let following: Bool?
public init(name: String, url: String, history: [History]? = nil, following: Bool? = nil) {
self.name = name
self.url = url
self.history = history
self.following = following
}
enum CodingKeys: String, CodingKey {
case name
case url

View File

@ -11,4 +11,15 @@ extension URL {
public static func httpScheme(domain: String) -> String {
return domain.hasSuffix(".onion") ? "http" : "https"
}
// inspired by https://stackoverflow.com/a/49072718
public func isValidURL() -> Bool {
if let detector = try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue),
let match = detector.firstMatch(in: absoluteString, options: [], range: NSRange(location: 0, length: absoluteString.utf16.count)) {
// it is a link, if the match covers the whole string
return match.range.length == absoluteString.utf16.count
} else {
return false
}
}
}

View File

@ -39,8 +39,8 @@ extension FLAnimatedImageView {
public func setImage(
url: URL?,
placeholder: UIImage?,
scaleToSize: CGSize?
placeholder: UIImage? = nil,
scaleToSize: CGSize? = nil
) {
// cancel task
cancelTask()

View File

@ -22,4 +22,18 @@ public enum MastodonRegex {
/// #
/// :
public static let autoCompletePattern = "(?:@([a-zA-Z0-9_]+)(@[a-zA-Z0-9_.-]+)?|#([^\\s.]+))|(^\\B:|\\s:)([a-zA-Z0-9_]+)"
public enum Search {
public static let username = "^@?[a-z0-9_-]+(@[\\S]+)?$"
/// See: https://github.com/mastodon/mastodon/blob/main/app/javascript/mastodon/utils/hashtags.ts
public static var hashtag: String {
let word = "\\p{L}\\p{M}\\p{N}\\p{Pc}"
let alpha = "\\p{L}\\p{M}"
let hashtag_separators = "_\\u00b7\\u200c"
return "^(([\(word)_][\(word)\(hashtag_separators)]*[\(alpha)\(hashtag_separators)][\(word)\(hashtag_separators)]*[\(word)_])|([\(word)_]*[\(alpha)][\(word)_]*))$"
}
}
}

View File

@ -5,7 +5,6 @@
// Created by MainasuK Cirno on 2021-7-21.
//
import os.log
import UIKit
import MastodonLocalization
@ -117,26 +116,3 @@ extension AvatarButton {
}
}
#if canImport(SwiftUI) && DEBUG
import SwiftUI
struct AvatarButton_Previews: PreviewProvider {
static var previews: some View {
UIViewPreview(width: 42) {
let avatarButton = AvatarButton()
avatarButton.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
avatarButton.widthAnchor.constraint(equalToConstant: 42),
avatarButton.heightAnchor.constraint(equalToConstant: 42),
])
return avatarButton
}
.previewLayout(.fixed(width: 42, height: 42))
}
}
#endif

View File

@ -0,0 +1,205 @@
// Copyright © 2023 Mastodon gGmbH. All rights reserved.
import UIKit
import CoreDataStack
import MetaTextKit
import MastodonAsset
import MastodonLocalization
import MastodonMeta
import MastodonCore
import MastodonSDK
public class CondensedUserView: UIView {
private static var metricFormatter = MastodonMetricFormatter()
private let avatarImageWrapperView: UIView
let avatarImageView: AvatarImageView
private let metaInformationStackView: UIStackView
private let upperLineStackView: UIStackView
let displayNameLabel: MetaLabel
let acctLabel: UILabel
private let lowerLineStackView: UIStackView
let followersLabel: UILabel
let verifiedLinkImageView: UIImageView
let verifiedLinkLabel: MetaLabel
private let contentStackView: UIStackView
public override init(frame: CGRect) {
avatarImageView = AvatarImageView()
avatarImageView.cornerConfiguration = AvatarImageView.CornerConfiguration(corner: .fixed(radius: 8))
avatarImageView.translatesAutoresizingMaskIntoConstraints = false
avatarImageWrapperView = UIView()
avatarImageWrapperView.translatesAutoresizingMaskIntoConstraints = false
avatarImageWrapperView.addSubview(avatarImageView)
displayNameLabel = MetaLabel(style: .statusName)
displayNameLabel.setContentCompressionResistancePriority(.required, for: .horizontal)
displayNameLabel.setContentHuggingPriority(.required, for: .horizontal)
acctLabel = UILabel()
acctLabel.textColor = .secondaryLabel
acctLabel.font = UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 15, weight: .regular))
acctLabel.setContentCompressionResistancePriority(.defaultHigh, for: .horizontal)
upperLineStackView = UIStackView(arrangedSubviews: [displayNameLabel, acctLabel])
upperLineStackView.distribution = .fill
upperLineStackView.alignment = .center
followersLabel = UILabel()
followersLabel.setContentCompressionResistancePriority(.defaultHigh, for: .horizontal)
followersLabel.textColor = .secondaryLabel
followersLabel.setContentHuggingPriority(.required, for: .horizontal)
verifiedLinkImageView = UIImageView()
verifiedLinkImageView.setContentCompressionResistancePriority(.defaultHigh - 1, for: .vertical)
verifiedLinkImageView.setContentHuggingPriority(.required, for: .horizontal)
verifiedLinkImageView.contentMode = .scaleAspectFit
verifiedLinkLabel = MetaLabel(style: .profileFieldValue)
verifiedLinkLabel.setContentCompressionResistancePriority(.defaultHigh - 2, for: .horizontal)
verifiedLinkLabel.translatesAutoresizingMaskIntoConstraints = false
verifiedLinkLabel.textAttributes = [
.font: UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 15, weight: .regular)),
.foregroundColor: UIColor.secondaryLabel
]
verifiedLinkLabel.linkAttributes = [
.font: UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 15, weight: .regular)),
.foregroundColor: Asset.Colors.Brand.blurple.color
]
verifiedLinkLabel.isUserInteractionEnabled = false
lowerLineStackView = UIStackView(arrangedSubviews: [followersLabel, verifiedLinkImageView, verifiedLinkLabel])
lowerLineStackView.distribution = .fill
lowerLineStackView.alignment = .center
lowerLineStackView.spacing = 4
lowerLineStackView.setCustomSpacing(2, after: verifiedLinkImageView)
metaInformationStackView = UIStackView(arrangedSubviews: [upperLineStackView, lowerLineStackView])
metaInformationStackView.axis = .vertical
metaInformationStackView.alignment = .leading
contentStackView = UIStackView(arrangedSubviews: [avatarImageWrapperView, metaInformationStackView])
contentStackView.translatesAutoresizingMaskIntoConstraints = false
contentStackView.axis = .horizontal
contentStackView.alignment = .center
contentStackView.spacing = 16
super.init(frame: .zero)
addSubview(contentStackView)
setupConstraints()
}
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
private func setupConstraints() {
let constraints = [
contentStackView.topAnchor.constraint(equalTo: topAnchor, constant: 8),
contentStackView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 16),
trailingAnchor.constraint(greaterThanOrEqualTo: contentStackView.trailingAnchor, constant: 16),
bottomAnchor.constraint(equalTo: contentStackView.bottomAnchor, constant: 8),
upperLineStackView.trailingAnchor.constraint(greaterThanOrEqualTo: metaInformationStackView.trailingAnchor),
lowerLineStackView.trailingAnchor.constraint(greaterThanOrEqualTo: metaInformationStackView.trailingAnchor),
metaInformationStackView.trailingAnchor.constraint(greaterThanOrEqualTo: contentStackView.trailingAnchor),
avatarImageView.widthAnchor.constraint(equalToConstant: 30),
avatarImageView.heightAnchor.constraint(equalTo: avatarImageView.widthAnchor),
avatarImageView.topAnchor.constraint(greaterThanOrEqualTo: avatarImageWrapperView.topAnchor),
avatarImageView.leadingAnchor.constraint(equalTo: avatarImageWrapperView.leadingAnchor),
avatarImageWrapperView.trailingAnchor.constraint(equalTo: avatarImageView.trailingAnchor),
avatarImageWrapperView.bottomAnchor.constraint(greaterThanOrEqualTo: avatarImageView.bottomAnchor),
avatarImageView.centerYAnchor.constraint(equalTo: avatarImageWrapperView.centerYAnchor),
]
NSLayoutConstraint.activate(constraints)
}
public func prepareForReuse() {
avatarImageView.prepareForReuse()
}
public func configure(with user: MastodonUser) {
let displayNameMetaContent: MetaContent
do {
let content = MastodonContent(content: user.displayNameWithFallback, emojis: user.emojis.asDictionary)
displayNameMetaContent = try MastodonMetaContent.convert(document: content)
} catch {
displayNameMetaContent = PlaintextMetaContent(string: user.displayNameWithFallback)
}
displayNameLabel.configure(content: displayNameMetaContent)
acctLabel.text = user.acct
followersLabel.attributedText = NSAttributedString(
format: NSAttributedString(string: L10n.Common.UserList.followersCount("%@"), attributes: [.font: UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 15, weight: .regular))]),
args: NSAttributedString(string: Self.metricFormatter.string(from: Int(user.followersCount)) ?? user.followersCount.formatted(), attributes: [.font: UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 15, weight: .bold))])
)
avatarImageView.setImage(url: user.avatarImageURL())
if let verifiedLink = user.verifiedLink?.value {
verifiedLinkImageView.image = UIImage(systemName: "checkmark")
verifiedLinkImageView.tintColor = Asset.Colors.Brand.blurple.color
let verifiedLinkMetaContent: MetaContent
do {
let mastodonContent = MastodonContent(content: verifiedLink, emojis: [:])
verifiedLinkMetaContent = try MastodonMetaContent.convert(document: mastodonContent)
} catch {
verifiedLinkMetaContent = PlaintextMetaContent(string: verifiedLink)
}
verifiedLinkLabel.configure(content: verifiedLinkMetaContent)
} else {
verifiedLinkImageView.image = UIImage(systemName: "questionmark.circle")
verifiedLinkImageView.tintColor = .secondaryLabel
verifiedLinkLabel.configure(content: PlaintextMetaContent(string: L10n.Common.UserList.noVerifiedLink))
}
}
public func configure(with account: Mastodon.Entity.Account) {
let displayNameMetaContent: MetaContent
do {
let content = MastodonContent(content: account.displayNameWithFallback, emojis: account.emojis?.asDictionary ?? [:])
displayNameMetaContent = try MastodonMetaContent.convert(document: content)
} catch {
displayNameMetaContent = PlaintextMetaContent(string: account.displayNameWithFallback)
}
displayNameLabel.configure(content: displayNameMetaContent)
acctLabel.text = account.acct
followersLabel.attributedText = NSAttributedString(
format: NSAttributedString(string: L10n.Common.UserList.followersCount("%@"), attributes: [.font: UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 15, weight: .regular))]),
args: NSAttributedString(string: Self.metricFormatter.string(from: account.followersCount) ?? account.followersCount.formatted(), attributes: [.font: UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 15, weight: .bold))])
)
avatarImageView.setImage(url: account.avatarImageURL())
if let verifiedLink = account.verifiedLink?.value {
verifiedLinkImageView.image = UIImage(systemName: "checkmark")
verifiedLinkImageView.tintColor = Asset.Colors.Brand.blurple.color
let verifiedLinkMetaContent: MetaContent
do {
let mastodonContent = MastodonContent(content: verifiedLink, emojis: [:])
verifiedLinkMetaContent = try MastodonMetaContent.convert(document: mastodonContent)
} catch {
verifiedLinkMetaContent = PlaintextMetaContent(string: verifiedLink)
}
verifiedLinkLabel.configure(content: verifiedLinkMetaContent)
} else {
verifiedLinkImageView.image = UIImage(systemName: "questionmark.circle")
verifiedLinkImageView.tintColor = .secondaryLabel
verifiedLinkLabel.configure(content: PlaintextMetaContent(string: L10n.Common.UserList.noVerifiedLink))
}
}
}

View File

@ -361,8 +361,6 @@ extension StatusView.ViewModel {
statusView.statusCardControl.alpha = isContentReveal ? 1 : 0
statusView.setSpoilerOverlayViewHidden(isHidden: isContentReveal)
self.logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): isContentReveal: \(isContentReveal)")
}
.store(in: &disposeBag)
@ -400,7 +398,6 @@ extension StatusView.ViewModel {
$mediaViewConfigurations
.sink { [weak self] configurations in
guard let self = self else { return }
self.logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): configure media")
statusView.mediaGridContainerView.prepareForReuse()

View File

@ -6,7 +6,6 @@
//
import CoreDataStack
import os.log
import UIKit
import Combine
import MetaTextKit
@ -19,9 +18,7 @@ extension UserView {
public final class ViewModel: ObservableObject {
public var disposeBag = Set<AnyCancellable>()
public var observations = Set<NSKeyValueObservation>()
let logger = Logger(subsystem: "StatusView", category: "ViewModel")
@Published public var authorAvatarImage: UIImage?
@Published public var authorAvatarImageURL: URL?
@Published public var authorName: MetaContent?

View File

@ -261,41 +261,48 @@ public extension UserView {
switch state {
case .loading:
followButtonWrapper.isHidden = false
followButton.isHidden = false
followButton.setTitle(nil, for: .normal)
followButton.setBackgroundColor(Asset.Colors.Button.disabled.color, for: .normal)
case .follow:
followButtonWrapper.isHidden = false
followButton.isHidden = false
followButton.setTitle(L10n.Common.Controls.Friendship.follow, for: .normal)
followButton.setBackgroundColor(Asset.Colors.Button.userFollow.color, for: .normal)
followButton.setTitleColor(.white, for: .normal)
case .request:
followButtonWrapper.isHidden = false
followButton.isHidden = false
followButton.setTitle(L10n.Common.Controls.Friendship.request, for: .normal)
followButton.setBackgroundColor(Asset.Colors.Button.userFollow.color, for: .normal)
followButton.setTitleColor(.white, for: .normal)
case .pending:
followButtonWrapper.isHidden = false
followButton.isHidden = false
followButton.setTitle(L10n.Common.Controls.Friendship.pending, for: .normal)
followButton.setTitleColor(Asset.Colors.Button.userFollowingTitle.color, for: .normal)
followButton.setBackgroundColor(Asset.Colors.Button.userFollowing.color, for: .normal)
case .unfollow:
followButtonWrapper.isHidden = false
followButton.isHidden = false
followButton.setTitle(L10n.Common.Controls.Friendship.following, for: .normal)
followButton.setBackgroundColor(Asset.Colors.Button.userFollowing.color, for: .normal)
followButton.setTitleColor(Asset.Colors.Button.userFollowingTitle.color, for: .normal)
case .blocked:
followButtonWrapper.isHidden = false
followButton.isHidden = false
followButton.setTitle(L10n.Common.Controls.Friendship.blocked, for: .normal)
followButton.setBackgroundColor(Asset.Colors.Button.userBlocked.color, for: .normal)
followButton.setTitleColor(.systemRed, for: .normal)
case .none:
followButtonWrapper.isHidden = true
followButton.isHidden = true
followButton.setTitle(nil, for: .normal)
followButton.setBackgroundColor(.clear, for: .normal)