diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index 73332bab..678fc710 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -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 = ""; }; DB336F40278E68480031E64B /* StatusView+Configuration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "StatusView+Configuration.swift"; sourceTree = ""; }; DB336F42278EB1680031E64B /* MediaView+Configuration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MediaView+Configuration.swift"; sourceTree = ""; }; - DB35FC1E2612F1D9006193C9 /* ProfileRelationshipActionButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileRelationshipActionButton.swift; sourceTree = ""; }; DB36679C268AB91B0027D07F /* ComposeStatusAttachmentTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeStatusAttachmentTableViewCell.swift; sourceTree = ""; }; DB36679E268ABAF20027D07F /* ComposeStatusAttachmentSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeStatusAttachmentSection.swift; sourceTree = ""; }; DB3667A0268ABB2E0027D07F /* ComposeStatusAttachmentItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeStatusAttachmentItem.swift; sourceTree = ""; }; @@ -963,6 +959,9 @@ DB3E6FEE2806D82600B035AE /* DiscoveryNewsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiscoveryNewsViewModel.swift; sourceTree = ""; }; DB3E6FF02806D96900B035AE /* DiscoveryNewsViewModel+Diffable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DiscoveryNewsViewModel+Diffable.swift"; sourceTree = ""; }; DB3E6FF22806D97400B035AE /* DiscoveryNewsViewModel+State.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DiscoveryNewsViewModel+State.swift"; sourceTree = ""; }; + DB3E6FF42807C40300B035AE /* DiscoveryForYouViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiscoveryForYouViewController.swift; sourceTree = ""; }; + DB3E6FF72807C45300B035AE /* DiscoveryForYouViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiscoveryForYouViewModel.swift; sourceTree = ""; }; + DB3E6FF92807C47900B035AE /* DiscoveryForYouViewModel+Diffable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DiscoveryForYouViewModel+Diffable.swift"; sourceTree = ""; }; DB427DD225BAA00100D1B89D /* Mastodon.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Mastodon.app; sourceTree = BUILT_PRODUCTS_DIR; }; DB427DD525BAA00100D1B89D /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; DB427DD725BAA00100D1B89D /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; @@ -1119,7 +1118,6 @@ DB6B74FF272FF73800C70B6E /* UserTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserTableViewCell.swift; sourceTree = ""; }; DB6B750327300B4000C70B6E /* TimelineFooterTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineFooterTableViewCell.swift; sourceTree = ""; }; DB6C8C0E25F0A6AE00AAA452 /* Mastodon+Entity+Error.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Mastodon+Entity+Error.swift"; sourceTree = ""; }; - DB6D1B3C2636857500ACB481 /* AppearancePreference.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppearancePreference.swift; sourceTree = ""; }; DB6D1B43263691CF00ACB481 /* Mastodon+API+Subscriptions+Policy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Mastodon+API+Subscriptions+Policy.swift"; sourceTree = ""; }; DB6D9F3426351B7A008423CD /* NotificationService+Decrypt.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NotificationService+Decrypt.swift"; sourceTree = ""; }; DB6D9F4826353FD6008423CD /* Subscription.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Subscription.swift; sourceTree = ""; }; @@ -1261,15 +1259,12 @@ DBB525552611EDCA002F1F29 /* UserTimelineViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserTimelineViewModel.swift; sourceTree = ""; }; DBB5255D2611F07A002F1F29 /* ProfileViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileViewModel.swift; sourceTree = ""; }; DBB525632612C988002F1F29 /* MeProfileViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeProfileViewModel.swift; sourceTree = ""; }; - DBB5256D2612D5A1002F1F29 /* ProfileStatusDashboardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileStatusDashboardView.swift; sourceTree = ""; }; - DBB525842612D6DD002F1F29 /* ProfileStatusDashboardMeterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileStatusDashboardMeterView.swift; sourceTree = ""; }; DBB8AB4526AECDE200F6D281 /* SendPostIntentHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SendPostIntentHandler.swift; sourceTree = ""; }; DBB8AB4926AED0B500F6D281 /* APIService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIService.swift; sourceTree = ""; }; DBB9759B262462E1004620BD /* ThreadMetaView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadMetaView.swift; sourceTree = ""; }; DBBC24A726A52F9000398BB9 /* ComposeToolbarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeToolbarView.swift; sourceTree = ""; }; DBBC24AB26A53D9300398BB9 /* ComposeStatusContentTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeStatusContentTableViewCell.swift; sourceTree = ""; }; DBBC24D626A54BCB00398BB9 /* MastodonRegex.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MastodonRegex.swift; sourceTree = ""; }; - DBBC24D826A54BCB00398BB9 /* MastodonMetricFormatter.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MastodonMetricFormatter.swift; sourceTree = ""; }; DBBC50C0278ED49200AF0CC6 /* MastodonAuthenticationBox.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonAuthenticationBox.swift; sourceTree = ""; }; DBBE1B4425F3474B0081417A /* MastodonPickServerAppearance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonPickServerAppearance.swift; sourceTree = ""; }; DBBF1DBE2652401B00E5B703 /* AutoCompleteViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutoCompleteViewModel.swift; sourceTree = ""; }; @@ -2147,6 +2142,16 @@ path = News; sourceTree = ""; }; + DB3E6FF62807C40500B035AE /* ForYou */ = { + isa = PBXGroup; + children = ( + DB3E6FF42807C40300B035AE /* DiscoveryForYouViewController.swift */, + DB3E6FF72807C45300B035AE /* DiscoveryForYouViewModel.swift */, + DB3E6FF92807C47900B035AE /* DiscoveryForYouViewModel+Diffable.swift */, + ); + path = ForYou; + sourceTree = ""; + }; 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; diff --git a/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist b/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist index 55845d58..1444a8b2 100644 --- a/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist +++ b/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist @@ -109,7 +109,7 @@ MastodonIntent.xcscheme_^#shared#^_ orderHint - 23 + 22 MastodonIntents.xcscheme_^#shared#^_ @@ -124,7 +124,7 @@ NotificationService.xcscheme_^#shared#^_ orderHint - 22 + 23 ShareActionExtension.xcscheme_^#shared#^_ diff --git a/Mastodon/Diffiable/Discovery/DiscoveryItem.swift b/Mastodon/Diffiable/Discovery/DiscoveryItem.swift index 181756d2..024c4a2d 100644 --- a/Mastodon/Diffiable/Discovery/DiscoveryItem.swift +++ b/Mastodon/Diffiable/Discovery/DiscoveryItem.swift @@ -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) case bottomLoader } diff --git a/Mastodon/Diffiable/Discovery/DiscoverySection.swift b/Mastodon/Diffiable/Discovery/DiscoverySection.swift index 32683609..f0c358a1 100644 --- a/Mastodon/Diffiable/Discovery/DiscoverySection.swift +++ b/Mastodon/Diffiable/Discovery/DiscoverySection.swift @@ -29,8 +29,9 @@ extension DiscoverySection { ) -> UITableViewDiffableDataSource { 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() diff --git a/Mastodon/Extension/CoreDataStack/MastodonUser.swift b/Mastodon/Extension/CoreDataStack/MastodonUser.swift index 02a98368..bc5f159d 100644 --- a/Mastodon/Extension/CoreDataStack/MastodonUser.swift +++ b/Mastodon/Extension/CoreDataStack/MastodonUser.swift @@ -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 { diff --git a/Mastodon/Persistence/Extension/MastodonEmoji.swift b/Mastodon/Persistence/Extension/MastodonEmoji.swift index e9274a24..2ea23c67 100644 --- a/Mastodon/Persistence/Extension/MastodonEmoji.swift +++ b/Mastodon/Persistence/Extension/MastodonEmoji.swift @@ -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 - } -} diff --git a/Mastodon/Scene/Discovery/DiscoveryViewModel.swift b/Mastodon/Scene/Discovery/DiscoveryViewModel.swift index acb92b5f..187d7311 100644 --- a/Mastodon/Scene/Discovery/DiscoveryViewModel.swift +++ b/Mastodon/Scene/Discovery/DiscoveryViewModel.swift @@ -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 } diff --git a/Mastodon/Scene/Discovery/ForYou/DiscoveryForYouViewController.swift b/Mastodon/Scene/Discovery/ForYou/DiscoveryForYouViewController.swift new file mode 100644 index 00000000..4654769d --- /dev/null +++ b/Mastodon/Scene/Discovery/ForYou/DiscoveryForYouViewController.swift @@ -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() + 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 + } +} diff --git a/Mastodon/Scene/Discovery/ForYou/DiscoveryForYouViewModel+Diffable.swift b/Mastodon/Scene/Discovery/ForYou/DiscoveryForYouViewModel+Diffable.swift new file mode 100644 index 00000000..23114073 --- /dev/null +++ b/Mastodon/Scene/Discovery/ForYou/DiscoveryForYouViewModel+Diffable.swift @@ -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() + snapshot.appendSections([.forYou]) + + let items = records.map { DiscoveryItem.user($0) } + snapshot.appendItems(items, toSection: .forYou) + + diffableDataSource.applySnapshot(snapshot, animated: false) + } + .store(in: &disposeBag) + } + +} diff --git a/Mastodon/Scene/Discovery/ForYou/DiscoveryForYouViewModel.swift b/Mastodon/Scene/Discovery/ForYou/DiscoveryForYouViewModel.swift new file mode 100644 index 00000000..7be4aeba --- /dev/null +++ b/Mastodon/Scene/Discovery/ForYou/DiscoveryForYouViewModel.swift @@ -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() + + // input + let context: AppContext + let userFetchedResultsController: UserFetchedResultsController + @Published var isFetching = false + + // output + var diffableDataSource: UITableViewDiffableDataSource? + let didLoadLatest = PassthroughSubject() + + 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 + } + } +} diff --git a/Mastodon/Scene/Discovery/News/DiscoveryNewsViewModel+State.swift b/Mastodon/Scene/Discovery/News/DiscoveryNewsViewModel+State.swift index 82d604d6..37d93e5b 100644 --- a/Mastodon/Scene/Discovery/News/DiscoveryNewsViewModel+State.swift +++ b/Mastodon/Scene/Discovery/News/DiscoveryNewsViewModel+State.swift @@ -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) diff --git a/Mastodon/Scene/Discovery/Posts/DiscoveryPostsViewModel+State.swift b/Mastodon/Scene/Discovery/Posts/DiscoveryPostsViewModel+State.swift index 199215d1..a8e2e7c2 100644 --- a/Mastodon/Scene/Discovery/Posts/DiscoveryPostsViewModel+State.swift +++ b/Mastodon/Scene/Discovery/Posts/DiscoveryPostsViewModel+State.swift @@ -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) diff --git a/Mastodon/Scene/Profile/ProfileViewModel.swift b/Mastodon/Scene/Profile/ProfileViewModel.swift index 403437da..ac8c12e9 100644 --- a/Mastodon/Scene/Profile/ProfileViewModel.swift +++ b/Mastodon/Scene/Profile/ProfileViewModel.swift @@ -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, diff --git a/Mastodon/Service/APIService/APIService+Recommend.swift b/Mastodon/Service/APIService/APIService+Recommend.swift index cb195b60..6e457ad0 100644 --- a/Mastodon/Service/APIService/APIService+Recommend.swift +++ b/Mastodon/Service/APIService/APIService+Recommend.swift @@ -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, diff --git a/MastodonSDK/Package.swift b/MastodonSDK/Package.swift index d6078090..dd562f14 100644 --- a/MastodonSDK/Package.swift +++ b/MastodonSDK/Package.swift @@ -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"), ] ), diff --git a/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Discovery/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Discovery/Contents.json new file mode 100644 index 00000000..6e965652 --- /dev/null +++ b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Discovery/Contents.json @@ -0,0 +1,9 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "provides-namespace" : true + } +} diff --git a/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Discovery/profile.card.background.colorset/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Discovery/profile.card.background.colorset/Contents.json new file mode 100644 index 00000000..1fc21a87 --- /dev/null +++ b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Discovery/profile.card.background.colorset/Contents.json @@ -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 + } +} diff --git a/MastodonSDK/Sources/MastodonAsset/Generated/Assets.swift b/MastodonSDK/Sources/MastodonAsset/Generated/Assets.swift index 26e54900..3e7fa5c1 100644 --- a/MastodonSDK/Sources/MastodonAsset/Generated/Assets.swift +++ b/MastodonSDK/Sources/MastodonAsset/Generated/Assets.swift @@ -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") diff --git a/Mastodon/Preference/AppearancePreference.swift b/MastodonSDK/Sources/MastodonCommon/Preference/Preference+Appearance.swift similarity index 81% rename from Mastodon/Preference/AppearancePreference.swift rename to MastodonSDK/Sources/MastodonCommon/Preference/Preference+Appearance.swift index 034bf965..713c926b 100644 --- a/Mastodon/Preference/AppearancePreference.swift +++ b/MastodonSDK/Sources/MastodonCommon/Preference/Preference+Appearance.swift @@ -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 diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Suggestions.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Suggestions.swift index 55808964..4c424f3b 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Suggestions.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Suggestions.swift @@ -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?, diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+V2+Suggestions.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+V2+Suggestions.swift index 9e6876b4..ed680dba 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+V2+Suggestions.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+V2+Suggestions.swift @@ -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?, diff --git a/MastodonSDK/Sources/MastodonUI/Extension/CoreDataStack/MastodonEmoji.swift b/MastodonSDK/Sources/MastodonUI/Extension/CoreDataStack/MastodonEmoji.swift new file mode 100644 index 00000000..4f097759 --- /dev/null +++ b/MastodonSDK/Sources/MastodonUI/Extension/CoreDataStack/MastodonEmoji.swift @@ -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 + } +} diff --git a/MastodonSDK/Sources/MastodonUI/Extension/CoreDataStack/MastodonUser.swift b/MastodonSDK/Sources/MastodonUI/Extension/CoreDataStack/MastodonUser.swift new file mode 100644 index 00000000..9b61ab5f --- /dev/null +++ b/MastodonSDK/Sources/MastodonUI/Extension/CoreDataStack/MastodonUser.swift @@ -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")! + } + +} diff --git a/MastodonSDK/Sources/MastodonUI/Extension/MetaLabel.swift b/MastodonSDK/Sources/MastodonUI/Extension/MetaLabel.swift index 119e9e03..24a4027f 100644 --- a/MastodonSDK/Sources/MastodonUI/Extension/MetaLabel.swift +++ b/MastodonSDK/Sources/MastodonUI/Extension/MetaLabel.swift @@ -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 diff --git a/Mastodon/Helper/MastodonMetricFormatter.swift b/MastodonSDK/Sources/MastodonUI/Helper/MastodonMetricFormatter.swift similarity index 95% rename from Mastodon/Helper/MastodonMetricFormatter.swift rename to MastodonSDK/Sources/MastodonUI/Helper/MastodonMetricFormatter.swift index 3c9c4dd7..50fca8cc 100644 --- a/Mastodon/Helper/MastodonMetricFormatter.swift +++ b/MastodonSDK/Sources/MastodonUI/Helper/MastodonMetricFormatter.swift @@ -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 ? "" : "-" diff --git a/MastodonSDK/Sources/MastodonUI/View/Content/ProfileCardView+Configuration.swift b/MastodonSDK/Sources/MastodonUI/View/Content/ProfileCardView+Configuration.swift new file mode 100644 index 00000000..3964099d --- /dev/null +++ b/MastodonSDK/Sources/MastodonUI/View/Content/ProfileCardView+Configuration.swift @@ -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) + } + +} diff --git a/MastodonSDK/Sources/MastodonUI/View/Content/ProfileCardView+ViewModel.swift b/MastodonSDK/Sources/MastodonUI/View/Content/ProfileCardView+ViewModel.swift new file mode 100644 index 00000000..0e01161d --- /dev/null +++ b/MastodonSDK/Sources/MastodonUI/View/Content/ProfileCardView+ViewModel.swift @@ -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() + + 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) + } +} diff --git a/MastodonSDK/Sources/MastodonUI/View/Content/ProfileCardView.swift b/MastodonSDK/Sources/MastodonUI/View/Content/ProfileCardView.swift new file mode 100644 index 00000000..a8e90906 --- /dev/null +++ b/MastodonSDK/Sources/MastodonUI/View/Content/ProfileCardView.swift @@ -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() + var disposeBag = Set() + + 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) + ]) + } + +} diff --git a/MastodonSDK/Sources/MastodonUI/View/Content/StatusView+ViewModel.swift b/MastodonSDK/Sources/MastodonUI/View/Content/StatusView+ViewModel.swift index b09fdb44..67cefa47 100644 --- a/MastodonSDK/Sources/MastodonUI/View/Content/StatusView+ViewModel.swift +++ b/MastodonSDK/Sources/MastodonUI/View/Content/StatusView+ViewModel.swift @@ -14,6 +14,7 @@ import MastodonSDK import MastodonAsset import MastodonLocalization import MastodonExtension +import MastodonCommon import CoreDataStack extension StatusView { diff --git a/Mastodon/Scene/Profile/Header/View/ProfileRelationshipActionButton.swift b/MastodonSDK/Sources/MastodonUI/View/Control/ProfileRelationshipActionButton.swift similarity index 80% rename from Mastodon/Scene/Profile/Header/View/ProfileRelationshipActionButton.swift rename to MastodonSDK/Sources/MastodonUI/View/Control/ProfileRelationshipActionButton.swift index 87c189a4..bdf696e7 100644 --- a/Mastodon/Scene/Profile/Header/View/ProfileRelationshipActionButton.swift +++ b/MastodonSDK/Sources/MastodonUI/View/Control/ProfileRelationshipActionButton.swift @@ -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) } } - diff --git a/Mastodon/Scene/Profile/Header/View/ProfileStatusDashboardMeterView.swift b/MastodonSDK/Sources/MastodonUI/View/Control/ProfileStatusDashboardMeterView.swift similarity index 91% rename from Mastodon/Scene/Profile/Header/View/ProfileStatusDashboardMeterView.swift rename to MastodonSDK/Sources/MastodonUI/View/Control/ProfileStatusDashboardMeterView.swift index 9176d7a3..0c9d243c 100644 --- a/Mastodon/Scene/Profile/Header/View/ProfileStatusDashboardMeterView.swift +++ b/MastodonSDK/Sources/MastodonUI/View/Control/ProfileStatusDashboardMeterView.swift @@ -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() } diff --git a/Mastodon/Scene/Profile/Header/View/ProfileStatusDashboardView.swift b/MastodonSDK/Sources/MastodonUI/View/Control/ProfileStatusDashboardView.swift similarity index 85% rename from Mastodon/Scene/Profile/Header/View/ProfileStatusDashboardView.swift rename to MastodonSDK/Sources/MastodonUI/View/Control/ProfileStatusDashboardView.swift index 9448f196..7d8e4fbc 100644 --- a/Mastodon/Scene/Profile/Header/View/ProfileStatusDashboardView.swift +++ b/MastodonSDK/Sources/MastodonUI/View/Control/ProfileStatusDashboardView.swift @@ -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 diff --git a/MastodonSDK/Sources/MastodonUI/View/TableViewCell/ProfileCardTableViewCell.swift b/MastodonSDK/Sources/MastodonUI/View/TableViewCell/ProfileCardTableViewCell.swift new file mode 100644 index 00000000..a793482a --- /dev/null +++ b/MastodonSDK/Sources/MastodonUI/View/TableViewCell/ProfileCardTableViewCell.swift @@ -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() + + 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), + ]) + } + +} diff --git a/MastodonSDK/Sources/MastodonUI/ViewModel/RelationshipViewModel.swift b/MastodonSDK/Sources/MastodonUI/ViewModel/RelationshipViewModel.swift new file mode 100644 index 00000000..cee31c14 --- /dev/null +++ b/MastodonSDK/Sources/MastodonUI/ViewModel/RelationshipViewModel.swift @@ -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() + + public var userObserver: AnyCancellable? + public var meObserver: AnyCancellable? + + // input + @Published public var user: MastodonUser? + @Published public var me: MastodonUser? + public let relationshipUpdatePublisher = CurrentValueSubject(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 { + 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 + } +}