feat: add For You tab for Discovery scene

This commit is contained in:
CMK 2022-04-14 21:15:21 +08:00
parent 8e0d526708
commit b0fca49413
34 changed files with 1261 additions and 223 deletions

View File

@ -214,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 */; };
@ -232,6 +231,9 @@
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 */; };
@ -370,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 */; };
@ -502,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 */; };
@ -518,7 +517,6 @@
DBBC24CB26A546C000398BB9 /* ThemePreference.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBD376AB2692ECDB007FEC24 /* ThemePreference.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 */; };
@ -594,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 */; };
@ -945,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>"; };
@ -963,6 +959,9 @@
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>"; };
@ -1119,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>"; };
@ -1261,15 +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>"; };
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>"; };
@ -2147,6 +2142,16 @@
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 = (
@ -2378,7 +2383,6 @@
children = (
DBA465942696E387002B41DB /* AppPreference.swift */,
DB647C5826F1EA2700F7F82C /* WizardPreference.swift */,
DB6D1B3C2636857500ACB481 /* AppearancePreference.swift */,
DBE54AC52636C89F004E7C0B /* NotificationPreference.swift */,
DB1D842F26566512000346B3 /* KeyboardPreference.swift */,
DBCBCC0C2680B908000F5B51 /* HomeTimelinePreference.swift */,
@ -3040,9 +3044,6 @@
isa = PBXGroup;
children = (
DBB5254F2611ED6D002F1F29 /* ProfileHeaderView.swift */,
DBB5256D2612D5A1002F1F29 /* ProfileStatusDashboardView.swift */,
DBB525842612D6DD002F1F29 /* ProfileStatusDashboardMeterView.swift */,
DB35FC1E2612F1D9006193C9 /* ProfileRelationshipActionButton.swift */,
DBF98149265E24F500E4BA07 /* ProfileFieldCollectionViewHeaderFooterView.swift */,
);
path = View;
@ -3061,7 +3062,6 @@
children = (
DBBC24D626A54BCB00398BB9 /* MastodonRegex.swift */,
DBBC50C0278ED49200AF0CC6 /* MastodonAuthenticationBox.swift */,
DBBC24D826A54BCB00398BB9 /* MastodonMetricFormatter.swift */,
DBF3B7402733EB9400E21627 /* MastodonLocalCode.swift */,
);
path = Helper;
@ -3113,6 +3113,7 @@
DBDFF19828055A0900557A48 /* Posts */,
DB3E6FDE2806A41200B035AE /* Hashtags */,
DB3E6FED2806D7FC00B035AE /* News */,
DB3E6FF62807C40500B035AE /* ForYou */,
DBDFF19928055A1400557A48 /* DiscoveryViewController.swift */,
DBDFF19B28055BD600557A48 /* DiscoveryViewModel.swift */,
);
@ -3947,7 +3948,6 @@
DBDFF1952805561700557A48 /* DiscoveryPostsViewModel+Diffable.swift in Sources */,
DB03A795272A981400EE37C5 /* ContentSplitViewController.swift in Sources */,
DBDFF19C28055BD600557A48 /* DiscoveryViewModel.swift in Sources */,
DBBC24DE26A54BCB00398BB9 /* MastodonMetricFormatter.swift in Sources */,
DB06180A2785B2AB0030EE79 /* MastodonRegisterAvatarTableViewCell.swift in Sources */,
DBB45B6227B51112002DC5A7 /* SuggestionAccountViewModel+Diffable.swift in Sources */,
DBB3BA2A26A81C020004F2D4 /* FLAnimatedImageView.swift in Sources */,
@ -3974,7 +3974,6 @@
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 */,
@ -4066,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 */,
@ -4112,7 +4111,6 @@
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 */,
@ -4178,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 */,
@ -4311,6 +4310,7 @@
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 */,
@ -4327,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 */,
@ -4424,7 +4423,6 @@
DBBC24A826A52F9000398BB9 /* ComposeToolbarView.swift in Sources */,
DBFEF05B26A57715006D7ED1 /* ComposeViewModel.swift in Sources */,
DBFEF06326A577F2006D7ED1 /* StatusAttachmentViewModel.swift in Sources */,
DBFEF06926A67E45006D7ED1 /* AppearancePreference.swift in Sources */,
DBC6461526A170AB00B0E31B /* ShareViewController.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;

View File

@ -109,7 +109,7 @@
<key>MastodonIntent.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>23</integer>
<integer>22</integer>
</dict>
<key>MastodonIntents.xcscheme_^#shared#^_</key>
<dict>
@ -124,7 +124,7 @@
<key>NotificationService.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>22</integer>
<integer>23</integer>
</dict>
<key>ShareActionExtension.xcscheme_^#shared#^_</key>
<dict>

View File

@ -7,9 +7,11 @@
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
}

View File

@ -29,8 +29,9 @@ extension DiscoverySection {
) -> 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):
@ -41,6 +42,17 @@ extension DiscoverySection {
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)
return cell
case .bottomLoader:
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self), for: indexPath) as! TimelineBottomLoaderTableViewCell
cell.activityIndicatorView.startAnimating()

View File

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

View File

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

View File

@ -16,6 +16,7 @@ final class DiscoveryViewModel {
let discoveryPostsViewController: DiscoveryPostsViewController
let discoveryHashtagsViewController: DiscoveryHashtagsViewController
let discoveryNewsViewController: DiscoveryNewsViewController
let discoveryForYouViewController: DiscoveryForYouViewController
// output
let barItems: [TMBarItemable] = {
@ -33,6 +34,7 @@ final class DiscoveryViewModel {
discoveryPostsViewController,
discoveryHashtagsViewController,
discoveryNewsViewController,
discoveryForYouViewController,
]
}
@ -61,6 +63,12 @@ final class DiscoveryViewModel {
viewController.viewModel = DiscoveryNewsViewModel(context: context)
return viewController
}()
discoveryForYouViewController = {
let viewController = DiscoveryForYouViewController()
setupDependency(viewController)
viewController.viewModel = DiscoveryForYouViewModel(context: context)
return viewController
}()
// end init
}

View File

@ -0,0 +1,127 @@
//
// 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
)
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: ScrollViewContainer
extension DiscoveryForYouViewController: ScrollViewContainer {
var scrollView: UIScrollView? {
tableView
}
}

View File

@ -0,0 +1,43 @@
//
// DiscoveryForYouViewModel+Diffable.swift
// Mastodon
//
// Created by MainasuK on 2022-4-14.
//
import UIKit
import Combine
extension DiscoveryForYouViewModel {
func setupDiffableDataSource(
tableView: UITableView
) {
diffableDataSource = DiscoverySection.diffableDataSource(
tableView: tableView,
context: context,
configuration: DiscoverySection.Configuration()
)
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)
}
}

View File

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

View File

@ -70,8 +70,6 @@ extension DiscoveryNewsViewModel.State {
super.didEnter(from: previousState)
guard let viewModel = viewModel, let stateMachine = stateMachine else { return }
viewModel.links = []
stateMachine.enter(Loading.self)
}
}
@ -113,7 +111,6 @@ extension DiscoveryNewsViewModel.State {
class Loading: DiscoveryNewsViewModel.State {
var offset: Int?
var isReloading: Bool { return offset == nil }
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
switch stateClass {
@ -146,6 +143,7 @@ extension DiscoveryNewsViewModel.State {
}
let offset = self.offset
let isReloading = offset == nil
Task {
do {
@ -169,7 +167,7 @@ extension DiscoveryNewsViewModel.State {
self.offset = newOffset
var hasNewItemsAppend = false
var links = viewModel.links
var links = isReloading ? [] : viewModel.links
for link in response.value {
guard !links.contains(link) else { continue }
links.append(link)

View File

@ -68,10 +68,7 @@ extension DiscoveryPostsViewModel.State {
override func didEnter(from previousState: GKState?) {
super.didEnter(from: previousState)
guard let viewModel = viewModel, let stateMachine = stateMachine else { return }
// reset
viewModel.statusFetchedResultsController.statusIDs.value = []
guard let _ = viewModel, let stateMachine = stateMachine else { return }
stateMachine.enter(Loading.self)
}
@ -145,6 +142,7 @@ extension DiscoveryPostsViewModel.State {
}
let offset = self.offset
let isReloading = offset == nil
Task {
do {
@ -168,7 +166,7 @@ extension DiscoveryPostsViewModel.State {
self.offset = newOffset
var hasNewStatusesAppend = false
var statusIDs = viewModel.statusFetchedResultsController.statusIDs.value
var statusIDs = isReloading ? [] : viewModel.statusFetchedResultsController.statusIDs.value
for status in response.value {
guard !statusIDs.contains(status.id) else { continue }
statusIDs.append(status.id)

View File

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

View File

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

View File

@ -94,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"),
]
),

View File

@ -0,0 +1,9 @@
{
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"provides-namespace" : true
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 ? "" : "-"

View File

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

View File

@ -0,0 +1,148 @@
//
// 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
extension ProfileCardView {
public class ViewModel: ObservableObject {
let logger = Logger(subsystem: "ProfileCardView", category: "ViewModel")
var disposeBag = Set<AnyCancellable>()
public let relationshipViewModel = RelationshipViewModel()
// 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
}
}
extension ProfileCardView.ViewModel {
func bind(view: ProfileCardView) {
bindHeader(view: view)
bindUser(view: view)
bindBio(view: view)
bindRelationship(view: view)
bindDashboard(view: view)
}
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)
}
}

View File

@ -0,0 +1,235 @@
//
// ProfileCardView.swift
//
//
// Created by MainasuK on 2022-4-14.
//
import UIKit
import Combine
import MetaTextKit
import MastodonAsset
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
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 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.backgroundColor = Asset.Scene.Discovery.profileCardBackground.color
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),
])
let avatarButtonBackgroundView = UIView()
avatarButtonBackgroundView.backgroundColor = Asset.Scene.Discovery.profileCardBackground.color
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)
])
}
}

View File

@ -14,6 +14,7 @@ import MastodonSDK
import MastodonAsset
import MastodonLocalization
import MastodonExtension
import MastodonCommon
import CoreDataStack
extension StatusView {

View File

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

View File

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

View File

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

View File

@ -0,0 +1,68 @@
//
// ProfileCardTableViewCell.swift
//
//
// Created by MainasuK on 2022-4-14.
//
import UIKit
import Combine
public final class ProfileCardTableViewCell: UITableViewCell {
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),
])
}
}

View File

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