diff --git a/Localization/StringsConvertor/input/Base.lproj/app.json b/Localization/StringsConvertor/input/Base.lproj/app.json index 739573267..139f20347 100644 --- a/Localization/StringsConvertor/input/Base.lproj/app.json +++ b/Localization/StringsConvertor/input/Base.lproj/app.json @@ -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" } }, diff --git a/Localization/app.json b/Localization/app.json index 739573267..b82bcb151 100644 --- a/Localization/app.json +++ b/Localization/app.json @@ -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" } diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index c46ea5ec7..20af38cce 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -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 = ""; }; D8099079294BC9390050219F /* PrivacyTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrivacyTableViewCell.swift; sourceTree = ""; }; D809907B294D25510050219F /* PrivacyViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrivacyViewModel.swift; sourceTree = ""; }; + D81A22742AB4643200905D71 /* SearchResultsOverviewTableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultsOverviewTableViewController.swift; sourceTree = ""; }; + D81A22772AB4782400905D71 /* SearchResultOverviewSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultOverviewSection.swift; sourceTree = ""; }; + D81A227A2AB47B9A00905D71 /* SearchResultDefaultSectionTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultDefaultSectionTableViewCell.swift; sourceTree = ""; }; D82463522A52B47B00A3DBDD /* be */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = be; path = be.lproj/Intents.strings; sourceTree = ""; }; D82463532A52B47B00A3DBDD /* be */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = be; path = be.lproj/WidgetExtension.strings; sourceTree = ""; }; D82463542A52B47B00A3DBDD /* be */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = be; path = be.lproj/InfoPlist.strings; sourceTree = ""; }; @@ -799,6 +804,8 @@ D8A6FE6629325F5900666A47 /* Localizable.stringsdict */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; path = Localizable.stringsdict; sourceTree = ""; }; D8BE30B22A179E26006B8270 /* SuggestionAccountTableViewFooter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionAccountTableViewFooter.swift; sourceTree = ""; }; D8BEBCB52A1B7FFD0004F475 /* SuggestionAccountTableViewCell+ViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SuggestionAccountTableViewCell+ViewModel.swift"; sourceTree = ""; }; + D8D688F52AB869CB000F651A /* SearchResultsProfileTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultsProfileTableViewCell.swift; sourceTree = ""; }; + D8D688F82AB8B970000F651A /* SearchResultOverviewCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultOverviewCoordinator.swift; sourceTree = ""; }; D8E5C345296DAB84007E76A7 /* DataSourceFacade+Status+History.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DataSourceFacade+Status+History.swift"; sourceTree = ""; }; D8E5C348296DB8A3007E76A7 /* StatusEditHistoryViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusEditHistoryViewController.swift; sourceTree = ""; }; D8F0372B29D232730027DE2E /* HashtagIntentHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HashtagIntentHandler.swift; sourceTree = ""; }; @@ -847,7 +854,6 @@ DB0FCB832796B2A2006C02E2 /* FavoriteViewController+DataSourceProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FavoriteViewController+DataSourceProvider.swift"; sourceTree = ""; }; DB0FCB852796BDA1006C02E2 /* SearchSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchSection.swift; sourceTree = ""; }; DB0FCB872796BDA9006C02E2 /* SearchItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchItem.swift; sourceTree = ""; }; - DB0FCB8B2796BF8D006C02E2 /* SearchViewModel+Diffable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SearchViewModel+Diffable.swift"; sourceTree = ""; }; DB0FCB8D2796C0B7006C02E2 /* TrendCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrendCollectionViewCell.swift; sourceTree = ""; }; DB0FCB912796DE19006C02E2 /* TrendSectionHeaderCollectionReusableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrendSectionHeaderCollectionReusableView.swift; sourceTree = ""; }; DB0FCB932797E2B0006C02E2 /* SearchResultViewModel+Diffable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SearchResultViewModel+Diffable.swift"; sourceTree = ""; }; @@ -929,7 +935,6 @@ DB4B779626CA50BA00B087B3 /* th */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = th; path = th.lproj/Intents.stringsdict; sourceTree = ""; }; DB4F0962269ED06300D62E92 /* SearchResultViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultViewController.swift; sourceTree = ""; }; DB4F0965269ED52200D62E92 /* SearchResultViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultViewModel.swift; sourceTree = ""; }; - DB4F0967269ED8AD00D62E92 /* SearchHistoryTableHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchHistoryTableHeaderView.swift; sourceTree = ""; }; DB4F0969269EDAD200D62E92 /* SearchResultViewModel+State.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SearchResultViewModel+State.swift"; sourceTree = ""; }; DB4F097426A037F500D62E92 /* SearchHistoryViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchHistoryViewModel.swift; sourceTree = ""; }; DB4F097A26A039FF00D62E92 /* SearchHistorySection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchHistorySection.swift; sourceTree = ""; }; @@ -989,7 +994,6 @@ DB63F74E2799405600455B82 /* SearchHistoryViewModel+Diffable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SearchHistoryViewModel+Diffable.swift"; sourceTree = ""; }; DB63F751279944AA00455B82 /* SearchHistorySectionHeaderCollectionReusableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchHistorySectionHeaderCollectionReusableView.swift; sourceTree = ""; }; DB63F7532799491600455B82 /* DataSourceFacade+SearchHistory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DataSourceFacade+SearchHistory.swift"; sourceTree = ""; }; - DB63F759279953F200455B82 /* SearchHistoryUserCollectionViewCell+ViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SearchHistoryUserCollectionViewCell+ViewModel.swift"; sourceTree = ""; }; DB63F76127996B6600455B82 /* SearchHistoryViewController+DataSourceProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SearchHistoryViewController+DataSourceProvider.swift"; sourceTree = ""; }; DB63F763279A5E3C00455B82 /* NotificationTimelineViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationTimelineViewController.swift; sourceTree = ""; }; DB63F766279A5EB300455B82 /* NotificationTimelineViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationTimelineViewModel.swift; sourceTree = ""; }; @@ -1795,6 +1799,26 @@ path = Privacy; sourceTree = ""; }; + D81A22732AB4641F00905D71 /* Search Results Overview */ = { + isa = PBXGroup; + children = ( + D81A22792AB47B8400905D71 /* Cells */, + D81A22742AB4643200905D71 /* SearchResultsOverviewTableViewController.swift */, + D81A22772AB4782400905D71 /* SearchResultOverviewSection.swift */, + D8D688F82AB8B970000F651A /* SearchResultOverviewCoordinator.swift */, + ); + path = "Search Results Overview"; + sourceTree = ""; + }; + D81A22792AB47B8400905D71 /* Cells */ = { + isa = PBXGroup; + children = ( + D81A227A2AB47B9A00905D71 /* SearchResultDefaultSectionTableViewCell.swift */, + D8D688F52AB869CB000F651A /* SearchResultsProfileTableViewCell.swift */, + ); + path = Cells; + sourceTree = ""; + }; 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 = ""; @@ -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 = ""; @@ -2136,14 +2158,6 @@ path = Status; sourceTree = ""; }; - DB4F098026A0475500D62E92 /* View */ = { - isa = PBXGroup; - children = ( - DB4F0967269ED8AD00D62E92 /* SearchHistoryTableHeaderView.swift */, - ); - path = View; - sourceTree = ""; - }; 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 = ""; @@ -2933,7 +2948,6 @@ 2DE0FAC62615F5D200CDF649 /* View */, DB9D6BE825E4F5340051B173 /* SearchViewController.swift */, 2D6DE3FF26141DF600A63F6A /* SearchViewModel.swift */, - DB0FCB8B2796BF8D006C02E2 /* SearchViewModel+Diffable.swift */, ); path = Search; sourceTree = ""; @@ -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 */, diff --git a/Mastodon/Coordinator/NeedsDependency.swift b/Mastodon/Coordinator/NeedsDependency.swift index c035437ac..be22fc8cd 100644 --- a/Mastodon/Coordinator/NeedsDependency.swift +++ b/Mastodon/Coordinator/NeedsDependency.swift @@ -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" diff --git a/Mastodon/Coordinator/SceneCoordinator.swift b/Mastodon/Coordinator/SceneCoordinator.swift index 4908a533a..4455e46d2 100644 --- a/Mastodon/Coordinator/SceneCoordinator.swift +++ b/Mastodon/Coordinator/SceneCoordinator.swift @@ -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) diff --git a/Mastodon/Diffable/User/UserSection.swift b/Mastodon/Diffable/User/UserSection.swift index cbad1ff72..24f13ddc6 100644 --- a/Mastodon/Diffable/User/UserSection.swift +++ b/Mastodon/Diffable/User/UserSection.swift @@ -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 diff --git a/Mastodon/Protocol/Provider/DataSourceFacade+Hashtag.swift b/Mastodon/Protocol/Provider/DataSourceFacade+Hashtag.swift index 6135c904a..2422adf6c 100644 --- a/Mastodon/Protocol/Provider/DataSourceFacade+Hashtag.swift +++ b/Mastodon/Protocol/Provider/DataSourceFacade+Hashtag.swift @@ -26,7 +26,7 @@ extension DataSourceFacade { @MainActor static func coordinateToHashtagScene( - provider: DataSourceProvider & AuthContextProvider, + provider: ViewControllerWithDependencies & AuthContextProvider, tag: Mastodon.Entity.Tag ) async { let hashtagTimelineViewModel = HashtagTimelineViewModel( diff --git a/Mastodon/Protocol/Provider/DataSourceFacade+Profile.swift b/Mastodon/Protocol/Provider/DataSourceFacade+Profile.swift index f434bc6ac..8f77a1888 100644 --- a/Mastodon/Protocol/Provider/DataSourceFacade+Profile.swift +++ b/Mastodon/Protocol/Provider/DataSourceFacade+Profile.swift @@ -33,7 +33,7 @@ extension DataSourceFacade { @MainActor static func coordinateToProfileScene( - provider: DataSourceProvider & AuthContextProvider, + provider: ViewControllerWithDependencies & AuthContextProvider, user: ManagedObjectRecord ) 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 -// ) -> 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 diff --git a/Mastodon/Protocol/Provider/DataSourceFacade+SearchHistory.swift b/Mastodon/Protocol/Provider/DataSourceFacade+SearchHistory.swift index 18d238c02..48d6b7f03 100644 --- a/Mastodon/Protocol/Provider/DataSourceFacade+SearchHistory.swift +++ b/Mastodon/Protocol/Provider/DataSourceFacade+SearchHistory.swift @@ -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 { diff --git a/Mastodon/Protocol/Provider/DataSourceFacade+Thread.swift b/Mastodon/Protocol/Provider/DataSourceFacade+Thread.swift index 41f5d58de..ad8d0e671 100644 --- a/Mastodon/Protocol/Provider/DataSourceFacade+Thread.swift +++ b/Mastodon/Protocol/Provider/DataSourceFacade+Thread.swift @@ -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 ) async { @@ -40,7 +40,7 @@ extension DataSourceFacade { @MainActor static func coordinateToStatusThreadScene( - provider: DataSourceProvider & AuthContextProvider, + provider: ViewControllerWithDependencies & AuthContextProvider, root: StatusItem.Thread ) async { let threadViewModel = ThreadViewModel( diff --git a/Mastodon/Protocol/Provider/DataSourceProvider+StatusTableViewCellDelegate.swift b/Mastodon/Protocol/Provider/DataSourceProvider+StatusTableViewCellDelegate.swift index dc6a9fda5..85718c94a 100644 --- a/Mastodon/Protocol/Provider/DataSourceProvider+StatusTableViewCellDelegate.swift +++ b/Mastodon/Protocol/Provider/DataSourceProvider+StatusTableViewCellDelegate.swift @@ -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 } diff --git a/Mastodon/Protocol/Provider/DataSourceProvider+UITableViewDelegate.swift b/Mastodon/Protocol/Provider/DataSourceProvider+UITableViewDelegate.swift index 6ced42601..299951ce2 100644 --- a/Mastodon/Protocol/Provider/DataSourceProvider+UITableViewDelegate.swift +++ b/Mastodon/Protocol/Provider/DataSourceProvider+UITableViewDelegate.swift @@ -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 } diff --git a/Mastodon/Protocol/Provider/DataSourceProvider.swift b/Mastodon/Protocol/Provider/DataSourceProvider.swift index 425e40417..e4287b6b9 100644 --- a/Mastodon/Protocol/Provider/DataSourceProvider.swift +++ b/Mastodon/Protocol/Provider/DataSourceProvider.swift @@ -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? } diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+Diffable.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+Diffable.swift index 24cf2258a..6da6a60fa 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+Diffable.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+Diffable.swift @@ -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 = { 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 } diff --git a/Mastodon/Scene/Search/Search/SearchViewController.swift b/Mastodon/Scene/Search/Search/SearchViewController.swift index e1505121d..093976e63 100644 --- a/Mastodon/Scene/Search/Search/SearchViewController.swift +++ b/Mastodon/Scene/Search/Search/SearchViewController.swift @@ -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() @@ -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) -// } -// } -//} diff --git a/Mastodon/Scene/Search/Search/SearchViewModel+Diffable.swift b/Mastodon/Scene/Search/Search/SearchViewModel+Diffable.swift deleted file mode 100644 index 3f448289a..000000000 --- a/Mastodon/Scene/Search/Search/SearchViewModel+Diffable.swift +++ /dev/null @@ -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() -// 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() -// snapshot.appendSections([.trend]) -// -// let trendItems = hashtags.map { SearchItem.trend($0) } -// snapshot.appendItems(trendItems, toSection: .trend) -// -// diffableDataSource.apply(snapshot) -// } -// .store(in: &disposeBag) -// } -// -//} diff --git a/Mastodon/Scene/Search/Search/SearchViewModel.swift b/Mastodon/Scene/Search/Search/SearchViewModel.swift index 51d614280..620099bfc 100644 --- a/Mastodon/Scene/Search/Search/SearchViewModel.swift +++ b/Mastodon/Scene/Search/Search/SearchViewModel.swift @@ -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, Error> { response } } -// .catch { error in Just(Result, 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) } - } diff --git a/Mastodon/Scene/Search/SearchDetail/Search Results Overview/Cells/SearchResultDefaultSectionTableViewCell.swift b/Mastodon/Scene/Search/SearchDetail/Search Results Overview/Cells/SearchResultDefaultSectionTableViewCell.swift new file mode 100644 index 000000000..e6cd2b902 --- /dev/null +++ b/Mastodon/Scene/Search/SearchDetail/Search Results Overview/Cells/SearchResultDefaultSectionTableViewCell.swift @@ -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 + } +} diff --git a/Mastodon/Scene/Search/SearchDetail/Search Results Overview/Cells/SearchResultsProfileTableViewCell.swift b/Mastodon/Scene/Search/SearchDetail/Search Results Overview/Cells/SearchResultsProfileTableViewCell.swift new file mode 100644 index 000000000..303c20aa8 --- /dev/null +++ b/Mastodon/Scene/Search/SearchDetail/Search Results Overview/Cells/SearchResultsProfileTableViewCell.swift @@ -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() + } +} diff --git a/Mastodon/Scene/Search/SearchDetail/Search Results Overview/SearchResultOverviewCoordinator.swift b/Mastodon/Scene/Search/SearchDetail/Search Results Overview/SearchResultOverviewCoordinator.swift new file mode 100644 index 000000000..a2d6c8e24 --- /dev/null +++ b/Mastodon/Scene/Search/SearchDetail/Search Results Overview/SearchResultOverviewCoordinator.swift @@ -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? + + 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)) + } + } + } + } +} diff --git a/Mastodon/Scene/Search/SearchDetail/Search Results Overview/SearchResultOverviewSection.swift b/Mastodon/Scene/Search/SearchDetail/Search Results Overview/SearchResultOverviewSection.swift new file mode 100644 index 000000000..a9a1438c9 --- /dev/null +++ b/Mastodon/Scene/Search/SearchDetail/Search Results Overview/SearchResultOverviewSection.swift @@ -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 + } + } + } +} diff --git a/Mastodon/Scene/Search/SearchDetail/Search Results Overview/SearchResultsOverviewTableViewController.swift b/Mastodon/Scene/Search/SearchDetail/Search Results Overview/SearchResultsOverviewTableViewController.swift new file mode 100644 index 000000000..db38027bc --- /dev/null +++ b/Mastodon/Scene/Search/SearchDetail/Search Results Overview/SearchResultsOverviewTableViewController.swift @@ -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? + + weak var delegate: SearchResultsOverviewTableViewControllerDelegate? + + var activeTask: Task? + + 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(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() + 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 {} diff --git a/Mastodon/Scene/Search/SearchDetail/SearchDetailViewController.swift b/Mastodon/Scene/Search/SearchDetail/SearchDetailViewController.swift index 84a0aedb2..82cb9795f 100644 --- a/Mastodon/Scene/Search/SearchDetail/SearchDetailViewController.swift +++ b/Mastodon/Scene/Search/SearchDetail/SearchDetailViewController.swift @@ -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() var observations = Set() + 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 - } } diff --git a/Mastodon/Scene/Search/SearchDetail/SearchDetailViewModel.swift b/Mastodon/Scene/Search/SearchDetail/SearchDetailViewModel.swift index 779aaa2dc..04228ba5a 100644 --- a/Mastodon/Scene/Search/SearchDetail/SearchDetailViewModel.swift +++ b/Mastodon/Scene/Search/SearchDetail/SearchDetailViewModel.swift @@ -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() let navigationBarFrame = CurrentValueSubject(.zero) - + // output let searchScopes = SearchScope.allCases let selectedSearchScope = CurrentValueSubject(.all) let searchText: CurrentValueSubject let searchActionPublisher = PassthroughSubject() - + 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 - } } } } diff --git a/Mastodon/Scene/Search/SearchDetail/SearchHistory/Cell/SearchHistorySectionHeaderCollectionReusableView.swift b/Mastodon/Scene/Search/SearchDetail/SearchHistory/Cell/SearchHistorySectionHeaderCollectionReusableView.swift index b6f30f94a..6375f5d29 100644 --- a/Mastodon/Scene/Search/SearchDetail/SearchHistory/Cell/SearchHistorySectionHeaderCollectionReusableView.swift +++ b/Mastodon/Scene/Search/SearchDetail/SearchHistory/Cell/SearchHistorySectionHeaderCollectionReusableView.swift @@ -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) } } diff --git a/Mastodon/Scene/Search/SearchDetail/SearchHistory/Cell/SearchHistoryUserCollectionViewCell+ViewModel.swift b/Mastodon/Scene/Search/SearchDetail/SearchHistory/Cell/SearchHistoryUserCollectionViewCell+ViewModel.swift deleted file mode 100644 index e31f050bd..000000000 --- a/Mastodon/Scene/Search/SearchDetail/SearchHistory/Cell/SearchHistoryUserCollectionViewCell+ViewModel.swift +++ /dev/null @@ -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) - - } -} diff --git a/Mastodon/Scene/Search/SearchDetail/SearchHistory/Cell/SearchHistoryUserCollectionViewCell.swift b/Mastodon/Scene/Search/SearchDetail/SearchHistory/Cell/SearchHistoryUserCollectionViewCell.swift index 1da7075f2..db315b1d1 100644 --- a/Mastodon/Scene/Search/SearchDetail/SearchHistory/Cell/SearchHistoryUserCollectionViewCell.swift +++ b/Mastodon/Scene/Search/SearchDetail/SearchHistory/Cell/SearchHistoryUserCollectionViewCell.swift @@ -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() - - 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 + } - } + diff --git a/Mastodon/Diffable/Search/SearchHistoryItem.swift b/Mastodon/Scene/Search/SearchDetail/SearchHistory/SearchHistoryItem.swift similarity index 100% rename from Mastodon/Diffable/Search/SearchHistoryItem.swift rename to Mastodon/Scene/Search/SearchDetail/SearchHistory/SearchHistoryItem.swift diff --git a/Mastodon/Diffable/Search/SearchHistorySection.swift b/Mastodon/Scene/Search/SearchDetail/SearchHistory/SearchHistorySection.swift similarity index 72% rename from Mastodon/Diffable/Search/SearchHistorySection.swift rename to Mastodon/Scene/Search/SearchDetail/SearchHistory/SearchHistorySection.swift index 813c5b59a..4269cdda7 100644 --- a/Mastodon/Diffable/Search/SearchHistorySection.swift +++ b/Mastodon/Scene/Search/SearchDetail/SearchHistory/SearchHistorySection.swift @@ -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> { 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(elementKind: UICollectionView.elementKindSectionHeader) { [weak dataSource] supplementaryView, elementKind, indexPath in + let trendHeaderRegister = UICollectionView.SupplementaryRegistration(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 diff --git a/Mastodon/Scene/Search/SearchDetail/SearchHistory/SearchHistoryViewController.swift b/Mastodon/Scene/Search/SearchDetail/SearchHistory/SearchHistoryViewController.swift index ff15b08ed..8645da5ac 100644 --- a/Mastodon/Scene/Search/SearchDetail/SearchHistory/SearchHistoryViewController.swift +++ b/Mastodon/Scene/Search/SearchDetail/SearchHistory/SearchHistoryViewController.swift @@ -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 {} diff --git a/Mastodon/Scene/Search/SearchDetail/SearchHistory/SearchHistoryViewModel+Diffable.swift b/Mastodon/Scene/Search/SearchDetail/SearchHistory/SearchHistoryViewModel+Diffable.swift index 92b70bf99..a8af8e845 100644 --- a/Mastodon/Scene/Search/SearchDetail/SearchHistory/SearchHistoryViewModel+Diffable.swift +++ b/Mastodon/Scene/Search/SearchDetail/SearchHistory/SearchHistoryViewModel+Diffable.swift @@ -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() 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 } diff --git a/Mastodon/Scene/Search/SearchDetail/SearchHistory/SearchHistoryViewModel.swift b/Mastodon/Scene/Search/SearchDetail/SearchHistory/SearchHistoryViewModel.swift index d20f2d495..5078895c0 100644 --- a/Mastodon/Scene/Search/SearchDetail/SearchHistory/SearchHistoryViewModel.swift +++ b/Mastodon/Scene/Search/SearchDetail/SearchHistory/SearchHistoryViewModel.swift @@ -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) -// } -//} diff --git a/Mastodon/Scene/Search/SearchDetail/SearchHistory/View/SearchHistoryTableHeaderView.swift b/Mastodon/Scene/Search/SearchDetail/SearchHistory/View/SearchHistoryTableHeaderView.swift deleted file mode 100644 index d827231c0..000000000 --- a/Mastodon/Scene/Search/SearchDetail/SearchHistory/View/SearchHistoryTableHeaderView.swift +++ /dev/null @@ -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() - - 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 - } -} diff --git a/Mastodon/Scene/Search/SearchDetail/SearchResult/Cell/HashtagTableViewCell.swift b/Mastodon/Scene/Search/SearchDetail/SearchResult/Cell/HashtagTableViewCell.swift index c8938c549..ccb74e31e 100644 --- a/Mastodon/Scene/Search/SearchDetail/SearchResult/Cell/HashtagTableViewCell.swift +++ b/Mastodon/Scene/Search/SearchDetail/SearchResult/Cell/HashtagTableViewCell.swift @@ -9,6 +9,8 @@ import UIKit import MetaTextKit final class HashtagTableViewCell: UITableViewCell { + + static let reuseIdentifier = "HashtagTableViewCell" let primaryLabel = MetaLabel(style: .statusName) diff --git a/Mastodon/Diffable/Search/SearchResultItem.swift b/Mastodon/Scene/Search/SearchDetail/SearchResult/SearchResultItem.swift similarity index 100% rename from Mastodon/Diffable/Search/SearchResultItem.swift rename to Mastodon/Scene/Search/SearchDetail/SearchResult/SearchResultItem.swift diff --git a/Mastodon/Diffable/Search/SearchResultSection.swift b/Mastodon/Scene/Search/SearchDetail/SearchResult/SearchResultSection.swift similarity index 95% rename from Mastodon/Diffable/Search/SearchResultSection.swift rename to Mastodon/Scene/Search/SearchDetail/SearchResult/SearchResultSection.swift index 90560150e..dbede1795 100644 --- a/Mastodon/Diffable/Search/SearchResultSection.swift +++ b/Mastodon/Scene/Search/SearchDetail/SearchResult/SearchResultSection.swift @@ -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()), diff --git a/Mastodon/Scene/Search/SearchDetail/SearchResult/SearchResultViewController+DataSourceProvider.swift b/Mastodon/Scene/Search/SearchDetail/SearchResult/SearchResultViewController+DataSourceProvider.swift index 71ac81ef6..f2e8c7c6e 100644 --- a/Mastodon/Scene/Search/SearchDetail/SearchResult/SearchResultViewController+DataSourceProvider.swift +++ b/Mastodon/Scene/Search/SearchDetail/SearchResult/SearchResultViewController+DataSourceProvider.swift @@ -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 } diff --git a/Mastodon/Scene/Search/SearchDetail/SearchResult/SearchResultViewController.swift b/Mastodon/Scene/Search/SearchDetail/SearchResult/SearchResultViewController.swift index e94d3033a..fa8b844a6 100644 --- a/Mastodon/Scene/Search/SearchDetail/SearchResult/SearchResultViewController.swift +++ b/Mastodon/Scene/Search/SearchDetail/SearchResult/SearchResultViewController.swift @@ -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 { } diff --git a/Mastodon/Scene/Search/SearchDetail/SearchResult/SearchResultViewModel+Diffable.swift b/Mastodon/Scene/Search/SearchDetail/SearchResult/SearchResultViewModel+Diffable.swift index 225f79b02..5b74ba8aa 100644 --- a/Mastodon/Scene/Search/SearchDetail/SearchResult/SearchResultViewModel+Diffable.swift +++ b/Mastodon/Scene/Search/SearchDetail/SearchResult/SearchResultViewModel+Diffable.swift @@ -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 } diff --git a/Mastodon/Scene/Search/SearchDetail/SearchResult/SearchResultViewModel+State.swift b/Mastodon/Scene/Search/SearchDetail/SearchResult/SearchResultViewModel+State.swift index 9b12e1af0..4858f0d2d 100644 --- a/Mastodon/Scene/Search/SearchDetail/SearchResult/SearchResultViewModel+State.swift +++ b/Mastodon/Scene/Search/SearchDetail/SearchResult/SearchResultViewModel+State.swift @@ -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 diff --git a/Mastodon/Scene/Search/SearchDetail/SearchResult/SearchResultViewModel.swift b/Mastodon/Scene/Search/SearchDetail/SearchResult/SearchResultViewModel.swift index c4cfa0f54..f66921535 100644 --- a/Mastodon/Scene/Search/SearchDetail/SearchResult/SearchResultViewModel.swift +++ b/Mastodon/Scene/Search/SearchDetail/SearchResult/SearchResultViewModel.swift @@ -20,14 +20,13 @@ final class SearchResultViewModel { // input let context: AppContext let authContext: AuthContext - let searchScope: SearchDetailViewModel.SearchScope - let searchText = CurrentValueSubject("") + let searchScope: SearchScope + let searchText: String @Published var hashtags: [Mastodon.Entity.Tag] = [] let userFetchedResultsController: UserFetchedResultsController let statusFetchedResultsController: StatusFetchedResultsController let listBatchFetchViewModel = ListBatchFetchViewModel() - let viewDidAppear = CurrentValueSubject(false) var cellFrameCache = NSCache() var navigationBarFrame = CurrentValueSubject(.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() - 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() -// 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 -// } } } diff --git a/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift b/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift index 16285ebeb..6c8c82527 100644 --- a/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift +++ b/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift @@ -13,6 +13,8 @@ import MastodonLocalization import MastodonUI final class StatusTableViewCell: UITableViewCell { + + static let reuseIdentifier = "StatusTableViewCell" static let marginForRegularHorizontalSizeClass: CGFloat = 64 diff --git a/Mastodon/Scene/Share/View/TableviewCell/UserTableViewCell+ViewModel.swift b/Mastodon/Scene/Share/View/TableviewCell/UserTableViewCell+ViewModel.swift index b83b8b47d..526e74ab3 100644 --- a/Mastodon/Scene/Share/View/TableviewCell/UserTableViewCell+ViewModel.swift +++ b/Mastodon/Scene/Share/View/TableviewCell/UserTableViewCell+ViewModel.swift @@ -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 } } diff --git a/Mastodon/Scene/Share/View/TableviewCell/UserTableViewCell.swift b/Mastodon/Scene/Share/View/TableviewCell/UserTableViewCell.swift index a05b80e9c..0f316bad8 100644 --- a/Mastodon/Scene/Share/View/TableviewCell/UserTableViewCell.swift +++ b/Mastodon/Scene/Share/View/TableviewCell/UserTableViewCell.swift @@ -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() diff --git a/MastodonSDK/Sources/CoreDataStack/Entity/Mastodon/MastodonUser.swift b/MastodonSDK/Sources/CoreDataStack/Entity/Mastodon/MastodonUser.swift index fe668ccb9..6f3c5f8eb 100644 --- a/MastodonSDK/Sources/CoreDataStack/Entity/Mastodon/MastodonUser.swift +++ b/MastodonSDK/Sources/CoreDataStack/Entity/Mastodon/MastodonUser.swift @@ -548,3 +548,10 @@ extension MastodonUser: AutoUpdatableObject { } } } + +extension MastodonUser { + public var verifiedLink: MastodonField? { + let firstVerified = fields.first(where: { $0.verifiedAt != nil }) + return firstVerified + } +} diff --git a/MastodonSDK/Sources/MastodonCore/FetchedResultsController/StatusFetchedResultsController.swift b/MastodonSDK/Sources/MastodonCore/FetchedResultsController/StatusFetchedResultsController.swift index c08673acb..bb4184bfc 100644 --- a/MastodonSDK/Sources/MastodonCore/FetchedResultsController/StatusFetchedResultsController.swift +++ b/MastodonSDK/Sources/MastodonCore/FetchedResultsController/StatusFetchedResultsController.swift @@ -90,8 +90,6 @@ extension StatusFetchedResultsController { // MARK: - NSFetchedResultsControllerDelegate extension StatusFetchedResultsController: NSFetchedResultsControllerDelegate { public func controller(_ controller: NSFetchedResultsController, didChangeContentWith snapshot: NSDiffableDataSourceSnapshotReference) { - os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) - let indexes = statusIDs let objects = fetchedResultsController.fetchedObjects ?? [] diff --git a/MastodonSDK/Sources/MastodonCore/FetchedResultsController/UserFetchedResultsController.swift b/MastodonSDK/Sources/MastodonCore/FetchedResultsController/UserFetchedResultsController.swift index d95a62bbb..cf4f1fc07 100644 --- a/MastodonSDK/Sources/MastodonCore/FetchedResultsController/UserFetchedResultsController.swift +++ b/MastodonSDK/Sources/MastodonCore/FetchedResultsController/UserFetchedResultsController.swift @@ -97,7 +97,6 @@ extension UserFetchedResultsController { // MARK: - NSFetchedResultsControllerDelegate extension UserFetchedResultsController: NSFetchedResultsControllerDelegate { public func controller(_ controller: NSFetchedResultsController, didChangeContentWith snapshot: NSDiffableDataSourceSnapshotReference) { - os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) let indexes = userIDs let objects = fetchedResultsController.fetchedObjects ?? [] diff --git a/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Account.swift b/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Account.swift index d68984587..812c558d8 100644 --- a/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Account.swift +++ b/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Account.swift @@ -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 } diff --git a/MastodonSDK/Sources/MastodonLocalization/Generated/Strings.swift b/MastodonSDK/Sources/MastodonLocalization/Generated/Strings.swift index 27f3c06d0..3367b4805 100644 --- a/MastodonSDK/Sources/MastodonLocalization/Generated/Strings.swift +++ b/MastodonSDK/Sources/MastodonLocalization/Generated/Strings.swift @@ -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") } } } diff --git a/MastodonSDK/Sources/MastodonLocalization/Resources/Base.lproj/Localizable.strings b/MastodonSDK/Sources/MastodonLocalization/Resources/Base.lproj/Localizable.strings index 7f00f1cc2..9ef23c951 100644 --- a/MastodonSDK/Sources/MastodonLocalization/Resources/Base.lproj/Localizable.strings +++ b/MastodonSDK/Sources/MastodonLocalization/Resources/Base.lproj/Localizable.strings @@ -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"; \ No newline at end of file +"Widget.MultipleFollowers.MockUser.DisplayName" = "Another follower"; diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift index 76bebe5a0..57d99dc41 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift @@ -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) ?? "") debugPrint(decodeError) #endif diff --git a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Account.swift b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Account.swift index 38bcf14fc..34fdad5ba 100644 --- a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Account.swift +++ b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Account.swift @@ -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 + } +} diff --git a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+History.swift b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+History.swift index 5f49d80f0..1acd3e84c 100644 --- a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+History.swift +++ b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+History.swift @@ -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 diff --git a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Tag.swift b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Tag.swift index 8d7baa6a6..6a3516904 100644 --- a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Tag.swift +++ b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Tag.swift @@ -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 diff --git a/MastodonSDK/Sources/MastodonSDK/Extension/URL.swift b/MastodonSDK/Sources/MastodonSDK/Extension/URL.swift index a9345c7b3..5675fc8c5 100644 --- a/MastodonSDK/Sources/MastodonSDK/Extension/URL.swift +++ b/MastodonSDK/Sources/MastodonSDK/Extension/URL.swift @@ -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 + } + } } diff --git a/MastodonSDK/Sources/MastodonUI/Extension/FLAnimatedImageView.swift b/MastodonSDK/Sources/MastodonUI/Extension/FLAnimatedImageView.swift index 3fc92a4b5..68c7fbc95 100644 --- a/MastodonSDK/Sources/MastodonUI/Extension/FLAnimatedImageView.swift +++ b/MastodonSDK/Sources/MastodonUI/Extension/FLAnimatedImageView.swift @@ -39,8 +39,8 @@ extension FLAnimatedImageView { public func setImage( url: URL?, - placeholder: UIImage?, - scaleToSize: CGSize? + placeholder: UIImage? = nil, + scaleToSize: CGSize? = nil ) { // cancel task cancelTask() diff --git a/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Helper/MastodonRegex.swift b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Helper/MastodonRegex.swift index c8c3f498b..76df54124 100644 --- a/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Helper/MastodonRegex.swift +++ b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Helper/MastodonRegex.swift @@ -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)_]*))$" + } + } } + diff --git a/MastodonSDK/Sources/MastodonUI/View/Button/AvatarButton.swift b/MastodonSDK/Sources/MastodonUI/View/Button/AvatarButton.swift index ab5c8a70e..6d399c1b2 100644 --- a/MastodonSDK/Sources/MastodonUI/View/Button/AvatarButton.swift +++ b/MastodonSDK/Sources/MastodonUI/View/Button/AvatarButton.swift @@ -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 - diff --git a/MastodonSDK/Sources/MastodonUI/View/Content/CondensedUserView.swift b/MastodonSDK/Sources/MastodonUI/View/Content/CondensedUserView.swift new file mode 100644 index 000000000..be766f5e6 --- /dev/null +++ b/MastodonSDK/Sources/MastodonUI/View/Content/CondensedUserView.swift @@ -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)) + } + } + +} diff --git a/MastodonSDK/Sources/MastodonUI/View/Content/StatusView+ViewModel.swift b/MastodonSDK/Sources/MastodonUI/View/Content/StatusView+ViewModel.swift index d7d2faf48..ebae0d12e 100644 --- a/MastodonSDK/Sources/MastodonUI/View/Content/StatusView+ViewModel.swift +++ b/MastodonSDK/Sources/MastodonUI/View/Content/StatusView+ViewModel.swift @@ -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() diff --git a/MastodonSDK/Sources/MastodonUI/View/Content/UserView+ViewModel.swift b/MastodonSDK/Sources/MastodonUI/View/Content/UserView+ViewModel.swift index 1b9a88772..4127ed4d4 100644 --- a/MastodonSDK/Sources/MastodonUI/View/Content/UserView+ViewModel.swift +++ b/MastodonSDK/Sources/MastodonUI/View/Content/UserView+ViewModel.swift @@ -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() public var observations = Set() - - let logger = Logger(subsystem: "StatusView", category: "ViewModel") - + @Published public var authorAvatarImage: UIImage? @Published public var authorAvatarImageURL: URL? @Published public var authorName: MetaContent? diff --git a/MastodonSDK/Sources/MastodonUI/View/Content/UserView.swift b/MastodonSDK/Sources/MastodonUI/View/Content/UserView.swift index 2ea1f1a1f..9221c7f90 100644 --- a/MastodonSDK/Sources/MastodonUI/View/Content/UserView.swift +++ b/MastodonSDK/Sources/MastodonUI/View/Content/UserView.swift @@ -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)