diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index 98da13748..73332bab8 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -31,7 +31,6 @@ 2D206B9225F60EA700143C56 /* UIControl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D206B9125F60EA700143C56 /* UIControl.swift */; }; 2D24E1232626ED9D00A59D4F /* UIView+Gesture.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D24E1222626ED9D00A59D4F /* UIView+Gesture.swift */; }; 2D32EAAC25CB96DC00C9ED86 /* TimelineMiddleLoaderTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D32EAAB25CB96DC00C9ED86 /* TimelineMiddleLoaderTableViewCell.swift */; }; - 2D32EABA25CB9B0500C9ED86 /* UIView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D32EAB925CB9B0500C9ED86 /* UIView.swift */; }; 2D34D9D126148D9E0081BFC0 /* APIService+Recommend.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D34D9D026148D9E0081BFC0 /* APIService+Recommend.swift */; }; 2D34D9DB261494120081BFC0 /* APIService+Search.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D34D9DA261494120081BFC0 /* APIService+Search.swift */; }; 2D35237A26256D920031AF25 /* NotificationSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D35237926256D920031AF25 /* NotificationSection.swift */; }; @@ -223,6 +222,16 @@ DB3667A6268AE2620027D07F /* ComposeStatusPollSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB3667A5268AE2620027D07F /* ComposeStatusPollSection.swift */; }; DB3667A8268AE2900027D07F /* ComposeStatusPollItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB3667A7268AE2900027D07F /* ComposeStatusPollItem.swift */; }; DB3D0FF325BAA61700EAA174 /* AlamofireImage in Frameworks */ = {isa = PBXBuildFile; productRef = DB3D0FF225BAA61700EAA174 /* AlamofireImage */; }; + DB3E6FDD2806A40F00B035AE /* DiscoveryHashtagsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB3E6FDC2806A40F00B035AE /* DiscoveryHashtagsViewController.swift */; }; + DB3E6FE02806A4ED00B035AE /* DiscoveryHashtagsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB3E6FDF2806A4ED00B035AE /* DiscoveryHashtagsViewModel.swift */; }; + DB3E6FE22806A50100B035AE /* DiscoveryHashtagsViewModel+Diffable.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB3E6FE12806A50100B035AE /* DiscoveryHashtagsViewModel+Diffable.swift */; }; + DB3E6FE42806A5B800B035AE /* DiscoverySection.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB3E6FE32806A5B800B035AE /* DiscoverySection.swift */; }; + DB3E6FE72806A7A200B035AE /* DiscoveryItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB3E6FE62806A7A200B035AE /* DiscoveryItem.swift */; }; + DB3E6FE92806BD2200B035AE /* ThemeService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB3E6FE82806BD2200B035AE /* ThemeService.swift */; }; + DB3E6FEC2806D7F100B035AE /* DiscoveryNewsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB3E6FEB2806D7F100B035AE /* DiscoveryNewsViewController.swift */; }; + DB3E6FEF2806D82600B035AE /* DiscoveryNewsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB3E6FEE2806D82600B035AE /* DiscoveryNewsViewModel.swift */; }; + DB3E6FF12806D96900B035AE /* DiscoveryNewsViewModel+Diffable.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB3E6FF02806D96900B035AE /* DiscoveryNewsViewModel+Diffable.swift */; }; + DB3E6FF32806D97400B035AE /* DiscoveryNewsViewModel+State.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB3E6FF22806D97400B035AE /* DiscoveryNewsViewModel+State.swift */; }; DB427DD625BAA00100D1B89D /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB427DD525BAA00100D1B89D /* AppDelegate.swift */; }; DB427DD825BAA00100D1B89D /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB427DD725BAA00100D1B89D /* SceneDelegate.swift */; }; DB427DDD25BAA00100D1B89D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = DB427DDB25BAA00100D1B89D /* Main.storyboard */; }; @@ -376,8 +385,6 @@ DB6D9F9726367249008423CD /* SettingsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6D9F9626367249008423CD /* SettingsViewController.swift */; }; DB6F5E35264E78E7009108F4 /* AutoCompleteViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6F5E34264E78E7009108F4 /* AutoCompleteViewController.swift */; }; DB6F5E38264E994A009108F4 /* AutoCompleteTopChevronView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6F5E37264E994A009108F4 /* AutoCompleteTopChevronView.swift */; }; - DB71C7CB271D5A0300BE3819 /* LineChartView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB71C7CA271D5A0300BE3819 /* LineChartView.swift */; }; - DB71C7CD271D7F4300BE3819 /* CurveAlgorithm.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB71C7CC271D7F4300BE3819 /* CurveAlgorithm.swift */; }; DB71FD5225F8CCAA00512AE1 /* APIService+Status.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB71FD5125F8CCAA00512AE1 /* APIService+Status.swift */; }; DB72601C25E36A2100235243 /* MastodonServerRulesViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB72601B25E36A2100235243 /* MastodonServerRulesViewController.swift */; }; DB72602725E36A6F00235243 /* MastodonServerRulesViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB72602625E36A6F00235243 /* MastodonServerRulesViewModel.swift */; }; @@ -508,16 +515,7 @@ DBBC24AA26A5301B00398BB9 /* MastodonSDK in Frameworks */ = {isa = PBXBuildFile; productRef = DBBC24A926A5301B00398BB9 /* MastodonSDK */; }; DBBC24AC26A53D9300398BB9 /* ComposeStatusContentTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBBC24AB26A53D9300398BB9 /* ComposeStatusContentTableViewCell.swift */; }; DBBC24B826A5421800398BB9 /* CommonOSLog in Frameworks */ = {isa = PBXBuildFile; productRef = DBBC24B726A5421800398BB9 /* CommonOSLog */; }; - DBBC24BC26A542F500398BB9 /* ThemeService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBBC24BB26A542F500398BB9 /* ThemeService.swift */; }; - DBBC24C026A5443100398BB9 /* MastodonTheme.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBBC24BE26A5443100398BB9 /* MastodonTheme.swift */; }; - DBBC24C126A5443100398BB9 /* SystemTheme.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBBC24BF26A5443100398BB9 /* SystemTheme.swift */; }; - DBBC24C426A544B900398BB9 /* Theme.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBBC24C326A544B900398BB9 /* Theme.swift */; }; - DBBC24C626A5456000398BB9 /* Theme.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBBC24C326A544B900398BB9 /* Theme.swift */; }; - DBBC24C726A5456400398BB9 /* SystemTheme.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBBC24BF26A5443100398BB9 /* SystemTheme.swift */; }; - DBBC24C826A5456400398BB9 /* ThemeService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBBC24BB26A542F500398BB9 /* ThemeService.swift */; }; - DBBC24C926A5456400398BB9 /* MastodonTheme.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBBC24BE26A5443100398BB9 /* MastodonTheme.swift */; }; DBBC24CB26A546C000398BB9 /* ThemePreference.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBD376AB2692ECDB007FEC24 /* ThemePreference.swift */; }; - DBBC24CF26A547AE00398BB9 /* ThemeService+Appearance.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBBC24CE26A547AE00398BB9 /* ThemeService+Appearance.swift */; }; DBBC24D126A5484F00398BB9 /* UITextView+Placeholder in Frameworks */ = {isa = PBXBuildFile; productRef = DBBC24D026A5484F00398BB9 /* UITextView+Placeholder */; }; DBBC24DC26A54BCB00398BB9 /* MastodonRegex.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBBC24D626A54BCB00398BB9 /* MastodonRegex.swift */; }; DBBC24DE26A54BCB00398BB9 /* MastodonMetricFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBBC24D826A54BCB00398BB9 /* MastodonMetricFormatter.swift */; }; @@ -735,7 +733,6 @@ 2D206B9125F60EA700143C56 /* UIControl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIControl.swift; sourceTree = ""; }; 2D24E1222626ED9D00A59D4F /* UIView+Gesture.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIView+Gesture.swift"; sourceTree = ""; }; 2D32EAAB25CB96DC00C9ED86 /* TimelineMiddleLoaderTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineMiddleLoaderTableViewCell.swift; sourceTree = ""; }; - 2D32EAB925CB9B0500C9ED86 /* UIView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIView.swift; sourceTree = ""; }; 2D34D9D026148D9E0081BFC0 /* APIService+Recommend.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Recommend.swift"; sourceTree = ""; }; 2D34D9DA261494120081BFC0 /* APIService+Search.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Search.swift"; sourceTree = ""; }; 2D35237926256D920031AF25 /* NotificationSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationSection.swift; sourceTree = ""; }; @@ -956,6 +953,16 @@ DB3667A5268AE2620027D07F /* ComposeStatusPollSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeStatusPollSection.swift; sourceTree = ""; }; DB3667A7268AE2900027D07F /* ComposeStatusPollItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeStatusPollItem.swift; sourceTree = ""; }; DB3D0FED25BAA42200EAA174 /* MastodonSDK */ = {isa = PBXFileReference; lastKnownFileType = folder; path = MastodonSDK; sourceTree = ""; }; + DB3E6FDC2806A40F00B035AE /* DiscoveryHashtagsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiscoveryHashtagsViewController.swift; sourceTree = ""; }; + DB3E6FDF2806A4ED00B035AE /* DiscoveryHashtagsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiscoveryHashtagsViewModel.swift; sourceTree = ""; }; + DB3E6FE12806A50100B035AE /* DiscoveryHashtagsViewModel+Diffable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DiscoveryHashtagsViewModel+Diffable.swift"; sourceTree = ""; }; + DB3E6FE32806A5B800B035AE /* DiscoverySection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiscoverySection.swift; sourceTree = ""; }; + DB3E6FE62806A7A200B035AE /* DiscoveryItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiscoveryItem.swift; sourceTree = ""; }; + DB3E6FE82806BD2200B035AE /* ThemeService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThemeService.swift; sourceTree = ""; }; + DB3E6FEB2806D7F100B035AE /* DiscoveryNewsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiscoveryNewsViewController.swift; sourceTree = ""; }; + DB3E6FEE2806D82600B035AE /* DiscoveryNewsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiscoveryNewsViewModel.swift; sourceTree = ""; }; + DB3E6FF02806D96900B035AE /* DiscoveryNewsViewModel+Diffable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DiscoveryNewsViewModel+Diffable.swift"; sourceTree = ""; }; + DB3E6FF22806D97400B035AE /* DiscoveryNewsViewModel+State.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DiscoveryNewsViewModel+State.swift"; sourceTree = ""; }; DB427DD225BAA00100D1B89D /* Mastodon.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Mastodon.app; sourceTree = BUILT_PRODUCTS_DIR; }; DB427DD525BAA00100D1B89D /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; DB427DD725BAA00100D1B89D /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; @@ -1126,8 +1133,6 @@ DB6D9F9626367249008423CD /* SettingsViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SettingsViewController.swift; sourceTree = ""; }; DB6F5E34264E78E7009108F4 /* AutoCompleteViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutoCompleteViewController.swift; sourceTree = ""; }; DB6F5E37264E994A009108F4 /* AutoCompleteTopChevronView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutoCompleteTopChevronView.swift; sourceTree = ""; }; - DB71C7CA271D5A0300BE3819 /* LineChartView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LineChartView.swift; sourceTree = ""; }; - DB71C7CC271D7F4300BE3819 /* CurveAlgorithm.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CurveAlgorithm.swift; sourceTree = ""; }; DB71FD5125F8CCAA00512AE1 /* APIService+Status.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Status.swift"; sourceTree = ""; }; DB72601B25E36A2100235243 /* MastodonServerRulesViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonServerRulesViewController.swift; sourceTree = ""; }; DB72602625E36A6F00235243 /* MastodonServerRulesViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonServerRulesViewModel.swift; sourceTree = ""; }; @@ -1263,11 +1268,6 @@ DBB9759B262462E1004620BD /* ThreadMetaView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadMetaView.swift; sourceTree = ""; }; DBBC24A726A52F9000398BB9 /* ComposeToolbarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeToolbarView.swift; sourceTree = ""; }; DBBC24AB26A53D9300398BB9 /* ComposeStatusContentTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeStatusContentTableViewCell.swift; sourceTree = ""; }; - DBBC24BB26A542F500398BB9 /* ThemeService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThemeService.swift; sourceTree = ""; }; - DBBC24BE26A5443100398BB9 /* MastodonTheme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonTheme.swift; sourceTree = ""; }; - DBBC24BF26A5443100398BB9 /* SystemTheme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemTheme.swift; sourceTree = ""; }; - DBBC24C326A544B900398BB9 /* Theme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Theme.swift; sourceTree = ""; }; - DBBC24CE26A547AE00398BB9 /* ThemeService+Appearance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ThemeService+Appearance.swift"; sourceTree = ""; }; DBBC24D626A54BCB00398BB9 /* MastodonRegex.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MastodonRegex.swift; sourceTree = ""; }; DBBC24D826A54BCB00398BB9 /* MastodonMetricFormatter.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MastodonMetricFormatter.swift; sourceTree = ""; }; DBBC50C0278ED49200AF0CC6 /* MastodonAuthenticationBox.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonAuthenticationBox.swift; sourceTree = ""; }; @@ -1668,7 +1668,6 @@ 2D5A3D0125CF8640002347D6 /* Vender */ = { isa = PBXGroup; children = ( - DB71C7CC271D7F4300BE3819 /* CurveAlgorithm.swift */, 2D5A3D0225CF8742002347D6 /* ControlContainableScrollViews.swift */, DB51D170262832380062B7A1 /* BlurHashDecode.swift */, DB51D171262832380062B7A1 /* BlurHashEncode.swift */, @@ -1687,7 +1686,6 @@ DB45FB0425CA87B4005A8AC7 /* APIService */, DB49A61925FF327D00B98345 /* EmojiService */, DB9A489B26036E19008B817C /* MastodonAttachmentService */, - DBBC24BD26A5441A00398BB9 /* ThemeService */, DB45FB0E25CA87D0005A8AC7 /* AuthenticationService.swift */, 2DA6054625F716A2006356F9 /* PlaybackState.swift */, DBC7A67B260DFADE00E57475 /* StatusPublishService.swift */, @@ -1731,6 +1729,7 @@ DB4F097626A0398000D62E92 /* Compose */, DB0617F727855B010030EE79 /* Notification */, DB4F097726A039A200D62E92 /* Search */, + DB3E6FE52806A5BA00B035AE /* Discovery */, DB0617FA27855B660030EE79 /* Settings */, DBCBED2226132E1D00B49291 /* FetchedResultsController */, ); @@ -1817,7 +1816,6 @@ isa = PBXGroup; children = ( 2DCB73FC2615C13900EC03D4 /* SearchRecommendCollectionHeader.swift */, - DB71C7CA271D5A0300BE3819 /* LineChartView.swift */, ); path = View; sourceTree = ""; @@ -2111,6 +2109,44 @@ path = Resources; sourceTree = ""; }; + DB3E6FDE2806A41200B035AE /* Hashtags */ = { + isa = PBXGroup; + children = ( + DB3E6FDC2806A40F00B035AE /* DiscoveryHashtagsViewController.swift */, + DB3E6FDF2806A4ED00B035AE /* DiscoveryHashtagsViewModel.swift */, + DB3E6FE12806A50100B035AE /* DiscoveryHashtagsViewModel+Diffable.swift */, + ); + path = Hashtags; + sourceTree = ""; + }; + DB3E6FE52806A5BA00B035AE /* Discovery */ = { + isa = PBXGroup; + children = ( + DB3E6FE32806A5B800B035AE /* DiscoverySection.swift */, + DB3E6FE62806A7A200B035AE /* DiscoveryItem.swift */, + ); + path = Discovery; + sourceTree = ""; + }; + DB3E6FEA2806BD2500B035AE /* MastodonUI */ = { + isa = PBXGroup; + children = ( + DB3E6FE82806BD2200B035AE /* ThemeService.swift */, + ); + path = MastodonUI; + sourceTree = ""; + }; + DB3E6FED2806D7FC00B035AE /* News */ = { + isa = PBXGroup; + children = ( + DB3E6FEB2806D7F100B035AE /* DiscoveryNewsViewController.swift */, + DB3E6FEE2806D82600B035AE /* DiscoveryNewsViewModel.swift */, + DB3E6FF02806D96900B035AE /* DiscoveryNewsViewModel+Diffable.swift */, + DB3E6FF22806D97400B035AE /* DiscoveryNewsViewModel+State.swift */, + ); + path = News; + sourceTree = ""; + }; DB427DC925BAA00100D1B89D = { isa = PBXGroup; children = ( @@ -2706,6 +2742,7 @@ isa = PBXGroup; children = ( DB084B5125CBC56300F898ED /* CoreDataStack */, + DB3E6FEA2806BD2500B035AE /* MastodonUI */, DB6C8C0525F0921200AAA452 /* MastodonSDK */, 2DF123A625C3B0210020F248 /* ActiveLabel.swift */, 5DF1056325F887CB00D6C0D4 /* AVPlayer.swift */, @@ -2723,7 +2760,6 @@ DBD376B1269302A4007FEC24 /* UITableViewCell.swift */, 0FAA101B25E10E760017CCDE /* UIFont.swift */, 2D206B9125F60EA700143C56 /* UIControl.swift */, - 2D32EAB925CB9B0500C9ED86 /* UIView.swift */, 5DA732CB2629CEF500A92342 /* UIView+Remove.swift */, 2D24E1222626ED9D00A59D4F /* UIView+Gesture.swift */, DB8AF55C25C138B7002E6C99 /* UIViewController.swift */, @@ -3020,18 +3056,6 @@ path = Service; sourceTree = ""; }; - DBBC24BD26A5441A00398BB9 /* ThemeService */ = { - isa = PBXGroup; - children = ( - DBBC24C326A544B900398BB9 /* Theme.swift */, - DBBC24BE26A5443100398BB9 /* MastodonTheme.swift */, - DBBC24BF26A5443100398BB9 /* SystemTheme.swift */, - DBBC24BB26A542F500398BB9 /* ThemeService.swift */, - DBBC24CE26A547AE00398BB9 /* ThemeService+Appearance.swift */, - ); - path = ThemeService; - sourceTree = ""; - }; DBBC24D526A54BCB00398BB9 /* Helper */ = { isa = PBXGroup; children = ( @@ -3087,6 +3111,8 @@ isa = PBXGroup; children = ( DBDFF19828055A0900557A48 /* Posts */, + DB3E6FDE2806A41200B035AE /* Hashtags */, + DB3E6FED2806D7FC00B035AE /* News */, DBDFF19928055A1400557A48 /* DiscoveryViewController.swift */, DBDFF19B28055BD600557A48 /* DiscoveryViewModel.swift */, ); @@ -3861,6 +3887,7 @@ DB336F43278EB1690031E64B /* MediaView+Configuration.swift in Sources */, DB66729625F9F91600D60309 /* ComposeStatusSection.swift in Sources */, DB482A3F261331E8008AE74C /* UserTimelineViewModel+State.swift in Sources */, + DB3E6FE02806A4ED00B035AE /* DiscoveryHashtagsViewModel.swift in Sources */, 2D38F1F725CD47AC00561493 /* HomeTimelineViewModel+LoadOldestState.swift in Sources */, DB447681260B3ED600B66B82 /* CustomEmojiPickerSection.swift in Sources */, DB0FCB7427956939006C02E2 /* DataSourceFacade+Status.swift in Sources */, @@ -3924,6 +3951,7 @@ DB06180A2785B2AB0030EE79 /* MastodonRegisterAvatarTableViewCell.swift in Sources */, DBB45B6227B51112002DC5A7 /* SuggestionAccountViewModel+Diffable.swift in Sources */, DBB3BA2A26A81C020004F2D4 /* FLAnimatedImageView.swift in Sources */, + DB3E6FF32806D97400B035AE /* DiscoveryNewsViewModel+State.swift in Sources */, DB6746ED278F45F0008A6B94 /* AutoGenerateProtocolRelayDelegate.swift in Sources */, DB0618032785A7100030EE79 /* RegisterSection.swift in Sources */, DB63F76B279A5ED300455B82 /* NotificationTimelineViewModel+LoadOldestState.swift in Sources */, @@ -3952,6 +3980,7 @@ 2D24E1232626ED9D00A59D4F /* UIView+Gesture.swift in Sources */, DBFEEC9D279C12C1004F81DD /* ProfileFieldEditCollectionViewCell.swift in Sources */, DB45FAE325CA7181005A8AC7 /* MastodonUser.swift in Sources */, + DB3E6FEC2806D7F100B035AE /* DiscoveryNewsViewController.swift in Sources */, DBA088DF26958164003EB4B2 /* UserFetchedResultsController.swift in Sources */, DB2FF510260B113300ADA9FE /* ComposeStatusPollExpiresOptionCollectionViewCell.swift in Sources */, 0F202213261351F5000C64BF /* APIService+HashtagTimeline.swift in Sources */, @@ -3993,7 +4022,6 @@ 2D4AD8A826316D3500613EFC /* SelectedAccountItem.swift in Sources */, DBE3CDFB261C6CA500430CC6 /* FavoriteViewModel.swift in Sources */, DB8AF52F25C13561002E6C99 /* DocumentStore.swift in Sources */, - DBBC24C126A5443100398BB9 /* SystemTheme.swift in Sources */, DBE3CE01261D623D00430CC6 /* FavoriteViewModel+State.swift in Sources */, 2D82BA0525E7897700E36F0F /* MastodonResendEmailViewModelNavigationDelegateShim.swift in Sources */, 2D38F1EB25CD477000561493 /* HomeTimelineViewModel+LoadLatestState.swift in Sources */, @@ -4020,6 +4048,7 @@ DB63F76227996B6600455B82 /* SearchHistoryViewController+DataSourceProvider.swift in Sources */, DB73BF4927140BA300781945 /* UICollectionViewDiffableDataSource.swift in Sources */, DBA5E7AB263BD3F5004598BB /* TimelineTableViewCellContextMenuConfiguration.swift in Sources */, + DB3E6FE92806BD2200B035AE /* ThemeService.swift in Sources */, DB73B490261F030A002E9E9F /* SafariActivity.swift in Sources */, DB63F7492799126300455B82 /* FollowerListViewController+DataSourceProvider.swift in Sources */, DB6D1B44263691CF00ACB481 /* Mastodon+API+Subscriptions+Policy.swift in Sources */, @@ -4056,6 +4085,7 @@ DBDFF1932805554900557A48 /* DiscoveryPostsViewModel.swift in Sources */, 2D9DB96B263A91D1007C1D71 /* APIService+DomainBlock.swift in Sources */, DBBF1DC92652538500E5B703 /* AutoCompleteSection.swift in Sources */, + DB3E6FE72806A7A200B035AE /* DiscoveryItem.swift in Sources */, DB8AF55D25C138B7002E6C99 /* UIViewController.swift in Sources */, DB7F48452620241000796008 /* ProfileHeaderViewModel.swift in Sources */, DB647C5926F1EA2700F7F82C /* WizardPreference.swift in Sources */, @@ -4078,6 +4108,7 @@ 5B90C462262599800002E742 /* SettingsSectionHeader.swift in Sources */, DB44768B260B3F2100B66B82 /* CustomEmojiPickerItem.swift in Sources */, 5DF1056425F887CB00D6C0D4 /* AVPlayer.swift in Sources */, + DB3E6FEF2806D82600B035AE /* DiscoveryNewsViewModel.swift in Sources */, DBBF1DCB2652539E00E5B703 /* AutoCompleteItem.swift in Sources */, DB84811727883C2600BBEABA /* MastodonRegisterPasswordHintTableViewCell.swift in Sources */, 2DA6054725F716A2006356F9 /* PlaybackState.swift in Sources */, @@ -4139,7 +4170,7 @@ DB4AA6B327BA34B6009EC082 /* CellFrameCacheContainer.swift in Sources */, DB0FCB942797E2B0006C02E2 /* SearchResultViewModel+Diffable.swift in Sources */, DB63F752279944AA00455B82 /* SearchHistorySectionHeaderCollectionReusableView.swift in Sources */, - DBBC24C426A544B900398BB9 /* Theme.swift in Sources */, + DB3E6FDD2806A40F00B035AE /* DiscoveryHashtagsViewController.swift in Sources */, DB938EED2623F79B00E5B6C1 /* ThreadViewModel.swift in Sources */, DBBC24AC26A53D9300398BB9 /* ComposeStatusContentTableViewCell.swift in Sources */, DBC7A67C260DFADE00E57475 /* StatusPublishService.swift in Sources */, @@ -4158,7 +4189,6 @@ DBBF1DC5265251C300E5B703 /* AutoCompleteViewModel+Diffable.swift in Sources */, DB603111279EB38500A935FE /* DataSourceFacade+Mute.swift in Sources */, DB68A04A25E9027700CFDF14 /* AdaptiveStatusBarStyleNavigationController.swift in Sources */, - DBBC24BC26A542F500398BB9 /* ThemeService.swift in Sources */, DB336F38278D7AAF0031E64B /* Poll+Property.swift in Sources */, 0FB3D33825E6401400AAD544 /* PickServerCell.swift in Sources */, DB6D9F8426358EEC008423CD /* SettingsItem.swift in Sources */, @@ -4171,7 +4201,6 @@ DB4FFC2C269EC39600D62E92 /* SearchTransitionController.swift in Sources */, DBA5E7A9263BD3A4004598BB /* ContextMenuImagePreviewViewController.swift in Sources */, DBF156E22702DA6900EC00B7 /* UIStatusBarManager+HandleTapAction.m in Sources */, - 2D32EABA25CB9B0500C9ED86 /* UIView.swift in Sources */, 2D38F20825CD491300561493 /* DisposeBagCollectable.swift in Sources */, DB6F5E35264E78E7009108F4 /* AutoCompleteViewController.swift in Sources */, DB697DE1278F5296004EF2F7 /* DataSourceFacade+Model.swift in Sources */, @@ -4179,7 +4208,6 @@ DB4F097526A037F500D62E92 /* SearchHistoryViewModel.swift in Sources */, DB6180F826391D660018D199 /* MediaPreviewingViewController.swift in Sources */, DB0140CF25C42AEE00F9F3CF /* OSLog.swift in Sources */, - DB71C7CB271D5A0300BE3819 /* LineChartView.swift in Sources */, DB98EB5627B0FF1B0082E365 /* ReportViewControllerAppearance.swift in Sources */, DB938F1526241FDF00E5B6C1 /* APIService+Thread.swift in Sources */, 2D206B8625F5FB0900143C56 /* Double.swift in Sources */, @@ -4201,6 +4229,7 @@ DBD376AC2692ECDB007FEC24 /* ThemePreference.swift in Sources */, DB4F097D26A03A5B00D62E92 /* SearchHistoryItem.swift in Sources */, DBD5B1FA27BD013700BD6B38 /* DataSourceProvider+StatusTableViewControllerNavigateable.swift in Sources */, + DB3E6FE22806A50100B035AE /* DiscoveryHashtagsViewModel+Diffable.swift in Sources */, DB68046C2636DC9E00430867 /* MastodonNotification.swift in Sources */, DBAE3F9E2616E308004B8251 /* APIService+Mute.swift in Sources */, DB427DD625BAA00100D1B89D /* AppDelegate.swift in Sources */, @@ -4247,7 +4276,6 @@ DB8AF52E25C13561002E6C99 /* ViewStateStore.swift in Sources */, DB1D61CF26F1B33600DA8662 /* WelcomeViewModel.swift in Sources */, 2DA7D04A25CA52CB00804E11 /* TimelineBottomLoaderTableViewCell.swift in Sources */, - DB71C7CD271D7F4300BE3819 /* CurveAlgorithm.swift in Sources */, DBD376B2269302A4007FEC24 /* UITableViewCell.swift in Sources */, DB4F0966269ED52200D62E92 /* SearchResultViewModel.swift in Sources */, DBBF1DBF2652401B00E5B703 /* AutoCompleteViewModel.swift in Sources */, @@ -4264,25 +4292,25 @@ 0F2021FB2613262F000C64BF /* HashtagTimelineViewController.swift in Sources */, DB697DDD278F521D004EF2F7 /* DataSourceFacade.swift in Sources */, DBCC3B30261440A50045B23D /* UITabBarController.swift in Sources */, + DB3E6FE42806A5B800B035AE /* DiscoverySection.swift in Sources */, DB8190C62601FF0400020C08 /* AttachmentContainerView.swift in Sources */, DB697DDB278F4DE3004EF2F7 /* DataSourceProvider+StatusTableViewCellDelegate.swift in Sources */, DB51D173262832380062B7A1 /* BlurHashEncode.swift in Sources */, 2D32EAAC25CB96DC00C9ED86 /* TimelineMiddleLoaderTableViewCell.swift in Sources */, DB87D4512609CF1E00D12C0D /* ComposeStatusPollOptionAppendEntryCollectionViewCell.swift in Sources */, DBB45B5627B39FC9002DC5A7 /* MediaPreviewVideoViewController.swift in Sources */, - DBBC24C026A5443100398BB9 /* MastodonTheme.swift in Sources */, DB0FCB8027968F70006C02E2 /* MastodonStatusThreadViewModel.swift in Sources */, DB0FCB6E27950E6B006C02E2 /* MastodonMention.swift in Sources */, DB67D08627312E67006A36CF /* WizardViewController.swift in Sources */, DB6746EB278ED8B0008A6B94 /* PollOptionView+Configuration.swift in Sources */, DB9A489026035963008B817C /* APIService+Media.swift in Sources */, DBFEEC99279BDCDE004F81DD /* ProfileAboutViewModel.swift in Sources */, - DBBC24CF26A547AE00398BB9 /* ThemeService+Appearance.swift in Sources */, 2D198649261C0B8500F0B013 /* SearchResultSection.swift in Sources */, DB4F097B26A039FF00D62E92 /* SearchHistorySection.swift in Sources */, DBB525302611EBF3002F1F29 /* ProfilePagingViewModel.swift in Sources */, DB9F58EC26EF435000E7BBE9 /* AccountViewController.swift in Sources */, 2D5A3D6225CFD9CB002347D6 /* HomeTimelineViewController+DebugAction.swift in Sources */, + DB3E6FF12806D96900B035AE /* DiscoveryNewsViewModel+Diffable.swift in Sources */, DB49A62525FF334C00B98345 /* EmojiService+CustomEmojiViewModel+LoadState.swift in Sources */, DB4924E226312AB200E9DB22 /* NotificationService.swift in Sources */, DB6D9F6F2635807F008423CD /* Setting.swift in Sources */, @@ -4390,15 +4418,11 @@ DBFEF07526A69192006D7ED1 /* APIService+Media.swift in Sources */, DBFEF06F26A690C4006D7ED1 /* APIService+APIError.swift in Sources */, DBFEF05C26A57715006D7ED1 /* StatusEditorView.swift in Sources */, - DBBC24C726A5456400398BB9 /* SystemTheme.swift in Sources */, - DBBC24C826A5456400398BB9 /* ThemeService.swift in Sources */, - DBBC24C926A5456400398BB9 /* MastodonTheme.swift in Sources */, DBFEF07C26A6BD0A006D7ED1 /* APIService+Status+Publish.swift in Sources */, DBB3BA2B26A81D060004F2D4 /* FLAnimatedImageView.swift in Sources */, DB6746E8278ED639008A6B94 /* MastodonAuthenticationBox.swift in Sources */, DBBC24A826A52F9000398BB9 /* ComposeToolbarView.swift in Sources */, DBFEF05B26A57715006D7ED1 /* ComposeViewModel.swift in Sources */, - DBBC24C626A5456000398BB9 /* Theme.swift in Sources */, DBFEF06326A577F2006D7ED1 /* StatusAttachmentViewModel.swift in Sources */, DBFEF06926A67E45006D7ED1 /* AppearancePreference.swift in Sources */, DBC6461526A170AB00B0E31B /* ShareViewController.swift in Sources */, diff --git a/Mastodon.xcodeproj/xcshareddata/xcschemes/Mastodon.xcscheme b/Mastodon.xcodeproj/xcshareddata/xcschemes/Mastodon.xcscheme index 488d5a2da..048ce3cf5 100644 --- a/Mastodon.xcodeproj/xcshareddata/xcschemes/Mastodon.xcscheme +++ b/Mastodon.xcodeproj/xcshareddata/xcschemes/Mastodon.xcscheme @@ -73,6 +73,7 @@ buildConfiguration = "Debug" selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" + enableAddressSanitizer = "YES" launchStyle = "0" useCustomWorkingDirectory = "NO" ignoresPersistentStateOnLaunch = "NO" @@ -89,6 +90,13 @@ ReferencedContainer = "container:Mastodon.xcodeproj"> + + + + NotificationService.xcscheme_^#shared#^_ orderHint - 24 + 22 ShareActionExtension.xcscheme_^#shared#^_ orderHint - 22 + 24 SuppressBuildableAutocreation diff --git a/Mastodon/Diffiable/Discovery/DiscoveryItem.swift b/Mastodon/Diffiable/Discovery/DiscoveryItem.swift new file mode 100644 index 000000000..181756d25 --- /dev/null +++ b/Mastodon/Diffiable/Discovery/DiscoveryItem.swift @@ -0,0 +1,15 @@ +// +// DiscoveryItem.swift +// Mastodon +// +// Created by MainasuK on 2022-4-13. +// + +import Foundation +import MastodonSDK + +enum DiscoveryItem: Hashable { + case hashtag(Mastodon.Entity.Tag) + case link(Mastodon.Entity.Link) + case bottomLoader +} diff --git a/Mastodon/Diffiable/Discovery/DiscoverySection.swift b/Mastodon/Diffiable/Discovery/DiscoverySection.swift new file mode 100644 index 000000000..32683609d --- /dev/null +++ b/Mastodon/Diffiable/Discovery/DiscoverySection.swift @@ -0,0 +1,52 @@ +// +// DiscoverySection.swift +// Mastodon +// +// Created by MainasuK on 2022-4-13. +// + +import os.log +import UIKit +import MastodonUI + +enum DiscoverySection: CaseIterable { + // case posts + case hashtags + case news + case forYou +} + +extension DiscoverySection { + + static let logger = Logger(subsystem: "DiscoverySection", category: "logic") + + struct Configuration { } + + static func diffableDataSource( + tableView: UITableView, + context: AppContext, + configuration: Configuration + ) -> UITableViewDiffableDataSource { + tableView.register(TrendTableViewCell.self, forCellReuseIdentifier: String(describing: TrendTableViewCell.self)) + tableView.register(NewsTableViewCell.self, forCellReuseIdentifier: String(describing: NewsTableViewCell.self)) + tableView.register(TimelineBottomLoaderTableViewCell.self, forCellReuseIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self)) + + return UITableViewDiffableDataSource(tableView: tableView) { tableView, indexPath, item in + switch item { + case .hashtag(let tag): + let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: TrendTableViewCell.self), for: indexPath) as! TrendTableViewCell + cell.trendView.configure(tag: tag) + return cell + case .link(let link): + let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: NewsTableViewCell.self), for: indexPath) as! NewsTableViewCell + cell.newsView.configure(link: link) + return cell + case .bottomLoader: + let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self), for: indexPath) as! TimelineBottomLoaderTableViewCell + cell.activityIndicatorView.startAnimating() + return cell + } + } + } + +} diff --git a/Mastodon/Diffiable/Search/SearchSection.swift b/Mastodon/Diffiable/Search/SearchSection.swift index 21f1d479c..4f550abf7 100644 --- a/Mastodon/Diffiable/Search/SearchSection.swift +++ b/Mastodon/Diffiable/Search/SearchSection.swift @@ -21,26 +21,7 @@ extension SearchSection { ) -> UICollectionViewDiffableDataSource { let trendCellRegister = UICollectionView.CellRegistration { cell, indexPath, item in - let primaryLabelText = "#" + item.name - let secondaryLabelText = L10n.Scene.Search.Recommend.HashTag.peopleTalking(item.talkingPeopleCount ?? 0) - cell.primaryLabel.text = primaryLabelText - cell.secondaryLabel.text = secondaryLabelText - - cell.lineChartView.data = (item.history ?? []) - .sorted(by: { $0.day < $1.day }) // latest last - .map { entry in - guard let point = Int(entry.accounts) else { - return .zero - } - return CGFloat(point) - } - - cell.isAccessibilityElement = true - cell.accessibilityLabel = [ - primaryLabelText, - secondaryLabelText - ].joined(separator: ", ") } let dataSource = UICollectionViewDiffableDataSource( diff --git a/Mastodon/Extension/MastodonSDK/Mastodon+Entity+Tag.swift b/Mastodon/Extension/MastodonSDK/Mastodon+Entity+Tag.swift index 2d0be6965..e217d3a82 100644 --- a/Mastodon/Extension/MastodonSDK/Mastodon+Entity+Tag.swift +++ b/Mastodon/Extension/MastodonSDK/Mastodon+Entity+Tag.swift @@ -17,14 +17,3 @@ extension Mastodon.Entity.Tag: Hashable { } } -extension Mastodon.Entity.Tag { - - /// the sum of recent 2 days - public var talkingPeopleCount: Int? { - return history? - .prefix(2) - .compactMap { Int($0.accounts) } - .reduce(0, +) - } - -} diff --git a/Mastodon/Service/ThemeService/ThemeService+Appearance.swift b/Mastodon/Extension/MastodonUI/ThemeService.swift similarity index 97% rename from Mastodon/Service/ThemeService/ThemeService+Appearance.swift rename to Mastodon/Extension/MastodonUI/ThemeService.swift index 896ed888e..5fe213d06 100644 --- a/Mastodon/Service/ThemeService/ThemeService+Appearance.swift +++ b/Mastodon/Extension/MastodonUI/ThemeService.swift @@ -1,11 +1,13 @@ // -// ThemeService+Appearance.swift +// ThemeService.swift // Mastodon // -// Created by MainasuK Cirno on 2021-7-19. +// Created by MainasuK on 2022-4-13. // import UIKit +import MastodonCommon +import MastodonUI extension ThemeService { func set(themeName: ThemeName) { diff --git a/Mastodon/Extension/UIView.swift b/Mastodon/Extension/UIView.swift deleted file mode 100644 index d4814b7ec..000000000 --- a/Mastodon/Extension/UIView.swift +++ /dev/null @@ -1,70 +0,0 @@ -// -// UIView.swift -// Mastodon -// -// Created by sxiaojian on 2021/2/4. -// - -import UIKit - -// MARK: - Convenience view creation method -extension UIView { - - static let separatorColor: UIColor = { - UIColor(dynamicProvider: { collection in - switch collection.userInterfaceStyle { - case .dark: - return ThemeService.shared.currentTheme.value.separator - default: - return .separator - } - }) - }() - - static var separatorLine: UIView { - let line = UIView() - line.backgroundColor = UIView.separatorColor - return line - } - - static func separatorLineHeight(of view: UIView) -> CGFloat { - return 1.0 / view.traitCollection.displayScale - } - -} - -// MARK: - Convenience view appearance modification method -extension UIView { - @discardableResult - func applyCornerRadius(radius: CGFloat) -> Self { - layer.masksToBounds = true - layer.cornerRadius = radius - layer.cornerCurve = .continuous - return self - } - - @discardableResult - func applyShadow( - color: UIColor, - alpha: Float, - x: CGFloat, - y: CGFloat, - blur: CGFloat, - spread: CGFloat = 0) -> Self - { - layer.masksToBounds = false - layer.shadowColor = color.cgColor - layer.shadowOpacity = alpha - layer.shadowOffset = CGSize(width: x, height: y) - layer.shadowRadius = blur / 2.0 - if spread == 0 { - layer.shadowPath = nil - } else { - let dx = -spread - let rect = bounds.insetBy(dx: dx, dy: dx) - layer.shadowPath = UIBezierPath(rect: rect).cgPath - } - return self - } -} - diff --git a/Mastodon/Preference/ThemePreference.swift b/Mastodon/Preference/ThemePreference.swift index 624047798..5465cb22f 100644 --- a/Mastodon/Preference/ThemePreference.swift +++ b/Mastodon/Preference/ThemePreference.swift @@ -5,17 +5,3 @@ // Created by MainasuK Cirno on 2021-7-5. // -import UIKit -import MastodonExtension - -extension UserDefaults { - - @objc dynamic var currentThemeNameRawValue: String { - get { - register(defaults: [#function: ThemeName.mastodon.rawValue]) - return string(forKey: #function) ?? ThemeName.mastodon.rawValue - } - set { self[#function] = newValue } - } - -} diff --git a/Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusPollOptionCollectionViewCell.swift b/Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusPollOptionCollectionViewCell.swift index 7ea43f154..c1869669c 100644 --- a/Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusPollOptionCollectionViewCell.swift +++ b/Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusPollOptionCollectionViewCell.swift @@ -10,6 +10,7 @@ import UIKit import Combine import MastodonAsset import MastodonLocalization +import MastodonUI protocol ComposeStatusPollOptionCollectionViewCellDelegate: AnyObject { func composeStatusPollOptionCollectionViewCell(_ cell: ComposeStatusPollOptionCollectionViewCell, textFieldDidBeginEditing textField: UITextField) diff --git a/Mastodon/Scene/Discovery/DiscoveryViewController.swift b/Mastodon/Scene/Discovery/DiscoveryViewController.swift index 4f909d6c2..dac2c99c3 100644 --- a/Mastodon/Scene/Discovery/DiscoveryViewController.swift +++ b/Mastodon/Scene/Discovery/DiscoveryViewController.swift @@ -10,6 +10,7 @@ import UIKit import Combine import Tabman import MastodonAsset +import MastodonUI public class DiscoveryViewController: TabmanViewController, NeedsDependency { diff --git a/Mastodon/Scene/Discovery/DiscoveryViewModel.swift b/Mastodon/Scene/Discovery/DiscoveryViewModel.swift index 137b86825..acb92b5f4 100644 --- a/Mastodon/Scene/Discovery/DiscoveryViewModel.swift +++ b/Mastodon/Scene/Discovery/DiscoveryViewModel.swift @@ -13,7 +13,9 @@ final class DiscoveryViewModel { // input let context: AppContext - let discoveryViewController: DiscoveryPostsViewController + let discoveryPostsViewController: DiscoveryPostsViewController + let discoveryHashtagsViewController: DiscoveryHashtagsViewController + let discoveryNewsViewController: DiscoveryNewsViewController // output let barItems: [TMBarItemable] = { @@ -28,19 +30,37 @@ final class DiscoveryViewModel { var viewControllers: [ScrollViewContainer] { return [ - discoveryViewController, + discoveryPostsViewController, + discoveryHashtagsViewController, + discoveryNewsViewController, ] } init(context: AppContext, coordinator: SceneCoordinator) { + func setupDependency(_ needsDependency: NeedsDependency) { + needsDependency.context = context + needsDependency.coordinator = coordinator + } + self.context = context - discoveryViewController = { + discoveryPostsViewController = { let viewController = DiscoveryPostsViewController() - viewController.context = context - viewController.coordinator = coordinator + setupDependency(viewController) viewController.viewModel = DiscoveryPostsViewModel(context: context) return viewController }() + discoveryHashtagsViewController = { + let viewController = DiscoveryHashtagsViewController() + setupDependency(viewController) + viewController.viewModel = DiscoveryHashtagsViewModel(context: context) + return viewController + }() + discoveryNewsViewController = { + let viewController = DiscoveryNewsViewController() + setupDependency(viewController) + viewController.viewModel = DiscoveryNewsViewModel(context: context) + return viewController + }() // end init } diff --git a/Mastodon/Scene/Discovery/Hashtags/DiscoveryHashtagsViewController.swift b/Mastodon/Scene/Discovery/Hashtags/DiscoveryHashtagsViewController.swift new file mode 100644 index 000000000..1dca1232a --- /dev/null +++ b/Mastodon/Scene/Discovery/Hashtags/DiscoveryHashtagsViewController.swift @@ -0,0 +1,113 @@ +// +// DiscoveryHashtagsViewController.swift +// Mastodon +// +// Created by MainasuK on 2022-4-13. +// + +import os.log +import UIKit +import Combine +import MastodonUI + +final class DiscoveryHashtagsViewController: UIViewController, NeedsDependency, MediaPreviewableViewController { + + let logger = Logger(subsystem: "TrendPostsViewController", category: "ViewController") + + weak var context: AppContext! { willSet { precondition(!isViewLoaded) } } + weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } } + + var disposeBag = Set() + var viewModel: DiscoveryHashtagsViewModel! + + let mediaPreviewTransitionController = MediaPreviewTransitionController() + + lazy var tableView: UITableView = { + let tableView = UITableView() + tableView.rowHeight = UITableView.automaticDimension + tableView.estimatedRowHeight = 100 + tableView.separatorStyle = .none + tableView.backgroundColor = .clear + return tableView + }() + + deinit { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + } + +} + +extension DiscoveryHashtagsViewController { + + override func viewDidLoad() { + super.viewDidLoad() + + view.backgroundColor = ThemeService.shared.currentTheme.value.secondarySystemBackgroundColor + ThemeService.shared.currentTheme + .receive(on: DispatchQueue.main) + .sink { [weak self] theme in + guard let self = self else { return } + self.view.backgroundColor = theme.secondarySystemBackgroundColor + } + .store(in: &disposeBag) + + tableView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(tableView) + NSLayoutConstraint.activate([ + tableView.topAnchor.constraint(equalTo: view.topAnchor), + tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + ]) + + tableView.delegate = self + viewModel.setupDiffableDataSource( + tableView: tableView + ) + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + + tableView.deselectRow(with: transitionCoordinator, animated: animated) + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + + viewModel.viewDidAppeared.send() + } + +} + +// MARK: - UITableViewDelegate +extension DiscoveryHashtagsViewController: UITableViewDelegate { + + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): \(indexPath)") + guard case let .hashtag(tag) = viewModel.diffableDataSource?.itemIdentifier(for: indexPath) else { return } + let hashtagTimelineViewModel = HashtagTimelineViewModel(context: context, hashtag: tag.name) + coordinator.present( + scene: .hashtagTimeline(viewModel: hashtagTimelineViewModel), + from: self, + transition: .show + ) + } + + func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) { + guard let cell = cell as? TrendTableViewCell else { return } + guard let diffableDataSource = viewModel.diffableDataSource else { return } + guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return } + + if let lastItem = diffableDataSource.snapshot().itemIdentifiers.last, item == lastItem { + cell.configureSeparator(style: .edge) + } + } +} + +// MARK: ScrollViewContainer +extension DiscoveryHashtagsViewController: ScrollViewContainer { + var scrollView: UIScrollView? { + tableView + } +} diff --git a/Mastodon/Scene/Discovery/Hashtags/DiscoveryHashtagsViewModel+Diffable.swift b/Mastodon/Scene/Discovery/Hashtags/DiscoveryHashtagsViewModel+Diffable.swift new file mode 100644 index 000000000..0370f3f58 --- /dev/null +++ b/Mastodon/Scene/Discovery/Hashtags/DiscoveryHashtagsViewModel+Diffable.swift @@ -0,0 +1,42 @@ +// +// DiscoveryHashtagsViewModel+Diffable.swift +// Mastodon +// +// Created by MainasuK on 2022-4-13. +// + +import UIKit + +extension DiscoveryHashtagsViewModel { + + func setupDiffableDataSource( + tableView: UITableView + ) { + diffableDataSource = DiscoverySection.diffableDataSource( + tableView: tableView, + context: context, + configuration: DiscoverySection.Configuration() + ) + + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([.hashtags]) + 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([.hashtags]) + + let items = hashtags.map { DiscoveryItem.hashtag($0) } + snapshot.appendItems(items, toSection: .hashtags) + + diffableDataSource.apply(snapshot) + } + .store(in: &disposeBag) + } + +} diff --git a/Mastodon/Scene/Discovery/Hashtags/DiscoveryHashtagsViewModel.swift b/Mastodon/Scene/Discovery/Hashtags/DiscoveryHashtagsViewModel.swift new file mode 100644 index 000000000..5f51d6459 --- /dev/null +++ b/Mastodon/Scene/Discovery/Hashtags/DiscoveryHashtagsViewModel.swift @@ -0,0 +1,63 @@ +// +// DiscoveryHashtagsViewModel.swift +// Mastodon +// +// Created by MainasuK on 2022-4-13. +// + +import os.log +import UIKit +import Combine +import GameplayKit +import CoreData +import CoreDataStack +import MastodonSDK + +final class DiscoveryHashtagsViewModel { + + var disposeBag = Set() + + // input + let context: AppContext + let viewDidAppeared = PassthroughSubject() + + // output + var diffableDataSource: UITableViewDiffableDataSource? + @Published var hashtags: [Mastodon.Entity.Tag] = [] + + init(context: AppContext) { + self.context = context + // end 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.filter { !$0.name.isEmpty } + case .failure: + break + } + } + .store(in: &disposeBag) + } + + deinit { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + } + +} diff --git a/Mastodon/Scene/Discovery/News/DiscoveryNewsViewController.swift b/Mastodon/Scene/Discovery/News/DiscoveryNewsViewController.swift new file mode 100644 index 000000000..4042e2cd5 --- /dev/null +++ b/Mastodon/Scene/Discovery/News/DiscoveryNewsViewController.swift @@ -0,0 +1,133 @@ +// +// DiscoveryNewsViewController.swift +// Mastodon +// +// Created by MainasuK on 2022-4-13. +// + +import os.log +import UIKit +import Combine +import MastodonUI + +final class DiscoveryNewsViewController: UIViewController, NeedsDependency, MediaPreviewableViewController { + + let logger = Logger(subsystem: "TrendPostsViewController", category: "ViewController") + + weak var context: AppContext! { willSet { precondition(!isViewLoaded) } } + weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } } + + var disposeBag = Set() + var viewModel: DiscoveryNewsViewModel! + + let mediaPreviewTransitionController = MediaPreviewTransitionController() + + lazy var tableView: UITableView = { + let tableView = UITableView() + tableView.rowHeight = UITableView.automaticDimension + tableView.estimatedRowHeight = 100 + tableView.separatorStyle = .none + tableView.backgroundColor = .clear + return tableView + }() + + let refreshControl = UIRefreshControl() + + deinit { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + } + +} + +extension DiscoveryNewsViewController { + + override func viewDidLoad() { + super.viewDidLoad() + + view.backgroundColor = ThemeService.shared.currentTheme.value.secondarySystemBackgroundColor + ThemeService.shared.currentTheme + .receive(on: DispatchQueue.main) + .sink { [weak self] theme in + guard let self = self else { return } + self.view.backgroundColor = theme.secondarySystemBackgroundColor + } + .store(in: &disposeBag) + + tableView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(tableView) + NSLayoutConstraint.activate([ + tableView.topAnchor.constraint(equalTo: view.topAnchor), + tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + ]) + + tableView.delegate = self + viewModel.setupDiffableDataSource( + tableView: tableView + ) + + tableView.refreshControl = refreshControl + refreshControl.addTarget(self, action: #selector(DiscoveryNewsViewController.refreshControlValueChanged(_:)), for: .valueChanged) + viewModel.didLoadLatest + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + guard let self = self else { return } + self.refreshControl.endRefreshing() + } + .store(in: &disposeBag) + + // setup batch fetch + viewModel.listBatchFetchViewModel.setup(scrollView: tableView) + viewModel.listBatchFetchViewModel.shouldFetch + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + guard let self = self else { return } + guard self.view.window != nil else { return } + self.viewModel.stateMachine.enter(DiscoveryNewsViewModel.State.Loading.self) + } + .store(in: &disposeBag) + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + + refreshControl.endRefreshing() + tableView.deselectRow(with: transitionCoordinator, animated: animated) + } + +} + +extension DiscoveryNewsViewController { + + @objc private func refreshControlValueChanged(_ sender: UIRefreshControl) { + guard viewModel.stateMachine.enter(DiscoveryNewsViewModel.State.Reloading.self) else { + sender.endRefreshing() + return + } + } + +} + +// MARK: - UITableViewDelegate +extension DiscoveryNewsViewController: UITableViewDelegate { + + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): \(indexPath)") + guard case let .link(link) = viewModel.diffableDataSource?.itemIdentifier(for: indexPath) else { return } + guard let url = URL(string: link.url) else { return } + coordinator.present( + scene: .safari(url: url), + from: self, + transition: .safariPresent(animated: true, completion: nil) + ) + } + +} + +// MARK: ScrollViewContainer +extension DiscoveryNewsViewController: ScrollViewContainer { + var scrollView: UIScrollView? { + tableView + } +} diff --git a/Mastodon/Scene/Discovery/News/DiscoveryNewsViewModel+Diffable.swift b/Mastodon/Scene/Discovery/News/DiscoveryNewsViewModel+Diffable.swift new file mode 100644 index 000000000..ab3634a3f --- /dev/null +++ b/Mastodon/Scene/Discovery/News/DiscoveryNewsViewModel+Diffable.swift @@ -0,0 +1,60 @@ +// +// DiscoveryNewsViewModel+Diffable.swift +// Mastodon +// +// Created by MainasuK on 2022-4-13. +// + +import UIKit +import Combine + +extension DiscoveryNewsViewModel { + + func setupDiffableDataSource( + tableView: UITableView + ) { + diffableDataSource = DiscoverySection.diffableDataSource( + tableView: tableView, + context: context, + configuration: DiscoverySection.Configuration() + ) + + stateMachine.enter(State.Reloading.self) + + $links + .receive(on: DispatchQueue.main) + .sink { [weak self] links in + guard let self = self else { return } + guard let diffableDataSource = self.diffableDataSource else { return } + + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([.news]) + + let items = links.map { DiscoveryItem.link($0) } + snapshot.appendItems(items, toSection: .news) + + if let currentState = self.stateMachine.currentState { + switch currentState { + case is State.Initial, + is State.Loading, + is State.Idle, + is State.Fail: + if !items.isEmpty { + snapshot.appendItems([.bottomLoader], toSection: .news) + } + case is State.Reloading: + break + case is State.NoMore: + break + default: + assertionFailure() + break + } + } + + diffableDataSource.applySnapshot(snapshot, animated: false) + } + .store(in: &disposeBag) + } + +} diff --git a/Mastodon/Scene/Discovery/News/DiscoveryNewsViewModel+State.swift b/Mastodon/Scene/Discovery/News/DiscoveryNewsViewModel+State.swift new file mode 100644 index 000000000..82d604d64 --- /dev/null +++ b/Mastodon/Scene/Discovery/News/DiscoveryNewsViewModel+State.swift @@ -0,0 +1,209 @@ +// +// DiscoveryNewsViewModel+State.swift +// Mastodon +// +// Created by MainasuK on 2022-4-13. +// + +import os.log +import Foundation +import GameplayKit +import MastodonSDK + +extension DiscoveryNewsViewModel { + class State: GKState, NamingState { + + let logger = Logger(subsystem: "DiscoveryNewsViewModel.State", category: "StateMachine") + + let id = UUID() + + var name: String { + String(describing: Self.self) + } + + weak var viewModel: DiscoveryNewsViewModel? + + init(viewModel: DiscoveryNewsViewModel) { + self.viewModel = viewModel + } + + override func didEnter(from previousState: GKState?) { + super.didEnter(from: previousState) + let previousState = previousState as? DiscoveryNewsViewModel.State + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): [\(self.id.uuidString)] enter \(self.name), previous: \(previousState?.name ?? "")") + } + + @MainActor + 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)] \(self.name)") + } + } +} + +extension DiscoveryNewsViewModel.State { + class Initial: DiscoveryNewsViewModel.State { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + switch stateClass { + case is Reloading.Type: + return true + default: + return false + } + } + } + + class Reloading: DiscoveryNewsViewModel.State { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + switch stateClass { + case is Loading.Type: + return true + default: + return false + } + } + + override func didEnter(from previousState: GKState?) { + super.didEnter(from: previousState) + guard let viewModel = viewModel, let stateMachine = stateMachine else { return } + + viewModel.links = [] + + stateMachine.enter(Loading.self) + } + } + + class Fail: DiscoveryNewsViewModel.State { + + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + switch stateClass { + case is Loading.Type: + return true + default: + return false + } + } + + override func didEnter(from previousState: GKState?) { + super.didEnter(from: previousState) + guard let _ = viewModel, let stateMachine = stateMachine else { return } + + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: retry loading 3s later…", ((#file as NSString).lastPathComponent), #line, #function) + DispatchQueue.main.asyncAfter(deadline: .now() + 3) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: retry loading", ((#file as NSString).lastPathComponent), #line, #function) + stateMachine.enter(Loading.self) + } + } + } + + class Idle: DiscoveryNewsViewModel.State { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + switch stateClass { + case is Reloading.Type, is Loading.Type: + return true + default: + return false + } + } + } + + class Loading: DiscoveryNewsViewModel.State { + + var offset: Int? + var isReloading: Bool { return offset == nil } + + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + switch stateClass { + case is Fail.Type: + return true + case is Idle.Type: + return true + case is NoMore.Type: + return true + default: + return false + } + } + + override func didEnter(from previousState: GKState?) { + super.didEnter(from: previousState) + guard let viewModel = viewModel, let stateMachine = stateMachine else { return } + + + switch previousState { + case is Reloading: + offset = nil + default: + break + } + + guard let authenticationBox = viewModel.context.authenticationService.activeMastodonAuthenticationBox.value else { + stateMachine.enter(Fail.self) + return + } + + let offset = self.offset + + Task { + do { + let response = try await viewModel.context.apiService.trendLinks( + domain: authenticationBox.domain, + query: Mastodon.API.Trends.StatusQuery( + offset: offset, + limit: nil + ) + ) + let newOffset: Int? = { + guard let offset = response.link?.offset else { return nil } + return self.offset.flatMap { max($0, offset) } ?? offset + }() + + let hasMore: Bool = { + guard let newOffset = newOffset else { return false } + return newOffset != self.offset // not the same one + }() + + self.offset = newOffset + + var hasNewItemsAppend = false + var links = viewModel.links + for link in response.value { + guard !links.contains(link) else { continue } + links.append(link) + hasNewItemsAppend = true + } + + if hasNewItemsAppend, hasMore { + await enter(state: Idle.self) + } else { + await enter(state: NoMore.self) + } + viewModel.links = links + viewModel.didLoadLatest.send() + } catch { + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): fetch news fail: \(error.localizedDescription)") + await enter(state: Fail.self) + viewModel.didLoadLatest.send() + } + } // end Task + } // end func + } + + class NoMore: DiscoveryNewsViewModel.State { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + switch stateClass { + case is Reloading.Type: + return true + default: + return false + } + } + + override func didEnter(from previousState: GKState?) { + super.didEnter(from: previousState) + } + } +} diff --git a/Mastodon/Scene/Discovery/News/DiscoveryNewsViewModel.swift b/Mastodon/Scene/Discovery/News/DiscoveryNewsViewModel.swift new file mode 100644 index 000000000..b87e7c050 --- /dev/null +++ b/Mastodon/Scene/Discovery/News/DiscoveryNewsViewModel.swift @@ -0,0 +1,51 @@ +// +// DiscoveryNewsViewModel.swift +// Mastodon +// +// Created by MainasuK on 2022-4-13. +// + +import os.log +import UIKit +import Combine +import GameplayKit +import CoreData +import CoreDataStack +import MastodonSDK + +final class DiscoveryNewsViewModel { + + var disposeBag = Set() + + // input + let context: AppContext + let listBatchFetchViewModel = ListBatchFetchViewModel() + + // output + @Published var links: [Mastodon.Entity.Link] = [] + var diffableDataSource: UITableViewDiffableDataSource? + private(set) lazy var stateMachine: GKStateMachine = { + let stateMachine = GKStateMachine(states: [ + State.Initial(viewModel: self), + State.Reloading(viewModel: self), + State.Fail(viewModel: self), + State.Idle(viewModel: self), + State.Loading(viewModel: self), + State.NoMore(viewModel: self), + ]) + stateMachine.enter(State.Initial.self) + return stateMachine + }() + + let didLoadLatest = PassthroughSubject() + + init(context: AppContext) { + self.context = context + // end init + } + + deinit { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + } + +} diff --git a/Mastodon/Scene/Discovery/Posts/DiscoveryPostsViewController.swift b/Mastodon/Scene/Discovery/Posts/DiscoveryPostsViewController.swift index 3e813dbd8..30e2faf96 100644 --- a/Mastodon/Scene/Discovery/Posts/DiscoveryPostsViewController.swift +++ b/Mastodon/Scene/Discovery/Posts/DiscoveryPostsViewController.swift @@ -30,6 +30,8 @@ final class DiscoveryPostsViewController: UIViewController, NeedsDependency, Med return tableView }() + let refreshControl = UIRefreshControl() + deinit { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) } @@ -65,6 +67,16 @@ extension DiscoveryPostsViewController { statusTableViewCellDelegate: self ) + tableView.refreshControl = refreshControl + refreshControl.addTarget(self, action: #selector(DiscoveryPostsViewController.refreshControlValueChanged(_:)), for: .valueChanged) + viewModel.didLoadLatest + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + guard let self = self else { return } + self.refreshControl.endRefreshing() + } + .store(in: &disposeBag) + // setup batch fetch viewModel.listBatchFetchViewModel.setup(scrollView: tableView) viewModel.listBatchFetchViewModel.shouldFetch @@ -77,6 +89,24 @@ extension DiscoveryPostsViewController { .store(in: &disposeBag) } + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + + refreshControl.endRefreshing() + tableView.deselectRow(with: transitionCoordinator, animated: animated) + } + +} + +extension DiscoveryPostsViewController { + + @objc private func refreshControlValueChanged(_ sender: UIRefreshControl) { + guard viewModel.stateMachine.enter(DiscoveryPostsViewModel.State.Reloading.self) else { + sender.endRefreshing() + return + } + } + } // MARK: - UITableViewDelegate diff --git a/Mastodon/Scene/Discovery/Posts/DiscoveryPostsViewModel+Diffable.swift b/Mastodon/Scene/Discovery/Posts/DiscoveryPostsViewModel+Diffable.swift index 3abb4a21b..5c82384c7 100644 --- a/Mastodon/Scene/Discovery/Posts/DiscoveryPostsViewModel+Diffable.swift +++ b/Mastodon/Scene/Discovery/Posts/DiscoveryPostsViewModel+Diffable.swift @@ -28,7 +28,7 @@ extension DiscoveryPostsViewModel { stateMachine.enter(State.Reloading.self) statusFetchedResultsController.$records - .debounce(for: .milliseconds(300), scheduler: DispatchQueue.main) + .receive(on: DispatchQueue.main) .sink { [weak self] records in guard let self = self else { return } guard let diffableDataSource = self.diffableDataSource else { return } @@ -46,7 +46,9 @@ extension DiscoveryPostsViewModel { is State.Loading, is State.Idle, is State.Fail: - snapshot.appendItems([.bottomLoader], toSection: .main) + if !items.isEmpty { + snapshot.appendItems([.bottomLoader], toSection: .main) + } case is State.NoMore: break default: @@ -54,7 +56,7 @@ extension DiscoveryPostsViewModel { break } } - + diffableDataSource.applySnapshot(snapshot, animated: false) } .store(in: &disposeBag) diff --git a/Mastodon/Scene/Discovery/Posts/DiscoveryPostsViewModel+State.swift b/Mastodon/Scene/Discovery/Posts/DiscoveryPostsViewModel+State.swift index 0a2178685..199215d14 100644 --- a/Mastodon/Scene/Discovery/Posts/DiscoveryPostsViewModel+State.swift +++ b/Mastodon/Scene/Discovery/Posts/DiscoveryPostsViewModel+State.swift @@ -13,7 +13,7 @@ import MastodonSDK extension DiscoveryPostsViewModel { class State: GKState, NamingState { - let logger = Logger(subsystem: "TrendPostsViewModel.State", category: "StateMachine") + let logger = Logger(subsystem: "DiscoveryPostsViewModel.State", category: "StateMachine") let id = UUID() @@ -132,7 +132,6 @@ extension DiscoveryPostsViewModel.State { super.didEnter(from: previousState) guard let viewModel = viewModel, let stateMachine = stateMachine else { return } - switch previousState { case is Reloading: offset = nil @@ -182,10 +181,11 @@ extension DiscoveryPostsViewModel.State { await enter(state: NoMore.self) } viewModel.statusFetchedResultsController.statusIDs.value = statusIDs - + viewModel.didLoadLatest.send() } catch { - logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): fetch user timeline fail: \(error.localizedDescription)") + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): fetch posts fail: \(error.localizedDescription)") await enter(state: Fail.self) + viewModel.didLoadLatest.send() } } // end Task } // end func diff --git a/Mastodon/Scene/Discovery/Posts/DiscoveryPostsViewModel.swift b/Mastodon/Scene/Discovery/Posts/DiscoveryPostsViewModel.swift index 100a2a347..590ccc161 100644 --- a/Mastodon/Scene/Discovery/Posts/DiscoveryPostsViewModel.swift +++ b/Mastodon/Scene/Discovery/Posts/DiscoveryPostsViewModel.swift @@ -37,6 +37,8 @@ final class DiscoveryPostsViewModel { return stateMachine }() + let didLoadLatest = PassthroughSubject() + init(context: AppContext) { self.context = context self.statusFetchedResultsController = StatusFetchedResultsController( diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift index 7b7f35e5d..549552725 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift @@ -17,6 +17,7 @@ import AlamofireImage import StoreKit import MastodonAsset import MastodonLocalization +import MastodonUI final class HomeTimelineViewController: UIViewController, NeedsDependency, MediaPreviewableViewController { @@ -291,7 +292,7 @@ extension HomeTimelineViewController { tableView.deselectRow(with: transitionCoordinator, animated: animated) // needs trigger manually after onboarding dismiss - setNeedsStatusBarAppearanceUpdate() + setNeedsStatusBarAppearanceUpdate() } override func viewDidAppear(_ animated: Bool) { diff --git a/Mastodon/Scene/Root/MainTab/MainTabBarController.swift b/Mastodon/Scene/Root/MainTab/MainTabBarController.swift index db50565aa..f470082f2 100644 --- a/Mastodon/Scene/Root/MainTab/MainTabBarController.swift +++ b/Mastodon/Scene/Root/MainTab/MainTabBarController.swift @@ -11,6 +11,7 @@ import Combine import SafariServices import MastodonAsset import MastodonLocalization +import MastodonUI class MainTabBarController: UITabBarController { diff --git a/Mastodon/Scene/Root/Sidebar/SidebarViewController.swift b/Mastodon/Scene/Root/Sidebar/SidebarViewController.swift index 6568ab0cd..7ac0f6e54 100644 --- a/Mastodon/Scene/Root/Sidebar/SidebarViewController.swift +++ b/Mastodon/Scene/Root/Sidebar/SidebarViewController.swift @@ -9,6 +9,7 @@ import os.log import UIKit import Combine import CoreDataStack +import MastodonUI protocol SidebarViewControllerDelegate: AnyObject { func sidebarViewController(_ sidebarViewController: SidebarViewController, didSelectTab tab: MainTabBarController.Tab) diff --git a/Mastodon/Scene/Search/Search/Cell/TrendCollectionViewCell.swift b/Mastodon/Scene/Search/Search/Cell/TrendCollectionViewCell.swift index a43d65df4..379cba70d 100644 --- a/Mastodon/Scene/Search/Search/Cell/TrendCollectionViewCell.swift +++ b/Mastodon/Scene/Search/Search/Cell/TrendCollectionViewCell.swift @@ -9,45 +9,13 @@ import UIKit import Combine import MetaTextKit import MastodonAsset +import MastodonUI final class TrendCollectionViewCell: UICollectionViewCell { var _disposeBag = Set() - let container: UIStackView = { - let stackView = UIStackView() - stackView.axis = .horizontal - stackView.spacing = 16 - return stackView - }() - - let infoContainer: UIStackView = { - let stackView = UIStackView() - stackView.axis = .vertical - return stackView - }() - - let lineChartContainer: UIStackView = { - let stackView = UIStackView() - stackView.axis = .vertical - return stackView - }() - - let primaryLabel: UILabel = { - let label = UILabel() - label.font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 17, weight: .regular)) - label.textColor = Asset.Colors.Label.primary.color - return label - }() - - let secondaryLabel: UILabel = { - let label = UILabel() - label.font = UIFontMetrics(forTextStyle: .subheadline).scaledFont(for: .systemFont(ofSize: 15, weight: .regular)) - label.textColor = Asset.Colors.Label.secondary.color - return label - }() - - let lineChartView = LineChartView() + let trendView = TrendView() override func prepareForReuse() { super.prepareForReuse() @@ -77,44 +45,13 @@ extension TrendCollectionViewCell { } .store(in: &_disposeBag) - container.translatesAutoresizingMaskIntoConstraints = false - contentView.addSubview(container) + trendView.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(trendView) NSLayoutConstraint.activate([ - container.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 11), - container.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), - container.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), - contentView.bottomAnchor.constraint(equalTo: container.bottomAnchor, constant: 11), - ]) - - container.layoutMargins = UIEdgeInsets(top: 0, left: 16, bottom: 0, right: 16) - container.isLayoutMarginsRelativeArrangement = true - - // container: H - [ info container | padding | line chart container ] - container.addArrangedSubview(infoContainer) - - // info container: V - [ primary | secondary ] - infoContainer.addArrangedSubview(primaryLabel) - infoContainer.addArrangedSubview(secondaryLabel) - - // padding - let padding = UIView() - container.addArrangedSubview(padding) - - // line chart - container.addArrangedSubview(lineChartContainer) - - let lineChartViewTopPadding = UIView() - let lineChartViewBottomPadding = UIView() - lineChartViewTopPadding.translatesAutoresizingMaskIntoConstraints = false - lineChartViewBottomPadding.translatesAutoresizingMaskIntoConstraints = false - lineChartView.translatesAutoresizingMaskIntoConstraints = false - lineChartContainer.addArrangedSubview(lineChartViewTopPadding) - lineChartContainer.addArrangedSubview(lineChartView) - lineChartContainer.addArrangedSubview(lineChartViewBottomPadding) - NSLayoutConstraint.activate([ - lineChartView.widthAnchor.constraint(equalToConstant: 50), - lineChartView.heightAnchor.constraint(equalToConstant: 26), - lineChartViewTopPadding.heightAnchor.constraint(equalTo: lineChartViewBottomPadding.heightAnchor), + trendView.topAnchor.constraint(equalTo: contentView.topAnchor), + trendView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + trendView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + trendView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), ]) } diff --git a/Mastodon/Scene/Search/SearchDetail/SearchHistory/View/SearchHistoryTableHeaderView.swift b/Mastodon/Scene/Search/SearchDetail/SearchHistory/View/SearchHistoryTableHeaderView.swift index 8ac661b18..fc41bdf27 100644 --- a/Mastodon/Scene/Search/SearchDetail/SearchHistory/View/SearchHistoryTableHeaderView.swift +++ b/Mastodon/Scene/Search/SearchDetail/SearchHistory/View/SearchHistoryTableHeaderView.swift @@ -10,6 +10,7 @@ import UIKit import Combine import MastodonAsset import MastodonLocalization +import MastodonUI protocol SearchHistoryTableHeaderViewDelegate: AnyObject { func searchHistoryTableHeaderView(_ searchHistoryTableHeaderView: SearchHistoryTableHeaderView, clearSearchHistoryButtonDidPressed button: UIButton) diff --git a/Mastodon/Scene/Share/View/Content/ContentWarningOverlayView.swift b/Mastodon/Scene/Share/View/Content/ContentWarningOverlayView.swift index 78c5462f5..8300f865a 100644 --- a/Mastodon/Scene/Share/View/Content/ContentWarningOverlayView.swift +++ b/Mastodon/Scene/Share/View/Content/ContentWarningOverlayView.swift @@ -11,6 +11,7 @@ import Combine import UIKit import MastodonAsset import MastodonLocalization +import MastodonUI protocol ContentWarningOverlayViewDelegate: AnyObject { func contentWarningOverlayViewDidPressed(_ contentWarningOverlayView: ContentWarningOverlayView) diff --git a/Mastodon/Scene/Share/View/Content/MediaView+Configuration.swift b/Mastodon/Scene/Share/View/Content/MediaView+Configuration.swift index ad2fa398d..cba1fcf6d 100644 --- a/Mastodon/Scene/Share/View/Content/MediaView+Configuration.swift +++ b/Mastodon/Scene/Share/View/Content/MediaView+Configuration.swift @@ -58,7 +58,7 @@ extension MediaView { }() if let previewURL = configuration.previewURL, - let url = URL(string: previewURL) + let url = URL(string: previewURL) { let placeholder = UIImage.placeholder(color: .systemGray6) let request = URLRequest(url: url) diff --git a/Mastodon/Scene/Share/View/Content/ThreadMetaView.swift b/Mastodon/Scene/Share/View/Content/ThreadMetaView.swift index c339654f5..0953feecd 100644 --- a/Mastodon/Scene/Share/View/Content/ThreadMetaView.swift +++ b/Mastodon/Scene/Share/View/Content/ThreadMetaView.swift @@ -6,6 +6,7 @@ // import UIKit +import MastodonUI final class ThreadMetaView: UIView { diff --git a/Mastodon/Service/APIService/APIService+Trend.swift b/Mastodon/Service/APIService/APIService+Trend.swift index 34edae09e..47dda6bd2 100644 --- a/Mastodon/Service/APIService/APIService+Trend.swift +++ b/Mastodon/Service/APIService/APIService+Trend.swift @@ -53,4 +53,17 @@ extension APIService { return response } + func trendLinks( + domain: String, + query: Mastodon.API.Trends.LinkQuery + ) async throws -> Mastodon.Response.Content<[Mastodon.Entity.Link]> { + let response = try await Mastodon.API.Trends.links( + session: session, + domain: domain, + query: query + ).singleOutput() + + return response + } + } diff --git a/Mastodon/Service/SettingService.swift b/Mastodon/Service/SettingService.swift index 1e8022c59..bd571d8f4 100644 --- a/Mastodon/Service/SettingService.swift +++ b/Mastodon/Service/SettingService.swift @@ -12,6 +12,7 @@ import CoreDataStack import MastodonSDK import MastodonAsset import MastodonLocalization +import MastodonCommon final class SettingService { @@ -190,18 +191,6 @@ extension SettingService { extension SettingService { static func updatePreference(setting: Setting) { - // set appearance -// let userInterfaceStyle: UIUserInterfaceStyle = { -// switch setting.appearance { -// case .automatic: return .unspecified -// case .light: return .light -// case .dark: return .dark -// } -// }() -// if UserDefaults.shared.customUserInterfaceStyle != userInterfaceStyle { -// UserDefaults.shared.customUserInterfaceStyle = userInterfaceStyle -// } - // set theme let themeName: ThemeName = setting.preferredTrueBlackDarkMode ? .system : .mastodon if UserDefaults.shared.currentThemeNameRawValue != themeName.rawValue { @@ -223,6 +212,6 @@ extension SettingService { if UserDefaults.shared.preferredUsingDefaultBrowser != setting.preferredUsingDefaultBrowser { UserDefaults.shared.preferredUsingDefaultBrowser = setting.preferredUsingDefaultBrowser } - } + } diff --git a/MastodonSDK/Sources/MastodonCommon/Preference/Preference+Theme.swift b/MastodonSDK/Sources/MastodonCommon/Preference/Preference+Theme.swift new file mode 100644 index 000000000..a87a8da75 --- /dev/null +++ b/MastodonSDK/Sources/MastodonCommon/Preference/Preference+Theme.swift @@ -0,0 +1,26 @@ +// +// Preference+Theme.swift +// +// +// Created by MainasuK on 2022-4-13. +// + +import UIKit +import MastodonExtension + +public enum ThemeName: String, CaseIterable { + case system + case mastodon +} + +extension UserDefaults { + + @objc public dynamic var currentThemeNameRawValue: String { + get { + register(defaults: [#function: ThemeName.mastodon.rawValue]) + return string(forKey: #function) ?? ThemeName.mastodon.rawValue + } + set { self[#function] = newValue } + } + +} diff --git a/MastodonSDK/Sources/MastodonExtension/UIView.swift b/MastodonSDK/Sources/MastodonExtension/UIView.swift index 5466c464d..f96d1618a 100644 --- a/MastodonSDK/Sources/MastodonExtension/UIView.swift +++ b/MastodonSDK/Sources/MastodonExtension/UIView.swift @@ -12,3 +12,37 @@ extension UIView { return UIScreen.main.scale != UIScreen.main.nativeScale } } + +extension UIView { + @discardableResult + public func applyCornerRadius(radius: CGFloat) -> Self { + layer.masksToBounds = true + layer.cornerRadius = radius + layer.cornerCurve = .continuous + return self + } + + @discardableResult + public func applyShadow( + color: UIColor, + alpha: Float, + x: CGFloat, + y: CGFloat, + blur: CGFloat, + spread: CGFloat = 0 + ) -> Self { + layer.masksToBounds = false + layer.shadowColor = color.cgColor + layer.shadowOpacity = alpha + layer.shadowOffset = CGSize(width: x, height: y) + layer.shadowRadius = blur / 2.0 + if spread == 0 { + layer.shadowPath = nil + } else { + let dx = -spread + let rect = bounds.insetBy(dx: dx, dy: dx) + layer.shadowPath = UIBezierPath(rect: rect).cgPath + } + return self + } +} diff --git a/MastodonSDK/Sources/MastodonExtension/UInt64.swift b/MastodonSDK/Sources/MastodonExtension/UInt64.swift new file mode 100644 index 000000000..03dfbaca7 --- /dev/null +++ b/MastodonSDK/Sources/MastodonExtension/UInt64.swift @@ -0,0 +1,12 @@ +// +// UInt64.swift +// +// +// Created by MainasuK on 2022-4-13. +// + +import Foundation + +extension UInt64 { + public static let second: UInt64 = 1_000_000_000 +} diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Trends.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Trends.swift index 25e130e32..d2dca8245 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Trends.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Trends.swift @@ -71,9 +71,9 @@ extension Mastodon.API.Trends { .appendingPathComponent("statuses") } - /// Trending tags + /// Trending status /// - /// Tags that are being used more frequently within the past week. + /// TBD /// /// Version history: /// 3.?.? @@ -83,7 +83,7 @@ extension Mastodon.API.Trends { /// - session: `URLSession` /// - domain: Mastodon instance domain. e.g. "example.com" /// - query: query - /// - Returns: `AnyPublisher` contains `Hashtags` nested in the response + /// - Returns: `[Status]` nested in the response public static func statuses( session: URLSession, @@ -126,3 +126,47 @@ extension Mastodon.API.Trends { } } + +extension Mastodon.API.Trends { + + static func trendLinksURL(domain: String) -> URL { + Mastodon.API.endpointURL(domain: domain) + .appendingPathComponent("trends") + .appendingPathComponent("links") + } + + /// Trending links + /// + /// TBD + /// + /// Version history: + /// 3.?.? + /// # Reference + /// [Document](https://docs.joinmastodon.org/methods/instance/trends/) + /// - Parameters: + /// - session: `URLSession` + /// - domain: Mastodon instance domain. e.g. "example.com" + /// - query: query + /// - Returns: `[Link]` nested in the response + + public static func links( + session: URLSession, + domain: String, + query: Mastodon.API.Trends.LinkQuery? + ) -> AnyPublisher, Error> { + let request = Mastodon.API.get( + url: trendLinksURL(domain: domain), + query: query, + authorization: nil + ) + return session.dataTaskPublisher(for: request) + .tryMap { data, response in + let value = try Mastodon.API.decode(type: [Mastodon.Entity.Link].self, from: data, response: response) + return Mastodon.Response.Content(value: value, response: response) + } + .eraseToAnyPublisher() + } + + public typealias LinkQuery = StatusQuery + +} diff --git a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Link.swift b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Link.swift new file mode 100644 index 000000000..d1d7bd673 --- /dev/null +++ b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Link.swift @@ -0,0 +1,54 @@ +// +// Mastodon+Entity+Link.swift +// +// +// Created by MainasuK on 2022-4-13. +// + +import Foundation + +extension Mastodon.Entity { + /// History + /// + /// - Since: 3.5.0 + /// - Version: 3.5.1 + /// # Last Update + /// 2022/4/13 + /// # Reference + /// [Document](TBD) + public struct Link: Codable { + public let url: String + public let title: String + public let description: String + public let providerName: String + public let providerURL: String + public let image: String + public let width: Int + public let height: Int + public let blurhash: String + public let history: [History] + + enum CodingKeys: String, CodingKey { + case url + case title + case description + case providerName = "provider_name" + case providerURL = "provider_url" + case image + case width + case height + case blurhash + case history + } + } +} + +extension Mastodon.Entity.Link: Hashable { + public static func == (lhs: Mastodon.Entity.Link, rhs: Mastodon.Entity.Link) -> Bool { + return lhs.url == rhs.url + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(url) + } +} diff --git a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Tag.swift b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Tag.swift index b017d1551..84875359a 100644 --- a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Tag.swift +++ b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Tag.swift @@ -37,6 +37,7 @@ extension Mastodon.Entity { public func hash(into hasher: inout Hasher) { hasher.combine(name) + hasher.combine(url) } } } diff --git a/MastodonSDK/Sources/MastodonUI/Extension/MastodonSDK/Mastodon+Entity+Link.swift b/MastodonSDK/Sources/MastodonUI/Extension/MastodonSDK/Mastodon+Entity+Link.swift new file mode 100644 index 000000000..8f00911f9 --- /dev/null +++ b/MastodonSDK/Sources/MastodonUI/Extension/MastodonSDK/Mastodon+Entity+Link.swift @@ -0,0 +1,21 @@ +// +// Mastodon+Entity+Link.swift +// +// +// Created by MainasuK on 2022-4-13. +// + +import Foundation +import MastodonSDK + +extension Mastodon.Entity.Link { + + /// the sum of recent 2 days + public var talkingPeopleCount: Int? { + return history + .prefix(2) + .compactMap { Int($0.accounts) } + .reduce(0, +) + } + +} diff --git a/MastodonSDK/Sources/MastodonUI/Extension/MastodonSDK/Mastodon+Entity+Tag.swift b/MastodonSDK/Sources/MastodonUI/Extension/MastodonSDK/Mastodon+Entity+Tag.swift new file mode 100644 index 000000000..4d58145e5 --- /dev/null +++ b/MastodonSDK/Sources/MastodonUI/Extension/MastodonSDK/Mastodon+Entity+Tag.swift @@ -0,0 +1,21 @@ +// +// Mastodon+Entity+Tag.swift +// +// +// Created by MainasuK on 2022-4-13. +// + +import Foundation +import MastodonSDK + +extension Mastodon.Entity.Tag { + + /// the sum of recent 2 days + public var talkingPeopleCount: Int? { + return history? + .prefix(2) + .compactMap { Int($0.accounts) } + .reduce(0, +) + } + +} diff --git a/MastodonSDK/Sources/MastodonUI/Extension/UIView.swift b/MastodonSDK/Sources/MastodonUI/Extension/UIView.swift new file mode 100644 index 000000000..0489965b5 --- /dev/null +++ b/MastodonSDK/Sources/MastodonUI/Extension/UIView.swift @@ -0,0 +1,34 @@ +// +// UIView.swift +// +// +// Created by MainasuK on 2022-4-13. +// + +import UIKit + +extension UIView { + + static let separatorColor: UIColor = { + UIColor(dynamicProvider: { collection in + switch collection.userInterfaceStyle { + case .dark: + return ThemeService.shared.currentTheme.value.separator + default: + return .separator + } + }) + }() + + + public static var separatorLine: UIView { + let line = UIView() + line.backgroundColor = UIView.separatorColor + return line + } + + public static func separatorLineHeight(of view: UIView) -> CGFloat { + return 1.0 / view.traitCollection.displayScale + } + +} diff --git a/Mastodon/Service/ThemeService/MastodonTheme.swift b/MastodonSDK/Sources/MastodonUI/Service/ThemeService/MastodonTheme.swift similarity index 98% rename from Mastodon/Service/ThemeService/MastodonTheme.swift rename to MastodonSDK/Sources/MastodonUI/Service/ThemeService/MastodonTheme.swift index 0dad463b6..76173590e 100644 --- a/Mastodon/Service/ThemeService/MastodonTheme.swift +++ b/MastodonSDK/Sources/MastodonUI/Service/ThemeService/MastodonTheme.swift @@ -7,6 +7,7 @@ import UIKit import MastodonAsset +import MastodonCommon struct MastodonTheme: Theme { diff --git a/Mastodon/Service/ThemeService/SystemTheme.swift b/MastodonSDK/Sources/MastodonUI/Service/ThemeService/SystemTheme.swift similarity index 98% rename from Mastodon/Service/ThemeService/SystemTheme.swift rename to MastodonSDK/Sources/MastodonUI/Service/ThemeService/SystemTheme.swift index 7796fde7b..cea10a281 100644 --- a/Mastodon/Service/ThemeService/SystemTheme.swift +++ b/MastodonSDK/Sources/MastodonUI/Service/ThemeService/SystemTheme.swift @@ -7,6 +7,7 @@ import UIKit import MastodonAsset +import MastodonCommon struct SystemTheme: Theme { diff --git a/Mastodon/Service/ThemeService/Theme.swift b/MastodonSDK/Sources/MastodonUI/Service/ThemeService/Theme.swift similarity index 82% rename from Mastodon/Service/ThemeService/Theme.swift rename to MastodonSDK/Sources/MastodonUI/Service/ThemeService/Theme.swift index 1a3b3c5d1..ae555da00 100644 --- a/Mastodon/Service/ThemeService/Theme.swift +++ b/MastodonSDK/Sources/MastodonUI/Service/ThemeService/Theme.swift @@ -6,6 +6,7 @@ // import UIKit +import MastodonCommon public protocol Theme { @@ -42,17 +43,3 @@ public protocol Theme { var notificationStatusBorderColor: UIColor { get } } - -public enum ThemeName: String, CaseIterable { - case system - case mastodon -} - -extension ThemeName { - public var theme: Theme { - switch self { - case .system: return SystemTheme() - case .mastodon: return MastodonTheme() - } - } -} diff --git a/Mastodon/Service/ThemeService/ThemeService.swift b/MastodonSDK/Sources/MastodonUI/Service/ThemeService/ThemeService.swift similarity index 57% rename from Mastodon/Service/ThemeService/ThemeService.swift rename to MastodonSDK/Sources/MastodonUI/Service/ThemeService/ThemeService.swift index b356d3469..b782c4138 100644 --- a/Mastodon/Service/ThemeService/ThemeService.swift +++ b/MastodonSDK/Sources/MastodonUI/Service/ThemeService/ThemeService.swift @@ -8,16 +8,17 @@ import UIKit import Combine import AppShared +import MastodonCommon // ref: https://zamzam.io/protocol-oriented-themes-for-ios-apps/ -final class ThemeService { +public final class ThemeService { - static let tintColor: UIColor = .label + public static let tintColor: UIColor = .label // MARK: - Singleton public static let shared = ThemeService() - let currentTheme: CurrentValueSubject + public let currentTheme: CurrentValueSubject private init() { let theme = ThemeName(rawValue: UserDefaults.shared.currentThemeNameRawValue)?.theme ?? ThemeName.mastodon.theme @@ -25,3 +26,12 @@ final class ThemeService { } } + +extension ThemeName { + public var theme: Theme { + switch self { + case .system: return SystemTheme() + case .mastodon: return MastodonTheme() + } + } +} diff --git a/Mastodon/Vender/CurveAlgorithm.swift b/MastodonSDK/Sources/MastodonUI/Vendor/CurveAlgorithm.swift similarity index 100% rename from Mastodon/Vender/CurveAlgorithm.swift rename to MastodonSDK/Sources/MastodonUI/Vendor/CurveAlgorithm.swift diff --git a/MastodonSDK/Sources/MastodonUI/View/Content/NewsView+Configuration.swift b/MastodonSDK/Sources/MastodonUI/View/Content/NewsView+Configuration.swift new file mode 100644 index 000000000..3116534a0 --- /dev/null +++ b/MastodonSDK/Sources/MastodonUI/View/Content/NewsView+Configuration.swift @@ -0,0 +1,43 @@ +// +// NewsView+Configuration.swift +// +// +// Created by MainasuK on 2022-4-13. +// + +import UIKit +import MastodonSDK +import MastodonLocalization +import AlamofireImage + +extension NewsView { + public func configure(link: Mastodon.Entity.Link) { + providerNameLabel.text = link.providerName + headlineLabel.text = link.title + footnoteLabel.text = L10n.Scene.Search.Recommend.HashTag.peopleTalking(link.talkingPeopleCount ?? 0) + + let configuration = MediaView.Configuration( + info: .image(info: .init( + aspectRadio: CGSize(width: link.width, height: link.height), + assetURL: link.image + )), + blurhash: link.blurhash + ) + imageView.setup(configuration: configuration) + + if let previewURL = configuration.previewURL, + let url = URL(string: previewURL) + { + let placeholder = UIImage.placeholder(color: .systemGray6) + let request = URLRequest(url: url) + ImageDownloader.default.download(request, completion: { response in + switch response.result { + case .success(let image): + configuration.previewImage = image + case .failure: + configuration.previewImage = placeholder + } + }) + } + } // end func +} diff --git a/MastodonSDK/Sources/MastodonUI/View/Content/NewsView.swift b/MastodonSDK/Sources/MastodonUI/View/Content/NewsView.swift new file mode 100644 index 000000000..ee9506a96 --- /dev/null +++ b/MastodonSDK/Sources/MastodonUI/View/Content/NewsView.swift @@ -0,0 +1,102 @@ +// +// NewsView.swift +// +// +// Created by MainasuK on 2022-4-13. +// + +import UIKit +import MastodonAsset + +public final class NewsView: UIView { + + let container = UIStackView() + + let providerNameLabel: UILabel = { + let label = UILabel() + label.font = UIFontMetrics(forTextStyle: .footnote).scaledFont(for: .systemFont(ofSize: 13, weight: .semibold)) + label.textColor = Asset.Colors.Label.primary.color + return label + }() + + let headlineLabel: UILabel = { + let label = UILabel() + label.font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 17, weight: .semibold)) + label.textColor = Asset.Colors.Label.primary.color + label.numberOfLines = 0 + return label + }() + + let footnoteLabel: UILabel = { + let label = UILabel() + label.font = UIFontMetrics(forTextStyle: .caption1).scaledFont(for: .systemFont(ofSize: 12, weight: .medium)) + label.textColor = Asset.Colors.Label.secondary.color + return label + }() + + let imageView = MediaView() + + public func prepareForReuse() { + imageView.prepareForReuse() + } + + override init(frame: CGRect) { + super.init(frame: frame) + _init() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + _init() + } + +} + +extension NewsView { + private func _init() { + // container: H - [ textContainer | imageView ] + container.axis = .horizontal + container.spacing = 8 + container.translatesAutoresizingMaskIntoConstraints = false + addSubview(container) + NSLayoutConstraint.activate([ + container.topAnchor.constraint(equalTo: topAnchor), + container.leadingAnchor.constraint(equalTo: leadingAnchor), + container.trailingAnchor.constraint(equalTo: trailingAnchor), + container.bottomAnchor.constraint(equalTo: bottomAnchor), + ]) + + // textContainer: V - [ providerContainer | headlineLabel | (spacer) | footnoteLabel ] + let textContainer = UIStackView() + textContainer.axis = .vertical + textContainer.spacing = 4 + container.addArrangedSubview(textContainer) + + // providerContainer: H - [ providerFavIconImageView | providerNameLabel | (spacer) ] + let providerContainer = UIStackView() + providerContainer.axis = .horizontal + textContainer.addArrangedSubview(providerContainer) + + providerContainer.addArrangedSubview(providerNameLabel) + + // headlineLabel + textContainer.addArrangedSubview(headlineLabel) + let spacer = UIView() + spacer.translatesAutoresizingMaskIntoConstraints = false + textContainer.addArrangedSubview(spacer) + NSLayoutConstraint.activate([ + spacer.heightAnchor.constraint(equalToConstant: 24).priority(.required - 1), + ]) + // footnoteLabel + textContainer.addArrangedSubview(footnoteLabel) + + // imageView + imageView.translatesAutoresizingMaskIntoConstraints = false + container.addArrangedSubview(imageView) + NSLayoutConstraint.activate([ + imageView.widthAnchor.constraint(equalToConstant: 132).priority(.required - 1), + ]) + imageView.isUserInteractionEnabled = false + } +} + diff --git a/MastodonSDK/Sources/MastodonUI/View/Content/TrendView+Configuration.swift b/MastodonSDK/Sources/MastodonUI/View/Content/TrendView+Configuration.swift new file mode 100644 index 000000000..cdd3dfd82 --- /dev/null +++ b/MastodonSDK/Sources/MastodonUI/View/Content/TrendView+Configuration.swift @@ -0,0 +1,35 @@ +// +// TrendView+Configuration.swift +// +// +// Created by MainasuK on 2022-4-13. +// + +import UIKit +import MastodonSDK +import MastodonLocalization + +extension TrendView { + public func configure(tag: Mastodon.Entity.Tag) { + let primaryLabelText = "#" + tag.name + let secondaryLabelText = L10n.Scene.Search.Recommend.HashTag.peopleTalking(tag.talkingPeopleCount ?? 0) + + primaryLabel.text = primaryLabelText + secondaryLabel.text = secondaryLabelText + + lineChartView.data = (tag.history ?? []) + .sorted(by: { $0.day < $1.day }) // latest last + .map { entry in + guard let point = Int(entry.accounts) else { + return .zero + } + return CGFloat(point) + } + + isAccessibilityElement = true + accessibilityLabel = [ + primaryLabelText, + secondaryLabelText + ].joined(separator: ", ") + } +} diff --git a/MastodonSDK/Sources/MastodonUI/View/Content/TrendView.swift b/MastodonSDK/Sources/MastodonUI/View/Content/TrendView.swift new file mode 100644 index 000000000..ff1b9b708 --- /dev/null +++ b/MastodonSDK/Sources/MastodonUI/View/Content/TrendView.swift @@ -0,0 +1,100 @@ +// +// TrendView.swift +// +// +// Created by MainasuK on 2022-4-13. +// + +import UIKit +import MastodonAsset + +public final class TrendView: UIView { + + let container: UIStackView = { + let stackView = UIStackView() + stackView.axis = .horizontal + stackView.spacing = 16 + return stackView + }() + + let infoContainer: UIStackView = { + let stackView = UIStackView() + stackView.axis = .vertical + return stackView + }() + + let lineChartContainer: UIStackView = { + let stackView = UIStackView() + stackView.axis = .vertical + return stackView + }() + + let primaryLabel: UILabel = { + let label = UILabel() + label.font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 17, weight: .regular)) + label.textColor = Asset.Colors.Label.primary.color + return label + }() + + let secondaryLabel: UILabel = { + let label = UILabel() + label.font = UIFontMetrics(forTextStyle: .subheadline).scaledFont(for: .systemFont(ofSize: 15, weight: .regular)) + label.textColor = Asset.Colors.Label.secondary.color + return label + }() + + let lineChartView = LineChartView() + + public override init(frame: CGRect) { + super.init(frame: frame) + _init() + } + + public required init?(coder: NSCoder) { + super.init(coder: coder) + _init() + } + +} + +extension TrendView { + private func _init() { + container.translatesAutoresizingMaskIntoConstraints = false + addSubview(container) + NSLayoutConstraint.activate([ + container.topAnchor.constraint(equalTo: topAnchor, constant: 11), + container.leadingAnchor.constraint(equalTo: leadingAnchor), + container.trailingAnchor.constraint(equalTo: trailingAnchor), + bottomAnchor.constraint(equalTo: container.bottomAnchor, constant: 11), + ]) + + // container: H - [ info container | padding | line chart container ] + container.addArrangedSubview(infoContainer) + + // info container: V - [ primary | secondary ] + infoContainer.addArrangedSubview(primaryLabel) + infoContainer.addArrangedSubview(secondaryLabel) + + // padding + let padding = UIView() + container.addArrangedSubview(padding) + + // line chart + container.addArrangedSubview(lineChartContainer) + + let lineChartViewTopPadding = UIView() + let lineChartViewBottomPadding = UIView() + lineChartViewTopPadding.translatesAutoresizingMaskIntoConstraints = false + lineChartViewBottomPadding.translatesAutoresizingMaskIntoConstraints = false + lineChartView.translatesAutoresizingMaskIntoConstraints = false + lineChartContainer.addArrangedSubview(lineChartViewTopPadding) + lineChartContainer.addArrangedSubview(lineChartView) + lineChartContainer.addArrangedSubview(lineChartViewBottomPadding) + NSLayoutConstraint.activate([ + lineChartView.widthAnchor.constraint(equalToConstant: 50), + lineChartView.heightAnchor.constraint(equalToConstant: 26), + lineChartViewTopPadding.heightAnchor.constraint(equalTo: lineChartViewBottomPadding.heightAnchor), + ]) + } +} + diff --git a/Mastodon/Scene/Search/Search/View/LineChartView.swift b/MastodonSDK/Sources/MastodonUI/View/Control/LineChartView.swift similarity index 85% rename from Mastodon/Scene/Search/Search/View/LineChartView.swift rename to MastodonSDK/Sources/MastodonUI/View/Control/LineChartView.swift index cd76fb0c8..c90b59f0e 100644 --- a/Mastodon/Scene/Search/Search/View/LineChartView.swift +++ b/MastodonSDK/Sources/MastodonUI/View/Control/LineChartView.swift @@ -7,12 +7,11 @@ import UIKit import Accelerate -import simd import MastodonAsset -final class LineChartView: UIView { +public final class LineChartView: UIView { - var data: [CGFloat] = [] { + public var data: [CGFloat] = [] { didSet { setNeedsLayout() } @@ -20,14 +19,13 @@ final class LineChartView: UIView { let lineShapeLayer = CAShapeLayer() let gradientLayer = CAGradientLayer() -// let dotShapeLayer = CAShapeLayer() - override init(frame: CGRect) { + public override init(frame: CGRect) { super.init(frame: frame) _init() } - required init?(coder: NSCoder) { + public required init?(coder: NSCoder) { super.init(coder: coder) _init() } @@ -38,10 +36,8 @@ extension LineChartView { private func _init() { lineShapeLayer.frame = bounds gradientLayer.frame = bounds -// dotShapeLayer.frame = bounds layer.addSublayer(lineShapeLayer) layer.addSublayer(gradientLayer) -// layer.addSublayer(dotShapeLayer) gradientLayer.colors = [ Asset.Colors.brandBlue.color.withAlphaComponent(0.5).cgColor, // set the same alpha to fill @@ -51,16 +47,14 @@ extension LineChartView { gradientLayer.endPoint = CGPoint(x: 0.5, y: 1) } - override func layoutSubviews() { + public override func layoutSubviews() { super.layoutSubviews() lineShapeLayer.frame = bounds gradientLayer.frame = bounds -// dotShapeLayer.frame = bounds guard data.count > 1 else { lineShapeLayer.path = nil -// dotShapeLayer.path = nil gradientLayer.isHidden = true return } @@ -113,9 +107,5 @@ extension LineChartView { maskLayer.strokeColor = UIColor.clear.cgColor maskLayer.lineWidth = 0.0 gradientLayer.mask = maskLayer - -// dotShapeLayer.lineWidth = 3 -// dotShapeLayer.fillColor = Asset.Colors.brandBlue.color.cgColor -// dotShapeLayer.path = dotPath.cgPath } } diff --git a/MastodonSDK/Sources/MastodonUI/View/TableViewCell/NewsTableViewCell.swift b/MastodonSDK/Sources/MastodonUI/View/TableViewCell/NewsTableViewCell.swift new file mode 100644 index 000000000..3515000f9 --- /dev/null +++ b/MastodonSDK/Sources/MastodonUI/View/TableViewCell/NewsTableViewCell.swift @@ -0,0 +1,56 @@ +// +// NewsTableViewCell.swift +// +// +// Created by MainasuK on 2022-4-13. +// + +import UIKit + +public final class NewsTableViewCell: UITableViewCell { + + public let newsView = NewsView() + + let separatorLine = UIView.separatorLine + + public override func prepareForReuse() { + super.prepareForReuse() + + newsView.prepareForReuse() + } + + public override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + _init() + } + + public required init?(coder: NSCoder) { + super.init(coder: coder) + _init() + } + +} + +extension NewsTableViewCell { + + private func _init() { + newsView.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(newsView) + NSLayoutConstraint.activate([ + newsView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 16), + newsView.leadingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.leadingAnchor), + newsView.trailingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.trailingAnchor), + contentView.bottomAnchor.constraint(equalTo: newsView.bottomAnchor, constant: 16), + ]) + + separatorLine.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(separatorLine) + NSLayoutConstraint.activate([ + separatorLine.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + separatorLine.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + separatorLine.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), + separatorLine.heightAnchor.constraint(equalToConstant: UIView.separatorLineHeight(of: contentView)), + ]) + } + +} diff --git a/MastodonSDK/Sources/MastodonUI/View/TableViewCell/TrendTableViewCell.swift b/MastodonSDK/Sources/MastodonUI/View/TableViewCell/TrendTableViewCell.swift new file mode 100644 index 000000000..8c1cebff0 --- /dev/null +++ b/MastodonSDK/Sources/MastodonUI/View/TableViewCell/TrendTableViewCell.swift @@ -0,0 +1,86 @@ +// +// TrendTableViewCell.swift +// +// +// Created by MainasuK on 2022-4-13. +// + +import UIKit + +public final class TrendTableViewCell: UITableViewCell { + + public let trendView = TrendView() + + let separatorLine = UIView.separatorLine + + public override func prepareForReuse() { + super.prepareForReuse() + + configureSeparator(style: .inset) + } + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + _init() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + _init() + } + +} + +extension TrendTableViewCell { + + private func _init() { + trendView.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(trendView) + NSLayoutConstraint.activate([ + trendView.topAnchor.constraint(equalTo: contentView.topAnchor), + trendView.leadingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.leadingAnchor), + trendView.trailingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.trailingAnchor), + trendView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), + ]) + + configureSeparator(style: .inset) + + accessibilityElements = [trendView] + } + +} + +extension TrendTableViewCell { + + public enum SeparatorStyle { + case edge + case inset + } + + public func configureSeparator(style: SeparatorStyle) { + separatorLine.removeFromSuperview() + separatorLine.removeConstraints(separatorLine.constraints) + + switch style { + case .edge: + separatorLine.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(separatorLine) + NSLayoutConstraint.activate([ + separatorLine.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + separatorLine.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + separatorLine.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), + separatorLine.heightAnchor.constraint(equalToConstant: UIView.separatorLineHeight(of: contentView)), + ]) + case .inset: + separatorLine.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(separatorLine) + NSLayoutConstraint.activate([ + separatorLine.leadingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.leadingAnchor), + separatorLine.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + separatorLine.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), + separatorLine.heightAnchor.constraint(equalToConstant: UIView.separatorLineHeight(of: contentView)), + ]) + } + } + +} diff --git a/ShareActionExtension/Scene/ShareViewController.swift b/ShareActionExtension/Scene/ShareViewController.swift index d45558f1a..622e0106b 100644 --- a/ShareActionExtension/Scene/ShareViewController.swift +++ b/ShareActionExtension/Scene/ShareViewController.swift @@ -12,6 +12,7 @@ import MastodonUI import SwiftUI import MastodonAsset import MastodonLocalization +import MastodonUI class ShareViewController: UIViewController { diff --git a/ShareActionExtension/Scene/ShareViewModel.swift b/ShareActionExtension/Scene/ShareViewModel.swift index fbad82209..c56f8ecfd 100644 --- a/ShareActionExtension/Scene/ShareViewModel.swift +++ b/ShareActionExtension/Scene/ShareViewModel.swift @@ -16,6 +16,7 @@ import SwiftUI import UniformTypeIdentifiers import MastodonAsset import MastodonLocalization +import MastodonUI final class ShareViewModel { diff --git a/ShareActionExtension/Scene/View/ComposeToolbarView.swift b/ShareActionExtension/Scene/View/ComposeToolbarView.swift index 73caac735..a903d3ebd 100644 --- a/ShareActionExtension/Scene/View/ComposeToolbarView.swift +++ b/ShareActionExtension/Scene/View/ComposeToolbarView.swift @@ -12,6 +12,7 @@ import MastodonSDK import MastodonUI import MastodonAsset import MastodonLocalization +import MastodonUI protocol ComposeToolbarViewDelegate: AnyObject { func composeToolbarView(_ composeToolbarView: ComposeToolbarView, contentWarningButtonDidPressed sender: UIButton)