Merge pull request #368 from mastodon/feature-discovery
New Discovery scene
This commit is contained in:
commit
b5e8e183f7
|
@ -15,8 +15,8 @@
|
|||
<key>CFBundlePackageType</key>
|
||||
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.3.0</string>
|
||||
<string>1.3.1</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>109</string>
|
||||
<string>110</string>
|
||||
</dict>
|
||||
</plist>
|
||||
|
|
|
@ -492,6 +492,14 @@
|
|||
"clear": "Clear"
|
||||
}
|
||||
},
|
||||
"discovery": {
|
||||
"tabs": {
|
||||
"posts": "Posts",
|
||||
"hashtags": "Hashtags",
|
||||
"news": "News",
|
||||
"for_you": "For You"
|
||||
}
|
||||
},
|
||||
"favorite": {
|
||||
"title": "Your Favorites"
|
||||
},
|
||||
|
|
|
@ -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 */; };
|
||||
|
@ -215,7 +214,6 @@
|
|||
DB336F3F278E668C0031E64B /* StatusTableViewCell+ViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB336F3E278E668C0031E64B /* StatusTableViewCell+ViewModel.swift */; };
|
||||
DB336F41278E68480031E64B /* StatusView+Configuration.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB336F40278E68480031E64B /* StatusView+Configuration.swift */; };
|
||||
DB336F43278EB1690031E64B /* MediaView+Configuration.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB336F42278EB1680031E64B /* MediaView+Configuration.swift */; };
|
||||
DB35FC1F2612F1D9006193C9 /* ProfileRelationshipActionButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB35FC1E2612F1D9006193C9 /* ProfileRelationshipActionButton.swift */; };
|
||||
DB36679D268AB91B0027D07F /* ComposeStatusAttachmentTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB36679C268AB91B0027D07F /* ComposeStatusAttachmentTableViewCell.swift */; };
|
||||
DB36679F268ABAF20027D07F /* ComposeStatusAttachmentSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB36679E268ABAF20027D07F /* ComposeStatusAttachmentSection.swift */; };
|
||||
DB3667A1268ABB2E0027D07F /* ComposeStatusAttachmentItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB3667A0268ABB2E0027D07F /* ComposeStatusAttachmentItem.swift */; };
|
||||
|
@ -223,6 +221,19 @@
|
|||
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 */; };
|
||||
DB3E6FF52807C40300B035AE /* DiscoveryForYouViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB3E6FF42807C40300B035AE /* DiscoveryForYouViewController.swift */; };
|
||||
DB3E6FF82807C45300B035AE /* DiscoveryForYouViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB3E6FF72807C45300B035AE /* DiscoveryForYouViewModel.swift */; };
|
||||
DB3E6FFA2807C47900B035AE /* DiscoveryForYouViewModel+Diffable.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB3E6FF92807C47900B035AE /* DiscoveryForYouViewModel+Diffable.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 */; };
|
||||
|
@ -361,7 +372,6 @@
|
|||
DB6B7500272FF73800C70B6E /* UserTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6B74FF272FF73800C70B6E /* UserTableViewCell.swift */; };
|
||||
DB6B750427300B4000C70B6E /* TimelineFooterTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6B750327300B4000C70B6E /* TimelineFooterTableViewCell.swift */; };
|
||||
DB6C8C0F25F0A6AE00AAA452 /* Mastodon+Entity+Error.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6C8C0E25F0A6AE00AAA452 /* Mastodon+Entity+Error.swift */; };
|
||||
DB6D1B3D2636857500ACB481 /* AppearancePreference.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6D1B3C2636857500ACB481 /* AppearancePreference.swift */; };
|
||||
DB6D1B44263691CF00ACB481 /* Mastodon+API+Subscriptions+Policy.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6D1B43263691CF00ACB481 /* Mastodon+API+Subscriptions+Policy.swift */; };
|
||||
DB6D9F3526351B7A008423CD /* NotificationService+Decrypt.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6D9F3426351B7A008423CD /* NotificationService+Decrypt.swift */; };
|
||||
DB6D9F42263527CE008423CD /* AlamofireImage in Frameworks */ = {isa = PBXBuildFile; productRef = DB6D9F41263527CE008423CD /* AlamofireImage */; };
|
||||
|
@ -376,8 +386,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 */; };
|
||||
|
@ -495,8 +503,6 @@
|
|||
DBB525562611EDCA002F1F29 /* UserTimelineViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBB525552611EDCA002F1F29 /* UserTimelineViewModel.swift */; };
|
||||
DBB5255E2611F07A002F1F29 /* ProfileViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBB5255D2611F07A002F1F29 /* ProfileViewModel.swift */; };
|
||||
DBB525642612C988002F1F29 /* MeProfileViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBB525632612C988002F1F29 /* MeProfileViewModel.swift */; };
|
||||
DBB5256E2612D5A1002F1F29 /* ProfileStatusDashboardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBB5256D2612D5A1002F1F29 /* ProfileStatusDashboardView.swift */; };
|
||||
DBB525852612D6DD002F1F29 /* ProfileStatusDashboardMeterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBB525842612D6DD002F1F29 /* ProfileStatusDashboardMeterView.swift */; };
|
||||
DBB8AB4626AECDE200F6D281 /* SendPostIntentHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBB8AB4526AECDE200F6D281 /* SendPostIntentHandler.swift */; };
|
||||
DBB8AB4826AED09C00F6D281 /* MastodonSDK in Frameworks */ = {isa = PBXBuildFile; productRef = DBB8AB4726AED09C00F6D281 /* MastodonSDK */; };
|
||||
DBB8AB4A26AED0B500F6D281 /* APIService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBB8AB4926AED0B500F6D281 /* APIService.swift */; };
|
||||
|
@ -508,19 +514,9 @@
|
|||
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 */; };
|
||||
DBBE1B4525F3474B0081417A /* MastodonPickServerAppearance.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBBE1B4425F3474B0081417A /* MastodonPickServerAppearance.swift */; };
|
||||
DBBF1DBF2652401B00E5B703 /* AutoCompleteViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBBF1DBE2652401B00E5B703 /* AutoCompleteViewModel.swift */; };
|
||||
DBBF1DC226524D2900E5B703 /* AutoCompleteTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBBF1DC126524D2900E5B703 /* AutoCompleteTableViewCell.swift */; };
|
||||
|
@ -550,6 +546,13 @@
|
|||
DBD5B1F827BCFD9D00BD6B38 /* DataSourceProvider+TableViewControllerNavigateable.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBD5B1F727BCFD9D00BD6B38 /* DataSourceProvider+TableViewControllerNavigateable.swift */; };
|
||||
DBD5B1FA27BD013700BD6B38 /* DataSourceProvider+StatusTableViewControllerNavigateable.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBD5B1F927BD013700BD6B38 /* DataSourceProvider+StatusTableViewControllerNavigateable.swift */; };
|
||||
DBD9149025DF6D8D00903DFD /* APIService+Onboarding.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBD9148F25DF6D8D00903DFD /* APIService+Onboarding.swift */; };
|
||||
DBDFF1902805543100557A48 /* DiscoveryPostsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBDFF18F2805543100557A48 /* DiscoveryPostsViewController.swift */; };
|
||||
DBDFF1932805554900557A48 /* DiscoveryPostsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBDFF1922805554900557A48 /* DiscoveryPostsViewModel.swift */; };
|
||||
DBDFF1952805561700557A48 /* DiscoveryPostsViewModel+Diffable.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBDFF1942805561700557A48 /* DiscoveryPostsViewModel+Diffable.swift */; };
|
||||
DBDFF197280556D900557A48 /* DiscoveryPostsViewModel+State.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBDFF196280556D900557A48 /* DiscoveryPostsViewModel+State.swift */; };
|
||||
DBDFF19A28055A1400557A48 /* DiscoveryViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBDFF19928055A1400557A48 /* DiscoveryViewController.swift */; };
|
||||
DBDFF19C28055BD600557A48 /* DiscoveryViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBDFF19B28055BD600557A48 /* DiscoveryViewModel.swift */; };
|
||||
DBDFF19E2805703700557A48 /* DiscoveryPostsViewController+DataSourceProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBDFF19D2805703700557A48 /* DiscoveryPostsViewController+DataSourceProvider.swift */; };
|
||||
DBE0821525CD382600FD6BBD /* MastodonRegisterViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBE0821425CD382600FD6BBD /* MastodonRegisterViewController.swift */; };
|
||||
DBE0822425CD3F1E00FD6BBD /* MastodonRegisterViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBE0822325CD3F1E00FD6BBD /* MastodonRegisterViewModel.swift */; };
|
||||
DBE3CA6827A39CAB00AFE27B /* AppShared.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DB68047F2637CD4C00430867 /* AppShared.framework */; };
|
||||
|
@ -589,7 +592,6 @@
|
|||
DBFEF05F26A57715006D7ED1 /* StatusAuthorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBFEF05926A576EE006D7ED1 /* StatusAuthorView.swift */; };
|
||||
DBFEF06026A57715006D7ED1 /* StatusAttachmentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBFEF05A26A576EE006D7ED1 /* StatusAttachmentView.swift */; };
|
||||
DBFEF06326A577F2006D7ED1 /* StatusAttachmentViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBFEF06226A577F2006D7ED1 /* StatusAttachmentViewModel.swift */; };
|
||||
DBFEF06926A67E45006D7ED1 /* AppearancePreference.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6D1B3C2636857500ACB481 /* AppearancePreference.swift */; };
|
||||
DBFEF06D26A67FB7006D7ED1 /* StatusAttachmentViewModel+UploadState.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBFEF06C26A67FB7006D7ED1 /* StatusAttachmentViewModel+UploadState.swift */; };
|
||||
DBFEF06F26A690C4006D7ED1 /* APIService+APIError.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB98337E25C9452D00AD9700 /* APIService+APIError.swift */; };
|
||||
DBFEF07326A6913D006D7ED1 /* APIService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBFEF07226A6913D006D7ED1 /* APIService.swift */; };
|
||||
|
@ -728,7 +730,6 @@
|
|||
2D206B9125F60EA700143C56 /* UIControl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIControl.swift; sourceTree = "<group>"; };
|
||||
2D24E1222626ED9D00A59D4F /* UIView+Gesture.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIView+Gesture.swift"; sourceTree = "<group>"; };
|
||||
2D32EAAB25CB96DC00C9ED86 /* TimelineMiddleLoaderTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineMiddleLoaderTableViewCell.swift; sourceTree = "<group>"; };
|
||||
2D32EAB925CB9B0500C9ED86 /* UIView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIView.swift; sourceTree = "<group>"; };
|
||||
2D34D9D026148D9E0081BFC0 /* APIService+Recommend.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Recommend.swift"; sourceTree = "<group>"; };
|
||||
2D34D9DA261494120081BFC0 /* APIService+Search.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Search.swift"; sourceTree = "<group>"; };
|
||||
2D35237926256D920031AF25 /* NotificationSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationSection.swift; sourceTree = "<group>"; };
|
||||
|
@ -941,7 +942,6 @@
|
|||
DB336F3E278E668C0031E64B /* StatusTableViewCell+ViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "StatusTableViewCell+ViewModel.swift"; sourceTree = "<group>"; };
|
||||
DB336F40278E68480031E64B /* StatusView+Configuration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "StatusView+Configuration.swift"; sourceTree = "<group>"; };
|
||||
DB336F42278EB1680031E64B /* MediaView+Configuration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MediaView+Configuration.swift"; sourceTree = "<group>"; };
|
||||
DB35FC1E2612F1D9006193C9 /* ProfileRelationshipActionButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileRelationshipActionButton.swift; sourceTree = "<group>"; };
|
||||
DB36679C268AB91B0027D07F /* ComposeStatusAttachmentTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeStatusAttachmentTableViewCell.swift; sourceTree = "<group>"; };
|
||||
DB36679E268ABAF20027D07F /* ComposeStatusAttachmentSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeStatusAttachmentSection.swift; sourceTree = "<group>"; };
|
||||
DB3667A0268ABB2E0027D07F /* ComposeStatusAttachmentItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeStatusAttachmentItem.swift; sourceTree = "<group>"; };
|
||||
|
@ -949,6 +949,19 @@
|
|||
DB3667A5268AE2620027D07F /* ComposeStatusPollSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeStatusPollSection.swift; sourceTree = "<group>"; };
|
||||
DB3667A7268AE2900027D07F /* ComposeStatusPollItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeStatusPollItem.swift; sourceTree = "<group>"; };
|
||||
DB3D0FED25BAA42200EAA174 /* MastodonSDK */ = {isa = PBXFileReference; lastKnownFileType = folder; path = MastodonSDK; sourceTree = "<group>"; };
|
||||
DB3E6FDC2806A40F00B035AE /* DiscoveryHashtagsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiscoveryHashtagsViewController.swift; sourceTree = "<group>"; };
|
||||
DB3E6FDF2806A4ED00B035AE /* DiscoveryHashtagsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiscoveryHashtagsViewModel.swift; sourceTree = "<group>"; };
|
||||
DB3E6FE12806A50100B035AE /* DiscoveryHashtagsViewModel+Diffable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DiscoveryHashtagsViewModel+Diffable.swift"; sourceTree = "<group>"; };
|
||||
DB3E6FE32806A5B800B035AE /* DiscoverySection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiscoverySection.swift; sourceTree = "<group>"; };
|
||||
DB3E6FE62806A7A200B035AE /* DiscoveryItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiscoveryItem.swift; sourceTree = "<group>"; };
|
||||
DB3E6FE82806BD2200B035AE /* ThemeService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThemeService.swift; sourceTree = "<group>"; };
|
||||
DB3E6FEB2806D7F100B035AE /* DiscoveryNewsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiscoveryNewsViewController.swift; sourceTree = "<group>"; };
|
||||
DB3E6FEE2806D82600B035AE /* DiscoveryNewsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiscoveryNewsViewModel.swift; sourceTree = "<group>"; };
|
||||
DB3E6FF02806D96900B035AE /* DiscoveryNewsViewModel+Diffable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DiscoveryNewsViewModel+Diffable.swift"; sourceTree = "<group>"; };
|
||||
DB3E6FF22806D97400B035AE /* DiscoveryNewsViewModel+State.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DiscoveryNewsViewModel+State.swift"; sourceTree = "<group>"; };
|
||||
DB3E6FF42807C40300B035AE /* DiscoveryForYouViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiscoveryForYouViewController.swift; sourceTree = "<group>"; };
|
||||
DB3E6FF72807C45300B035AE /* DiscoveryForYouViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiscoveryForYouViewModel.swift; sourceTree = "<group>"; };
|
||||
DB3E6FF92807C47900B035AE /* DiscoveryForYouViewModel+Diffable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DiscoveryForYouViewModel+Diffable.swift"; sourceTree = "<group>"; };
|
||||
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 = "<group>"; };
|
||||
DB427DD725BAA00100D1B89D /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; };
|
||||
|
@ -1105,7 +1118,6 @@
|
|||
DB6B74FF272FF73800C70B6E /* UserTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserTableViewCell.swift; sourceTree = "<group>"; };
|
||||
DB6B750327300B4000C70B6E /* TimelineFooterTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineFooterTableViewCell.swift; sourceTree = "<group>"; };
|
||||
DB6C8C0E25F0A6AE00AAA452 /* Mastodon+Entity+Error.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Mastodon+Entity+Error.swift"; sourceTree = "<group>"; };
|
||||
DB6D1B3C2636857500ACB481 /* AppearancePreference.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppearancePreference.swift; sourceTree = "<group>"; };
|
||||
DB6D1B43263691CF00ACB481 /* Mastodon+API+Subscriptions+Policy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Mastodon+API+Subscriptions+Policy.swift"; sourceTree = "<group>"; };
|
||||
DB6D9F3426351B7A008423CD /* NotificationService+Decrypt.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NotificationService+Decrypt.swift"; sourceTree = "<group>"; };
|
||||
DB6D9F4826353FD6008423CD /* Subscription.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Subscription.swift; sourceTree = "<group>"; };
|
||||
|
@ -1119,8 +1131,6 @@
|
|||
DB6D9F9626367249008423CD /* SettingsViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SettingsViewController.swift; sourceTree = "<group>"; };
|
||||
DB6F5E34264E78E7009108F4 /* AutoCompleteViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutoCompleteViewController.swift; sourceTree = "<group>"; };
|
||||
DB6F5E37264E994A009108F4 /* AutoCompleteTopChevronView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutoCompleteTopChevronView.swift; sourceTree = "<group>"; };
|
||||
DB71C7CA271D5A0300BE3819 /* LineChartView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LineChartView.swift; sourceTree = "<group>"; };
|
||||
DB71C7CC271D7F4300BE3819 /* CurveAlgorithm.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CurveAlgorithm.swift; sourceTree = "<group>"; };
|
||||
DB71FD5125F8CCAA00512AE1 /* APIService+Status.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Status.swift"; sourceTree = "<group>"; };
|
||||
DB72601B25E36A2100235243 /* MastodonServerRulesViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonServerRulesViewController.swift; sourceTree = "<group>"; };
|
||||
DB72602625E36A6F00235243 /* MastodonServerRulesViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonServerRulesViewModel.swift; sourceTree = "<group>"; };
|
||||
|
@ -1249,20 +1259,12 @@
|
|||
DBB525552611EDCA002F1F29 /* UserTimelineViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserTimelineViewModel.swift; sourceTree = "<group>"; };
|
||||
DBB5255D2611F07A002F1F29 /* ProfileViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileViewModel.swift; sourceTree = "<group>"; };
|
||||
DBB525632612C988002F1F29 /* MeProfileViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeProfileViewModel.swift; sourceTree = "<group>"; };
|
||||
DBB5256D2612D5A1002F1F29 /* ProfileStatusDashboardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileStatusDashboardView.swift; sourceTree = "<group>"; };
|
||||
DBB525842612D6DD002F1F29 /* ProfileStatusDashboardMeterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileStatusDashboardMeterView.swift; sourceTree = "<group>"; };
|
||||
DBB8AB4526AECDE200F6D281 /* SendPostIntentHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SendPostIntentHandler.swift; sourceTree = "<group>"; };
|
||||
DBB8AB4926AED0B500F6D281 /* APIService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIService.swift; sourceTree = "<group>"; };
|
||||
DBB9759B262462E1004620BD /* ThreadMetaView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadMetaView.swift; sourceTree = "<group>"; };
|
||||
DBBC24A726A52F9000398BB9 /* ComposeToolbarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeToolbarView.swift; sourceTree = "<group>"; };
|
||||
DBBC24AB26A53D9300398BB9 /* ComposeStatusContentTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeStatusContentTableViewCell.swift; sourceTree = "<group>"; };
|
||||
DBBC24BB26A542F500398BB9 /* ThemeService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThemeService.swift; sourceTree = "<group>"; };
|
||||
DBBC24BE26A5443100398BB9 /* MastodonTheme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonTheme.swift; sourceTree = "<group>"; };
|
||||
DBBC24BF26A5443100398BB9 /* SystemTheme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemTheme.swift; sourceTree = "<group>"; };
|
||||
DBBC24C326A544B900398BB9 /* Theme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Theme.swift; sourceTree = "<group>"; };
|
||||
DBBC24CE26A547AE00398BB9 /* ThemeService+Appearance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ThemeService+Appearance.swift"; sourceTree = "<group>"; };
|
||||
DBBC24D626A54BCB00398BB9 /* MastodonRegex.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MastodonRegex.swift; sourceTree = "<group>"; };
|
||||
DBBC24D826A54BCB00398BB9 /* MastodonMetricFormatter.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MastodonMetricFormatter.swift; sourceTree = "<group>"; };
|
||||
DBBC50C0278ED49200AF0CC6 /* MastodonAuthenticationBox.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonAuthenticationBox.swift; sourceTree = "<group>"; };
|
||||
DBBE1B4425F3474B0081417A /* MastodonPickServerAppearance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonPickServerAppearance.swift; sourceTree = "<group>"; };
|
||||
DBBF1DBE2652401B00E5B703 /* AutoCompleteViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutoCompleteViewModel.swift; sourceTree = "<group>"; };
|
||||
|
@ -1293,6 +1295,13 @@
|
|||
DBD5B1F727BCFD9D00BD6B38 /* DataSourceProvider+TableViewControllerNavigateable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DataSourceProvider+TableViewControllerNavigateable.swift"; sourceTree = "<group>"; };
|
||||
DBD5B1F927BD013700BD6B38 /* DataSourceProvider+StatusTableViewControllerNavigateable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DataSourceProvider+StatusTableViewControllerNavigateable.swift"; sourceTree = "<group>"; };
|
||||
DBD9148F25DF6D8D00903DFD /* APIService+Onboarding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Onboarding.swift"; sourceTree = "<group>"; };
|
||||
DBDFF18F2805543100557A48 /* DiscoveryPostsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiscoveryPostsViewController.swift; sourceTree = "<group>"; };
|
||||
DBDFF1922805554900557A48 /* DiscoveryPostsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiscoveryPostsViewModel.swift; sourceTree = "<group>"; };
|
||||
DBDFF1942805561700557A48 /* DiscoveryPostsViewModel+Diffable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DiscoveryPostsViewModel+Diffable.swift"; sourceTree = "<group>"; };
|
||||
DBDFF196280556D900557A48 /* DiscoveryPostsViewModel+State.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DiscoveryPostsViewModel+State.swift"; sourceTree = "<group>"; };
|
||||
DBDFF19928055A1400557A48 /* DiscoveryViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiscoveryViewController.swift; sourceTree = "<group>"; };
|
||||
DBDFF19B28055BD600557A48 /* DiscoveryViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiscoveryViewModel.swift; sourceTree = "<group>"; };
|
||||
DBDFF19D2805703700557A48 /* DiscoveryPostsViewController+DataSourceProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DiscoveryPostsViewController+DataSourceProvider.swift"; sourceTree = "<group>"; };
|
||||
DBE0821425CD382600FD6BBD /* MastodonRegisterViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonRegisterViewController.swift; sourceTree = "<group>"; };
|
||||
DBE0822325CD3F1E00FD6BBD /* MastodonRegisterViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonRegisterViewModel.swift; sourceTree = "<group>"; };
|
||||
DBE3CDBA261C427900430CC6 /* TimelineHeaderTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineHeaderTableViewCell.swift; sourceTree = "<group>"; };
|
||||
|
@ -1654,7 +1663,6 @@
|
|||
2D5A3D0125CF8640002347D6 /* Vender */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
DB71C7CC271D7F4300BE3819 /* CurveAlgorithm.swift */,
|
||||
2D5A3D0225CF8742002347D6 /* ControlContainableScrollViews.swift */,
|
||||
DB51D170262832380062B7A1 /* BlurHashDecode.swift */,
|
||||
DB51D171262832380062B7A1 /* BlurHashEncode.swift */,
|
||||
|
@ -1673,7 +1681,6 @@
|
|||
DB45FB0425CA87B4005A8AC7 /* APIService */,
|
||||
DB49A61925FF327D00B98345 /* EmojiService */,
|
||||
DB9A489B26036E19008B817C /* MastodonAttachmentService */,
|
||||
DBBC24BD26A5441A00398BB9 /* ThemeService */,
|
||||
DB45FB0E25CA87D0005A8AC7 /* AuthenticationService.swift */,
|
||||
2DA6054625F716A2006356F9 /* PlaybackState.swift */,
|
||||
DBC7A67B260DFADE00E57475 /* StatusPublishService.swift */,
|
||||
|
@ -1717,6 +1724,7 @@
|
|||
DB4F097626A0398000D62E92 /* Compose */,
|
||||
DB0617F727855B010030EE79 /* Notification */,
|
||||
DB4F097726A039A200D62E92 /* Search */,
|
||||
DB3E6FE52806A5BA00B035AE /* Discovery */,
|
||||
DB0617FA27855B660030EE79 /* Settings */,
|
||||
DBCBED2226132E1D00B49291 /* FetchedResultsController */,
|
||||
);
|
||||
|
@ -1803,7 +1811,6 @@
|
|||
isa = PBXGroup;
|
||||
children = (
|
||||
2DCB73FC2615C13900EC03D4 /* SearchRecommendCollectionHeader.swift */,
|
||||
DB71C7CA271D5A0300BE3819 /* LineChartView.swift */,
|
||||
);
|
||||
path = View;
|
||||
sourceTree = "<group>";
|
||||
|
@ -2097,6 +2104,54 @@
|
|||
path = Resources;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
DB3E6FDE2806A41200B035AE /* Hashtags */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
DB3E6FDC2806A40F00B035AE /* DiscoveryHashtagsViewController.swift */,
|
||||
DB3E6FDF2806A4ED00B035AE /* DiscoveryHashtagsViewModel.swift */,
|
||||
DB3E6FE12806A50100B035AE /* DiscoveryHashtagsViewModel+Diffable.swift */,
|
||||
);
|
||||
path = Hashtags;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
DB3E6FE52806A5BA00B035AE /* Discovery */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
DB3E6FE32806A5B800B035AE /* DiscoverySection.swift */,
|
||||
DB3E6FE62806A7A200B035AE /* DiscoveryItem.swift */,
|
||||
);
|
||||
path = Discovery;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
DB3E6FEA2806BD2500B035AE /* MastodonUI */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
DB3E6FE82806BD2200B035AE /* ThemeService.swift */,
|
||||
);
|
||||
path = MastodonUI;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
DB3E6FED2806D7FC00B035AE /* News */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
DB3E6FEB2806D7F100B035AE /* DiscoveryNewsViewController.swift */,
|
||||
DB3E6FEE2806D82600B035AE /* DiscoveryNewsViewModel.swift */,
|
||||
DB3E6FF02806D96900B035AE /* DiscoveryNewsViewModel+Diffable.swift */,
|
||||
DB3E6FF22806D97400B035AE /* DiscoveryNewsViewModel+State.swift */,
|
||||
);
|
||||
path = News;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
DB3E6FF62807C40500B035AE /* ForYou */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
DB3E6FF42807C40300B035AE /* DiscoveryForYouViewController.swift */,
|
||||
DB3E6FF72807C45300B035AE /* DiscoveryForYouViewModel.swift */,
|
||||
DB3E6FF92807C47900B035AE /* DiscoveryForYouViewModel+Diffable.swift */,
|
||||
);
|
||||
path = ForYou;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
DB427DC925BAA00100D1B89D = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
|
@ -2328,7 +2383,6 @@
|
|||
children = (
|
||||
DBA465942696E387002B41DB /* AppPreference.swift */,
|
||||
DB647C5826F1EA2700F7F82C /* WizardPreference.swift */,
|
||||
DB6D1B3C2636857500ACB481 /* AppearancePreference.swift */,
|
||||
DBE54AC52636C89F004E7C0B /* NotificationPreference.swift */,
|
||||
DB1D842F26566512000346B3 /* KeyboardPreference.swift */,
|
||||
DBCBCC0C2680B908000F5B51 /* HomeTimelinePreference.swift */,
|
||||
|
@ -2682,6 +2736,7 @@
|
|||
2DAC9E36262FC20B0062E1A6 /* SuggestionAccount */,
|
||||
DB9D6C0825E4F5A60051B173 /* Profile */,
|
||||
DB9D6BEE25E4F5370051B173 /* Search */,
|
||||
DBDFF1912805544800557A48 /* Discovery */,
|
||||
5B90C455262599800002E742 /* Settings */,
|
||||
);
|
||||
path = Scene;
|
||||
|
@ -2691,6 +2746,7 @@
|
|||
isa = PBXGroup;
|
||||
children = (
|
||||
DB084B5125CBC56300F898ED /* CoreDataStack */,
|
||||
DB3E6FEA2806BD2500B035AE /* MastodonUI */,
|
||||
DB6C8C0525F0921200AAA452 /* MastodonSDK */,
|
||||
2DF123A625C3B0210020F248 /* ActiveLabel.swift */,
|
||||
5DF1056325F887CB00D6C0D4 /* AVPlayer.swift */,
|
||||
|
@ -2708,7 +2764,6 @@
|
|||
DBD376B1269302A4007FEC24 /* UITableViewCell.swift */,
|
||||
0FAA101B25E10E760017CCDE /* UIFont.swift */,
|
||||
2D206B9125F60EA700143C56 /* UIControl.swift */,
|
||||
2D32EAB925CB9B0500C9ED86 /* UIView.swift */,
|
||||
5DA732CB2629CEF500A92342 /* UIView+Remove.swift */,
|
||||
2D24E1222626ED9D00A59D4F /* UIView+Gesture.swift */,
|
||||
DB8AF55C25C138B7002E6C99 /* UIViewController.swift */,
|
||||
|
@ -2989,9 +3044,6 @@
|
|||
isa = PBXGroup;
|
||||
children = (
|
||||
DBB5254F2611ED6D002F1F29 /* ProfileHeaderView.swift */,
|
||||
DBB5256D2612D5A1002F1F29 /* ProfileStatusDashboardView.swift */,
|
||||
DBB525842612D6DD002F1F29 /* ProfileStatusDashboardMeterView.swift */,
|
||||
DB35FC1E2612F1D9006193C9 /* ProfileRelationshipActionButton.swift */,
|
||||
DBF98149265E24F500E4BA07 /* ProfileFieldCollectionViewHeaderFooterView.swift */,
|
||||
);
|
||||
path = View;
|
||||
|
@ -3005,24 +3057,11 @@
|
|||
path = Service;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
DBBC24BD26A5441A00398BB9 /* ThemeService */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
DBBC24C326A544B900398BB9 /* Theme.swift */,
|
||||
DBBC24BE26A5443100398BB9 /* MastodonTheme.swift */,
|
||||
DBBC24BF26A5443100398BB9 /* SystemTheme.swift */,
|
||||
DBBC24BB26A542F500398BB9 /* ThemeService.swift */,
|
||||
DBBC24CE26A547AE00398BB9 /* ThemeService+Appearance.swift */,
|
||||
);
|
||||
path = ThemeService;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
DBBC24D526A54BCB00398BB9 /* Helper */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
DBBC24D626A54BCB00398BB9 /* MastodonRegex.swift */,
|
||||
DBBC50C0278ED49200AF0CC6 /* MastodonAuthenticationBox.swift */,
|
||||
DBBC24D826A54BCB00398BB9 /* MastodonMetricFormatter.swift */,
|
||||
DBF3B7402733EB9400E21627 /* MastodonLocalCode.swift */,
|
||||
);
|
||||
path = Helper;
|
||||
|
@ -3068,6 +3107,31 @@
|
|||
path = FetchedResultsController;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
DBDFF1912805544800557A48 /* Discovery */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
DBDFF19828055A0900557A48 /* Posts */,
|
||||
DB3E6FDE2806A41200B035AE /* Hashtags */,
|
||||
DB3E6FED2806D7FC00B035AE /* News */,
|
||||
DB3E6FF62807C40500B035AE /* ForYou */,
|
||||
DBDFF19928055A1400557A48 /* DiscoveryViewController.swift */,
|
||||
DBDFF19B28055BD600557A48 /* DiscoveryViewModel.swift */,
|
||||
);
|
||||
path = Discovery;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
DBDFF19828055A0900557A48 /* Posts */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
DBDFF18F2805543100557A48 /* DiscoveryPostsViewController.swift */,
|
||||
DBDFF19D2805703700557A48 /* DiscoveryPostsViewController+DataSourceProvider.swift */,
|
||||
DBDFF1922805554900557A48 /* DiscoveryPostsViewModel.swift */,
|
||||
DBDFF1942805561700557A48 /* DiscoveryPostsViewModel+Diffable.swift */,
|
||||
DBDFF196280556D900557A48 /* DiscoveryPostsViewModel+State.swift */,
|
||||
);
|
||||
path = Posts;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
DBE0821A25CD382900FD6BBD /* Register */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
|
@ -3797,6 +3861,7 @@
|
|||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
DBB525212611EBD6002F1F29 /* ProfilePagingViewController.swift in Sources */,
|
||||
DBDFF19E2805703700557A48 /* DiscoveryPostsViewController+DataSourceProvider.swift in Sources */,
|
||||
DB6180EB26391C140018D199 /* MediaPreviewTransitionItem.swift in Sources */,
|
||||
DB63F74727990B0600455B82 /* DataSourceFacade+Hashtag.swift in Sources */,
|
||||
DB98337125C9443200AD9700 /* APIService+Authentication.swift in Sources */,
|
||||
|
@ -3823,6 +3888,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 */,
|
||||
|
@ -3858,6 +3924,7 @@
|
|||
DB697DD6278F4C29004EF2F7 /* DataSourceProvider.swift in Sources */,
|
||||
DB0FCB8E2796C0B7006C02E2 /* TrendCollectionViewCell.swift in Sources */,
|
||||
0F1E2D0B2615C39400C38565 /* DoubleTitleLabelNavigationBarTitleView.swift in Sources */,
|
||||
DBDFF1902805543100557A48 /* DiscoveryPostsViewController.swift in Sources */,
|
||||
DB697DD9278F4CED004EF2F7 /* HomeTimelineViewController+DataSourceProvider.swift in Sources */,
|
||||
DB9A488A26034D40008B817C /* ComposeViewModel+PublishState.swift in Sources */,
|
||||
DB45FAD725CA6C76005A8AC7 /* UIBarButtonItem.swift in Sources */,
|
||||
|
@ -3878,11 +3945,13 @@
|
|||
DB297B1B2679FAE200704C90 /* PlaceholderImageCacheService.swift in Sources */,
|
||||
DB0FCB8C2796BF8D006C02E2 /* SearchViewModel+Diffable.swift in Sources */,
|
||||
2D8FCA082637EABB00137F46 /* APIService+FollowRequest.swift in Sources */,
|
||||
DBDFF1952805561700557A48 /* DiscoveryPostsViewModel+Diffable.swift in Sources */,
|
||||
DB03A795272A981400EE37C5 /* ContentSplitViewController.swift in Sources */,
|
||||
DBBC24DE26A54BCB00398BB9 /* MastodonMetricFormatter.swift in Sources */,
|
||||
DBDFF19C28055BD600557A48 /* DiscoveryViewModel.swift in Sources */,
|
||||
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 */,
|
||||
|
@ -3905,12 +3974,12 @@
|
|||
DBE3CE07261D6A0E00430CC6 /* FavoriteViewModel+Diffable.swift in Sources */,
|
||||
2D34D9DB261494120081BFC0 /* APIService+Search.swift in Sources */,
|
||||
5B90C48B26259C120002E742 /* APIService+CoreData+Subscriptions.swift in Sources */,
|
||||
DBB5256E2612D5A1002F1F29 /* ProfileStatusDashboardView.swift in Sources */,
|
||||
DBA9443E265CFA6400C537E1 /* ProfileFieldCollectionViewCell.swift in Sources */,
|
||||
DB025B93278D6501002F581E /* Persistence.swift in Sources */,
|
||||
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 */,
|
||||
|
@ -3952,7 +4021,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 */,
|
||||
|
@ -3979,6 +4047,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 */,
|
||||
|
@ -3996,11 +4065,11 @@
|
|||
DB697DD4278F4927004EF2F7 /* StatusTableViewCellDelegate.swift in Sources */,
|
||||
DB0FCB902796C5EB006C02E2 /* APIService+Trend.swift in Sources */,
|
||||
DBA5E7A5263BD28C004598BB /* ContextMenuImagePreviewViewModel.swift in Sources */,
|
||||
DB3E6FF52807C40300B035AE /* DiscoveryForYouViewController.swift in Sources */,
|
||||
DB9D7C21269824B80054B3DF /* APIService+Filter.swift in Sources */,
|
||||
2D38F1E525CD46C100561493 /* HomeTimelineViewModel.swift in Sources */,
|
||||
DB0FCB842796B2A2006C02E2 /* FavoriteViewController+DataSourceProvider.swift in Sources */,
|
||||
DBCC3B36261440BA0045B23D /* UINavigationController.swift in Sources */,
|
||||
DBB525852612D6DD002F1F29 /* ProfileStatusDashboardMeterView.swift in Sources */,
|
||||
DB0FCB68279507EF006C02E2 /* DataSourceFacade+Meta.swift in Sources */,
|
||||
DB63F75C279956D000455B82 /* Persistence+Tag.swift in Sources */,
|
||||
2D84350525FF858100EECE90 /* UIScrollView.swift in Sources */,
|
||||
|
@ -4012,8 +4081,10 @@
|
|||
DB1D843026566512000346B3 /* KeyboardPreference.swift in Sources */,
|
||||
DB852D1926FAEB6B00FC9D81 /* SidebarViewController.swift in Sources */,
|
||||
2D206B9225F60EA700143C56 /* UIControl.swift in Sources */,
|
||||
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 */,
|
||||
|
@ -4036,10 +4107,10 @@
|
|||
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 */,
|
||||
DB35FC1F2612F1D9006193C9 /* ProfileRelationshipActionButton.swift in Sources */,
|
||||
DB98EB6727B216560082E365 /* ReportResultViewModel+Diffable.swift in Sources */,
|
||||
DBC7A672260C897100E57475 /* StatusContentWarningEditorView.swift in Sources */,
|
||||
DB025B95278D6530002F581E /* Persistence+MastodonUser.swift in Sources */,
|
||||
|
@ -4069,6 +4140,7 @@
|
|||
2D5981A125E4A593000FB903 /* MastodonConfirmEmailViewModel.swift in Sources */,
|
||||
DB6B74F6272FBCDB00C70B6E /* FollowerListViewModel+State.swift in Sources */,
|
||||
DB87D4452609BE0500D12C0D /* ComposeStatusPollOptionCollectionViewCell.swift in Sources */,
|
||||
DBDFF197280556D900557A48 /* DiscoveryPostsViewModel+State.swift in Sources */,
|
||||
DB336F2C278D6FC30031E64B /* Persistence+Status.swift in Sources */,
|
||||
DB336F2A278D6F2B0031E64B /* MastodonField.swift in Sources */,
|
||||
DB0FCB7A279576A2006C02E2 /* DataSourceFacade+Thread.swift in Sources */,
|
||||
|
@ -4096,7 +4168,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 */,
|
||||
|
@ -4104,6 +4176,7 @@
|
|||
DB852D1C26FB021500FC9D81 /* RootSplitViewController.swift in Sources */,
|
||||
DB697DD1278F4871004EF2F7 /* AutoGenerateTableViewDelegate.swift in Sources */,
|
||||
DB02CDBF2625AE5000D0A2AF /* AdaptiveUserInterfaceStyleBarButtonItem.swift in Sources */,
|
||||
DB3E6FFA2807C47900B035AE /* DiscoveryForYouViewModel+Diffable.swift in Sources */,
|
||||
DB1FD44425F26CCC004CFCFC /* PickServerSection.swift in Sources */,
|
||||
DBB45B6027B50A4F002DC5A7 /* RecommendAccountItem.swift in Sources */,
|
||||
0FB3D30F25E525CD00AAD544 /* PickServerCategoryView.swift in Sources */,
|
||||
|
@ -4115,7 +4188,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 */,
|
||||
|
@ -4128,7 +4200,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 */,
|
||||
|
@ -4136,7 +4207,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 */,
|
||||
|
@ -4158,6 +4228,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 */,
|
||||
|
@ -4204,7 +4275,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 */,
|
||||
|
@ -4221,31 +4291,33 @@
|
|||
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 */,
|
||||
DB3E6FF82807C45300B035AE /* DiscoveryForYouViewModel.swift in Sources */,
|
||||
DB49A62525FF334C00B98345 /* EmojiService+CustomEmojiViewModel+LoadState.swift in Sources */,
|
||||
DB4924E226312AB200E9DB22 /* NotificationService.swift in Sources */,
|
||||
DB6D9F6F2635807F008423CD /* Setting.swift in Sources */,
|
||||
DB6F5E38264E994A009108F4 /* AutoCompleteTopChevronView.swift in Sources */,
|
||||
DB6746F0278F463B008A6B94 /* AutoGenerateProtocolDelegate.swift in Sources */,
|
||||
DBB525412611ED54002F1F29 /* ProfileHeaderViewController.swift in Sources */,
|
||||
DBDFF19A28055A1400557A48 /* DiscoveryViewController.swift in Sources */,
|
||||
DB9D6BFF25E4F5940051B173 /* ProfileViewController.swift in Sources */,
|
||||
DB63F756279949BD00455B82 /* Persistence+SearchHistory.swift in Sources */,
|
||||
2D4AD8A226316CD200613EFC /* SelectedAccountSection.swift in Sources */,
|
||||
|
@ -4255,7 +4327,6 @@
|
|||
DB789A1225F9F2CC0071ACA0 /* ComposeViewModel.swift in Sources */,
|
||||
DB63F74B279914A000455B82 /* FollowingListViewController+DataSourceProvider.swift in Sources */,
|
||||
DBB525362611ECEB002F1F29 /* UserTimelineViewController.swift in Sources */,
|
||||
DB6D1B3D2636857500ACB481 /* AppearancePreference.swift in Sources */,
|
||||
DB938F3326243D6200E5B6C1 /* TimelineTopLoaderTableViewCell.swift in Sources */,
|
||||
DB98EB4927B0F0CD0082E365 /* ReportStatusTableViewCell.swift in Sources */,
|
||||
DB3667A4268AE2370027D07F /* ComposeStatusPollTableViewCell.swift in Sources */,
|
||||
|
@ -4346,17 +4417,12 @@
|
|||
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 */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
|
@ -4662,7 +4728,7 @@
|
|||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = Mastodon/Mastodon.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 109;
|
||||
CURRENT_PROJECT_VERSION = 110;
|
||||
DEVELOPMENT_ASSET_PATHS = "Mastodon/Resources/Preview\\ Assets.xcassets";
|
||||
DEVELOPMENT_TEAM = 5Z4GVSS33P;
|
||||
INFOPLIST_FILE = Mastodon/Info.plist;
|
||||
|
@ -4691,7 +4757,7 @@
|
|||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = Mastodon/Mastodon.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 109;
|
||||
CURRENT_PROJECT_VERSION = 110;
|
||||
DEVELOPMENT_ASSET_PATHS = "Mastodon/Resources/Preview\\ Assets.xcassets";
|
||||
DEVELOPMENT_TEAM = 5Z4GVSS33P;
|
||||
INFOPLIST_FILE = Mastodon/Info.plist;
|
||||
|
@ -4799,11 +4865,11 @@
|
|||
APPLICATION_EXTENSION_API_ONLY = YES;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 109;
|
||||
CURRENT_PROJECT_VERSION = 110;
|
||||
DEFINES_MODULE = YES;
|
||||
DEVELOPMENT_TEAM = 5Z4GVSS33P;
|
||||
DYLIB_COMPATIBILITY_VERSION = 1;
|
||||
DYLIB_CURRENT_VERSION = 109;
|
||||
DYLIB_CURRENT_VERSION = 110;
|
||||
DYLIB_INSTALL_NAME_BASE = "@rpath";
|
||||
INFOPLIST_FILE = AppShared/Info.plist;
|
||||
INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks";
|
||||
|
@ -4830,11 +4896,11 @@
|
|||
APPLICATION_EXTENSION_API_ONLY = YES;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 109;
|
||||
CURRENT_PROJECT_VERSION = 110;
|
||||
DEFINES_MODULE = YES;
|
||||
DEVELOPMENT_TEAM = 5Z4GVSS33P;
|
||||
DYLIB_COMPATIBILITY_VERSION = 1;
|
||||
DYLIB_CURRENT_VERSION = 109;
|
||||
DYLIB_CURRENT_VERSION = 110;
|
||||
DYLIB_INSTALL_NAME_BASE = "@rpath";
|
||||
INFOPLIST_FILE = AppShared/Info.plist;
|
||||
INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks";
|
||||
|
@ -4859,7 +4925,7 @@
|
|||
buildSettings = {
|
||||
CODE_SIGN_ENTITLEMENTS = MastodonIntent/MastodonIntent.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 109;
|
||||
CURRENT_PROJECT_VERSION = 110;
|
||||
DEVELOPMENT_TEAM = 5Z4GVSS33P;
|
||||
INFOPLIST_FILE = MastodonIntent/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
|
@ -4884,7 +4950,7 @@
|
|||
buildSettings = {
|
||||
CODE_SIGN_ENTITLEMENTS = MastodonIntent/MastodonIntent.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 109;
|
||||
CURRENT_PROJECT_VERSION = 110;
|
||||
DEVELOPMENT_TEAM = 5Z4GVSS33P;
|
||||
INFOPLIST_FILE = MastodonIntent/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
|
@ -4909,7 +4975,7 @@
|
|||
buildSettings = {
|
||||
CODE_SIGN_ENTITLEMENTS = ShareActionExtension/ShareActionExtension.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 109;
|
||||
CURRENT_PROJECT_VERSION = 110;
|
||||
DEVELOPMENT_TEAM = 5Z4GVSS33P;
|
||||
INFOPLIST_FILE = ShareActionExtension/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
|
@ -4934,7 +5000,7 @@
|
|||
buildSettings = {
|
||||
CODE_SIGN_ENTITLEMENTS = ShareActionExtension/ShareActionExtension.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 109;
|
||||
CURRENT_PROJECT_VERSION = 110;
|
||||
DEVELOPMENT_TEAM = 5Z4GVSS33P;
|
||||
INFOPLIST_FILE = ShareActionExtension/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
|
@ -5020,7 +5086,7 @@
|
|||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = Mastodon/Mastodon.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 109;
|
||||
CURRENT_PROJECT_VERSION = 110;
|
||||
DEVELOPMENT_ASSET_PATHS = "Mastodon/Resources/Preview\\ Assets.xcassets";
|
||||
DEVELOPMENT_TEAM = 5Z4GVSS33P;
|
||||
INFOPLIST_FILE = Mastodon/Info.plist;
|
||||
|
@ -5087,11 +5153,11 @@
|
|||
APPLICATION_EXTENSION_API_ONLY = YES;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 109;
|
||||
CURRENT_PROJECT_VERSION = 110;
|
||||
DEFINES_MODULE = YES;
|
||||
DEVELOPMENT_TEAM = 5Z4GVSS33P;
|
||||
DYLIB_COMPATIBILITY_VERSION = 1;
|
||||
DYLIB_CURRENT_VERSION = 109;
|
||||
DYLIB_CURRENT_VERSION = 110;
|
||||
DYLIB_INSTALL_NAME_BASE = "@rpath";
|
||||
INFOPLIST_FILE = AppShared/Info.plist;
|
||||
INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks";
|
||||
|
@ -5116,7 +5182,7 @@
|
|||
buildSettings = {
|
||||
CODE_SIGN_ENTITLEMENTS = NotificationService/NotificationService.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 109;
|
||||
CURRENT_PROJECT_VERSION = 110;
|
||||
DEVELOPMENT_TEAM = 5Z4GVSS33P;
|
||||
INFOPLIST_FILE = NotificationService/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
|
@ -5140,7 +5206,7 @@
|
|||
buildSettings = {
|
||||
CODE_SIGN_ENTITLEMENTS = ShareActionExtension/ShareActionExtension.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 109;
|
||||
CURRENT_PROJECT_VERSION = 110;
|
||||
DEVELOPMENT_TEAM = 5Z4GVSS33P;
|
||||
INFOPLIST_FILE = ShareActionExtension/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
|
@ -5165,7 +5231,7 @@
|
|||
buildSettings = {
|
||||
CODE_SIGN_ENTITLEMENTS = MastodonIntent/MastodonIntent.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 109;
|
||||
CURRENT_PROJECT_VERSION = 110;
|
||||
DEVELOPMENT_TEAM = 5Z4GVSS33P;
|
||||
INFOPLIST_FILE = MastodonIntent/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
|
@ -5190,7 +5256,7 @@
|
|||
buildSettings = {
|
||||
CODE_SIGN_ENTITLEMENTS = NotificationService/NotificationService.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 109;
|
||||
CURRENT_PROJECT_VERSION = 110;
|
||||
DEVELOPMENT_TEAM = 5Z4GVSS33P;
|
||||
INFOPLIST_FILE = NotificationService/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
|
@ -5214,7 +5280,7 @@
|
|||
buildSettings = {
|
||||
CODE_SIGN_ENTITLEMENTS = NotificationService/NotificationService.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 109;
|
||||
CURRENT_PROJECT_VERSION = 110;
|
||||
DEVELOPMENT_TEAM = 5Z4GVSS33P;
|
||||
INFOPLIST_FILE = NotificationService/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
|
|
|
@ -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">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
<AdditionalOptions>
|
||||
<AdditionalOption
|
||||
key = "NSZombieEnabled"
|
||||
value = "YES"
|
||||
isEnabled = "YES">
|
||||
</AdditionalOption>
|
||||
</AdditionalOptions>
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "Release"
|
||||
|
|
|
@ -109,7 +109,7 @@
|
|||
<key>MastodonIntent.xcscheme_^#shared#^_</key>
|
||||
<dict>
|
||||
<key>orderHint</key>
|
||||
<integer>24</integer>
|
||||
<integer>33</integer>
|
||||
</dict>
|
||||
<key>MastodonIntents.xcscheme_^#shared#^_</key>
|
||||
<dict>
|
||||
|
@ -124,12 +124,12 @@
|
|||
<key>NotificationService.xcscheme_^#shared#^_</key>
|
||||
<dict>
|
||||
<key>orderHint</key>
|
||||
<integer>22</integer>
|
||||
<integer>32</integer>
|
||||
</dict>
|
||||
<key>ShareActionExtension.xcscheme_^#shared#^_</key>
|
||||
<dict>
|
||||
<key>orderHint</key>
|
||||
<integer>23</integer>
|
||||
<integer>31</integer>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>SuppressBuildableAutocreation</key>
|
||||
|
|
|
@ -144,7 +144,7 @@ extension SceneCoordinator {
|
|||
case popover(sourceView: UIView)
|
||||
case panModal
|
||||
case custom(transitioningDelegate: UIViewControllerTransitioningDelegate)
|
||||
case customPush
|
||||
case customPush(animated: Bool)
|
||||
case safariPresent(animated: Bool, completion: (() -> Void)? = nil)
|
||||
case alertController(animated: Bool, completion: (() -> Void)? = nil)
|
||||
case activityViewControllerPresent(animated: Bool, completion: (() -> Void)? = nil)
|
||||
|
@ -339,10 +339,10 @@ extension SceneCoordinator {
|
|||
viewController.transitioningDelegate = transitioningDelegate
|
||||
(splitViewController ?? presentingViewController)?.present(viewController, animated: true, completion: nil)
|
||||
|
||||
case .customPush:
|
||||
case .customPush(let animated):
|
||||
// set delegate in view controller
|
||||
assert(sender?.navigationController?.delegate != nil)
|
||||
sender?.navigationController?.pushViewController(viewController, animated: true)
|
||||
sender?.navigationController?.pushViewController(viewController, animated: animated)
|
||||
|
||||
case .safariPresent(let animated, let completion):
|
||||
if UserDefaults.shared.preferredUsingDefaultBrowser, case let .safari(url) = scene {
|
||||
|
|
|
@ -0,0 +1,17 @@
|
|||
//
|
||||
// DiscoveryItem.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by MainasuK on 2022-4-13.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import MastodonSDK
|
||||
import CoreDataStack
|
||||
|
||||
enum DiscoveryItem: Hashable {
|
||||
case hashtag(Mastodon.Entity.Tag)
|
||||
case link(Mastodon.Entity.Link)
|
||||
case user(ManagedObjectRecord<MastodonUser>)
|
||||
case bottomLoader
|
||||
}
|
|
@ -0,0 +1,71 @@
|
|||
//
|
||||
// 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")
|
||||
|
||||
class Configuration {
|
||||
weak var profileCardTableViewCellDelegate: ProfileCardTableViewCellDelegate?
|
||||
|
||||
public init(profileCardTableViewCellDelegate: ProfileCardTableViewCellDelegate? = nil) {
|
||||
self.profileCardTableViewCellDelegate = profileCardTableViewCellDelegate
|
||||
}
|
||||
}
|
||||
|
||||
static func diffableDataSource(
|
||||
tableView: UITableView,
|
||||
context: AppContext,
|
||||
configuration: Configuration
|
||||
) -> UITableViewDiffableDataSource<DiscoverySection, DiscoveryItem> {
|
||||
tableView.register(TrendTableViewCell.self, forCellReuseIdentifier: String(describing: TrendTableViewCell.self))
|
||||
tableView.register(NewsTableViewCell.self, forCellReuseIdentifier: String(describing: NewsTableViewCell.self))
|
||||
tableView.register(ProfileCardTableViewCell.self, forCellReuseIdentifier: String(describing: ProfileCardTableViewCell.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 .user(let record):
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: ProfileCardTableViewCell.self), for: indexPath) as! ProfileCardTableViewCell
|
||||
context.managedObjectContext.performAndWait {
|
||||
guard let user = record.object(in: context.managedObjectContext) else { return }
|
||||
cell.profileCardView.configure(user: user)
|
||||
}
|
||||
context.authenticationService.activeMastodonAuthentication
|
||||
.map { $0?.user }
|
||||
.assign(to: \.me, on: cell.profileCardView.viewModel.relationshipViewModel)
|
||||
.store(in: &cell.disposeBag)
|
||||
cell.delegate = configuration.profileCardTableViewCellDelegate
|
||||
return cell
|
||||
case .bottomLoader:
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self), for: indexPath) as! TimelineBottomLoaderTableViewCell
|
||||
cell.activityIndicatorView.startAnimating()
|
||||
return cell
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -21,26 +21,7 @@ extension SearchSection {
|
|||
) -> UICollectionViewDiffableDataSource<SearchSection, SearchItem> {
|
||||
|
||||
let trendCellRegister = UICollectionView.CellRegistration<TrendCollectionViewCell, Mastodon.Entity.Tag> { 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<SearchSection, SearchItem>(
|
||||
|
|
|
@ -9,53 +9,6 @@ import Foundation
|
|||
import CoreDataStack
|
||||
import MastodonSDK
|
||||
|
||||
extension MastodonUser {
|
||||
|
||||
public var displayNameWithFallback: String {
|
||||
return !displayName.isEmpty ? displayName : username
|
||||
}
|
||||
|
||||
public var acctWithDomain: String {
|
||||
if !acct.contains("@") {
|
||||
// Safe concat due to username cannot contains "@"
|
||||
return username + "@" + domain
|
||||
} else {
|
||||
return acct
|
||||
}
|
||||
}
|
||||
|
||||
public var domainFromAcct: String {
|
||||
if !acct.contains("@") {
|
||||
return domain
|
||||
} else {
|
||||
let domain = acct.split(separator: "@").last
|
||||
return String(domain!)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension MastodonUser {
|
||||
|
||||
public func headerImageURL() -> URL? {
|
||||
return URL(string: header)
|
||||
}
|
||||
|
||||
public func headerImageURLWithFallback(domain: String) -> URL {
|
||||
return URL(string: header) ?? URL(string: "https://\(domain)/headers/original/missing.png")!
|
||||
}
|
||||
|
||||
public func avatarImageURL() -> URL? {
|
||||
let string = UserDefaults.shared.preferredStaticAvatar ? avatarStatic ?? avatar : avatar
|
||||
return URL(string: string)
|
||||
}
|
||||
|
||||
public func avatarImageURLWithFallback(domain: String) -> URL {
|
||||
return avatarImageURL() ?? URL(string: "https://\(domain)/avatars/original/missing.png")!
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension MastodonUser {
|
||||
|
||||
public var profileURL: URL {
|
||||
|
|
|
@ -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, +)
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -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) {
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -30,7 +30,7 @@
|
|||
<key>CFBundlePackageType</key>
|
||||
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.3.0</string>
|
||||
<string>1.3.1</string>
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
|
@ -43,7 +43,7 @@
|
|||
</dict>
|
||||
</array>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>109</string>
|
||||
<string>110</string>
|
||||
<key>ITSAppUsesNonExemptEncryption</key>
|
||||
<false/>
|
||||
<key>LSApplicationQueriesSchemes</key>
|
||||
|
|
|
@ -22,13 +22,3 @@ extension MastodonEmoji {
|
|||
)
|
||||
}
|
||||
}
|
||||
|
||||
extension Collection where Element == MastodonEmoji {
|
||||
public var asDictionary: MastodonContent.Emojis {
|
||||
var dictionary: MastodonContent.Emojis = [:]
|
||||
for emoji in self {
|
||||
dictionary[emoji.code] = emoji.url
|
||||
}
|
||||
return dictionary
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 }
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -122,12 +122,12 @@ extension DataSourceFacade {
|
|||
let barButtonItem: UIBarButtonItem?
|
||||
}
|
||||
|
||||
@MainActor
|
||||
static func createProfileActionMenu(
|
||||
dependency: NeedsDependency,
|
||||
user: ManagedObjectRecord<MastodonUser>
|
||||
) -> UIMenu {
|
||||
var children: [UIMenuElement] = []
|
||||
// @MainActor
|
||||
// static func createProfileActionMenu(
|
||||
// dependency: NeedsDependency,
|
||||
// user: ManagedObjectRecord<MastodonUser>
|
||||
// ) -> UIMenu {
|
||||
// var children: [UIMenuElement] = []
|
||||
// let name = mastodonUser.displayNameWithFallback
|
||||
//
|
||||
// if let shareUser = shareUser {
|
||||
|
@ -339,9 +339,9 @@ extension DataSourceFacade {
|
|||
// }
|
||||
// children.append(deleteAction)
|
||||
// }
|
||||
|
||||
return UIMenu(title: "", options: [], children: children)
|
||||
}
|
||||
//
|
||||
// return UIMenu(title: "", options: [], children: children)
|
||||
// }
|
||||
|
||||
static func createActivityViewController(
|
||||
dependency: NeedsDependency,
|
||||
|
|
|
@ -10,6 +10,7 @@ import UIKit
|
|||
import Combine
|
||||
import MastodonAsset
|
||||
import MastodonLocalization
|
||||
import MastodonUI
|
||||
|
||||
protocol ComposeStatusPollOptionCollectionViewCellDelegate: AnyObject {
|
||||
func composeStatusPollOptionCollectionViewCell(_ cell: ComposeStatusPollOptionCollectionViewCell, textFieldDidBeginEditing textField: UITextField)
|
||||
|
|
|
@ -0,0 +1,128 @@
|
|||
//
|
||||
// DiscoveryViewController.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by MainasuK on 2022-4-12.
|
||||
//
|
||||
|
||||
import os.log
|
||||
import UIKit
|
||||
import Combine
|
||||
import Tabman
|
||||
import MastodonAsset
|
||||
import MastodonUI
|
||||
|
||||
public class DiscoveryViewController: TabmanViewController, NeedsDependency {
|
||||
|
||||
public static let containerViewMarginForRegularHorizontalSizeClass: CGFloat = 64
|
||||
public static let containerViewMarginForCompactHorizontalSizeClass: CGFloat = 16
|
||||
|
||||
var disposeBag = Set<AnyCancellable>()
|
||||
|
||||
let logger = Logger(subsystem: "DiscoveryViewController", category: "ViewController")
|
||||
|
||||
weak var context: AppContext! { willSet { precondition(!isViewLoaded) } }
|
||||
weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } }
|
||||
|
||||
private(set) lazy var viewModel = DiscoveryViewModel(
|
||||
context: context,
|
||||
coordinator: coordinator
|
||||
)
|
||||
|
||||
private(set) lazy var buttonBar: TMBar.ButtonBar = {
|
||||
let buttonBar = TMBar.ButtonBar()
|
||||
buttonBar.backgroundView.style = .custom(view: buttonBarBackgroundView)
|
||||
buttonBar.layout.interButtonSpacing = 0
|
||||
buttonBar.layout.contentInset = .zero
|
||||
buttonBar.indicator.backgroundColor = Asset.Colors.Label.primary.color
|
||||
buttonBar.indicator.weight = .custom(value: 2)
|
||||
return buttonBar
|
||||
}()
|
||||
|
||||
let buttonBarBackgroundView: UIView = {
|
||||
let view = UIView()
|
||||
let barBottomLine = UIView.separatorLine
|
||||
barBottomLine.backgroundColor = Asset.Colors.Label.secondary.color.withAlphaComponent(0.5)
|
||||
barBottomLine.translatesAutoresizingMaskIntoConstraints = false
|
||||
view.addSubview(barBottomLine)
|
||||
NSLayoutConstraint.activate([
|
||||
barBottomLine.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||
barBottomLine.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||
barBottomLine.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
||||
barBottomLine.heightAnchor.constraint(equalToConstant: 2).priority(.required - 1),
|
||||
])
|
||||
return view
|
||||
}()
|
||||
|
||||
func customizeButtonBarAppearance() {
|
||||
// The implmention use CATextlayer. Adapt for Dark Mode without dynamic colors
|
||||
// Needs trigger update when `userInterfaceStyle` chagnes
|
||||
let userInterfaceStyle = traitCollection.userInterfaceStyle
|
||||
buttonBar.buttons.customize { button in
|
||||
switch userInterfaceStyle {
|
||||
case .dark:
|
||||
// Asset.Colors.Label.primary.color
|
||||
button.selectedTintColor = UIColor(red: 238.0/255.0, green: 238.0/255.0, blue: 238.0/255.0, alpha: 1.0)
|
||||
// Asset.Colors.Label.secondary.color
|
||||
button.tintColor = UIColor(red: 151.0/255.0, green: 157.0/255.0, blue: 173.0/255.0, alpha: 1.0)
|
||||
default:
|
||||
// Asset.Colors.Label.primary.color
|
||||
button.selectedTintColor = UIColor(red: 40.0/255.0, green: 44.0/255.0, blue: 55.0/255.0, alpha: 1.0)
|
||||
// Asset.Colors.Label.secondary.color
|
||||
button.tintColor = UIColor(red: 60.0/255.0, green: 60.0/255.0, blue: 67.0/255.0, alpha: 0.6)
|
||||
}
|
||||
|
||||
button.backgroundColor = .clear
|
||||
button.contentInset = UIEdgeInsets(top: 12, left: 26, bottom: 12, right: 26)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension DiscoveryViewController {
|
||||
|
||||
public override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
setupAppearance(theme: ThemeService.shared.currentTheme.value)
|
||||
ThemeService.shared.currentTheme
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] theme in
|
||||
guard let self = self else { return }
|
||||
self.setupAppearance(theme: theme)
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
|
||||
dataSource = viewModel
|
||||
addBar(
|
||||
buttonBar,
|
||||
dataSource: viewModel,
|
||||
at: .top
|
||||
)
|
||||
customizeButtonBarAppearance()
|
||||
|
||||
viewModel.$viewControllers
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] _ in
|
||||
guard let self = self else { return }
|
||||
self.reloadData()
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
}
|
||||
|
||||
public override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
|
||||
super.traitCollectionDidChange(previousTraitCollection)
|
||||
|
||||
customizeButtonBarAppearance()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension DiscoveryViewController {
|
||||
|
||||
private func setupAppearance(theme: Theme) {
|
||||
view.backgroundColor = theme.secondarySystemBackgroundColor
|
||||
buttonBarBackgroundView.backgroundColor = theme.systemBackgroundColor
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,156 @@
|
|||
//
|
||||
// DiscoveryViewModel.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by MainasuK on 2022-4-12.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
import Combine
|
||||
import Tabman
|
||||
import Pageboy
|
||||
|
||||
final class DiscoveryViewModel {
|
||||
|
||||
var disposeBag = Set<AnyCancellable>()
|
||||
|
||||
// input
|
||||
let context: AppContext
|
||||
let discoveryPostsViewController: DiscoveryPostsViewController
|
||||
let discoveryHashtagsViewController: DiscoveryHashtagsViewController
|
||||
let discoveryNewsViewController: DiscoveryNewsViewController
|
||||
let discoveryForYouViewController: DiscoveryForYouViewController
|
||||
|
||||
@Published var viewControllers: [ScrollViewContainer & PageViewController]
|
||||
|
||||
init(context: AppContext, coordinator: SceneCoordinator) {
|
||||
func setupDependency(_ needsDependency: NeedsDependency) {
|
||||
needsDependency.context = context
|
||||
needsDependency.coordinator = coordinator
|
||||
}
|
||||
|
||||
self.context = context
|
||||
discoveryPostsViewController = {
|
||||
let viewController = DiscoveryPostsViewController()
|
||||
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
|
||||
}()
|
||||
discoveryForYouViewController = {
|
||||
let viewController = DiscoveryForYouViewController()
|
||||
setupDependency(viewController)
|
||||
viewController.viewModel = DiscoveryForYouViewModel(context: context)
|
||||
return viewController
|
||||
}()
|
||||
self.viewControllers = [
|
||||
discoveryPostsViewController,
|
||||
discoveryHashtagsViewController,
|
||||
discoveryNewsViewController,
|
||||
discoveryForYouViewController,
|
||||
]
|
||||
// end init
|
||||
|
||||
discoveryPostsViewController.viewModel.$isServerSupportEndpoint
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] isServerSupportEndpoint in
|
||||
guard let self = self else { return }
|
||||
if !isServerSupportEndpoint {
|
||||
self.viewControllers.removeAll(where: {
|
||||
$0 === self.discoveryPostsViewController || $0 === self.discoveryPostsViewController
|
||||
})
|
||||
}
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
|
||||
discoveryNewsViewController.viewModel.$isServerSupportEndpoint
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] isServerSupportEndpoint in
|
||||
guard let self = self else { return }
|
||||
if !isServerSupportEndpoint {
|
||||
self.viewControllers.removeAll(where: { $0 === self.discoveryNewsViewController })
|
||||
}
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
// MARK: - PageboyViewControllerDataSource
|
||||
extension DiscoveryViewModel: PageboyViewControllerDataSource {
|
||||
|
||||
func numberOfViewControllers(in pageboyViewController: PageboyViewController) -> Int {
|
||||
return viewControllers.count
|
||||
}
|
||||
|
||||
func viewController(for pageboyViewController: PageboyViewController, at index: PageboyViewController.PageIndex) -> UIViewController? {
|
||||
return viewControllers[index]
|
||||
}
|
||||
|
||||
func defaultPage(for pageboyViewController: PageboyViewController) -> PageboyViewController.Page? {
|
||||
return .first
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - TMBarDataSource
|
||||
extension DiscoveryViewModel: TMBarDataSource {
|
||||
func barItem(for bar: TMBar, at index: Int) -> TMBarItemable {
|
||||
guard !viewControllers.isEmpty, index < viewControllers.count else {
|
||||
assertionFailure()
|
||||
return TMBarItem(title: "")
|
||||
}
|
||||
return viewControllers[index].tabItem
|
||||
}
|
||||
}
|
||||
|
||||
protocol PageViewController: UIViewController {
|
||||
var tabItemTitle: String { get }
|
||||
var tabItem: TMBarItemable { get }
|
||||
}
|
||||
|
||||
// MARK: - PageViewController
|
||||
extension DiscoveryPostsViewController: PageViewController {
|
||||
var tabItemTitle: String { "Posts" }
|
||||
var tabItem: TMBarItemable {
|
||||
return TMBarItem(title: tabItemTitle)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// MARK: - PageViewController
|
||||
extension DiscoveryHashtagsViewController: PageViewController {
|
||||
var tabItemTitle: String { "Hashtags" }
|
||||
var tabItem: TMBarItemable {
|
||||
|
||||
return TMBarItem(title: tabItemTitle)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - PageViewController
|
||||
extension DiscoveryNewsViewController: PageViewController {
|
||||
var tabItemTitle: String { "News" }
|
||||
var tabItem: TMBarItemable {
|
||||
return TMBarItem(title: tabItemTitle)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - PageViewController
|
||||
extension DiscoveryForYouViewController: PageViewController {
|
||||
var tabItemTitle: String { "For You" }
|
||||
var tabItem: TMBarItemable {
|
||||
return TMBarItem(title: tabItemTitle)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,146 @@
|
|||
//
|
||||
// DiscoveryForYouViewController.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by MainasuK on 2022-4-14.
|
||||
//
|
||||
|
||||
import os.log
|
||||
import UIKit
|
||||
import Combine
|
||||
import MastodonUI
|
||||
|
||||
final class DiscoveryForYouViewController: UIViewController, NeedsDependency, MediaPreviewableViewController {
|
||||
|
||||
let logger = Logger(subsystem: "DiscoveryForYouViewController", category: "ViewController")
|
||||
|
||||
weak var context: AppContext! { willSet { precondition(!isViewLoaded) } }
|
||||
weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } }
|
||||
|
||||
var disposeBag = Set<AnyCancellable>()
|
||||
var viewModel: DiscoveryForYouViewModel!
|
||||
|
||||
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 DiscoveryForYouViewController {
|
||||
|
||||
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,
|
||||
profileCardTableViewCellDelegate: self
|
||||
)
|
||||
|
||||
tableView.refreshControl = refreshControl
|
||||
refreshControl.addTarget(self, action: #selector(DiscoveryForYouViewController.refreshControlValueChanged(_:)), for: .valueChanged)
|
||||
viewModel.$isFetching
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] isFetching in
|
||||
guard let self = self else { return }
|
||||
if !isFetching {
|
||||
self.refreshControl.endRefreshing()
|
||||
}
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
}
|
||||
|
||||
override func viewWillAppear(_ animated: Bool) {
|
||||
super.viewWillAppear(animated)
|
||||
|
||||
refreshControl.endRefreshing()
|
||||
tableView.deselectRow(with: transitionCoordinator, animated: animated)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension DiscoveryForYouViewController {
|
||||
|
||||
@objc private func refreshControlValueChanged(_ sender: UIRefreshControl) {
|
||||
Task {
|
||||
try await viewModel.fetch()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - UITableViewDelegate
|
||||
extension DiscoveryForYouViewController: 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 .user(record) = viewModel.diffableDataSource?.itemIdentifier(for: indexPath) else { return }
|
||||
guard let user = record.object(in: context.managedObjectContext) else { return }
|
||||
let profileViewModel = CachedProfileViewModel(
|
||||
context: context,
|
||||
mastodonUser: user
|
||||
)
|
||||
coordinator.present(
|
||||
scene: .profile(viewModel: profileViewModel),
|
||||
from: self,
|
||||
transition: .show
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - ProfileCardTableViewCellDelegate
|
||||
extension DiscoveryForYouViewController: ProfileCardTableViewCellDelegate {
|
||||
func profileCardTableViewCell(_ cell: ProfileCardTableViewCell, profileCardView: ProfileCardView, relationshipButtonDidPressed button: ProfileRelationshipActionButton) {
|
||||
guard let authenticationBox = viewModel.context.authenticationService.activeMastodonAuthenticationBox.value else { return }
|
||||
guard let indexPath = tableView.indexPath(for: cell) else { return }
|
||||
guard case let .user(record) = viewModel.diffableDataSource?.itemIdentifier(for: indexPath) else { return }
|
||||
|
||||
Task {
|
||||
try await DataSourceFacade.responseToUserFollowAction(
|
||||
dependency: self,
|
||||
user: record,
|
||||
authenticationBox: authenticationBox
|
||||
)
|
||||
} // end Task
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: ScrollViewContainer
|
||||
extension DiscoveryForYouViewController: ScrollViewContainer {
|
||||
var scrollView: UIScrollView? {
|
||||
tableView
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
//
|
||||
// DiscoveryForYouViewModel+Diffable.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by MainasuK on 2022-4-14.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
import Combine
|
||||
import MastodonUI
|
||||
|
||||
extension DiscoveryForYouViewModel {
|
||||
|
||||
func setupDiffableDataSource(
|
||||
tableView: UITableView,
|
||||
profileCardTableViewCellDelegate: ProfileCardTableViewCellDelegate
|
||||
) {
|
||||
diffableDataSource = DiscoverySection.diffableDataSource(
|
||||
tableView: tableView,
|
||||
context: context,
|
||||
configuration: DiscoverySection.Configuration(
|
||||
profileCardTableViewCellDelegate: profileCardTableViewCellDelegate
|
||||
)
|
||||
)
|
||||
|
||||
Task {
|
||||
try await fetch()
|
||||
}
|
||||
|
||||
userFetchedResultsController.$records
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] records in
|
||||
guard let self = self else { return }
|
||||
guard let diffableDataSource = self.diffableDataSource else { return }
|
||||
|
||||
var snapshot = NSDiffableDataSourceSnapshot<DiscoverySection, DiscoveryItem>()
|
||||
snapshot.appendSections([.forYou])
|
||||
|
||||
let items = records.map { DiscoveryItem.user($0) }
|
||||
snapshot.appendItems(items, toSection: .forYou)
|
||||
|
||||
diffableDataSource.applySnapshot(snapshot, animated: false)
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,75 @@
|
|||
//
|
||||
// DiscoveryForYouViewModel.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by MainasuK on 2022-4-14.
|
||||
//
|
||||
|
||||
import os.log
|
||||
import UIKit
|
||||
import Combine
|
||||
import GameplayKit
|
||||
import CoreData
|
||||
import CoreDataStack
|
||||
import MastodonSDK
|
||||
|
||||
final class DiscoveryForYouViewModel {
|
||||
|
||||
var disposeBag = Set<AnyCancellable>()
|
||||
|
||||
// input
|
||||
let context: AppContext
|
||||
let userFetchedResultsController: UserFetchedResultsController
|
||||
@Published var isFetching = false
|
||||
|
||||
// output
|
||||
var diffableDataSource: UITableViewDiffableDataSource<DiscoverySection, DiscoveryItem>?
|
||||
let didLoadLatest = PassthroughSubject<Void, Never>()
|
||||
|
||||
init(context: AppContext) {
|
||||
self.context = context
|
||||
self.userFetchedResultsController = UserFetchedResultsController(
|
||||
managedObjectContext: context.managedObjectContext,
|
||||
domain: nil,
|
||||
additionalPredicate: nil
|
||||
)
|
||||
// end init
|
||||
|
||||
context.authenticationService.activeMastodonAuthenticationBox
|
||||
.map { $0?.domain }
|
||||
.assign(to: \.domain, on: userFetchedResultsController)
|
||||
.store(in: &disposeBag)
|
||||
}
|
||||
|
||||
deinit {
|
||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension DiscoveryForYouViewModel {
|
||||
func fetch() async throws {
|
||||
guard !isFetching else { return }
|
||||
isFetching = true
|
||||
defer { isFetching = false }
|
||||
|
||||
guard let authenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { return }
|
||||
|
||||
do {
|
||||
let response = try await context.apiService.suggestionAccountV2(
|
||||
query: nil,
|
||||
authenticationBox: authenticationBox
|
||||
)
|
||||
let userIDs = response.value.map { $0.account.id }
|
||||
userFetchedResultsController.userIDs = userIDs
|
||||
} catch {
|
||||
// fallback V1
|
||||
let response2 = try await context.apiService.suggestionAccount(
|
||||
query: nil,
|
||||
authenticationBox: authenticationBox
|
||||
)
|
||||
let userIDs = response2.value.map { $0.id }
|
||||
userFetchedResultsController.userIDs = userIDs
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,133 @@
|
|||
//
|
||||
// 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<AnyCancellable>()
|
||||
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
|
||||
}()
|
||||
|
||||
let refreshControl = UIRefreshControl()
|
||||
|
||||
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.refreshControl = refreshControl
|
||||
refreshControl.addTarget(self, action: #selector(DiscoveryHashtagsViewController.refreshControlValueChanged(_:)), for: .valueChanged)
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension DiscoveryHashtagsViewController {
|
||||
|
||||
@objc private func refreshControlValueChanged(_ sender: UIRefreshControl) {
|
||||
Task { @MainActor in
|
||||
do {
|
||||
try await viewModel.fetch()
|
||||
} catch {
|
||||
// do nothing
|
||||
}
|
||||
sender.endRefreshing()
|
||||
} // end Task
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
|
@ -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<DiscoverySection, DiscoveryItem>()
|
||||
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<DiscoverySection, DiscoveryItem>()
|
||||
snapshot.appendSections([.hashtags])
|
||||
|
||||
let items = hashtags.map { DiscoveryItem.hashtag($0) }
|
||||
snapshot.appendItems(items, toSection: .hashtags)
|
||||
|
||||
diffableDataSource.apply(snapshot)
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,76 @@
|
|||
//
|
||||
// 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 {
|
||||
|
||||
let logger = Logger(subsystem: "DiscoveryHashtagsViewModel", category: "ViewModel")
|
||||
|
||||
var disposeBag = Set<AnyCancellable>()
|
||||
|
||||
// input
|
||||
let context: AppContext
|
||||
let viewDidAppeared = PassthroughSubject<Void, Never>()
|
||||
|
||||
// output
|
||||
var diffableDataSource: UITableViewDiffableDataSource<DiscoverySection, DiscoveryItem>?
|
||||
@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<Mastodon.Response.Content<[Mastodon.Entity.Tag]>, Error> { response } }
|
||||
.catch { error in Just(Result<Mastodon.Response.Content<[Mastodon.Entity.Tag]>, Error> { throw error }) }
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] result in
|
||||
guard let self = self else { return }
|
||||
switch result {
|
||||
case .success(let response):
|
||||
self.hashtags = response.value.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)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension DiscoveryHashtagsViewModel {
|
||||
|
||||
@MainActor
|
||||
func fetch() async throws {
|
||||
guard let authenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { return }
|
||||
let response = try await context.apiService.trendHashtags(domain: authenticationBox.domain, query: nil)
|
||||
hashtags = response.value.filter { !$0.name.isEmpty }
|
||||
logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): fetch tags: \(response.value.count)")
|
||||
}
|
||||
}
|
|
@ -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<AnyCancellable>()
|
||||
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
|
||||
}
|
||||
}
|
|
@ -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<DiscoverySection, DiscoveryItem>()
|
||||
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)
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,213 @@
|
|||
//
|
||||
// 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 ?? "<nil>")")
|
||||
}
|
||||
|
||||
@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 }
|
||||
|
||||
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?
|
||||
|
||||
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
|
||||
let isReloading = offset == nil
|
||||
|
||||
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 = isReloading ? [] : 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)")
|
||||
if let error = error as? Mastodon.API.Error, error.httpResponseStatus.code == 404 {
|
||||
viewModel.isServerSupportEndpoint = false
|
||||
await enter(state: NoMore.self)
|
||||
} else {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,74 @@
|
|||
//
|
||||
// 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<AnyCancellable>()
|
||||
|
||||
// input
|
||||
let context: AppContext
|
||||
let listBatchFetchViewModel = ListBatchFetchViewModel()
|
||||
|
||||
// output
|
||||
@Published var links: [Mastodon.Entity.Link] = []
|
||||
var diffableDataSource: UITableViewDiffableDataSource<DiscoverySection, DiscoveryItem>?
|
||||
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<Void, Never>()
|
||||
@Published var isServerSupportEndpoint = true
|
||||
|
||||
init(context: AppContext) {
|
||||
self.context = context
|
||||
// end init
|
||||
|
||||
Task {
|
||||
await checkServerEndpoint()
|
||||
} // end Task
|
||||
}
|
||||
|
||||
deinit {
|
||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
extension DiscoveryNewsViewModel {
|
||||
func checkServerEndpoint() async {
|
||||
guard let authenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { return }
|
||||
|
||||
do {
|
||||
_ = try await context.apiService.trendLinks(
|
||||
domain: authenticationBox.domain,
|
||||
query: .init(offset: nil, limit: nil)
|
||||
)
|
||||
} catch let error as Mastodon.API.Error where error.httpResponseStatus.code == 404 {
|
||||
isServerSupportEndpoint = false
|
||||
} catch {
|
||||
// do nothing
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,34 @@
|
|||
//
|
||||
// DiscoveryPostsViewController+DataSourceProvider.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by MainasuK on 2022-4-12.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
extension DiscoveryPostsViewController: DataSourceProvider {
|
||||
func item(from source: DataSourceItem.Source) async -> DataSourceItem? {
|
||||
var _indexPath = source.indexPath
|
||||
if _indexPath == nil, let cell = source.tableViewCell {
|
||||
_indexPath = await self.indexPath(for: cell)
|
||||
}
|
||||
guard let indexPath = _indexPath else { return nil }
|
||||
|
||||
guard let item = viewModel.diffableDataSource?.itemIdentifier(for: indexPath) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
switch item {
|
||||
case .status(let record):
|
||||
return .status(record: record)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func indexPath(for cell: UITableViewCell) async -> IndexPath? {
|
||||
return tableView.indexPath(for: cell)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,148 @@
|
|||
//
|
||||
// DiscoveryPostsViewController.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by MainasuK on 2022-4-12.
|
||||
//
|
||||
|
||||
import os.log
|
||||
import UIKit
|
||||
import Combine
|
||||
|
||||
final class DiscoveryPostsViewController: 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<AnyCancellable>()
|
||||
var viewModel: DiscoveryPostsViewModel!
|
||||
|
||||
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 DiscoveryPostsViewController {
|
||||
|
||||
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,
|
||||
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
|
||||
.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(DiscoveryPostsViewModel.State.Loading.self)
|
||||
}
|
||||
.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
|
||||
extension DiscoveryPostsViewController: UITableViewDelegate, AutoGenerateTableViewDelegate {
|
||||
// sourcery:inline:DiscoveryPostsViewController.AutoGenerateTableViewDelegate
|
||||
|
||||
// Generated using Sourcery
|
||||
// DO NOT EDIT
|
||||
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||
aspectTableView(tableView, didSelectRowAt: indexPath)
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, contextMenuConfigurationForRowAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? {
|
||||
return aspectTableView(tableView, contextMenuConfigurationForRowAt: indexPath, point: point)
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, previewForHighlightingContextMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? {
|
||||
return aspectTableView(tableView, previewForHighlightingContextMenuWithConfiguration: configuration)
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, previewForDismissingContextMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? {
|
||||
return aspectTableView(tableView, previewForDismissingContextMenuWithConfiguration: configuration)
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) {
|
||||
aspectTableView(tableView, willPerformPreviewActionForMenuWith: configuration, animator: animator)
|
||||
}
|
||||
// sourcery:end
|
||||
}
|
||||
|
||||
// MARK: - StatusTableViewCellDelegate
|
||||
extension DiscoveryPostsViewController: StatusTableViewCellDelegate { }
|
||||
|
||||
// MARK: ScrollViewContainer
|
||||
extension DiscoveryPostsViewController: ScrollViewContainer {
|
||||
var scrollView: UIScrollView? {
|
||||
tableView
|
||||
}
|
||||
}
|
|
@ -0,0 +1,65 @@
|
|||
//
|
||||
// DiscoveryPostsViewModel+Diffable.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by MainasuK on 2022-4-12.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
import Combine
|
||||
|
||||
extension DiscoveryPostsViewModel {
|
||||
|
||||
func setupDiffableDataSource(
|
||||
tableView: UITableView,
|
||||
statusTableViewCellDelegate: StatusTableViewCellDelegate
|
||||
) {
|
||||
diffableDataSource = StatusSection.diffableDataSource(
|
||||
tableView: tableView,
|
||||
context: context,
|
||||
configuration: StatusSection.Configuration(
|
||||
statusTableViewCellDelegate: statusTableViewCellDelegate,
|
||||
timelineMiddleLoaderTableViewCellDelegate: nil,
|
||||
filterContext: .none,
|
||||
activeFilters: nil
|
||||
)
|
||||
)
|
||||
|
||||
stateMachine.enter(State.Reloading.self)
|
||||
|
||||
statusFetchedResultsController.$records
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] records in
|
||||
guard let self = self else { return }
|
||||
guard let diffableDataSource = self.diffableDataSource else { return }
|
||||
|
||||
var snapshot = NSDiffableDataSourceSnapshot<StatusSection, StatusItem>()
|
||||
snapshot.appendSections([.main])
|
||||
|
||||
let items = records.map { StatusItem.status(record: $0) }
|
||||
snapshot.appendItems(items, toSection: .main)
|
||||
|
||||
if let currentState = self.stateMachine.currentState {
|
||||
switch currentState {
|
||||
case is State.Initial,
|
||||
is State.Reloading,
|
||||
is State.Loading,
|
||||
is State.Idle,
|
||||
is State.Fail:
|
||||
if !items.isEmpty {
|
||||
snapshot.appendItems([.bottomLoader], toSection: .main)
|
||||
}
|
||||
case is State.NoMore:
|
||||
break
|
||||
default:
|
||||
assertionFailure()
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
diffableDataSource.applySnapshot(snapshot, animated: false)
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,213 @@
|
|||
//
|
||||
// DiscoveryPostsViewModel+State.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by MainasuK on 2022-4-12.
|
||||
//
|
||||
|
||||
import os.log
|
||||
import Foundation
|
||||
import GameplayKit
|
||||
import MastodonSDK
|
||||
|
||||
extension DiscoveryPostsViewModel {
|
||||
class State: GKState, NamingState {
|
||||
|
||||
let logger = Logger(subsystem: "DiscoveryPostsViewModel.State", category: "StateMachine")
|
||||
|
||||
let id = UUID()
|
||||
|
||||
var name: String {
|
||||
String(describing: Self.self)
|
||||
}
|
||||
|
||||
weak var viewModel: DiscoveryPostsViewModel?
|
||||
|
||||
init(viewModel: DiscoveryPostsViewModel) {
|
||||
self.viewModel = viewModel
|
||||
}
|
||||
|
||||
override func didEnter(from previousState: GKState?) {
|
||||
super.didEnter(from: previousState)
|
||||
let previousState = previousState as? DiscoveryPostsViewModel.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 ?? "<nil>")")
|
||||
}
|
||||
|
||||
@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 DiscoveryPostsViewModel.State {
|
||||
class Initial: DiscoveryPostsViewModel.State {
|
||||
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
|
||||
switch stateClass {
|
||||
case is Reloading.Type:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class Reloading: DiscoveryPostsViewModel.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 }
|
||||
|
||||
stateMachine.enter(Loading.self)
|
||||
}
|
||||
}
|
||||
|
||||
class Fail: DiscoveryPostsViewModel.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: DiscoveryPostsViewModel.State {
|
||||
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
|
||||
switch stateClass {
|
||||
case is Reloading.Type, is Loading.Type:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class Loading: DiscoveryPostsViewModel.State {
|
||||
|
||||
var offset: Int?
|
||||
|
||||
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
|
||||
let isReloading = offset == nil
|
||||
|
||||
Task {
|
||||
do {
|
||||
let response = try await viewModel.context.apiService.trendStatuses(
|
||||
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 hasNewStatusesAppend = false
|
||||
var statusIDs = isReloading ? [] : viewModel.statusFetchedResultsController.statusIDs.value
|
||||
for status in response.value {
|
||||
guard !statusIDs.contains(status.id) else { continue }
|
||||
statusIDs.append(status.id)
|
||||
hasNewStatusesAppend = true
|
||||
}
|
||||
|
||||
if hasNewStatusesAppend, hasMore {
|
||||
await enter(state: Idle.self)
|
||||
} else {
|
||||
await enter(state: NoMore.self)
|
||||
}
|
||||
viewModel.statusFetchedResultsController.statusIDs.value = statusIDs
|
||||
viewModel.didLoadLatest.send()
|
||||
// } catch let error as?
|
||||
} catch {
|
||||
logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): fetch posts fail: \(error.localizedDescription)")
|
||||
if let error = error as? Mastodon.API.Error, error.httpResponseStatus.code == 404 {
|
||||
viewModel.isServerSupportEndpoint = false
|
||||
await enter(state: NoMore.self)
|
||||
} else {
|
||||
await enter(state: Fail.self)
|
||||
}
|
||||
|
||||
viewModel.didLoadLatest.send()
|
||||
}
|
||||
} // end Task
|
||||
} // end func
|
||||
}
|
||||
|
||||
class NoMore: DiscoveryPostsViewModel.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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,83 @@
|
|||
//
|
||||
// DiscoveryPostsViewModel.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by MainasuK on 2022-4-12.
|
||||
//
|
||||
|
||||
import os.log
|
||||
import UIKit
|
||||
import Combine
|
||||
import GameplayKit
|
||||
import CoreData
|
||||
import CoreDataStack
|
||||
import MastodonSDK
|
||||
|
||||
final class DiscoveryPostsViewModel {
|
||||
|
||||
var disposeBag = Set<AnyCancellable>()
|
||||
|
||||
// input
|
||||
let context: AppContext
|
||||
let statusFetchedResultsController: StatusFetchedResultsController
|
||||
let listBatchFetchViewModel = ListBatchFetchViewModel()
|
||||
|
||||
// output
|
||||
var diffableDataSource: UITableViewDiffableDataSource<StatusSection, StatusItem>?
|
||||
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<Void, Never>()
|
||||
@Published var isServerSupportEndpoint = true
|
||||
|
||||
init(context: AppContext) {
|
||||
self.context = context
|
||||
self.statusFetchedResultsController = StatusFetchedResultsController(
|
||||
managedObjectContext: context.managedObjectContext,
|
||||
domain: nil,
|
||||
additionalTweetPredicate: nil
|
||||
)
|
||||
// end init
|
||||
|
||||
context.authenticationService.activeMastodonAuthentication
|
||||
.map { $0?.domain }
|
||||
.assign(to: \.value, on: statusFetchedResultsController.domain)
|
||||
.store(in: &disposeBag)
|
||||
|
||||
Task {
|
||||
await checkServerEndpoint()
|
||||
} // end Task
|
||||
}
|
||||
|
||||
deinit {
|
||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension DiscoveryPostsViewModel {
|
||||
func checkServerEndpoint() async {
|
||||
guard let authenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { return }
|
||||
|
||||
do {
|
||||
_ = try await context.apiService.trendStatuses(
|
||||
domain: authenticationBox.domain,
|
||||
query: .init(offset: nil, limit: nil)
|
||||
)
|
||||
} catch let error as Mastodon.API.Error where error.httpResponseStatus.code == 404 {
|
||||
isServerSupportEndpoint = false
|
||||
} catch {
|
||||
// do nothing
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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) {
|
||||
|
|
|
@ -402,6 +402,7 @@ extension ProfileViewController {
|
|||
}
|
||||
|
||||
extension ProfileViewController {
|
||||
|
||||
private func updateBarButtonInsets() {
|
||||
let margin: CGFloat = {
|
||||
switch traitCollection.userInterfaceIdiom {
|
||||
|
|
|
@ -13,10 +13,11 @@ import MastodonSDK
|
|||
import MastodonMeta
|
||||
import MastodonAsset
|
||||
import MastodonLocalization
|
||||
import MastodonUI
|
||||
|
||||
// please override this base class
|
||||
class ProfileViewModel: NSObject {
|
||||
|
||||
|
||||
let logger = Logger(subsystem: "ProfileViewModel", category: "ViewModel")
|
||||
|
||||
typealias UserID = String
|
||||
|
@ -372,101 +373,6 @@ extension ProfileViewModel {
|
|||
|
||||
}
|
||||
|
||||
extension ProfileViewModel {
|
||||
|
||||
enum RelationshipAction: Int, CaseIterable {
|
||||
case none // set hide from UI
|
||||
case follow
|
||||
case request
|
||||
case pending
|
||||
case following
|
||||
case muting
|
||||
case blocked
|
||||
case blocking
|
||||
case suspended
|
||||
case edit
|
||||
case editing
|
||||
case updating
|
||||
|
||||
var option: RelationshipActionOptionSet {
|
||||
return RelationshipActionOptionSet(rawValue: 1 << rawValue)
|
||||
}
|
||||
}
|
||||
|
||||
// construct option set on the enum for safe iterator
|
||||
struct RelationshipActionOptionSet: OptionSet {
|
||||
let rawValue: Int
|
||||
|
||||
static let none = RelationshipAction.none.option
|
||||
static let follow = RelationshipAction.follow.option
|
||||
static let request = RelationshipAction.request.option
|
||||
static let pending = RelationshipAction.pending.option
|
||||
static let following = RelationshipAction.following.option
|
||||
static let muting = RelationshipAction.muting.option
|
||||
static let blocked = RelationshipAction.blocked.option
|
||||
static let blocking = RelationshipAction.blocking.option
|
||||
static let suspended = RelationshipAction.suspended.option
|
||||
static let edit = RelationshipAction.edit.option
|
||||
static let editing = RelationshipAction.editing.option
|
||||
static let updating = RelationshipAction.updating.option
|
||||
|
||||
static let editOptions: RelationshipActionOptionSet = [.edit, .editing, .updating]
|
||||
|
||||
func highPriorityAction(except: RelationshipActionOptionSet) -> RelationshipAction? {
|
||||
let set = subtracting(except)
|
||||
for action in RelationshipAction.allCases.reversed() where set.contains(action.option) {
|
||||
return action
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
var title: String {
|
||||
guard let highPriorityAction = self.highPriorityAction(except: []) else {
|
||||
assertionFailure()
|
||||
return " "
|
||||
}
|
||||
switch highPriorityAction {
|
||||
case .none: return " "
|
||||
case .follow: return L10n.Common.Controls.Friendship.follow
|
||||
case .request: return L10n.Common.Controls.Friendship.request
|
||||
case .pending: return L10n.Common.Controls.Friendship.pending
|
||||
case .following: return L10n.Common.Controls.Friendship.following
|
||||
case .muting: return L10n.Common.Controls.Friendship.muted
|
||||
case .blocked: return L10n.Common.Controls.Friendship.follow // blocked by user
|
||||
case .blocking: return L10n.Common.Controls.Friendship.blocked
|
||||
case .suspended: return L10n.Common.Controls.Friendship.follow
|
||||
case .edit: return L10n.Common.Controls.Friendship.editInfo
|
||||
case .editing: return L10n.Common.Controls.Actions.done
|
||||
case .updating: return " "
|
||||
}
|
||||
}
|
||||
|
||||
@available(*, deprecated, message: "")
|
||||
var backgroundColor: UIColor {
|
||||
guard let highPriorityAction = self.highPriorityAction(except: []) else {
|
||||
assertionFailure()
|
||||
return Asset.Colors.brandBlue.color
|
||||
}
|
||||
switch highPriorityAction {
|
||||
case .none: return Asset.Colors.brandBlue.color
|
||||
case .follow: return Asset.Colors.brandBlue.color
|
||||
case .request: return Asset.Colors.brandBlue.color
|
||||
case .pending: return Asset.Colors.brandBlue.color
|
||||
case .following: return Asset.Colors.brandBlue.color
|
||||
case .muting: return Asset.Colors.alertYellow.color
|
||||
case .blocked: return Asset.Colors.brandBlue.color
|
||||
case .blocking: return Asset.Colors.danger.color
|
||||
case .suspended: return Asset.Colors.brandBlue.color
|
||||
case .edit: return Asset.Colors.brandBlue.color
|
||||
case .editing: return Asset.Colors.brandBlue.color
|
||||
case .updating: return Asset.Colors.brandBlue.color
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
extension ProfileViewModel {
|
||||
func updateProfileInfo(
|
||||
headerProfileInfo: ProfileHeaderViewModel.ProfileInfo,
|
||||
|
|
|
@ -36,7 +36,7 @@ final class UserTimelineViewController: UIViewController, NeedsDependency, Media
|
|||
let cellFrameCache = NSCache<NSNumber, NSValue>()
|
||||
|
||||
deinit {
|
||||
os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
|
||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -11,6 +11,7 @@ import Combine
|
|||
import SafariServices
|
||||
import MastodonAsset
|
||||
import MastodonLocalization
|
||||
import MastodonUI
|
||||
|
||||
class MainTabBarController: UITabBarController {
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -9,45 +9,13 @@ import UIKit
|
|||
import Combine
|
||||
import MetaTextKit
|
||||
import MastodonAsset
|
||||
import MastodonUI
|
||||
|
||||
final class TrendCollectionViewCell: UICollectionViewCell {
|
||||
|
||||
var _disposeBag = Set<AnyCancellable>()
|
||||
|
||||
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),
|
||||
])
|
||||
}
|
||||
|
||||
|
|
|
@ -15,7 +15,7 @@ import MastodonLocalization
|
|||
|
||||
final class HeightFixedSearchBar: UISearchBar {
|
||||
override var intrinsicContentSize: CGSize {
|
||||
return CGSize(width: CGFloat.greatestFiniteMagnitude, height: 44)
|
||||
return CGSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -35,19 +35,26 @@ final class SearchViewController: UIViewController, NeedsDependency {
|
|||
// layout alongside with split mode button (on iPad)
|
||||
let titleViewContainer = UIView()
|
||||
let searchBar = HeightFixedSearchBar()
|
||||
|
||||
let collectionView: UICollectionView = {
|
||||
var configuration = UICollectionLayoutListConfiguration(appearance: .insetGrouped)
|
||||
configuration.backgroundColor = .clear
|
||||
configuration.headerMode = .supplementary
|
||||
let layout = UICollectionViewCompositionalLayout.list(using: configuration)
|
||||
let collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout)
|
||||
collectionView.backgroundColor = .clear
|
||||
return collectionView
|
||||
}()
|
||||
|
||||
// let collectionView: UICollectionView = {
|
||||
// var configuration = UICollectionLayoutListConfiguration(appearance: .insetGrouped)
|
||||
// configuration.backgroundColor = .clear
|
||||
// configuration.headerMode = .supplementary
|
||||
// let layout = UICollectionViewCompositionalLayout.list(using: configuration)
|
||||
// let collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout)
|
||||
// collectionView.backgroundColor = .clear
|
||||
// return collectionView
|
||||
// }()
|
||||
|
||||
let searchBarTapPublisher = PassthroughSubject<Void, Never>()
|
||||
|
||||
private(set) lazy var discoveryViewController: DiscoveryViewController = {
|
||||
let viewController = DiscoveryViewController()
|
||||
viewController.context = context
|
||||
viewController.coordinator = coordinator
|
||||
return viewController
|
||||
}()
|
||||
|
||||
deinit {
|
||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
|
||||
}
|
||||
|
@ -71,19 +78,31 @@ extension SearchViewController {
|
|||
|
||||
setupSearchBar()
|
||||
|
||||
collectionView.translatesAutoresizingMaskIntoConstraints = false
|
||||
view.addSubview(collectionView)
|
||||
// collectionView.translatesAutoresizingMaskIntoConstraints = false
|
||||
// view.addSubview(collectionView)
|
||||
// NSLayoutConstraint.activate([
|
||||
// collectionView.topAnchor.constraint(equalTo: view.topAnchor),
|
||||
// collectionView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||
// collectionView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||
// collectionView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
||||
// ])
|
||||
//
|
||||
// collectionView.delegate = self
|
||||
// viewModel.setupDiffableDataSource(
|
||||
// collectionView: collectionView
|
||||
// )
|
||||
|
||||
addChild(discoveryViewController)
|
||||
discoveryViewController.view.translatesAutoresizingMaskIntoConstraints = false
|
||||
view.addSubview(discoveryViewController.view)
|
||||
NSLayoutConstraint.activate([
|
||||
collectionView.topAnchor.constraint(equalTo: view.topAnchor),
|
||||
collectionView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||
collectionView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||
collectionView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
||||
discoveryViewController.view.topAnchor.constraint(equalTo: view.topAnchor),
|
||||
discoveryViewController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||
discoveryViewController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||
discoveryViewController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
||||
])
|
||||
|
||||
collectionView.delegate = self
|
||||
viewModel.setupDiffableDataSource(
|
||||
collectionView: collectionView
|
||||
)
|
||||
// discoveryViewController.view.isHidden = true
|
||||
}
|
||||
|
||||
override func viewDidAppear(_ animated: Bool) {
|
||||
|
@ -113,7 +132,10 @@ extension SearchViewController {
|
|||
searchBar.trailingAnchor.constraint(equalTo: titleViewContainer.trailingAnchor),
|
||||
searchBar.bottomAnchor.constraint(equalTo: titleViewContainer.bottomAnchor),
|
||||
])
|
||||
searchBar.setContentHuggingPriority(.required, for: .horizontal)
|
||||
searchBar.setContentHuggingPriority(.required, for: .vertical)
|
||||
navigationItem.titleView = titleViewContainer
|
||||
// navigationItem.titleView = searchBar
|
||||
|
||||
searchBarTapPublisher
|
||||
.throttle(for: 0.5, scheduler: DispatchQueue.main, latest: false)
|
||||
|
@ -123,7 +145,10 @@ extension SearchViewController {
|
|||
let searchDetailViewModel = SearchDetailViewModel()
|
||||
searchDetailViewModel.needsBecomeFirstResponder = true
|
||||
self.navigationController?.delegate = self.searchTransitionController
|
||||
self.coordinator.present(scene: .searchDetail(viewModel: searchDetailViewModel), from: self, transition: .customPush)
|
||||
// FIXME:
|
||||
// use `.customPush(animated: false)` false to disable navigation bar animation for searchBar layout
|
||||
// but that should be a fade transition whe fixed size searchBar
|
||||
self.coordinator.present(scene: .searchDetail(viewModel: searchDetailViewModel), from: self, transition: .customPush(animated: false))
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
}
|
||||
|
@ -151,21 +176,21 @@ extension SearchViewController: UISearchControllerDelegate {
|
|||
}
|
||||
|
||||
// MARK: - UICollectionViewDelegate
|
||||
extension SearchViewController: UICollectionViewDelegate {
|
||||
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
|
||||
logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): select item at: \(indexPath.debugDescription)")
|
||||
|
||||
defer {
|
||||
collectionView.deselectItem(at: indexPath, animated: true)
|
||||
}
|
||||
|
||||
guard let diffableDataSource = viewModel.diffableDataSource else { return }
|
||||
guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return }
|
||||
|
||||
switch item {
|
||||
case .trend(let hashtag):
|
||||
let viewModel = HashtagTimelineViewModel(context: context, hashtag: hashtag.name)
|
||||
coordinator.present(scene: .hashtagTimeline(viewModel: viewModel), from: self, transition: .show)
|
||||
}
|
||||
}
|
||||
}
|
||||
//extension SearchViewController: UICollectionViewDelegate {
|
||||
// func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
|
||||
// logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): select item at: \(indexPath.debugDescription)")
|
||||
//
|
||||
// defer {
|
||||
// collectionView.deselectItem(at: indexPath, animated: true)
|
||||
// }
|
||||
//
|
||||
// guard let diffableDataSource = viewModel.diffableDataSource else { return }
|
||||
// guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return }
|
||||
//
|
||||
// switch item {
|
||||
// case .trend(let hashtag):
|
||||
// let viewModel = HashtagTimelineViewModel(context: context, hashtag: hashtag.name)
|
||||
// coordinator.present(scene: .hashtagTimeline(viewModel: viewModel), from: self, transition: .show)
|
||||
// }
|
||||
// }
|
||||
//}
|
||||
|
|
|
@ -8,35 +8,35 @@
|
|||
import UIKit
|
||||
import MastodonSDK
|
||||
|
||||
extension SearchViewModel {
|
||||
|
||||
func setupDiffableDataSource(
|
||||
collectionView: UICollectionView
|
||||
) {
|
||||
diffableDataSource = SearchSection.diffableDataSource(
|
||||
collectionView: collectionView,
|
||||
context: context
|
||||
)
|
||||
|
||||
var snapshot = NSDiffableDataSourceSnapshot<SearchSection, SearchItem>()
|
||||
snapshot.appendSections([.trend])
|
||||
diffableDataSource?.apply(snapshot)
|
||||
|
||||
$hashtags
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] hashtags in
|
||||
guard let self = self else { return }
|
||||
guard let diffableDataSource = self.diffableDataSource else { return }
|
||||
|
||||
var snapshot = NSDiffableDataSourceSnapshot<SearchSection, SearchItem>()
|
||||
snapshot.appendSections([.trend])
|
||||
|
||||
let trendItems = hashtags.map { SearchItem.trend($0) }
|
||||
snapshot.appendItems(trendItems, toSection: .trend)
|
||||
|
||||
diffableDataSource.apply(snapshot)
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
}
|
||||
|
||||
}
|
||||
//extension SearchViewModel {
|
||||
//
|
||||
// func setupDiffableDataSource(
|
||||
// collectionView: UICollectionView
|
||||
// ) {
|
||||
// diffableDataSource = SearchSection.diffableDataSource(
|
||||
// collectionView: collectionView,
|
||||
// context: context
|
||||
// )
|
||||
//
|
||||
// var snapshot = NSDiffableDataSourceSnapshot<SearchSection, SearchItem>()
|
||||
// snapshot.appendSections([.trend])
|
||||
// diffableDataSource?.apply(snapshot)
|
||||
//
|
||||
// $hashtags
|
||||
// .receive(on: DispatchQueue.main)
|
||||
// .sink { [weak self] hashtags in
|
||||
// guard let self = self else { return }
|
||||
// guard let diffableDataSource = self.diffableDataSource else { return }
|
||||
//
|
||||
// var snapshot = NSDiffableDataSourceSnapshot<SearchSection, SearchItem>()
|
||||
// snapshot.appendSections([.trend])
|
||||
//
|
||||
// let trendItems = hashtags.map { SearchItem.trend($0) }
|
||||
// snapshot.appendItems(trendItems, toSection: .trend)
|
||||
//
|
||||
// diffableDataSource.apply(snapshot)
|
||||
// }
|
||||
// .store(in: &disposeBag)
|
||||
// }
|
||||
//
|
||||
//}
|
||||
|
|
|
@ -29,31 +29,31 @@ final class SearchViewModel: NSObject {
|
|||
self.context = context
|
||||
super.init()
|
||||
|
||||
Publishers.CombineLatest(
|
||||
context.authenticationService.activeMastodonAuthenticationBox,
|
||||
viewDidAppeared
|
||||
)
|
||||
.compactMap { authenticationBox, _ -> MastodonAuthenticationBox? in
|
||||
return authenticationBox
|
||||
}
|
||||
.throttle(for: 3, scheduler: DispatchQueue.main, latest: true)
|
||||
.asyncMap { authenticationBox in
|
||||
try await context.apiService.trends(domain: authenticationBox.domain, query: nil)
|
||||
}
|
||||
.retry(3)
|
||||
.map { response in Result<Mastodon.Response.Content<[Mastodon.Entity.Tag]>, Error> { response } }
|
||||
.catch { error in Just(Result<Mastodon.Response.Content<[Mastodon.Entity.Tag]>, Error> { throw error }) }
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] result in
|
||||
guard let self = self else { return }
|
||||
switch result {
|
||||
case .success(let response):
|
||||
self.hashtags = response.value
|
||||
case .failure:
|
||||
break
|
||||
}
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
// Publishers.CombineLatest(
|
||||
// context.authenticationService.activeMastodonAuthenticationBox,
|
||||
// viewDidAppeared
|
||||
// )
|
||||
// .compactMap { authenticationBox, _ -> MastodonAuthenticationBox? in
|
||||
// return authenticationBox
|
||||
// }
|
||||
// .throttle(for: 3, scheduler: DispatchQueue.main, latest: true)
|
||||
// .asyncMap { authenticationBox in
|
||||
// try await context.apiService.trendHashtags(domain: authenticationBox.domain, query: nil)
|
||||
// }
|
||||
// .retry(3)
|
||||
// .map { response in Result<Mastodon.Response.Content<[Mastodon.Entity.Tag]>, Error> { response } }
|
||||
// .catch { error in Just(Result<Mastodon.Response.Content<[Mastodon.Entity.Tag]>, Error> { throw error }) }
|
||||
// .receive(on: DispatchQueue.main)
|
||||
// .sink { [weak self] result in
|
||||
// guard let self = self else { return }
|
||||
// switch result {
|
||||
// case .success(let response):
|
||||
// self.hashtags = response.value
|
||||
// case .failure:
|
||||
// break
|
||||
// }
|
||||
// }
|
||||
// .store(in: &disposeBag)
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -12,6 +12,14 @@ import Pageboy
|
|||
import MastodonAsset
|
||||
import MastodonLocalization
|
||||
|
||||
final class CustomSearchController: UISearchController {
|
||||
|
||||
let customSearchBar = UISearchBar(frame: CGRect(x: 0, y: 0, width: 300, height: 100))
|
||||
|
||||
override var searchBar: UISearchBar { customSearchBar }
|
||||
|
||||
}
|
||||
|
||||
// Fake search bar not works on iPad with UISplitViewController
|
||||
// check device and fallback to standard UISearchController
|
||||
final class SearchDetailViewController: PageboyViewController, NeedsDependency {
|
||||
|
@ -48,8 +56,8 @@ final class SearchDetailViewController: PageboyViewController, NeedsDependency {
|
|||
return navigationBar
|
||||
}()
|
||||
|
||||
let searchController: UISearchController = {
|
||||
let searchController = UISearchController()
|
||||
let searchController: CustomSearchController = {
|
||||
let searchController = CustomSearchController()
|
||||
searchController.automaticallyShowsScopeBar = false
|
||||
searchController.dimsBackgroundDuringPresentation = false
|
||||
return searchController
|
||||
|
@ -235,11 +243,17 @@ extension SearchDetailViewController {
|
|||
|
||||
if isPhoneDevice {
|
||||
searchBar.setShowsCancelButton(true, animated: animated)
|
||||
searchBar.becomeFirstResponder()
|
||||
UIView.performWithoutAnimation {
|
||||
self.searchBar.becomeFirstResponder()
|
||||
}
|
||||
} else {
|
||||
searchController.isActive = true
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.33) {
|
||||
self.searchController.searchBar.becomeFirstResponder()
|
||||
searchController.searchBar.setShowsCancelButton(true, animated: false)
|
||||
searchController.searchBar.setShowsScope(true, animated: false)
|
||||
UIView.performWithoutAnimation {
|
||||
self.searchController.isActive = true
|
||||
}
|
||||
DispatchQueue.main.async {
|
||||
self.searchController.searchBar.becomeFirstResponder()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -10,6 +10,7 @@ import UIKit
|
|||
import Combine
|
||||
import MastodonAsset
|
||||
import MastodonLocalization
|
||||
import MastodonUI
|
||||
|
||||
protocol SearchHistoryTableHeaderViewDelegate: AnyObject {
|
||||
func searchHistoryTableHeaderView(_ searchHistoryTableHeaderView: SearchHistoryTableHeaderView, clearSearchHistoryButtonDidPressed button: UIButton)
|
||||
|
|
|
@ -11,6 +11,7 @@ import Combine
|
|||
import UIKit
|
||||
import MastodonAsset
|
||||
import MastodonLocalization
|
||||
import MastodonUI
|
||||
|
||||
protocol ContentWarningOverlayViewDelegate: AnyObject {
|
||||
func contentWarningOverlayViewDidPressed(_ contentWarningOverlayView: ContentWarningOverlayView)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -6,6 +6,7 @@
|
|||
//
|
||||
|
||||
import UIKit
|
||||
import MastodonUI
|
||||
|
||||
final class ThreadMetaView: UIView {
|
||||
|
||||
|
|
|
@ -46,14 +46,17 @@ extension SearchToSearchDetailViewControllerAnimatedTransitioning {
|
|||
let toViewEndFrame = transitionContext.finalFrame(for: toVC)
|
||||
transitionContext.containerView.addSubview(toView)
|
||||
toView.frame = toViewEndFrame
|
||||
toView.setNeedsLayout()
|
||||
toView.layoutIfNeeded()
|
||||
toVC.searchBar.setNeedsLayout()
|
||||
toVC.searchBar.layoutIfNeeded()
|
||||
toView.alpha = 0
|
||||
|
||||
let animator = UIViewPropertyAnimator(duration: transitionDuration(using: transitionContext), curve: curve)
|
||||
animator.addAnimations {
|
||||
|
||||
toView.alpha = 1
|
||||
}
|
||||
animator.addCompletion { position in
|
||||
toView.alpha = 1
|
||||
transitionContext.completeTransition(true)
|
||||
}
|
||||
return animator
|
||||
|
|
|
@ -13,12 +13,13 @@ import CoreDataStack
|
|||
import OSLog
|
||||
|
||||
extension APIService {
|
||||
|
||||
func suggestionAccount(
|
||||
query: Mastodon.API.Suggestions.Query?,
|
||||
authenticationBox: MastodonAuthenticationBox
|
||||
) async throws -> Mastodon.Response.Content<[Mastodon.Entity.Account]> {
|
||||
|
||||
let response = try await Mastodon.API.Suggestions.get(
|
||||
let response = try await Mastodon.API.Suggestions.accounts(
|
||||
session: session,
|
||||
domain: authenticationBox.domain,
|
||||
query: query,
|
||||
|
@ -47,7 +48,7 @@ extension APIService {
|
|||
query: Mastodon.API.Suggestions.Query?,
|
||||
authenticationBox: MastodonAuthenticationBox
|
||||
) async throws -> Mastodon.Response.Content<[Mastodon.Entity.V2.SuggestionAccount]> {
|
||||
let response = try await Mastodon.API.V2.Suggestions.get(
|
||||
let response = try await Mastodon.API.V2.Suggestions.accounts(
|
||||
session: session,
|
||||
domain: authenticationBox.domain,
|
||||
query: query,
|
||||
|
|
|
@ -9,11 +9,12 @@ import Foundation
|
|||
import MastodonSDK
|
||||
|
||||
extension APIService {
|
||||
func trends(
|
||||
|
||||
func trendHashtags(
|
||||
domain: String,
|
||||
query: Mastodon.API.Trends.Query?
|
||||
query: Mastodon.API.Trends.HashtagQuery?
|
||||
) async throws -> Mastodon.Response.Content<[Mastodon.Entity.Tag]> {
|
||||
let response = try await Mastodon.API.Trends.get(
|
||||
let response = try await Mastodon.API.Trends.hashtags(
|
||||
session: session,
|
||||
domain: domain,
|
||||
query: query
|
||||
|
@ -21,4 +22,48 @@ extension APIService {
|
|||
|
||||
return response
|
||||
}
|
||||
|
||||
func trendStatuses(
|
||||
domain: String,
|
||||
query: Mastodon.API.Trends.StatusQuery
|
||||
) async throws -> Mastodon.Response.Content<[Mastodon.Entity.Status]> {
|
||||
let response = try await Mastodon.API.Trends.statuses(
|
||||
session: session,
|
||||
domain: domain,
|
||||
query: query
|
||||
).singleOutput()
|
||||
|
||||
let managedObjectContext = backgroundManagedObjectContext
|
||||
try await managedObjectContext.performChanges {
|
||||
for entity in response.value {
|
||||
_ = Persistence.Status.createOrMerge(
|
||||
in: managedObjectContext,
|
||||
context: Persistence.Status.PersistContext(
|
||||
domain: domain,
|
||||
entity: entity,
|
||||
me: nil,
|
||||
statusCache: nil,
|
||||
userCache: nil,
|
||||
networkDate: response.networkDate
|
||||
)
|
||||
)
|
||||
} // end for … in
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -17,9 +17,9 @@
|
|||
<key>CFBundlePackageType</key>
|
||||
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.3.0</string>
|
||||
<string>1.3.1</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>109</string>
|
||||
<string>110</string>
|
||||
<key>NSExtension</key>
|
||||
<dict>
|
||||
<key>NSExtensionAttributes</key>
|
||||
|
|
|
@ -60,7 +60,9 @@ let package = Package(
|
|||
),
|
||||
.target(
|
||||
name: "MastodonCommon",
|
||||
dependencies: []
|
||||
dependencies: [
|
||||
"MastodonExtension"
|
||||
]
|
||||
),
|
||||
.target(
|
||||
name: "MastodonExtension",
|
||||
|
@ -92,6 +94,7 @@ let package = Package(
|
|||
.product(name: "Alamofire", package: "Alamofire"),
|
||||
.product(name: "AlamofireImage", package: "AlamofireImage"),
|
||||
.product(name: "MetaTextKit", package: "MetaTextKit"),
|
||||
.product(name: "MastodonMeta", package: "MetaTextKit"),
|
||||
.product(name: "FLAnimatedImage", package: "FLAnimatedImage"),
|
||||
]
|
||||
),
|
||||
|
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
},
|
||||
"properties" : {
|
||||
"provides-namespace" : true
|
||||
}
|
||||
}
|
|
@ -0,0 +1,38 @@
|
|||
{
|
||||
"colors" : [
|
||||
{
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "251",
|
||||
"green" : "250",
|
||||
"red" : "249"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "4",
|
||||
"green" : "5",
|
||||
"red" : "6"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
|
@ -103,6 +103,9 @@ public enum Asset {
|
|||
public static let star = ImageAsset(name: "ObjectsAndTools/star")
|
||||
}
|
||||
public enum Scene {
|
||||
public enum Discovery {
|
||||
public static let profileCardBackground = ColorAsset(name: "Scene/Discovery/profile.card.background")
|
||||
}
|
||||
public enum Onboarding {
|
||||
public static let avatarPlaceholder = ImageAsset(name: "Scene/Onboarding/avatar.placeholder")
|
||||
public static let background = ColorAsset(name: "Scene/Onboarding/background")
|
||||
|
|
|
@ -9,7 +9,7 @@ import UIKit
|
|||
|
||||
extension UserDefaults {
|
||||
|
||||
@objc dynamic var customUserInterfaceStyle: UIUserInterfaceStyle {
|
||||
@objc public dynamic var customUserInterfaceStyle: UIUserInterfaceStyle {
|
||||
get {
|
||||
register(defaults: [#function: UIUserInterfaceStyle.unspecified.rawValue])
|
||||
return UIUserInterfaceStyle(rawValue: integer(forKey: #function)) ?? .unspecified
|
||||
|
@ -17,7 +17,7 @@ extension UserDefaults {
|
|||
set { self[#function] = newValue.rawValue }
|
||||
}
|
||||
|
||||
@objc dynamic var preferredStaticAvatar: Bool {
|
||||
@objc public dynamic var preferredStaticAvatar: Bool {
|
||||
get {
|
||||
// default false
|
||||
// without set register to profile timeline performance
|
||||
|
@ -26,7 +26,7 @@ extension UserDefaults {
|
|||
set { self[#function] = newValue }
|
||||
}
|
||||
|
||||
@objc dynamic var preferredStaticEmoji: Bool {
|
||||
@objc public dynamic var preferredStaticEmoji: Bool {
|
||||
get {
|
||||
// default false
|
||||
// without set register to profile timeline performance
|
|
@ -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 }
|
||||
}
|
||||
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -27,7 +27,7 @@ extension Mastodon.API.Suggestions {
|
|||
/// - query: query
|
||||
/// - authorization: User token.
|
||||
/// - Returns: `AnyPublisher` contains `Accounts` nested in the response
|
||||
public static func get(
|
||||
public static func accounts(
|
||||
session: URLSession,
|
||||
domain: String,
|
||||
query: Mastodon.API.Suggestions.Query?,
|
||||
|
|
|
@ -9,6 +9,7 @@ import Combine
|
|||
import Foundation
|
||||
|
||||
extension Mastodon.API.Trends {
|
||||
|
||||
static func trendsURL(domain: String) -> URL {
|
||||
Mastodon.API.endpointURL(domain: domain).appendingPathComponent("trends")
|
||||
}
|
||||
|
@ -27,10 +28,10 @@ extension Mastodon.API.Trends {
|
|||
/// - query: query
|
||||
/// - Returns: `AnyPublisher` contains `Hashtags` nested in the response
|
||||
|
||||
public static func get(
|
||||
public static func hashtags(
|
||||
session: URLSession,
|
||||
domain: String,
|
||||
query: Mastodon.API.Trends.Query?
|
||||
query: Mastodon.API.Trends.HashtagQuery?
|
||||
) -> AnyPublisher<Mastodon.Response.Content<[Mastodon.Entity.Tag]>, Error> {
|
||||
let request = Mastodon.API.get(
|
||||
url: trendsURL(domain: domain),
|
||||
|
@ -44,10 +45,8 @@ extension Mastodon.API.Trends {
|
|||
}
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
}
|
||||
|
||||
extension Mastodon.API.Trends {
|
||||
public struct Query: Codable, GetQuery {
|
||||
public struct HashtagQuery: Codable, GetQuery {
|
||||
public init(limit: Int?) {
|
||||
self.limit = limit
|
||||
}
|
||||
|
@ -61,4 +60,113 @@ extension Mastodon.API.Trends {
|
|||
return items
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension Mastodon.API.Trends {
|
||||
|
||||
static func trendStatusesURL(domain: String) -> URL {
|
||||
Mastodon.API.endpointURL(domain: domain)
|
||||
.appendingPathComponent("trends")
|
||||
.appendingPathComponent("statuses")
|
||||
}
|
||||
|
||||
/// Trending status
|
||||
///
|
||||
/// 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: `[Status]` nested in the response
|
||||
|
||||
public static func statuses(
|
||||
session: URLSession,
|
||||
domain: String,
|
||||
query: Mastodon.API.Trends.StatusQuery?
|
||||
) -> AnyPublisher<Mastodon.Response.Content<[Mastodon.Entity.Status]>, Error> {
|
||||
let request = Mastodon.API.get(
|
||||
url: trendStatusesURL(domain: domain),
|
||||
query: query,
|
||||
authorization: nil
|
||||
)
|
||||
return session.dataTaskPublisher(for: request)
|
||||
.tryMap { data, response in
|
||||
let value = try Mastodon.API.decode(type: [Mastodon.Entity.Status].self, from: data, response: response)
|
||||
return Mastodon.Response.Content(value: value, response: response)
|
||||
}
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
public struct StatusQuery: Codable, GetQuery {
|
||||
|
||||
public let offset: Int?
|
||||
public let limit: Int? // Maximum number of results to return. Defaults to 10.
|
||||
|
||||
public init(
|
||||
offset: Int?,
|
||||
limit: Int?
|
||||
) {
|
||||
self.offset = offset
|
||||
self.limit = limit
|
||||
}
|
||||
|
||||
var queryItems: [URLQueryItem]? {
|
||||
var items: [URLQueryItem] = []
|
||||
offset.flatMap { items.append(URLQueryItem(name: "offset", value: String($0))) }
|
||||
limit.flatMap { items.append(URLQueryItem(name: "limit", value: String($0))) }
|
||||
guard !items.isEmpty else { return nil }
|
||||
return items
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
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<Mastodon.Response.Content<[Mastodon.Entity.Link]>, 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
|
||||
|
||||
}
|
||||
|
|
|
@ -20,7 +20,7 @@ extension Mastodon.API.V2.Suggestions {
|
|||
/// - query: query
|
||||
/// - authorization: User token.
|
||||
/// - Returns: `AnyPublisher` contains `AccountsSuggestion` nested in the response
|
||||
public static func get(
|
||||
public static func accounts(
|
||||
session: URLSession,
|
||||
domain: String,
|
||||
query: Mastodon.API.Suggestions.Query?,
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -37,6 +37,7 @@ extension Mastodon.Entity {
|
|||
|
||||
public func hash(into hasher: inout Hasher) {
|
||||
hasher.combine(name)
|
||||
hasher.combine(url)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -106,6 +106,7 @@ extension Mastodon.Response {
|
|||
public struct Link {
|
||||
public let maxID: Mastodon.Entity.Status.ID?
|
||||
public let minID: Mastodon.Entity.Status.ID?
|
||||
public let offset: Int?
|
||||
|
||||
init(link: String) {
|
||||
self.maxID = {
|
||||
|
@ -125,6 +126,15 @@ extension Mastodon.Response {
|
|||
let id = link[range]
|
||||
return String(id)
|
||||
}()
|
||||
|
||||
self.offset = {
|
||||
guard let regex = try? NSRegularExpression(pattern: "offset=([[:digit:]]+)", options: []) else { return nil }
|
||||
let results = regex.matches(in: link, options: [], range: NSRange(link.startIndex..<link.endIndex, in: link))
|
||||
guard let match = results.first else { return nil }
|
||||
guard let range = Range(match.range(at: 1), in: link) else { return nil }
|
||||
let offset = link[range]
|
||||
return Int(offset)
|
||||
}()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,20 @@
|
|||
//
|
||||
// MastodonEmoji.swift
|
||||
//
|
||||
//
|
||||
// Created by MainasuK on 2022-4-14.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import CoreDataStack
|
||||
import MastodonMeta
|
||||
|
||||
extension Collection where Element == MastodonEmoji {
|
||||
public var asDictionary: MastodonContent.Emojis {
|
||||
var dictionary: MastodonContent.Emojis = [:]
|
||||
for emoji in self {
|
||||
dictionary[emoji.code] = emoji.url
|
||||
}
|
||||
return dictionary
|
||||
}
|
||||
}
|
|
@ -0,0 +1,57 @@
|
|||
//
|
||||
// MastodonUser.swift
|
||||
//
|
||||
//
|
||||
// Created by MainasuK on 2022-4-14.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import CoreDataStack
|
||||
import MastodonCommon
|
||||
|
||||
extension MastodonUser {
|
||||
|
||||
public var displayNameWithFallback: String {
|
||||
return !displayName.isEmpty ? displayName : username
|
||||
}
|
||||
|
||||
public var acctWithDomain: String {
|
||||
if !acct.contains("@") {
|
||||
// Safe concat due to username cannot contains "@"
|
||||
return username + "@" + domain
|
||||
} else {
|
||||
return acct
|
||||
}
|
||||
}
|
||||
|
||||
public var domainFromAcct: String {
|
||||
if !acct.contains("@") {
|
||||
return domain
|
||||
} else {
|
||||
let domain = acct.split(separator: "@").last
|
||||
return String(domain!)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension MastodonUser {
|
||||
|
||||
public func headerImageURL() -> URL? {
|
||||
return URL(string: header)
|
||||
}
|
||||
|
||||
public func headerImageURLWithFallback(domain: String) -> URL {
|
||||
return URL(string: header) ?? URL(string: "https://\(domain)/headers/original/missing.png")!
|
||||
}
|
||||
|
||||
public func avatarImageURL() -> URL? {
|
||||
let string = UserDefaults.shared.preferredStaticAvatar ? avatarStatic ?? avatar : avatar
|
||||
return URL(string: string)
|
||||
}
|
||||
|
||||
public func avatarImageURLWithFallback(domain: String) -> URL {
|
||||
return avatarImageURL() ?? URL(string: "https://\(domain)/avatars/original/missing.png")!
|
||||
}
|
||||
|
||||
}
|
|
@ -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, +)
|
||||
}
|
||||
|
||||
}
|
|
@ -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, +)
|
||||
}
|
||||
|
||||
}
|
|
@ -20,6 +20,8 @@ extension MetaLabel {
|
|||
case notificationTitle
|
||||
case profileFieldName
|
||||
case profileFieldValue
|
||||
case profileCardName
|
||||
case profileCardUsername
|
||||
case recommendAccountName
|
||||
case titleView
|
||||
case settingTableFooter
|
||||
|
@ -51,7 +53,7 @@ extension MetaLabel {
|
|||
textColor = Asset.Colors.Label.secondary.color
|
||||
|
||||
case .statusName:
|
||||
font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 15, weight: .bold))
|
||||
font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 15, weight: .semibold))
|
||||
textColor = Asset.Colors.Label.primary.color
|
||||
|
||||
case .statusUsername:
|
||||
|
@ -80,6 +82,14 @@ extension MetaLabel {
|
|||
font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 17, weight: .regular))
|
||||
textColor = Asset.Colors.Label.primary.color
|
||||
|
||||
case .profileCardName:
|
||||
font = .systemFont(ofSize: 17, weight: .semibold)
|
||||
textColor = Asset.Colors.Label.primary.color
|
||||
|
||||
case .profileCardUsername:
|
||||
font = .systemFont(ofSize: 15, weight: .regular)
|
||||
textColor = Asset.Colors.Label.secondary.color
|
||||
|
||||
case .titleView:
|
||||
font = .systemFont(ofSize: 17, weight: .semibold)
|
||||
textColor = Asset.Colors.Label.primary.color
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
}
|
|
@ -7,8 +7,8 @@
|
|||
|
||||
import Foundation
|
||||
|
||||
final public class MastodonMetricFormatter: Formatter {
|
||||
|
||||
public final class MastodonMetricFormatter: Formatter {
|
||||
|
||||
public func string(from number: Int) -> String? {
|
||||
let isPositive = number >= 0
|
||||
let symbol = isPositive ? "" : "-"
|
|
@ -7,6 +7,7 @@
|
|||
|
||||
import UIKit
|
||||
import MastodonAsset
|
||||
import MastodonCommon
|
||||
|
||||
struct MastodonTheme: Theme {
|
||||
|
|
@ -7,6 +7,7 @@
|
|||
|
||||
import UIKit
|
||||
import MastodonAsset
|
||||
import MastodonCommon
|
||||
|
||||
struct SystemTheme: Theme {
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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<Theme, Never>
|
||||
public let currentTheme: CurrentValueSubject<Theme, Never>
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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.Plural.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
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,88 @@
|
|||
//
|
||||
// ProfileCardView+Configuration.swift
|
||||
//
|
||||
//
|
||||
// Created by MainasuK on 2022-4-14.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Combine
|
||||
import CoreDataStack
|
||||
import Meta
|
||||
import MastodonMeta
|
||||
|
||||
extension ProfileCardView {
|
||||
|
||||
public func configure(user: MastodonUser) {
|
||||
// banner
|
||||
user.publisher(for: \.header)
|
||||
.map { URL(string: $0) }
|
||||
.assign(to: \.authorBannerImageURL, on: viewModel)
|
||||
.store(in: &disposeBag)
|
||||
// author avatar
|
||||
Publishers.CombineLatest3(
|
||||
user.publisher(for: \.avatar),
|
||||
user.publisher(for: \.avatarStatic),
|
||||
UserDefaults.shared.publisher(for: \.preferredStaticAvatar)
|
||||
)
|
||||
.map { _ in user.avatarImageURL() }
|
||||
.assign(to: \.authorAvatarImageURL, on: viewModel)
|
||||
.store(in: &disposeBag)
|
||||
// name
|
||||
Publishers.CombineLatest(
|
||||
user.publisher(for: \.displayName),
|
||||
user.publisher(for: \.emojis)
|
||||
)
|
||||
.map { _, emojis in
|
||||
do {
|
||||
let content = MastodonContent(content: user.displayNameWithFallback, emojis: emojis.asDictionary)
|
||||
let metaContent = try MastodonMetaContent.convert(document: content)
|
||||
return metaContent
|
||||
} catch {
|
||||
assertionFailure(error.localizedDescription)
|
||||
return PlaintextMetaContent(string: user.displayNameWithFallback)
|
||||
}
|
||||
}
|
||||
.assign(to: \.authorName, on: viewModel)
|
||||
.store(in: &disposeBag)
|
||||
// username
|
||||
user.publisher(for: \.acct)
|
||||
.map { $0 as String? }
|
||||
.assign(to: \.authorUsername, on: viewModel)
|
||||
.store(in: &disposeBag)
|
||||
// bio
|
||||
Publishers.CombineLatest(
|
||||
user.publisher(for: \.note),
|
||||
user.publisher(for: \.emojis)
|
||||
)
|
||||
.map { note, emojis in
|
||||
guard let note = note else { return nil }
|
||||
do {
|
||||
let content = MastodonContent(content: note, emojis: emojis.asDictionary)
|
||||
let metaContent = try MastodonMetaContent.convert(document: content)
|
||||
return metaContent
|
||||
} catch {
|
||||
assertionFailure(error.localizedDescription)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
.assign(to: \.bioContent, on: viewModel)
|
||||
.store(in: &disposeBag)
|
||||
// relationship
|
||||
viewModel.relationshipViewModel.user = user
|
||||
// dashboard
|
||||
user.publisher(for: \.statusesCount)
|
||||
.map { Int($0) }
|
||||
.assign(to: \.statusesCount, on: viewModel)
|
||||
.store(in: &disposeBag)
|
||||
user.publisher(for: \.followingCount)
|
||||
.map { Int($0) }
|
||||
.assign(to: \.followingCount, on: viewModel)
|
||||
.store(in: &disposeBag)
|
||||
user.publisher(for: \.followersCount)
|
||||
.map { Int($0) }
|
||||
.assign(to: \.followersCount, on: viewModel)
|
||||
.store(in: &disposeBag)
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,188 @@
|
|||
//
|
||||
// ProfileCardView+ViewModel.swift
|
||||
//
|
||||
//
|
||||
// Created by MainasuK on 2022-4-14.
|
||||
//
|
||||
|
||||
import os.log
|
||||
import UIKit
|
||||
import Combine
|
||||
import Meta
|
||||
import AlamofireImage
|
||||
import CoreDataStack
|
||||
import MastodonLocalization
|
||||
import MastodonAsset
|
||||
|
||||
extension ProfileCardView {
|
||||
public class ViewModel: ObservableObject {
|
||||
let logger = Logger(subsystem: "ProfileCardView", category: "ViewModel")
|
||||
var disposeBag = Set<AnyCancellable>()
|
||||
|
||||
public let relationshipViewModel = RelationshipViewModel()
|
||||
|
||||
@Published public var userInterfaceStyle: UIUserInterfaceStyle?
|
||||
@Published public var backgroundColor: UIColor?
|
||||
|
||||
// Author
|
||||
@Published public var authorBannerImageURL: URL?
|
||||
@Published public var authorAvatarImageURL: URL?
|
||||
@Published public var authorName: MetaContent?
|
||||
@Published public var authorUsername: String?
|
||||
|
||||
@Published public var bioContent: MetaContent?
|
||||
|
||||
@Published public var statusesCount: Int?
|
||||
@Published public var followingCount: Int?
|
||||
@Published public var followersCount: Int?
|
||||
|
||||
@Published public var isUpdating = false
|
||||
@Published public var isFollowedBy = false
|
||||
@Published public var isMuting = false
|
||||
@Published public var isBlocking = false
|
||||
@Published public var isBlockedBy = false
|
||||
|
||||
init() {
|
||||
backgroundColor = ThemeService.shared.currentTheme.value.systemBackgroundColor
|
||||
Publishers.CombineLatest(
|
||||
ThemeService.shared.currentTheme,
|
||||
$userInterfaceStyle
|
||||
)
|
||||
.sink { [weak self] theme, userInterfaceStyle in
|
||||
guard let self = self else { return }
|
||||
guard let userInterfaceStyle = userInterfaceStyle else { return }
|
||||
switch userInterfaceStyle {
|
||||
case .dark:
|
||||
self.backgroundColor = theme.systemBackgroundColor
|
||||
case .light, .unspecified:
|
||||
self.backgroundColor = Asset.Scene.Discovery.profileCardBackground.color
|
||||
@unknown default:
|
||||
self.backgroundColor = Asset.Scene.Discovery.profileCardBackground.color
|
||||
assertionFailure()
|
||||
// do nothing
|
||||
}
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension ProfileCardView.ViewModel {
|
||||
func bind(view: ProfileCardView) {
|
||||
bindAppearacne(view: view)
|
||||
bindHeader(view: view)
|
||||
bindUser(view: view)
|
||||
bindBio(view: view)
|
||||
bindRelationship(view: view)
|
||||
bindDashboard(view: view)
|
||||
}
|
||||
|
||||
private func bindAppearacne(view: ProfileCardView) {
|
||||
userInterfaceStyle = view.traitCollection.userInterfaceStyle
|
||||
|
||||
$backgroundColor
|
||||
.assign(to: \.backgroundColor, on: view.container)
|
||||
.store(in: &disposeBag)
|
||||
$backgroundColor
|
||||
.assign(to: \.backgroundColor, on: view.avatarButtonBackgroundView)
|
||||
.store(in: &disposeBag)
|
||||
}
|
||||
|
||||
|
||||
private func bindHeader(view: ProfileCardView) {
|
||||
$authorBannerImageURL
|
||||
.sink { url in
|
||||
guard let url = url else { return }
|
||||
view.bannerImageView.af.setImage(
|
||||
withURL: url,
|
||||
placeholderImage: .placeholder(color: .systemGray3),
|
||||
imageTransition: .crossDissolve(0.3)
|
||||
)
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
}
|
||||
|
||||
private func bindUser(view: ProfileCardView) {
|
||||
$authorAvatarImageURL
|
||||
.sink { url in
|
||||
view.avatarButton.avatarImageView.configure(
|
||||
configuration: .init(
|
||||
url: url,
|
||||
placeholder: .placeholder(color: .systemGray3)
|
||||
)
|
||||
)
|
||||
view.avatarButton.avatarImageView.configure(
|
||||
cornerConfiguration: .init(corner: .fixed(radius: 12))
|
||||
)
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
|
||||
// name
|
||||
$authorName
|
||||
.sink { metaContent in
|
||||
let metaContent = metaContent ?? PlaintextMetaContent(string: " ")
|
||||
view.authorNameLabel.configure(content: metaContent)
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
// username
|
||||
$authorUsername
|
||||
.map { text -> String in
|
||||
guard let text = text else { return "" }
|
||||
return "@\(text)"
|
||||
}
|
||||
.sink { username in
|
||||
let metaContent = PlaintextMetaContent(string: username)
|
||||
view.authorUsernameLabel.configure(content: metaContent)
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
}
|
||||
|
||||
private func bindBio(view: ProfileCardView) {
|
||||
$bioContent
|
||||
.sink { metaContent in
|
||||
let metaContent = metaContent ?? PlaintextMetaContent(string: " ")
|
||||
view.bioMetaText.configure(content: metaContent)
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
}
|
||||
|
||||
private func bindRelationship(view: ProfileCardView) {
|
||||
relationshipViewModel.$optionSet
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { relationshipActionSet in
|
||||
let relationshipActionSet = relationshipActionSet ?? .follow
|
||||
view.relationshipActionButton.configure(actionOptionSet: relationshipActionSet)
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
}
|
||||
|
||||
private func bindDashboard(view: ProfileCardView) {
|
||||
$statusesCount
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { count in
|
||||
let text = count.flatMap { MastodonMetricFormatter().string(from: $0) } ?? "-"
|
||||
view.statusDashboardView.postDashboardMeterView.numberLabel.text = text
|
||||
view.statusDashboardView.postDashboardMeterView.isAccessibilityElement = true
|
||||
view.statusDashboardView.postDashboardMeterView.accessibilityLabel = L10n.Plural.Count.post(count ?? 0)
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
$followingCount
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { count in
|
||||
let text = count.flatMap { MastodonMetricFormatter().string(from: $0) } ?? "-"
|
||||
view.statusDashboardView.followingDashboardMeterView.numberLabel.text = text
|
||||
view.statusDashboardView.followingDashboardMeterView.isAccessibilityElement = true
|
||||
view.statusDashboardView.followingDashboardMeterView.accessibilityLabel = L10n.Plural.Count.following(count ?? 0)
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
$followersCount
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { count in
|
||||
let text = count.flatMap { MastodonMetricFormatter().string(from: $0) } ?? "-"
|
||||
view.statusDashboardView.followersDashboardMeterView.numberLabel.text = text
|
||||
view.statusDashboardView.followersDashboardMeterView.isAccessibilityElement = true
|
||||
view.statusDashboardView.followersDashboardMeterView.accessibilityLabel = L10n.Plural.Count.follower(count ?? 0)
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,255 @@
|
|||
//
|
||||
// ProfileCardView.swift
|
||||
//
|
||||
//
|
||||
// Created by MainasuK on 2022-4-14.
|
||||
//
|
||||
|
||||
import os.log
|
||||
import UIKit
|
||||
import Combine
|
||||
import MetaTextKit
|
||||
import MastodonAsset
|
||||
|
||||
public protocol ProfileCardViewDelegate: AnyObject {
|
||||
func profileCardView(_ profileCardView: ProfileCardView, relationshipButtonDidPressed button: ProfileRelationshipActionButton)
|
||||
}
|
||||
|
||||
public final class ProfileCardView: UIView {
|
||||
|
||||
static let avatarSize = CGSize(width: 56, height: 56)
|
||||
static let friendshipActionButtonSize = CGSize(width: 108, height: 34)
|
||||
static let contentMargin: CGFloat = 16
|
||||
|
||||
weak var delegate: ProfileCardViewDelegate?
|
||||
private var _disposeBag = Set<AnyCancellable>()
|
||||
var disposeBag = Set<AnyCancellable>()
|
||||
|
||||
let container = UIStackView()
|
||||
|
||||
let bannerImageView: UIImageView = {
|
||||
let imageView = UIImageView()
|
||||
imageView.contentMode = .scaleAspectFill
|
||||
imageView.layer.masksToBounds = true
|
||||
imageView.layer.cornerRadius = 3
|
||||
imageView.layer.cornerCurve = .continuous
|
||||
return imageView
|
||||
}()
|
||||
|
||||
// avatar
|
||||
public let avatarButtonBackgroundView = UIView()
|
||||
public let avatarButton = AvatarButton()
|
||||
|
||||
// author name
|
||||
public let authorNameLabel = MetaLabel(style: .profileCardName)
|
||||
|
||||
// author username
|
||||
public let authorUsernameLabel = MetaLabel(style: .profileCardUsername)
|
||||
|
||||
let bioMetaText: MetaText = {
|
||||
let metaText = MetaText()
|
||||
metaText.textView.backgroundColor = .clear
|
||||
metaText.textView.isEditable = false
|
||||
metaText.textView.isSelectable = true
|
||||
metaText.textView.isScrollEnabled = false
|
||||
//metaText.textView.textContainer.lineFragmentPadding = 0
|
||||
//metaText.textView.textContainerInset = .zero
|
||||
metaText.textView.layer.masksToBounds = false
|
||||
metaText.textView.textDragInteraction?.isEnabled = false // disable drag for link and attachment
|
||||
|
||||
metaText.textView.layer.masksToBounds = true
|
||||
metaText.textView.layer.cornerCurve = .continuous
|
||||
metaText.textView.layer.cornerRadius = 10
|
||||
|
||||
metaText.paragraphStyle = {
|
||||
let style = NSMutableParagraphStyle()
|
||||
style.lineSpacing = 5
|
||||
style.paragraphSpacing = 8
|
||||
return style
|
||||
}()
|
||||
metaText.textAttributes = [
|
||||
.font: UIFont.preferredFont(forTextStyle: .body),
|
||||
.foregroundColor: Asset.Colors.Label.primary.color,
|
||||
]
|
||||
metaText.linkAttributes = [
|
||||
.font: UIFont.preferredFont(forTextStyle: .body),
|
||||
.foregroundColor: Asset.Colors.brandBlue.color,
|
||||
]
|
||||
return metaText
|
||||
}()
|
||||
|
||||
let statusDashboardView = ProfileStatusDashboardView()
|
||||
|
||||
let relationshipActionButtonShadowContainer = ShadowBackgroundContainer()
|
||||
let relationshipActionButton: ProfileRelationshipActionButton = {
|
||||
let button = ProfileRelationshipActionButton()
|
||||
button.titleLabel?.font = .systemFont(ofSize: 17, weight: .semibold)
|
||||
button.titleLabel?.adjustsFontSizeToFitWidth = true
|
||||
button.titleLabel?.minimumScaleFactor = 0.5
|
||||
return button
|
||||
}()
|
||||
|
||||
public private(set) lazy var viewModel: ViewModel = {
|
||||
let viewModel = ViewModel()
|
||||
viewModel.bind(view: self)
|
||||
return viewModel
|
||||
}()
|
||||
|
||||
public func prepareForReuse() {
|
||||
disposeBag.removeAll()
|
||||
bannerImageView.af.cancelImageRequest()
|
||||
bannerImageView.image = nil
|
||||
}
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
_init()
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
super.init(coder: coder)
|
||||
_init()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension ProfileCardView {
|
||||
private func _init() {
|
||||
avatarButton.isUserInteractionEnabled = false
|
||||
authorNameLabel.isUserInteractionEnabled = false
|
||||
authorUsernameLabel.isUserInteractionEnabled = false
|
||||
bioMetaText.textView.isUserInteractionEnabled = false
|
||||
statusDashboardView.isUserInteractionEnabled = false
|
||||
|
||||
// container: V - [ bannerContainer | authorContainer | bioMetaText | infoContainer ]
|
||||
container.axis = .vertical
|
||||
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),
|
||||
])
|
||||
|
||||
// bannerContainer
|
||||
let bannerContainer = UIView()
|
||||
bannerContainer.translatesAutoresizingMaskIntoConstraints = false
|
||||
container.addArrangedSubview(bannerContainer)
|
||||
container.setCustomSpacing(6, after: bannerContainer)
|
||||
|
||||
// bannerImageView
|
||||
bannerImageView.translatesAutoresizingMaskIntoConstraints = false
|
||||
bannerContainer.addSubview(bannerImageView)
|
||||
NSLayoutConstraint.activate([
|
||||
bannerImageView.topAnchor.constraint(equalTo: bannerContainer.topAnchor, constant: 4),
|
||||
bannerImageView.leadingAnchor.constraint(equalTo: bannerContainer.leadingAnchor, constant: 4),
|
||||
bannerContainer.trailingAnchor.constraint(equalTo: bannerImageView.trailingAnchor, constant: 4),
|
||||
bannerImageView.bottomAnchor.constraint(equalTo: bannerContainer.bottomAnchor),
|
||||
bannerImageView.widthAnchor.constraint(equalTo: bannerImageView.heightAnchor, multiplier: 335.0/128.0).priority(.required - 1),
|
||||
])
|
||||
|
||||
// authorContainer: H - [ avatarPlaceholder | authorInfoContainer ]
|
||||
let authorContainer = UIStackView()
|
||||
authorContainer.axis = .horizontal
|
||||
authorContainer.spacing = 16
|
||||
let authorContainerAdaptiveMarginContainerView = AdaptiveMarginContainerView()
|
||||
authorContainerAdaptiveMarginContainerView.contentView = authorContainer
|
||||
authorContainerAdaptiveMarginContainerView.margin = ProfileCardView.contentMargin
|
||||
container.addArrangedSubview(authorContainerAdaptiveMarginContainerView)
|
||||
|
||||
// avatarPlaceholder
|
||||
let avatarPlaceholder = UIView()
|
||||
avatarPlaceholder.translatesAutoresizingMaskIntoConstraints = false
|
||||
authorContainer.addArrangedSubview(avatarPlaceholder)
|
||||
NSLayoutConstraint.activate([
|
||||
avatarPlaceholder.widthAnchor.constraint(equalToConstant: ProfileCardView.avatarSize.width).priority(.required - 1),
|
||||
avatarPlaceholder.heightAnchor.constraint(equalToConstant: ProfileCardView.avatarSize.height - 14).priority(.required - 1),
|
||||
])
|
||||
|
||||
avatarButton.translatesAutoresizingMaskIntoConstraints = false
|
||||
authorContainer.addSubview(avatarButton)
|
||||
NSLayoutConstraint.activate([
|
||||
avatarButton.leadingAnchor.constraint(equalTo: avatarPlaceholder.leadingAnchor),
|
||||
avatarButton.trailingAnchor.constraint(equalTo: avatarPlaceholder.trailingAnchor),
|
||||
avatarButton.bottomAnchor.constraint(equalTo: avatarPlaceholder.bottomAnchor),
|
||||
avatarButton.heightAnchor.constraint(equalToConstant: ProfileCardView.avatarSize.height).priority(.required - 1),
|
||||
])
|
||||
|
||||
avatarButtonBackgroundView.layer.masksToBounds = true
|
||||
avatarButtonBackgroundView.layer.cornerCurve = .continuous
|
||||
avatarButtonBackgroundView.layer.cornerRadius = 12
|
||||
avatarButtonBackgroundView.translatesAutoresizingMaskIntoConstraints = false
|
||||
authorContainer.insertSubview(avatarButtonBackgroundView, belowSubview: avatarButton)
|
||||
NSLayoutConstraint.activate([
|
||||
avatarButtonBackgroundView.centerXAnchor.constraint(equalTo: avatarButton.centerXAnchor),
|
||||
avatarButtonBackgroundView.centerYAnchor.constraint(equalTo: avatarButton.centerYAnchor),
|
||||
avatarButtonBackgroundView.widthAnchor.constraint(equalToConstant: ProfileCardView.avatarSize.width + 4).priority(.required - 1),
|
||||
avatarButtonBackgroundView.heightAnchor.constraint(equalToConstant: ProfileCardView.avatarSize.height + 4).priority(.required - 1),
|
||||
])
|
||||
|
||||
// authorInfoContainer: V - [ authorNameLabel | authorUsernameLabel ]
|
||||
let authorInfoContainer = UIStackView()
|
||||
authorInfoContainer.axis = .vertical
|
||||
authorInfoContainer.spacing = 2
|
||||
authorContainer.addArrangedSubview(authorInfoContainer)
|
||||
|
||||
authorInfoContainer.addArrangedSubview(authorNameLabel)
|
||||
authorInfoContainer.addArrangedSubview(authorUsernameLabel)
|
||||
|
||||
// bioMetaText
|
||||
let bioMetaTextAdaptiveMarginContainerView = AdaptiveMarginContainerView()
|
||||
bioMetaTextAdaptiveMarginContainerView.contentView = bioMetaText.textView
|
||||
bioMetaTextAdaptiveMarginContainerView.margin = ProfileCardView.contentMargin
|
||||
container.addArrangedSubview(bioMetaTextAdaptiveMarginContainerView)
|
||||
container.setCustomSpacing(16, after: bioMetaTextAdaptiveMarginContainerView)
|
||||
|
||||
// infoContainer: H - [ statusDashboardView | (spacer) | relationshipActionButton ]
|
||||
let infoContainer = UIStackView()
|
||||
infoContainer.axis = .horizontal
|
||||
let infoContainerAdaptiveMarginContainerView = AdaptiveMarginContainerView()
|
||||
infoContainerAdaptiveMarginContainerView.contentView = infoContainer
|
||||
infoContainerAdaptiveMarginContainerView.margin = ProfileCardView.contentMargin
|
||||
container.addArrangedSubview(infoContainerAdaptiveMarginContainerView)
|
||||
infoContainer.addArrangedSubview(statusDashboardView)
|
||||
infoContainer.addArrangedSubview(UIView())
|
||||
let relationshipActionButtonShadowContainer = ShadowBackgroundContainer()
|
||||
infoContainer.addArrangedSubview(relationshipActionButtonShadowContainer)
|
||||
|
||||
relationshipActionButton.translatesAutoresizingMaskIntoConstraints = false
|
||||
relationshipActionButtonShadowContainer.addSubview(relationshipActionButton)
|
||||
NSLayoutConstraint.activate([
|
||||
relationshipActionButton.topAnchor.constraint(equalTo: relationshipActionButtonShadowContainer.topAnchor),
|
||||
relationshipActionButton.leadingAnchor.constraint(equalTo: relationshipActionButtonShadowContainer.leadingAnchor),
|
||||
relationshipActionButton.trailingAnchor.constraint(equalTo: relationshipActionButtonShadowContainer.trailingAnchor),
|
||||
relationshipActionButton.bottomAnchor.constraint(equalTo: relationshipActionButtonShadowContainer.bottomAnchor),
|
||||
relationshipActionButton.widthAnchor.constraint(greaterThanOrEqualToConstant: ProfileCardView.friendshipActionButtonSize.width).priority(.required - 1),
|
||||
relationshipActionButton.heightAnchor.constraint(equalToConstant: ProfileCardView.friendshipActionButtonSize.height).priority(.defaultHigh),
|
||||
])
|
||||
|
||||
let bottomPadding = UIView()
|
||||
bottomPadding.translatesAutoresizingMaskIntoConstraints = false
|
||||
container.addArrangedSubview(bottomPadding)
|
||||
NSLayoutConstraint.activate([
|
||||
bottomPadding.heightAnchor.constraint(equalToConstant: 16)
|
||||
])
|
||||
|
||||
relationshipActionButton.addTarget(self, action: #selector(ProfileCardView.relationshipActionButtonDidPressed(_:)), for: .touchUpInside)
|
||||
}
|
||||
|
||||
public override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
|
||||
super.traitCollectionDidChange(previousTraitCollection)
|
||||
|
||||
viewModel.userInterfaceStyle = traitCollection.userInterfaceStyle
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension ProfileCardView {
|
||||
@objc private func relationshipActionButtonDidPressed(_ sender: UIButton) {
|
||||
os_log(.debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
|
||||
assert(sender === relationshipActionButton)
|
||||
delegate?.profileCardView(self, relationshipButtonDidPressed: relationshipActionButton)
|
||||
}
|
||||
}
|
|
@ -14,6 +14,7 @@ import MastodonSDK
|
|||
import MastodonAsset
|
||||
import MastodonLocalization
|
||||
import MastodonExtension
|
||||
import MastodonCommon
|
||||
import CoreDataStack
|
||||
|
||||
extension StatusView {
|
||||
|
|
|
@ -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.Plural.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: ", ")
|
||||
}
|
||||
}
|
|
@ -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),
|
||||
])
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -6,23 +6,23 @@
|
|||
//
|
||||
|
||||
import UIKit
|
||||
import MastodonUI
|
||||
import MastodonAsset
|
||||
import MastodonLocalization
|
||||
|
||||
final class ProfileRelationshipActionButton: RoundedEdgesButton {
|
||||
public final class ProfileRelationshipActionButton: RoundedEdgesButton {
|
||||
|
||||
let activityIndicatorView: UIActivityIndicatorView = {
|
||||
public let activityIndicatorView: UIActivityIndicatorView = {
|
||||
let activityIndicatorView = UIActivityIndicatorView(style: .medium)
|
||||
activityIndicatorView.color = Asset.Colors.Label.primaryReverse.color
|
||||
return activityIndicatorView
|
||||
}()
|
||||
|
||||
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()
|
||||
}
|
||||
|
@ -47,7 +47,7 @@ extension ProfileRelationshipActionButton {
|
|||
configureAppearance()
|
||||
}
|
||||
|
||||
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
|
||||
public override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
|
||||
super.traitCollectionDidChange(previousTraitCollection)
|
||||
|
||||
configureAppearance()
|
||||
|
@ -55,7 +55,7 @@ extension ProfileRelationshipActionButton {
|
|||
}
|
||||
|
||||
extension ProfileRelationshipActionButton {
|
||||
func configure(actionOptionSet: ProfileViewModel.RelationshipActionOptionSet) {
|
||||
public func configure(actionOptionSet: RelationshipActionOptionSet) {
|
||||
setTitle(actionOptionSet.title, for: .normal)
|
||||
|
||||
configureAppearance()
|
||||
|
@ -87,9 +87,5 @@ extension ProfileRelationshipActionButton {
|
|||
setBackgroundImage(.placeholder(color: Asset.Scene.Profile.RelationshipButton.backgroundHighlightedLight.color), for: .highlighted)
|
||||
setBackgroundImage(.placeholder(color: Asset.Scene.Profile.RelationshipButton.backgroundHighlightedLight.color), for: .disabled)
|
||||
}
|
||||
// setBackgroundImage(.placeholder(color: actionOptionSet.backgroundColor), for: .normal)
|
||||
// setBackgroundImage(.placeholder(color: actionOptionSet.backgroundColor.withAlphaComponent(0.5)), for: .highlighted)
|
||||
// setBackgroundImage(.placeholder(color: actionOptionSet.backgroundColor.withAlphaComponent(0.5)), for: .disabled)
|
||||
}
|
||||
}
|
||||
|
|
@ -9,9 +9,9 @@ import UIKit
|
|||
import MastodonAsset
|
||||
import MastodonLocalization
|
||||
|
||||
final class ProfileStatusDashboardMeterView: UIView {
|
||||
public final class ProfileStatusDashboardMeterView: UIView {
|
||||
|
||||
let numberLabel: UILabel = {
|
||||
public let numberLabel: UILabel = {
|
||||
let label = UILabel()
|
||||
label.font = {
|
||||
let font = UIFont.systemFont(ofSize: 20, weight: .semibold)
|
||||
|
@ -25,7 +25,7 @@ final class ProfileStatusDashboardMeterView: UIView {
|
|||
return label
|
||||
}()
|
||||
|
||||
let textLabel: UILabel = {
|
||||
public let textLabel: UILabel = {
|
||||
let label = UILabel()
|
||||
label.font = .systemFont(ofSize: 13, weight: .regular)
|
||||
label.textColor = Asset.Colors.Label.primary.color
|
||||
|
@ -41,12 +41,12 @@ final class ProfileStatusDashboardMeterView: UIView {
|
|||
return label
|
||||
}()
|
||||
|
||||
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()
|
||||
}
|
|
@ -10,24 +10,24 @@ import UIKit
|
|||
import MastodonAsset
|
||||
import MastodonLocalization
|
||||
|
||||
protocol ProfileStatusDashboardViewDelegate: AnyObject {
|
||||
public protocol ProfileStatusDashboardViewDelegate: AnyObject {
|
||||
func profileStatusDashboardView(_ dashboardView: ProfileStatusDashboardView, dashboardMeterViewDidPressed dashboardMeterView: ProfileStatusDashboardMeterView, meter: ProfileStatusDashboardView.Meter)
|
||||
}
|
||||
|
||||
final class ProfileStatusDashboardView: UIView {
|
||||
public final class ProfileStatusDashboardView: UIView {
|
||||
|
||||
let postDashboardMeterView = ProfileStatusDashboardMeterView()
|
||||
let followingDashboardMeterView = ProfileStatusDashboardMeterView()
|
||||
let followersDashboardMeterView = ProfileStatusDashboardMeterView()
|
||||
public let postDashboardMeterView = ProfileStatusDashboardMeterView()
|
||||
public let followingDashboardMeterView = ProfileStatusDashboardMeterView()
|
||||
public let followersDashboardMeterView = ProfileStatusDashboardMeterView()
|
||||
|
||||
weak var delegate: ProfileStatusDashboardViewDelegate?
|
||||
public weak var delegate: ProfileStatusDashboardViewDelegate?
|
||||
|
||||
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()
|
||||
}
|
||||
|
@ -35,7 +35,7 @@ final class ProfileStatusDashboardView: UIView {
|
|||
}
|
||||
|
||||
extension ProfileStatusDashboardView {
|
||||
enum Meter: Hashable {
|
||||
public enum Meter: Hashable {
|
||||
case post
|
||||
case following
|
||||
case follower
|
||||
|
@ -83,7 +83,7 @@ extension ProfileStatusDashboardView {
|
|||
|
||||
extension ProfileStatusDashboardView {
|
||||
@objc private func tapGestureRecognizerHandler(_ sender: UITapGestureRecognizer) {
|
||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
|
||||
os_log(.debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
|
||||
guard let sourceView = sender.view as? ProfileStatusDashboardMeterView else {
|
||||
assertionFailure()
|
||||
return
|
|
@ -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)),
|
||||
])
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,82 @@
|
|||
//
|
||||
// ProfileCardTableViewCell.swift
|
||||
//
|
||||
//
|
||||
// Created by MainasuK on 2022-4-14.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
import Combine
|
||||
|
||||
public protocol ProfileCardTableViewCellDelegate: AnyObject {
|
||||
func profileCardTableViewCell(_ cell: ProfileCardTableViewCell, profileCardView: ProfileCardView, relationshipButtonDidPressed button: ProfileRelationshipActionButton)
|
||||
}
|
||||
|
||||
public final class ProfileCardTableViewCell: UITableViewCell {
|
||||
|
||||
public weak var delegate: ProfileCardTableViewCellDelegate?
|
||||
public var disposeBag = Set<AnyCancellable>()
|
||||
|
||||
public let profileCardView: ProfileCardView = {
|
||||
let profileCardView = ProfileCardView()
|
||||
profileCardView.layer.masksToBounds = true
|
||||
profileCardView.layer.cornerRadius = 6
|
||||
profileCardView.layer.cornerCurve = .continuous
|
||||
return profileCardView
|
||||
}()
|
||||
|
||||
public override func prepareForReuse() {
|
||||
super.prepareForReuse()
|
||||
|
||||
disposeBag.removeAll()
|
||||
profileCardView.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 ProfileCardTableViewCell {
|
||||
|
||||
private func _init() {
|
||||
selectionStyle = .none
|
||||
|
||||
let shadowBackgroundContainer = ShadowBackgroundContainer()
|
||||
shadowBackgroundContainer.cornerRadius = 6
|
||||
shadowBackgroundContainer.translatesAutoresizingMaskIntoConstraints = false
|
||||
contentView.addSubview(shadowBackgroundContainer)
|
||||
NSLayoutConstraint.activate([
|
||||
shadowBackgroundContainer.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 10),
|
||||
shadowBackgroundContainer.leadingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.leadingAnchor),
|
||||
shadowBackgroundContainer.trailingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.trailingAnchor),
|
||||
contentView.bottomAnchor.constraint(equalTo: shadowBackgroundContainer.bottomAnchor, constant: 10),
|
||||
])
|
||||
|
||||
profileCardView.translatesAutoresizingMaskIntoConstraints = false
|
||||
contentView.addSubview(profileCardView)
|
||||
NSLayoutConstraint.activate([
|
||||
profileCardView.topAnchor.constraint(equalTo: shadowBackgroundContainer.topAnchor),
|
||||
profileCardView.leadingAnchor.constraint(equalTo: shadowBackgroundContainer.leadingAnchor),
|
||||
profileCardView.trailingAnchor.constraint(equalTo: shadowBackgroundContainer.trailingAnchor),
|
||||
profileCardView.bottomAnchor.constraint(equalTo: shadowBackgroundContainer.bottomAnchor),
|
||||
])
|
||||
|
||||
profileCardView.delegate = self
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - ProfileCardViewDelegate
|
||||
extension ProfileCardTableViewCell: ProfileCardViewDelegate {
|
||||
public func profileCardView(_ profileCardView: ProfileCardView, relationshipButtonDidPressed button: ProfileRelationshipActionButton) {
|
||||
delegate?.profileCardTableViewCell(self, profileCardView: profileCardView, relationshipButtonDidPressed: button)
|
||||
}
|
||||
}
|
|
@ -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)),
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,253 @@
|
|||
//
|
||||
// RelationshipViewModel.swift
|
||||
//
|
||||
//
|
||||
// Created by MainasuK on 2022-4-14.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
import Combine
|
||||
import MastodonAsset
|
||||
import MastodonLocalization
|
||||
import CoreDataStack
|
||||
|
||||
public enum RelationshipAction: Int, CaseIterable {
|
||||
case isMyself
|
||||
case followingBy
|
||||
case blockingBy
|
||||
case none // set hide from UI
|
||||
case follow
|
||||
case request
|
||||
case pending
|
||||
case following
|
||||
case muting
|
||||
case blocked
|
||||
case blocking
|
||||
case suspended
|
||||
case edit
|
||||
case editing
|
||||
case updating
|
||||
|
||||
public var option: RelationshipActionOptionSet {
|
||||
return RelationshipActionOptionSet(rawValue: 1 << rawValue)
|
||||
}
|
||||
}
|
||||
|
||||
// construct option set on the enum for safe iterator
|
||||
public struct RelationshipActionOptionSet: OptionSet {
|
||||
|
||||
public let rawValue: Int
|
||||
|
||||
public init(rawValue: Int) {
|
||||
self.rawValue = rawValue
|
||||
}
|
||||
|
||||
public static let isMyself = RelationshipAction.isMyself.option
|
||||
public static let followingBy = RelationshipAction.followingBy.option
|
||||
public static let blockingBy = RelationshipAction.blockingBy.option
|
||||
public static let none = RelationshipAction.none.option
|
||||
public static let follow = RelationshipAction.follow.option
|
||||
public static let request = RelationshipAction.request.option
|
||||
public static let pending = RelationshipAction.pending.option
|
||||
public static let following = RelationshipAction.following.option
|
||||
public static let muting = RelationshipAction.muting.option
|
||||
public static let blocked = RelationshipAction.blocked.option
|
||||
public static let blocking = RelationshipAction.blocking.option
|
||||
public static let suspended = RelationshipAction.suspended.option
|
||||
public static let edit = RelationshipAction.edit.option
|
||||
public static let editing = RelationshipAction.editing.option
|
||||
public static let updating = RelationshipAction.updating.option
|
||||
|
||||
public static let editOptions: RelationshipActionOptionSet = [.edit, .editing, .updating]
|
||||
|
||||
public func highPriorityAction(except: RelationshipActionOptionSet) -> RelationshipAction? {
|
||||
let set = subtracting(except)
|
||||
for action in RelationshipAction.allCases.reversed() where set.contains(action.option) {
|
||||
return action
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
public var title: String {
|
||||
guard let highPriorityAction = self.highPriorityAction(except: []) else {
|
||||
assertionFailure()
|
||||
return " "
|
||||
}
|
||||
switch highPriorityAction {
|
||||
case .isMyself: return ""
|
||||
case .followingBy: return " "
|
||||
case .blockingBy: return " "
|
||||
case .none: return " "
|
||||
case .follow: return L10n.Common.Controls.Friendship.follow
|
||||
case .request: return L10n.Common.Controls.Friendship.request
|
||||
case .pending: return L10n.Common.Controls.Friendship.pending
|
||||
case .following: return L10n.Common.Controls.Friendship.following
|
||||
case .muting: return L10n.Common.Controls.Friendship.muted
|
||||
case .blocked: return L10n.Common.Controls.Friendship.follow // blocked by user
|
||||
case .blocking: return L10n.Common.Controls.Friendship.blocked
|
||||
case .suspended: return L10n.Common.Controls.Friendship.follow
|
||||
case .edit: return L10n.Common.Controls.Friendship.editInfo
|
||||
case .editing: return L10n.Common.Controls.Actions.done
|
||||
case .updating: return " "
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public final class RelationshipViewModel {
|
||||
|
||||
var disposeBag = Set<AnyCancellable>()
|
||||
|
||||
public var userObserver: AnyCancellable?
|
||||
public var meObserver: AnyCancellable?
|
||||
|
||||
// input
|
||||
@Published public var user: MastodonUser?
|
||||
@Published public var me: MastodonUser?
|
||||
public let relationshipUpdatePublisher = CurrentValueSubject<Void, Never>(Void()) // needs initial event
|
||||
|
||||
// output
|
||||
@Published public var isMyself = false
|
||||
@Published public var optionSet: RelationshipActionOptionSet?
|
||||
|
||||
@Published public var isFollowing = false
|
||||
@Published public var isFollowingBy = false
|
||||
@Published public var isMuting = false
|
||||
@Published public var isBlocking = false
|
||||
@Published public var isBlockingBy = false
|
||||
|
||||
public init() {
|
||||
Publishers.CombineLatest3(
|
||||
$user,
|
||||
$me,
|
||||
relationshipUpdatePublisher
|
||||
)
|
||||
.sink { [weak self] user, me, _ in
|
||||
guard let self = self else { return }
|
||||
self.update(user: user, me: me)
|
||||
|
||||
guard let user = user, let me = me else {
|
||||
self.userObserver = nil
|
||||
self.meObserver = nil
|
||||
return
|
||||
}
|
||||
|
||||
// do not modify object to prevent infinity loop
|
||||
self.userObserver = RelationshipViewModel.createObjectChangePublisher(user: user)
|
||||
.sink { [weak self] _ in
|
||||
guard let self = self else { return }
|
||||
self.relationshipUpdatePublisher.send()
|
||||
}
|
||||
|
||||
self.meObserver = RelationshipViewModel.createObjectChangePublisher(user: me)
|
||||
.sink { [weak self] _ in
|
||||
guard let self = self else { return }
|
||||
self.relationshipUpdatePublisher.send()
|
||||
}
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension RelationshipViewModel {
|
||||
|
||||
public static func createObjectChangePublisher(user: MastodonUser) -> AnyPublisher<Void, Never> {
|
||||
return ManagedObjectObserver
|
||||
.observe(object: user)
|
||||
.map { _ in Void() }
|
||||
.catch { error in
|
||||
return Just(Void())
|
||||
}
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension RelationshipViewModel {
|
||||
private func update(user: MastodonUser?, me: MastodonUser?) {
|
||||
guard let user = user,
|
||||
let me = me
|
||||
else {
|
||||
reset()
|
||||
return
|
||||
}
|
||||
|
||||
let optionSet = RelationshipViewModel.optionSet(user: user, me: me)
|
||||
|
||||
self.isMyself = optionSet.contains(.isMyself)
|
||||
self.isFollowingBy = optionSet.contains(.followingBy)
|
||||
self.isFollowing = optionSet.contains(.following)
|
||||
self.isMuting = optionSet.contains(.muting)
|
||||
self.isBlockingBy = optionSet.contains(.blockingBy)
|
||||
self.isBlocking = optionSet.contains(.blocking)
|
||||
|
||||
|
||||
self.optionSet = optionSet
|
||||
}
|
||||
|
||||
private func reset() {
|
||||
isMyself = false
|
||||
isFollowingBy = false
|
||||
isFollowing = false
|
||||
isMuting = false
|
||||
isBlockingBy = false
|
||||
isBlocking = false
|
||||
optionSet = nil
|
||||
}
|
||||
}
|
||||
|
||||
extension RelationshipViewModel {
|
||||
|
||||
public static func optionSet(user: MastodonUser, me: MastodonUser) -> RelationshipActionOptionSet {
|
||||
let isMyself = user.id == me.id && user.domain == me.domain
|
||||
guard !isMyself else {
|
||||
return [.isMyself]
|
||||
}
|
||||
|
||||
let isProtected = user.locked
|
||||
let isFollowingBy = me.followingBy.contains(user)
|
||||
let isFollowing = user.followingBy.contains(me)
|
||||
let isPending = user.followRequestedBy.contains(me)
|
||||
let isMuting = user.mutingBy.contains(me)
|
||||
let isBlockingBy = me.blockingBy.contains(user)
|
||||
let isBlocking = user.blockingBy.contains(me)
|
||||
|
||||
var optionSet: RelationshipActionOptionSet = [.follow]
|
||||
|
||||
if isMyself {
|
||||
optionSet.insert(.isMyself)
|
||||
}
|
||||
|
||||
if isProtected {
|
||||
optionSet.insert(.request)
|
||||
}
|
||||
|
||||
if isFollowingBy {
|
||||
optionSet.insert(.followingBy)
|
||||
}
|
||||
|
||||
if isFollowing {
|
||||
optionSet.insert(.following)
|
||||
}
|
||||
|
||||
if isPending {
|
||||
optionSet.insert(.pending)
|
||||
}
|
||||
|
||||
if isMuting {
|
||||
optionSet.insert(.muting)
|
||||
}
|
||||
|
||||
if isBlockingBy {
|
||||
optionSet.insert(.blockingBy)
|
||||
}
|
||||
|
||||
if isBlocking {
|
||||
optionSet.insert(.blocking)
|
||||
}
|
||||
|
||||
return optionSet
|
||||
}
|
||||
}
|
|
@ -15,8 +15,8 @@
|
|||
<key>CFBundlePackageType</key>
|
||||
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.3.0</string>
|
||||
<string>1.3.1</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>109</string>
|
||||
<string>110</string>
|
||||
</dict>
|
||||
</plist>
|
||||
|
|
|
@ -15,8 +15,8 @@
|
|||
<key>CFBundlePackageType</key>
|
||||
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.3.0</string>
|
||||
<string>1.3.1</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>109</string>
|
||||
<string>110</string>
|
||||
</dict>
|
||||
</plist>
|
||||
|
|
|
@ -17,9 +17,9 @@
|
|||
<key>CFBundlePackageType</key>
|
||||
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.3.0</string>
|
||||
<string>1.3.1</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>109</string>
|
||||
<string>110</string>
|
||||
<key>NSExtension</key>
|
||||
<dict>
|
||||
<key>NSExtensionPointIdentifier</key>
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue