diff --git a/AppShared/Info.plist b/AppShared/Info.plist index bc0be73ac..094d6d538 100644 --- a/AppShared/Info.plist +++ b/AppShared/Info.plist @@ -15,8 +15,8 @@ CFBundlePackageType $(PRODUCT_BUNDLE_PACKAGE_TYPE) CFBundleShortVersionString - 1.4.2 + 1.4.3 CFBundleVersion - 133 + 138 diff --git a/Documentation/Acknowledgments.md b/Documentation/Acknowledgments.md index eab4b93f5..ff6dbc081 100644 --- a/Documentation/Acknowledgments.md +++ b/Documentation/Acknowledgments.md @@ -27,6 +27,7 @@ - [SwiftUI-Introspect](https://github.com/siteline/SwiftUI-Introspect) - [SwiftyJSON](https://github.com/SwiftyJSON/SwiftyJSON) - [Tabman](https://github.com/uias/Tabman) +- [TabBarPager](https://github.com/TwidereProject/TabBarPager) - [TwidereX-iOS](https://github.com/TwidereProject/TwidereX-iOS) - [ThirdPartyMailer](https://github.com/vtourraine/ThirdPartyMailer) - [TOCropViewController](https://github.com/TimOliver/TOCropViewController) diff --git a/Localization/StringsConvertor/input/fr.lproj/app.json b/Localization/StringsConvertor/input/fr.lproj/app.json index 97a8566e9..f481d156b 100644 --- a/Localization/StringsConvertor/input/fr.lproj/app.json +++ b/Localization/StringsConvertor/input/fr.lproj/app.json @@ -241,7 +241,7 @@ }, "input": { "placeholder": "Trouvez un serveur ou rejoignez le vôtre...", - "search_servers_or_enter_url": "Search servers or enter URL" + "search_servers_or_enter_url": "Rechercher des serveurs ou entrer une URL" }, "empty_state": { "finding_servers": "Recherche des serveurs disponibles...", @@ -621,7 +621,7 @@ "whats_wrong_with_this_post": "Qu’est-ce qui ne va pas avec ce message ?", "whats_wrong_with_this_account": "Qu’est-ce qui ne va pas avec ce compte ?", "whats_wrong_with_this_username": "Qu’est-ce qui ne va pas avec %s ?", - "select_the_best_match": "Select the best match", + "select_the_best_match": "Sélectionnez ce qui correspond le mieux", "i_dont_like_it": "Je n’aime pas", "it_is_not_something_you_want_to_see": "C’est quelque chose que vous ne souhaitez pas voir", "its_spam": "C’est du spam", @@ -655,8 +655,8 @@ "mute_user": "Masquer %s", "you_wont_see_their_posts_or_reblogs_in_your_home_feed_they_wont_know_they_ve_been_muted": "Vous ne verrez plus leurs messages ou leurs partages dans votre flux personnel. Iels ne sauront pas qu’iels ont été mis en sourdine.", "block_user": "Bloquer %s", - "they_will_no_longer_be_able_to_follow_or_see_your_posts_but_they_can_see_if_theyve_been_blocked": "They will no longer be able to follow or see your posts, but they can see if they’ve been blocked.", - "while_we_review_this_you_can_take_action_against_user": "While we review this, you can take action against %s" + "they_will_no_longer_be_able_to_follow_or_see_your_posts_but_they_can_see_if_theyve_been_blocked": "Ils ne seront plus en mesure de suivre ou de voir vos messages, mais iels peuvent voir s’iels ont été bloqué·e·s.", + "while_we_review_this_you_can_take_action_against_user": "Pendant que nous étudions votre requête, vous pouvez prendre des mesures contre %s" } }, "preview": { diff --git a/Localization/StringsConvertor/input/gl.lproj/app.json b/Localization/StringsConvertor/input/gl.lproj/app.json index af5ee5d50..02c33f321 100644 --- a/Localization/StringsConvertor/input/gl.lproj/app.json +++ b/Localization/StringsConvertor/input/gl.lproj/app.json @@ -241,7 +241,7 @@ }, "input": { "placeholder": "Buscar comunidades", - "search_servers_or_enter_url": "Search servers or enter URL" + "search_servers_or_enter_url": "Busca un servidor ou escribe URL" }, "empty_state": { "finding_servers": "Buscando servidores dispoñibles...", @@ -462,22 +462,22 @@ } }, "follower": { - "title": "follower", + "title": "seguidora", "footer": "Non se mostran seguidoras desde outros servidores." }, "following": { - "title": "following", + "title": "seguindo", "footer": "Non se mostran os seguimentos desde outros servidores." }, "familiarFollowers": { - "title": "Followers you familiar", - "followed_by_names": "Followed by %s" + "title": "Seguimentos próximos", + "followed_by_names": "Seguimentos de %s" }, "favorited_by": { - "title": "Favorited By" + "title": "Favorecido por" }, "reblogged_by": { - "title": "Reblogged By" + "title": "Promovido por" }, "search": { "title": "Procurar", diff --git a/Localization/StringsConvertor/input/it.lproj/app.json b/Localization/StringsConvertor/input/it.lproj/app.json index 9543837b5..57ed4f46b 100644 --- a/Localization/StringsConvertor/input/it.lproj/app.json +++ b/Localization/StringsConvertor/input/it.lproj/app.json @@ -124,8 +124,8 @@ } }, "status": { - "user_reblogged": "%s hanno condiviso", - "user_replied_to": "Rispondi a %s", + "user_reblogged": "%s ha condiviso", + "user_replied_to": "Risposta a %s", "show_post": "Mostra il post", "show_user_profile": "Mostra il profilo dell'utente", "content_warning": "Avviso sul contenuto", @@ -343,7 +343,7 @@ "title": "Inizio", "navigation_bar_state": { "offline": "Non in linea", - "new_posts": "Vedi nuovi post", + "new_posts": "Vedi i nuovi post", "published": "Pubblicato!", "Publishing": "Pubblicazione post...", "accessibility": { @@ -533,7 +533,7 @@ "notification_description": { "followed_you": "ti ha seguito", "favorited_your_post": "ha apprezzato il tuo post", - "reblogged_your_post": "ha ripostato il tuo post", + "reblogged_your_post": "ha condiviso il tuo post", "mentioned_you": "ti ha menzionato", "request_to_follow_you": "richiesta di seguirti", "poll_has_ended": "sondaggio terminato" diff --git a/Localization/StringsConvertor/input/ja.lproj/app.json b/Localization/StringsConvertor/input/ja.lproj/app.json index a4e55aae5..e61b09753 100644 --- a/Localization/StringsConvertor/input/ja.lproj/app.json +++ b/Localization/StringsConvertor/input/ja.lproj/app.json @@ -241,7 +241,7 @@ }, "input": { "placeholder": "サーバーを探す", - "search_servers_or_enter_url": "Search servers or enter URL" + "search_servers_or_enter_url": "サーバーを検索またはURLを入力" }, "empty_state": { "finding_servers": "利用可能なサーバーの検索...", @@ -617,46 +617,46 @@ "text_placeholder": "追加コメントを入力", "reported": "報告済み", "step_one": { - "step_1_of_4": "Step 1 of 4", - "whats_wrong_with_this_post": "What's wrong with this post?", - "whats_wrong_with_this_account": "What's wrong with this account?", - "whats_wrong_with_this_username": "What's wrong with %s?", - "select_the_best_match": "Select the best match", - "i_dont_like_it": "I don’t like it", - "it_is_not_something_you_want_to_see": "It is not something you want to see", - "its_spam": "It’s spam", - "malicious_links_fake_engagement_or_repetetive_replies": "Malicious links, fake engagement, or repetetive replies", - "it_violates_server_rules": "It violates server rules", - "you_are_aware_that_it_breaks_specific_rules": "You are aware that it breaks specific rules", - "its_something_else": "It’s something else", - "the_issue_does_not_fit_into_other_categories": "The issue does not fit into other categories" + "step_1_of_4": "ステップ 1/4", + "whats_wrong_with_this_post": "この投稿のどこが問題ですか?", + "whats_wrong_with_this_account": "このアカウントのどこが問題ですか?", + "whats_wrong_with_this_username": "%sさんのどこが問題ですか?", + "select_the_best_match": "最も近いものを選んでください", + "i_dont_like_it": "興味がありません", + "it_is_not_something_you_want_to_see": "見たくない内容の場合", + "its_spam": "これはスパムです", + "malicious_links_fake_engagement_or_repetetive_replies": "悪意あるリンクや虚偽の情報、執拗な返信など", + "it_violates_server_rules": "サーバーのルールに違反しています", + "you_are_aware_that_it_breaks_specific_rules": "ルールに違反しているのを見つけた場合", + "its_something_else": "その他", + "the_issue_does_not_fit_into_other_categories": "当てはまる選択肢がない場合" }, "step_two": { - "step_2_of_4": "Step 2 of 4", - "which_rules_are_being_violated": "Which rules are being violated?", + "step_2_of_4": "ステップ 2/4", + "which_rules_are_being_violated": "どのルールに違反していますか?", "select_all_that_apply": "Select all that apply", "i_just_don’t_like_it": "I just don’t like it" }, "step_three": { - "step_3_of_4": "Step 3 of 4", + "step_3_of_4": "ステップ 3/4", "are_there_any_posts_that_back_up_this_report": "Are there any posts that back up this report?", "select_all_that_apply": "Select all that apply" }, "step_four": { - "step_4_of_4": "Step 4 of 4", - "is_there_anything_else_we_should_know": "Is there anything else we should know?" + "step_4_of_4": "ステップ 4/4", + "is_there_anything_else_we_should_know": "その他に私たちに伝えておくべき事はありますか?" }, "step_final": { "dont_want_to_see_this": "Don’t want to see this?", "when_you_see_something_you_dont_like_on_mastodon_you_can_remove_the_person_from_your_experience.": "When you see something you don’t like on Mastodon, you can remove the person from your experience.", - "unfollow": "Unfollow", - "unfollowed": "Unfollowed", - "unfollow_user": "Unfollow %s", - "mute_user": "Mute %s", + "unfollow": "フォロー解除", + "unfollowed": "フォロー解除しました", + "unfollow_user": "%sをフォロー解除", + "mute_user": "%sをミュート", "you_wont_see_their_posts_or_reblogs_in_your_home_feed_they_wont_know_they_ve_been_muted": "You won’t see their posts or reblogs in your home feed. They won’t know they’ve been muted.", - "block_user": "Block %s", + "block_user": "%sをブロック", "they_will_no_longer_be_able_to_follow_or_see_your_posts_but_they_can_see_if_theyve_been_blocked": "They will no longer be able to follow or see your posts, but they can see if they’ve been blocked.", - "while_we_review_this_you_can_take_action_against_user": "While we review this, you can take action against %s" + "while_we_review_this_you_can_take_action_against_user": "私たちが確認している間でも、あなたは%sさんに対して対応することができます。" } }, "preview": { diff --git a/Localization/StringsConvertor/input/kab.lproj/app.json b/Localization/StringsConvertor/input/kab.lproj/app.json index 8987669c0..fa2cac641 100644 --- a/Localization/StringsConvertor/input/kab.lproj/app.json +++ b/Localization/StringsConvertor/input/kab.lproj/app.json @@ -241,7 +241,7 @@ }, "input": { "placeholder": "Nadi timɣiwnin", - "search_servers_or_enter_url": "Search servers or enter URL" + "search_servers_or_enter_url": "Nadi timɣiwnin neɣ sekcem URL" }, "empty_state": { "finding_servers": "Tifin n yiqeddacen yellan...", @@ -251,7 +251,7 @@ }, "register": { "title": "Aha ad nebdu asbadu ɣef %s", - "lets_get_you_set_up_on_domain": "Let’s get you set up on %s", + "lets_get_you_set_up_on_domain": "Aha ad nebdu asbadu ɣef %s", "input": { "avatar": { "delete": "Kkes" @@ -322,7 +322,7 @@ "confirm_email": { "title": "Taɣawsa taneggarut.", "subtitle": "Sit ɣef useɣwen i ak-n-uznen i wakken ad tesneqdeḍ amiḍan-ik.", - "tap_the_link_we_emailed_to_you_to_verify_your_account": "Tap the link we emailed to you to verify your account", + "tap_the_link_we_emailed_to_you_to_verify_your_account": "Sit ɣef useɣwen i ak-n-uznen i wakken ad tesneqdeḍ amiḍan-ik", "button": { "open_email_app": "Ldi asnas n yimayl", "resend": "Ales tuzna" @@ -347,7 +347,7 @@ "published": "Yettwasuffeɣ!", "Publishing": "Asuffeɣ tasuffeɣt...", "accessibility": { - "logo_label": "Logo Button", + "logo_label": "Taqeffalt n ulugu", "logo_hint": "Tap to scroll to top and tap again to previous location" } } @@ -462,11 +462,11 @@ } }, "follower": { - "title": "follower", + "title": "aneḍfar", "footer": "Ineḍfaren seg yiqeddacen-nniḍen ur d-ttwaskanen ara." }, "following": { - "title": "following", + "title": "yeṭṭafar", "footer": "Ineḍfaren seg yiqeddacen-nniḍen ur d-ttwaskanen ara." }, "familiarFollowers": { @@ -517,7 +517,7 @@ "posts": "Tisuffaɣ", "hashtags": "Ihacṭagen", "news": "Isallen", - "community": "Community", + "community": "Tamɣiwent", "for_you": "I kečč·kem" }, "intro": "Tigi d tisuffaɣ i d-ijebbden s waṭas deg tama-inek•inem n Mastodon." @@ -617,46 +617,46 @@ "text_placeholder": "Aru neɣ senteḍ iwenniten-nniḍen", "reported": "YETTWAMMEL", "step_one": { - "step_1_of_4": "Step 1 of 4", - "whats_wrong_with_this_post": "What's wrong with this post?", + "step_1_of_4": "Aḥric 1 seg 4", + "whats_wrong_with_this_post": "Acu n wugur yellan d tsuffeɣt-a?", "whats_wrong_with_this_account": "What's wrong with this account?", - "whats_wrong_with_this_username": "What's wrong with %s?", - "select_the_best_match": "Select the best match", - "i_dont_like_it": "I don’t like it", - "it_is_not_something_you_want_to_see": "It is not something you want to see", - "its_spam": "It’s spam", - "malicious_links_fake_engagement_or_repetetive_replies": "Malicious links, fake engagement, or repetetive replies", - "it_violates_server_rules": "It violates server rules", - "you_are_aware_that_it_breaks_specific_rules": "You are aware that it breaks specific rules", - "its_something_else": "It’s something else", - "the_issue_does_not_fit_into_other_categories": "The issue does not fit into other categories" + "whats_wrong_with_this_username": "Acu n wugur yellan d %s?", + "select_the_best_match": "Fren amṣada akk igerrzen", + "i_dont_like_it": "Ur ḥemmleɣ ara aya", + "it_is_not_something_you_want_to_see": "D ayen akk ur bɣiɣ ara ad waliɣ", + "its_spam": "D aspam", + "malicious_links_fake_engagement_or_repetetive_replies": "Yir iseɣwan, yir agman d tririyin i d-yettuɣalen", + "it_violates_server_rules": "Truẓi n yilugan n uqeddac", + "you_are_aware_that_it_breaks_specific_rules": "Teẓriḍ y•tettruẓu kra n yilugan", + "its_something_else": "Ɣef ssebba-nniḍen", + "the_issue_does_not_fit_into_other_categories": "Ugur ur yemṣada ara akk d taggayin-nniḍen" }, "step_two": { - "step_2_of_4": "Step 2 of 4", - "which_rules_are_being_violated": "Which rules are being violated?", - "select_all_that_apply": "Select all that apply", - "i_just_don’t_like_it": "I just don’t like it" + "step_2_of_4": "Aḥric 2 seg 4", + "which_rules_are_being_violated": "Acu n yilugan i yettwarẓan?", + "select_all_that_apply": "Fren akk tifrat ara yettusnasen", + "i_just_don’t_like_it": "Ur ḥemmleɣ ara kan aya" }, "step_three": { - "step_3_of_4": "Step 3 of 4", - "are_there_any_posts_that_back_up_this_report": "Are there any posts that back up this report?", - "select_all_that_apply": "Select all that apply" + "step_3_of_4": "Aḥric 3 seg 4", + "are_there_any_posts_that_back_up_this_report": "Llant tsuffaɣ ara isdemren aneqqis-a?", + "select_all_that_apply": "Fren akk tifrat ara yettusnasen" }, "step_four": { - "step_4_of_4": "Step 4 of 4", - "is_there_anything_else_we_should_know": "Is there anything else we should know?" + "step_4_of_4": "Aḥric 4 seg 4", + "is_there_anything_else_we_should_know": "Yella wayen-nniḍen i ilaqen ad t-nẓer?" }, "step_final": { - "dont_want_to_see_this": "Don’t want to see this?", - "when_you_see_something_you_dont_like_on_mastodon_you_can_remove_the_person_from_your_experience.": "When you see something you don’t like on Mastodon, you can remove the person from your experience.", - "unfollow": "Unfollow", - "unfollowed": "Unfollowed", - "unfollow_user": "Unfollow %s", - "mute_user": "Mute %s", - "you_wont_see_their_posts_or_reblogs_in_your_home_feed_they_wont_know_they_ve_been_muted": "You won’t see their posts or reblogs in your home feed. They won’t know they’ve been muted.", - "block_user": "Block %s", - "they_will_no_longer_be_able_to_follow_or_see_your_posts_but_they_can_see_if_theyve_been_blocked": "They will no longer be able to follow or see your posts, but they can see if they’ve been blocked.", - "while_we_review_this_you_can_take_action_against_user": "While we review this, you can take action against %s" + "dont_want_to_see_this": "Ur tebɣiḍ ara ad twaliḍ aya?", + "when_you_see_something_you_dont_like_on_mastodon_you_can_remove_the_person_from_your_experience.": "Mi ara twaliḍ kra ur ak•am-neɛǧib ara ɣef Mastodon, tzemreḍ ad tekkseḍ amdan-nni seg tirmit-ik•im.", + "unfollow": "Ur ṭṭafaṛ ara", + "unfollowed": "Y•Teḥbes aḍfar n", + "unfollow_user": "Y•Teḥbes aḍfar n %s", + "mute_user": "Sgugem %s", + "you_wont_see_their_posts_or_reblogs_in_your_home_feed_they_wont_know_they_ve_been_muted": "Ur tettwaliḍ ara tisuffaɣ-nsen neɣ iriblugen-nsen deg usuddem-inek•inem agejdan. Ur ẓerren ara belli tesgugmeḍ-ten.", + "block_user": "Sewḥel %s", + "they_will_no_longer_be_able_to_follow_or_see_your_posts_but_they_can_see_if_theyve_been_blocked": "Ur ttuɣalen ara ad izmiren ad ak•akem-ḍefren neɣ ad walin tisuffaɣ-inek•inem, maca ad walin ma yella ttusweḥlen.", + "while_we_review_this_you_can_take_action_against_user": "Ideg nekkni nessenqad tuttra-inek•inem, tzemreḍ ad tḥadreḍ mgal %s" } }, "preview": { diff --git a/Localization/StringsConvertor/input/kmr.lproj/app.json b/Localization/StringsConvertor/input/kmr.lproj/app.json index 65f891f93..cc4fc8947 100644 --- a/Localization/StringsConvertor/input/kmr.lproj/app.json +++ b/Localization/StringsConvertor/input/kmr.lproj/app.json @@ -241,7 +241,7 @@ }, "input": { "placeholder": "Li rajekaran bigere", - "search_servers_or_enter_url": "Search servers or enter URL" + "search_servers_or_enter_url": "Li rajekaran bigere an jî girêdanê têxe" }, "empty_state": { "finding_servers": "Peydakirina rajekarên berdest...", diff --git a/Localization/StringsConvertor/input/th.lproj/app.json b/Localization/StringsConvertor/input/th.lproj/app.json index 97a00b41a..dbd6153b0 100644 --- a/Localization/StringsConvertor/input/th.lproj/app.json +++ b/Localization/StringsConvertor/input/th.lproj/app.json @@ -241,7 +241,7 @@ }, "input": { "placeholder": "ค้นหาเซิร์ฟเวอร์", - "search_servers_or_enter_url": "Search servers or enter URL" + "search_servers_or_enter_url": "ค้นหาเซิร์ฟเวอร์หรือป้อน URL" }, "empty_state": { "finding_servers": "กำลังค้นหาเซิร์ฟเวอร์ที่พร้อมใช้งาน...", diff --git a/Localization/StringsConvertor/input/vi.lproj/app.json b/Localization/StringsConvertor/input/vi.lproj/app.json index 58650a088..62c1d240c 100644 --- a/Localization/StringsConvertor/input/vi.lproj/app.json +++ b/Localization/StringsConvertor/input/vi.lproj/app.json @@ -241,7 +241,7 @@ }, "input": { "placeholder": "Tìm máy chủ", - "search_servers_or_enter_url": "Search servers or enter URL" + "search_servers_or_enter_url": "Tìm máy chủ hoặc nhập URL" }, "empty_state": { "finding_servers": "Đang tìm máy chủ hoạt động...", diff --git a/Localization/app.json b/Localization/app.json index 7c50eba7f..2a8634a67 100644 --- a/Localization/app.json +++ b/Localization/app.json @@ -241,7 +241,7 @@ }, "input": { "placeholder": "Search servers", - "search_servers_or_enter_url": "Search communities or enter URL" + "search_servers_or_enter_url": "Search servers or enter URL" }, "empty_state": { "finding_servers": "Finding available servers...", diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index 2187fa0c5..efe519f74 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -145,6 +145,8 @@ DB0EF72B26FDB1D200347686 /* SidebarListCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0EF72A26FDB1D200347686 /* SidebarListCollectionViewCell.swift */; }; DB0EF72E26FDB24F00347686 /* SidebarListContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0EF72D26FDB24F00347686 /* SidebarListContentView.swift */; }; DB0F8150264D1E2500F2A12B /* PickServerLoaderTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0F814F264D1E2500F2A12B /* PickServerLoaderTableViewCell.swift */; }; + DB0F9D54283EB3C000379AF8 /* ProfileHeaderView+ViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0F9D53283EB3C000379AF8 /* ProfileHeaderView+ViewModel.swift */; }; + DB0F9D56283EB46200379AF8 /* ProfileHeaderView+Configuration.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0F9D55283EB46200379AF8 /* ProfileHeaderView+Configuration.swift */; }; DB0FCB68279507EF006C02E2 /* DataSourceFacade+Meta.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0FCB67279507EF006C02E2 /* DataSourceFacade+Meta.swift */; }; DB0FCB6C27950E29006C02E2 /* MastodonMentionContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0FCB6B27950E29006C02E2 /* MastodonMentionContainer.swift */; }; DB0FCB6E27950E6B006C02E2 /* MastodonMention.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0FCB6D27950E6B006C02E2 /* MastodonMention.swift */; }; @@ -232,7 +234,6 @@ DB3EA8F5281BB65200598866 /* MastodonSDK in Frameworks */ = {isa = PBXBuildFile; productRef = DB3EA8F4281BB65200598866 /* MastodonSDK */; }; DB3EA8FC281BBAE100598866 /* AlamofireImage in Frameworks */ = {isa = PBXBuildFile; productRef = DB3EA8FB281BBAE100598866 /* AlamofireImage */; }; DB3EA8FE281BBAF200598866 /* Alamofire in Frameworks */ = {isa = PBXBuildFile; productRef = DB3EA8FD281BBAF200598866 /* Alamofire */; }; - DB3EA900281BBB1D00598866 /* MetaTextKit in Frameworks */ = {isa = PBXBuildFile; productRef = DB3EA8FF281BBB1D00598866 /* MetaTextKit */; }; DB3EA902281BBD5D00598866 /* CommonOSLog in Frameworks */ = {isa = PBXBuildFile; productRef = DB3EA901281BBD5D00598866 /* CommonOSLog */; }; DB3EA904281BBD9400598866 /* Introspect in Frameworks */ = {isa = PBXBuildFile; productRef = DB3EA903281BBD9400598866 /* Introspect */; }; DB3EA906281BBE8200598866 /* AlamofireImage in Frameworks */ = {isa = PBXBuildFile; productRef = DB3EA905281BBE8200598866 /* AlamofireImage */; }; @@ -267,6 +268,7 @@ DB47AB6227CF752B00CD73C7 /* MastodonUISnapshotTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB47AB6127CF752B00CD73C7 /* MastodonUISnapshotTests.swift */; }; DB482A3F261331E8008AE74C /* UserTimelineViewModel+State.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB482A3E261331E8008AE74C /* UserTimelineViewModel+State.swift */; }; DB482A4B261340A7008AE74C /* APIService+UserTimeline.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB482A4A261340A7008AE74C /* APIService+UserTimeline.swift */; }; + DB486C0F282E41F200F69423 /* TabBarPager in Frameworks */ = {isa = PBXBuildFile; productRef = DB486C0E282E41F200F69423 /* TabBarPager */; }; DB4924E226312AB200E9DB22 /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB4924E126312AB200E9DB22 /* NotificationService.swift */; }; DB4932B126F1FB5300EF46D4 /* WizardCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB4932B026F1FB5300EF46D4 /* WizardCardView.swift */; }; DB4932B726F30F0700EF46D4 /* Array.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F20223826146553000C64BF /* Array.swift */; }; @@ -376,6 +378,7 @@ DB697DDD278F521D004EF2F7 /* DataSourceFacade.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB697DDC278F521D004EF2F7 /* DataSourceFacade.swift */; }; DB697DDF278F524F004EF2F7 /* DataSourceFacade+Profile.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB697DDE278F524F004EF2F7 /* DataSourceFacade+Profile.swift */; }; DB697DE1278F5296004EF2F7 /* DataSourceFacade+Model.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB697DE0278F5296004EF2F7 /* DataSourceFacade+Model.swift */; }; + DB6988DE2848D11C002398EF /* PagerTabStripNavigateable.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6988DD2848D11C002398EF /* PagerTabStripNavigateable.swift */; }; DB6B35182601FA3400DC1E11 /* MastodonAttachmentService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6B35172601FA3400DC1E11 /* MastodonAttachmentService.swift */; }; DB6B351E2601FAEE00DC1E11 /* ComposeStatusAttachmentCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6B351D2601FAEE00DC1E11 /* ComposeStatusAttachmentCollectionViewCell.swift */; }; DB6B74EF272FB55000C70B6E /* FollowerListViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6B74EE272FB55000C70B6E /* FollowerListViewController.swift */; }; @@ -507,7 +510,6 @@ DBB45B6027B50A4F002DC5A7 /* RecommendAccountItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBB45B5F27B50A4F002DC5A7 /* RecommendAccountItem.swift */; }; DBB45B6227B51112002DC5A7 /* SuggestionAccountViewModel+Diffable.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBB45B6127B51112002DC5A7 /* SuggestionAccountViewModel+Diffable.swift */; }; DBB525082611EAC0002F1F29 /* Tabman in Frameworks */ = {isa = PBXBuildFile; productRef = DBB525072611EAC0002F1F29 /* Tabman */; }; - DBB5250E2611EBAF002F1F29 /* ProfileSegmentedViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBB5250D2611EBAF002F1F29 /* ProfileSegmentedViewController.swift */; }; DBB525212611EBD6002F1F29 /* ProfilePagingViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBB525202611EBD6002F1F29 /* ProfilePagingViewController.swift */; }; DBB525302611EBF3002F1F29 /* ProfilePagingViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBB5252F2611EBF3002F1F29 /* ProfilePagingViewModel.swift */; }; DBB525362611ECEB002F1F29 /* UserTimelineViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBB525352611ECEB002F1F29 /* UserTimelineViewController.swift */; }; @@ -888,6 +890,8 @@ DB0EF72D26FDB24F00347686 /* SidebarListContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarListContentView.swift; sourceTree = ""; }; DB0F814E264CFFD300F2A12B /* ar */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ar; path = ar.lproj/InfoPlist.strings; sourceTree = ""; }; DB0F814F264D1E2500F2A12B /* PickServerLoaderTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PickServerLoaderTableViewCell.swift; sourceTree = ""; }; + DB0F9D53283EB3C000379AF8 /* ProfileHeaderView+ViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ProfileHeaderView+ViewModel.swift"; sourceTree = ""; }; + DB0F9D55283EB46200379AF8 /* ProfileHeaderView+Configuration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ProfileHeaderView+Configuration.swift"; sourceTree = ""; }; DB0FCB67279507EF006C02E2 /* DataSourceFacade+Meta.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DataSourceFacade+Meta.swift"; sourceTree = ""; }; DB0FCB6B27950E29006C02E2 /* MastodonMentionContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonMentionContainer.swift; sourceTree = ""; }; DB0FCB6D27950E6B006C02E2 /* MastodonMention.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonMention.swift; sourceTree = ""; }; @@ -1136,6 +1140,7 @@ DB697DDC278F521D004EF2F7 /* DataSourceFacade.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataSourceFacade.swift; sourceTree = ""; }; DB697DDE278F524F004EF2F7 /* DataSourceFacade+Profile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DataSourceFacade+Profile.swift"; sourceTree = ""; }; DB697DE0278F5296004EF2F7 /* DataSourceFacade+Model.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DataSourceFacade+Model.swift"; sourceTree = ""; }; + DB6988DD2848D11C002398EF /* PagerTabStripNavigateable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PagerTabStripNavigateable.swift; sourceTree = ""; }; DB6B35172601FA3400DC1E11 /* MastodonAttachmentService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonAttachmentService.swift; sourceTree = ""; }; DB6B351D2601FAEE00DC1E11 /* ComposeStatusAttachmentCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeStatusAttachmentCollectionViewCell.swift; sourceTree = ""; }; DB6B74EE272FB55000C70B6E /* FollowerListViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FollowerListViewController.swift; sourceTree = ""; }; @@ -1285,7 +1290,6 @@ DBB45B5A27B3A109002DC5A7 /* MediaPreviewTransitionViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPreviewTransitionViewController.swift; sourceTree = ""; }; DBB45B5F27B50A4F002DC5A7 /* RecommendAccountItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecommendAccountItem.swift; sourceTree = ""; }; DBB45B6127B51112002DC5A7 /* SuggestionAccountViewModel+Diffable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SuggestionAccountViewModel+Diffable.swift"; sourceTree = ""; }; - DBB5250D2611EBAF002F1F29 /* ProfileSegmentedViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileSegmentedViewController.swift; sourceTree = ""; }; DBB525202611EBD6002F1F29 /* ProfilePagingViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfilePagingViewController.swift; sourceTree = ""; }; DBB5252F2611EBF3002F1F29 /* ProfilePagingViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfilePagingViewModel.swift; sourceTree = ""; }; DBB525352611ECEB002F1F29 /* UserTimelineViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserTimelineViewController.swift; sourceTree = ""; }; @@ -1429,6 +1433,7 @@ DB6804862637CD4C00430867 /* AppShared.framework in Frameworks */, DBF96326262EC0A6001D8D25 /* AuthenticationServices.framework in Frameworks */, DBAC6483267D0B21007FE9FD /* DifferenceKit in Frameworks */, + DB486C0F282E41F200F69423 /* TabBarPager in Frameworks */, DB552D4F26BBD10C00E481F6 /* OrderedCollections in Frameworks */, 2D61336925C18A4F00CAE157 /* AlamofireNetworkActivityIndicator in Frameworks */, DBAC64A1267E6D02007FE9FD /* Fuzi in Frameworks */, @@ -1469,7 +1474,6 @@ EE93E8E8F9E0C39EAAEBD92F /* Pods_AppShared.framework in Frameworks */, DB3EA904281BBD9400598866 /* Introspect in Frameworks */, DB3EA902281BBD5D00598866 /* CommonOSLog in Frameworks */, - DB3EA900281BBB1D00598866 /* MetaTextKit in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1748,6 +1752,7 @@ DBCA0EBB282BB38A0029E2B0 /* PageboyNavigateable.swift */, DB1D843326579931000346B3 /* TableViewControllerNavigateable.swift */, DB1D842D26552C4D000346B3 /* StatusTableViewControllerNavigateable.swift */, + DB6988DD2848D11C002398EF /* PagerTabStripNavigateable.swift */, ); path = Protocol; sourceTree = ""; @@ -3004,8 +3009,8 @@ DB9D6C0825E4F5A60051B173 /* Profile */ = { isa = PBXGroup; children = ( - DBB525132611EBB1002F1F29 /* Segmented */, DBB525462611ED57002F1F29 /* Header */, + DBB525262611EBDA002F1F29 /* Paging */, DBB5253B2611ECF5002F1F29 /* Timeline */, DBE3CDF1261C6B3100430CC6 /* Favorite */, DB6B74F0272FB55400C70B6E /* Follower */, @@ -3104,15 +3109,6 @@ path = Video; sourceTree = ""; }; - DBB525132611EBB1002F1F29 /* Segmented */ = { - isa = PBXGroup; - children = ( - DBB525262611EBDA002F1F29 /* Paging */, - DBB5250D2611EBAF002F1F29 /* ProfileSegmentedViewController.swift */, - ); - path = Segmented; - sourceTree = ""; - }; DBB525262611EBDA002F1F29 /* Paging */ = { isa = PBXGroup; children = ( @@ -3148,6 +3144,8 @@ isa = PBXGroup; children = ( DBB5254F2611ED6D002F1F29 /* ProfileHeaderView.swift */, + DB0F9D53283EB3C000379AF8 /* ProfileHeaderView+ViewModel.swift */, + DB0F9D55283EB46200379AF8 /* ProfileHeaderView+Configuration.swift */, DBF98149265E24F500E4BA07 /* ProfileFieldCollectionViewHeaderFooterView.swift */, ); path = View; @@ -3448,6 +3446,7 @@ DBA5A52E26F07ED800CACBAA /* PanModal */, DB3EA911281BBEA800598866 /* AlamofireImage */, DB3EA913281BBEA800598866 /* Alamofire */, + DB486C0E282E41F200F69423 /* TabBarPager */, ); productName = Mastodon; productReference = DB427DD225BAA00100D1B89D /* Mastodon.app */; @@ -3512,7 +3511,6 @@ DB3EA8F4281BB65200598866 /* MastodonSDK */, DB3EA8FB281BBAE100598866 /* AlamofireImage */, DB3EA8FD281BBAF200598866 /* Alamofire */, - DB3EA8FF281BBB1D00598866 /* MetaTextKit */, DB3EA901281BBD5D00598866 /* CommonOSLog */, DB3EA903281BBD9400598866 /* Introspect */, ); @@ -3665,11 +3663,11 @@ DBAC649F267E6D01007FE9FD /* XCRemoteSwiftPackageReference "Fuzi" */, DBF7A0FA26830C33004176A2 /* XCRemoteSwiftPackageReference "FPSIndicator" */, DB0E2D2C26833FF600865C3C /* XCRemoteSwiftPackageReference "Nuke-FLAnimatedImage-Plugin" */, - DB01E23126A98F0900C3965B /* XCRemoteSwiftPackageReference "MetaTextKit" */, DB552D4D26BBD10C00E481F6 /* XCRemoteSwiftPackageReference "swift-collections" */, DBA5A52D26F07ED800CACBAA /* XCRemoteSwiftPackageReference "PanModal" */, DB8D8E2D28192EED009FD90F /* XCRemoteSwiftPackageReference "SwiftUI-Introspect" */, DB3EA8F6281BBA4C00598866 /* XCRemoteSwiftPackageReference "Alamofire" */, + DB486C0D282E41F200F69423 /* XCRemoteSwiftPackageReference "TabBarPager" */, ); productRefGroup = DB427DD325BAA00100D1B89D /* Products */; projectDirPath = ""; @@ -4037,7 +4035,6 @@ DB0FCB7C2795821F006C02E2 /* StatusThreadRootTableViewCell.swift in Sources */, DB72601C25E36A2100235243 /* MastodonServerRulesViewController.swift in Sources */, DB5B54A32833BD1A00DEF8B2 /* UserListViewModel.swift in Sources */, - DBB5250E2611EBAF002F1F29 /* ProfileSegmentedViewController.swift in Sources */, DBF156DF2701B17600EC00B7 /* SidebarAddAccountCollectionViewCell.swift in Sources */, DB0617F1278413D00030EE79 /* PickServerServerSectionTableHeaderView.swift in Sources */, DB0FCB7E27958957006C02E2 /* StatusThreadRootTableViewCell+ViewModel.swift in Sources */, @@ -4256,6 +4253,7 @@ DB63F752279944AA00455B82 /* SearchHistorySectionHeaderCollectionReusableView.swift in Sources */, DB3E6FDD2806A40F00B035AE /* DiscoveryHashtagsViewController.swift in Sources */, DB938EED2623F79B00E5B6C1 /* ThreadViewModel.swift in Sources */, + DB6988DE2848D11C002398EF /* PagerTabStripNavigateable.swift in Sources */, DBBC24AC26A53D9300398BB9 /* ComposeStatusContentTableViewCell.swift in Sources */, DBC7A67C260DFADE00E57475 /* StatusPublishService.swift in Sources */, 2DCB73FD2615C13900EC03D4 /* SearchRecommendCollectionHeader.swift in Sources */, @@ -4394,6 +4392,7 @@ DB67D08627312E67006A36CF /* WizardViewController.swift in Sources */, DB6746EB278ED8B0008A6B94 /* PollOptionView+Configuration.swift in Sources */, DB9A489026035963008B817C /* APIService+Media.swift in Sources */, + DB0F9D54283EB3C000379AF8 /* ProfileHeaderView+ViewModel.swift in Sources */, DBFEEC99279BDCDE004F81DD /* ProfileAboutViewModel.swift in Sources */, 2D198649261C0B8500F0B013 /* SearchResultSection.swift in Sources */, DB4F097B26A039FF00D62E92 /* SearchHistorySection.swift in Sources */, @@ -4402,6 +4401,7 @@ 2D5A3D6225CFD9CB002347D6 /* HomeTimelineViewController+DebugAction.swift in Sources */, DB3E6FF12806D96900B035AE /* DiscoveryNewsViewModel+Diffable.swift in Sources */, DB3E6FF82807C45300B035AE /* DiscoveryForYouViewModel.swift in Sources */, + DB0F9D56283EB46200379AF8 /* ProfileHeaderView+Configuration.swift in Sources */, DB49A62525FF334C00B98345 /* EmojiService+CustomEmojiViewModel+LoadState.swift in Sources */, DB4924E226312AB200E9DB22 /* NotificationService.swift in Sources */, DB6D9F6F2635807F008423CD /* Setting.swift in Sources */, @@ -4839,7 +4839,7 @@ CODE_SIGN_ENTITLEMENTS = Mastodon/Mastodon.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 133; + CURRENT_PROJECT_VERSION = 138; DEVELOPMENT_ASSET_PATHS = "Mastodon/Resources/Preview\\ Assets.xcassets"; DEVELOPMENT_TEAM = 5Z4GVSS33P; INFOPLIST_FILE = Mastodon/Info.plist; @@ -4869,7 +4869,7 @@ CODE_SIGN_ENTITLEMENTS = Mastodon/Mastodon.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 133; + CURRENT_PROJECT_VERSION = 138; DEVELOPMENT_ASSET_PATHS = "Mastodon/Resources/Preview\\ Assets.xcassets"; DEVELOPMENT_TEAM = 5Z4GVSS33P; INFOPLIST_FILE = Mastodon/Info.plist; @@ -4977,11 +4977,11 @@ APPLICATION_EXTENSION_API_ONLY = YES; CLANG_ENABLE_MODULES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 133; + CURRENT_PROJECT_VERSION = 138; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5Z4GVSS33P; DYLIB_COMPATIBILITY_VERSION = 1; - DYLIB_CURRENT_VERSION = 133; + DYLIB_CURRENT_VERSION = 138; DYLIB_INSTALL_NAME_BASE = "@rpath"; INFOPLIST_FILE = AppShared/Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; @@ -5008,11 +5008,11 @@ APPLICATION_EXTENSION_API_ONLY = YES; CLANG_ENABLE_MODULES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 133; + CURRENT_PROJECT_VERSION = 138; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5Z4GVSS33P; DYLIB_COMPATIBILITY_VERSION = 1; - DYLIB_CURRENT_VERSION = 133; + DYLIB_CURRENT_VERSION = 138; DYLIB_INSTALL_NAME_BASE = "@rpath"; INFOPLIST_FILE = AppShared/Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; @@ -5103,7 +5103,7 @@ CODE_SIGN_ENTITLEMENTS = Mastodon/Mastodon.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 133; + CURRENT_PROJECT_VERSION = 138; DEVELOPMENT_ASSET_PATHS = "Mastodon/Resources/Preview\\ Assets.xcassets"; DEVELOPMENT_TEAM = 5Z4GVSS33P; INFOPLIST_FILE = Mastodon/Info.plist; @@ -5171,11 +5171,11 @@ APPLICATION_EXTENSION_API_ONLY = YES; CLANG_ENABLE_MODULES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 133; + CURRENT_PROJECT_VERSION = 138; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5Z4GVSS33P; DYLIB_COMPATIBILITY_VERSION = 1; - DYLIB_CURRENT_VERSION = 133; + DYLIB_CURRENT_VERSION = 138; DYLIB_INSTALL_NAME_BASE = "@rpath"; INFOPLIST_FILE = AppShared/Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; @@ -5200,7 +5200,7 @@ buildSettings = { CODE_SIGN_ENTITLEMENTS = NotificationService/NotificationService.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 133; + CURRENT_PROJECT_VERSION = 138; DEVELOPMENT_TEAM = 5Z4GVSS33P; INFOPLIST_FILE = NotificationService/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( @@ -5223,7 +5223,7 @@ buildSettings = { CODE_SIGN_ENTITLEMENTS = ShareActionExtension/ShareActionExtension.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 133; + CURRENT_PROJECT_VERSION = 138; DEVELOPMENT_TEAM = 5Z4GVSS33P; INFOPLIST_FILE = ShareActionExtension/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( @@ -5247,7 +5247,7 @@ buildSettings = { CODE_SIGN_ENTITLEMENTS = MastodonIntent/MastodonIntent.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 133; + CURRENT_PROJECT_VERSION = 138; DEVELOPMENT_TEAM = 5Z4GVSS33P; INFOPLIST_FILE = MastodonIntent/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( @@ -5271,7 +5271,7 @@ buildSettings = { CODE_SIGN_ENTITLEMENTS = MastodonIntent/MastodonIntent.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 133; + CURRENT_PROJECT_VERSION = 138; DEVELOPMENT_TEAM = 5Z4GVSS33P; INFOPLIST_FILE = MastodonIntent/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( @@ -5295,7 +5295,7 @@ buildSettings = { CODE_SIGN_ENTITLEMENTS = MastodonIntent/MastodonIntent.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 133; + CURRENT_PROJECT_VERSION = 138; DEVELOPMENT_TEAM = 5Z4GVSS33P; INFOPLIST_FILE = MastodonIntent/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( @@ -5319,7 +5319,7 @@ buildSettings = { CODE_SIGN_ENTITLEMENTS = ShareActionExtension/ShareActionExtension.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 133; + CURRENT_PROJECT_VERSION = 138; DEVELOPMENT_TEAM = 5Z4GVSS33P; INFOPLIST_FILE = ShareActionExtension/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( @@ -5343,7 +5343,7 @@ buildSettings = { CODE_SIGN_ENTITLEMENTS = ShareActionExtension/ShareActionExtension.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 133; + CURRENT_PROJECT_VERSION = 138; DEVELOPMENT_TEAM = 5Z4GVSS33P; INFOPLIST_FILE = ShareActionExtension/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( @@ -5430,7 +5430,7 @@ CODE_SIGN_ENTITLEMENTS = Mastodon/Mastodon.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 133; + CURRENT_PROJECT_VERSION = 138; DEVELOPMENT_ASSET_PATHS = "Mastodon/Resources/Preview\\ Assets.xcassets"; DEVELOPMENT_TEAM = 5Z4GVSS33P; INFOPLIST_FILE = Mastodon/Info.plist; @@ -5497,11 +5497,11 @@ APPLICATION_EXTENSION_API_ONLY = YES; CLANG_ENABLE_MODULES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 133; + CURRENT_PROJECT_VERSION = 138; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5Z4GVSS33P; DYLIB_COMPATIBILITY_VERSION = 1; - DYLIB_CURRENT_VERSION = 133; + DYLIB_CURRENT_VERSION = 138; DYLIB_INSTALL_NAME_BASE = "@rpath"; INFOPLIST_FILE = AppShared/Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; @@ -5525,7 +5525,7 @@ buildSettings = { CODE_SIGN_ENTITLEMENTS = NotificationService/NotificationService.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 133; + CURRENT_PROJECT_VERSION = 138; DEVELOPMENT_TEAM = 5Z4GVSS33P; INFOPLIST_FILE = NotificationService/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( @@ -5548,7 +5548,7 @@ buildSettings = { CODE_SIGN_ENTITLEMENTS = ShareActionExtension/ShareActionExtension.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 133; + CURRENT_PROJECT_VERSION = 138; DEVELOPMENT_TEAM = 5Z4GVSS33P; INFOPLIST_FILE = ShareActionExtension/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( @@ -5572,7 +5572,7 @@ buildSettings = { CODE_SIGN_ENTITLEMENTS = MastodonIntent/MastodonIntent.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 133; + CURRENT_PROJECT_VERSION = 138; DEVELOPMENT_TEAM = 5Z4GVSS33P; INFOPLIST_FILE = MastodonIntent/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( @@ -5596,7 +5596,7 @@ buildSettings = { CODE_SIGN_ENTITLEMENTS = NotificationService/NotificationService.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 133; + CURRENT_PROJECT_VERSION = 138; DEVELOPMENT_TEAM = 5Z4GVSS33P; INFOPLIST_FILE = NotificationService/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( @@ -5619,7 +5619,7 @@ buildSettings = { CODE_SIGN_ENTITLEMENTS = NotificationService/NotificationService.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 133; + CURRENT_PROJECT_VERSION = 138; DEVELOPMENT_TEAM = 5Z4GVSS33P; INFOPLIST_FILE = NotificationService/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( @@ -5763,14 +5763,6 @@ minimumVersion = 0.1.1; }; }; - DB01E23126A98F0900C3965B /* XCRemoteSwiftPackageReference "MetaTextKit" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/TwidereProject/MetaTextKit.git"; - requirement = { - kind = exactVersion; - version = 2.2.3; - }; - }; DB0E2D2C26833FF600865C3C /* XCRemoteSwiftPackageReference "Nuke-FLAnimatedImage-Plugin" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/kean/Nuke-FLAnimatedImage-Plugin.git"; @@ -5795,6 +5787,14 @@ minimumVersion = 5.4.0; }; }; + DB486C0D282E41F200F69423 /* XCRemoteSwiftPackageReference "TabBarPager" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/TwidereProject/TabBarPager.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 0.1.0; + }; + }; DB552D4D26BBD10C00E481F6 /* XCRemoteSwiftPackageReference "swift-collections" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/apple/swift-collections.git"; @@ -5904,11 +5904,6 @@ package = DB3EA8F6281BBA4C00598866 /* XCRemoteSwiftPackageReference "Alamofire" */; productName = Alamofire; }; - DB3EA8FF281BBB1D00598866 /* MetaTextKit */ = { - isa = XCSwiftPackageProductDependency; - package = DB01E23126A98F0900C3965B /* XCRemoteSwiftPackageReference "MetaTextKit" */; - productName = MetaTextKit; - }; DB3EA901281BBD5D00598866 /* CommonOSLog */ = { isa = XCSwiftPackageProductDependency; package = DB0140BB25C40D7500F9F3CF /* XCRemoteSwiftPackageReference "CommonOSLog" */; @@ -5959,6 +5954,11 @@ package = DB3EA8F6281BBA4C00598866 /* XCRemoteSwiftPackageReference "Alamofire" */; productName = Alamofire; }; + DB486C0E282E41F200F69423 /* TabBarPager */ = { + isa = XCSwiftPackageProductDependency; + package = DB486C0D282E41F200F69423 /* XCRemoteSwiftPackageReference "TabBarPager" */; + productName = TabBarPager; + }; DB552D4E26BBD10C00E481F6 /* OrderedCollections */ = { isa = XCSwiftPackageProductDependency; package = DB552D4D26BBD10C00E481F6 /* XCRemoteSwiftPackageReference "swift-collections" */; diff --git a/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist b/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist index 3e2139aa3..1c922a0b5 100644 --- a/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist +++ b/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist @@ -9,7 +9,7 @@ isShown orderHint - 5 + 9 CoreDataStack.xcscheme_^#shared#^_ @@ -24,22 +24,22 @@ Mastodon - RTL.xcscheme_^#shared#^_ orderHint - 8 + 11 Mastodon - Release.xcscheme_^#shared#^_ orderHint - 2 + 4 Mastodon - Snapshot.xcscheme_^#shared#^_ orderHint - 3 + 6 Mastodon - ar.xcscheme orderHint - 4 + 8 Mastodon - ar.xcscheme_^#shared#^_ @@ -114,7 +114,7 @@ MastodonIntent.xcscheme_^#shared#^_ orderHint - 28 + 30 MastodonIntents.xcscheme_^#shared#^_ @@ -134,7 +134,7 @@ ShareActionExtension.xcscheme_^#shared#^_ orderHint - 27 + 31 SuppressBuildableAutocreation diff --git a/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved index cca51e911..29c81554a 100644 --- a/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -105,8 +105,8 @@ "repositoryURL": "https://github.com/TwidereProject/MetaTextKit.git", "state": { "branch": null, - "revision": "2b9556a78b2986b8c0b04adc6da8ec206b448a0c", - "version": "2.2.3" + "revision": "dcd5255d6930c2fab408dc8562c577547e477624", + "version": "2.2.5" } }, { @@ -208,6 +208,15 @@ "version": "5.0.1" } }, + { + "package": "TabBarPager", + "repositoryURL": "https://github.com/TwidereProject/TabBarPager.git", + "state": { + "branch": null, + "revision": "488aa66d157a648901b61721212c0dec23d27ee5", + "version": "0.1.0" + } + }, { "package": "Tabman", "repositoryURL": "https://github.com/uias/Tabman", diff --git a/Mastodon/Coordinator/SceneCoordinator.swift b/Mastodon/Coordinator/SceneCoordinator.swift index 9df3040c7..4a4b43407 100644 --- a/Mastodon/Coordinator/SceneCoordinator.swift +++ b/Mastodon/Coordinator/SceneCoordinator.swift @@ -342,6 +342,7 @@ extension SceneCoordinator { case .custom(let transitioningDelegate): viewController.modalPresentationStyle = .custom viewController.transitioningDelegate = transitioningDelegate + // viewController.modalPresentationCapturesStatusBarAppearance = true (splitViewController ?? presentingViewController)?.present(viewController, animated: true, completion: nil) case .customPush(let animated): diff --git a/Mastodon/Info.plist b/Mastodon/Info.plist index 66fdfcaaf..0407f6502 100644 --- a/Mastodon/Info.plist +++ b/Mastodon/Info.plist @@ -30,7 +30,7 @@ CFBundlePackageType $(PRODUCT_BUNDLE_PACKAGE_TYPE) CFBundleShortVersionString - 1.4.2 + 1.4.3 CFBundleURLTypes @@ -43,7 +43,7 @@ CFBundleVersion - 133 + 138 ITSAppUsesNonExemptEncryption LSApplicationQueriesSchemes diff --git a/Mastodon/Protocol/PagerTabStripNavigateable.swift b/Mastodon/Protocol/PagerTabStripNavigateable.swift new file mode 100644 index 000000000..14ef8bbe6 --- /dev/null +++ b/Mastodon/Protocol/PagerTabStripNavigateable.swift @@ -0,0 +1,106 @@ +// +// PagerTabStripNavigateable.swift +// Mastodon +// +// Created by MainasuK on 2022-6-2. +// + +import UIKit +import XLPagerTabStrip +import MastodonLocalization + +typealias PagerTabStripNavigateable = PagerTabStripNavigateableCore & PagerTabStripNavigateableRelay + +protocol PagerTabStripNavigateableCore: AnyObject { + var navigateablePageViewController: PagerTabStripViewController { get } + var pagerTabStripNavigateKeyCommands: [UIKeyCommand] { get } + + func pagerTabStripNavigateKeyCommandHandler(_ sender: UIKeyCommand) + func navigate(direction: PagerTabStripNavigationDirection) +} + +@objc protocol PagerTabStripNavigateableRelay: AnyObject { + func pagerTabStripNavigateKeyCommandHandlerRelay(_ sender: UIKeyCommand) +} + +enum PagerTabStripNavigationDirection: String, CaseIterable { + case previous + case next + + var title: String { + switch self { + case .previous: return L10n.Common.Controls.Keyboard.SegmentedControl.previousSection + case .next: return L10n.Common.Controls.Keyboard.SegmentedControl.nextSection + } + } + + // UIKeyCommand input + var input: String { + switch self { + case .previous: return "[" + case .next: return "]" + } + } + + var modifierFlags: UIKeyModifierFlags { + switch self { + case .previous: return [.shift, .command] + case .next: return [.shift, .command] + } + } + + var propertyList: Any { + return rawValue + } +} + +extension PagerTabStripNavigateableCore where Self: PagerTabStripNavigateableRelay { + var pagerTabStripNavigateKeyCommands: [UIKeyCommand] { + PagerTabStripNavigationDirection.allCases.map { direction in + UIKeyCommand( + title: direction.title, + image: nil, + action: #selector(Self.pagerTabStripNavigateKeyCommandHandlerRelay(_:)), + input: direction.input, + modifierFlags: direction.modifierFlags, + propertyList: direction.propertyList, + alternates: [], + discoverabilityTitle: nil, + attributes: [], + state: .off + ) + } + } + + func pagerTabStripNavigateKeyCommandHandler(_ sender: UIKeyCommand) { + guard let rawValue = sender.propertyList as? String, + let direction = PagerTabStripNavigationDirection(rawValue: rawValue) else { return } + navigate(direction: direction) + } + +} + +extension PagerTabStripNavigateableCore { + func navigate(direction: PagerTabStripNavigationDirection) { + let index = navigateablePageViewController.currentIndex + let targetIndex: Int + + switch direction { + case .previous: + targetIndex = index - 1 + case .next: + targetIndex = index + 1 + } + + guard targetIndex >= 0, + !navigateablePageViewController.viewControllers.isEmpty, + targetIndex < navigateablePageViewController.viewControllers.count, + navigateablePageViewController.canMoveTo(index: targetIndex) + else { + return + } + + navigateablePageViewController.moveToViewController(at: targetIndex) + } +} + diff --git a/Mastodon/Protocol/Provider/DataSourceProvider+StatusTableViewControllerNavigateable.swift b/Mastodon/Protocol/Provider/DataSourceProvider+StatusTableViewControllerNavigateable.swift index ce4e03cdb..c80121e98 100644 --- a/Mastodon/Protocol/Provider/DataSourceProvider+StatusTableViewControllerNavigateable.swift +++ b/Mastodon/Protocol/Provider/DataSourceProvider+StatusTableViewControllerNavigateable.swift @@ -112,6 +112,12 @@ extension StatusTableViewControllerNavigateableCore where Self: DataSourceProvid @MainActor private func previewImage() async { guard let status = await statusRecord() else { return } + + // workaround media preview not first responder issue + if let presentedViewController = presentedViewController as? MediaPreviewViewController { + presentedViewController.dismiss(animated: true, completion: nil) + return + } guard let provider = self as? (DataSourceProvider & MediaPreviewableViewController) else { return } guard let indexPathForSelectedRow = tableView.indexPathForSelectedRow, diff --git a/Mastodon/Protocol/ScrollViewContainer.swift b/Mastodon/Protocol/ScrollViewContainer.swift index c9f10ba3a..ae79d0e0f 100644 --- a/Mastodon/Protocol/ScrollViewContainer.swift +++ b/Mastodon/Protocol/ScrollViewContainer.swift @@ -8,12 +8,12 @@ import UIKit protocol ScrollViewContainer: UIViewController { - var scrollView: UIScrollView? { get } + var scrollView: UIScrollView { get } func scrollToTop(animated: Bool) } extension ScrollViewContainer { func scrollToTop(animated: Bool) { - scrollView?.scrollRectToVisible(CGRect(origin: .zero, size: CGSize(width: 1, height: 1)), animated: animated) + scrollView.scrollRectToVisible(CGRect(origin: .zero, size: CGSize(width: 1, height: 1)), animated: animated) } } diff --git a/Mastodon/Scene/Account/Cell/AccountListTableViewCell.swift b/Mastodon/Scene/Account/Cell/AccountListTableViewCell.swift index 2b480464d..96111d9f8 100644 --- a/Mastodon/Scene/Account/Cell/AccountListTableViewCell.swift +++ b/Mastodon/Scene/Account/Cell/AccountListTableViewCell.swift @@ -23,7 +23,6 @@ final class AccountListTableViewCell: UITableViewCell { let checkmarkImageView: UIImageView = { let image = UIImage(systemName: "checkmark", withConfiguration: UIImage.SymbolConfiguration(pointSize: 17, weight: .semibold)) let imageView = UIImageView(image: image) - imageView.tintColor = .label return imageView }() let separatorLine = UIView.separatorLine diff --git a/Mastodon/Scene/Account/View/BadgeButton.swift b/Mastodon/Scene/Account/View/BadgeButton.swift index 785053be9..c4fd28e89 100644 --- a/Mastodon/Scene/Account/View/BadgeButton.swift +++ b/Mastodon/Scene/Account/View/BadgeButton.swift @@ -26,10 +26,14 @@ final class BadgeButton: UIButton { extension BadgeButton { private func _init() { titleLabel?.font = UIFontMetrics(forTextStyle: .caption1).scaledFont(for: .systemFont(ofSize: 13, weight: .medium)) - setBackgroundColor(.systemBackground, for: .normal) - setTitleColor(.label, for: .normal) - contentEdgeInsets = UIEdgeInsets(top: 6, left: 6, bottom: 6, right: 6) + setAppearance() + } + + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + + setAppearance() } override func layoutSubviews() { @@ -39,6 +43,12 @@ extension BadgeButton { layer.cornerRadius = frame.height * 0.5 } + private func setAppearance() { + setBackgroundColor(Asset.Colors.Label.primary.color, for: .normal) + setTitleColor(Asset.Colors.Label.primaryReverse.color, for: .normal) + tintColor = Asset.Colors.Label.primary.color + } + func setBadge(number: Int) { let number = min(99, max(0, number)) setTitle("\(number)", for: .normal) diff --git a/Mastodon/Scene/Discovery/Community/DiscoveryCommunityViewController.swift b/Mastodon/Scene/Discovery/Community/DiscoveryCommunityViewController.swift index 4cc32c250..524805ad7 100644 --- a/Mastodon/Scene/Discovery/Community/DiscoveryCommunityViewController.swift +++ b/Mastodon/Scene/Discovery/Community/DiscoveryCommunityViewController.swift @@ -148,9 +148,7 @@ extension DiscoveryCommunityViewController: StatusTableViewCellDelegate { } // MARK: ScrollViewContainer extension DiscoveryCommunityViewController: ScrollViewContainer { - var scrollView: UIScrollView? { - tableView - } + var scrollView: UIScrollView { tableView } } extension DiscoveryCommunityViewController { diff --git a/Mastodon/Scene/Discovery/DiscoveryViewController.swift b/Mastodon/Scene/Discovery/DiscoveryViewController.swift index 1803f687a..d94e6e592 100644 --- a/Mastodon/Scene/Discovery/DiscoveryViewController.swift +++ b/Mastodon/Scene/Discovery/DiscoveryViewController.swift @@ -130,8 +130,8 @@ extension DiscoveryViewController { // MARK: - ScrollViewContainer extension DiscoveryViewController: ScrollViewContainer { - var scrollView: UIScrollView? { - return (currentViewController as? ScrollViewContainer)?.scrollView + var scrollView: UIScrollView { + return (currentViewController as? ScrollViewContainer)?.scrollView ?? UIScrollView() } } diff --git a/Mastodon/Scene/Discovery/ForYou/DiscoveryForYouViewController.swift b/Mastodon/Scene/Discovery/ForYou/DiscoveryForYouViewController.swift index c7b8fb7f5..9f6368e63 100644 --- a/Mastodon/Scene/Discovery/ForYou/DiscoveryForYouViewController.swift +++ b/Mastodon/Scene/Discovery/ForYou/DiscoveryForYouViewController.swift @@ -168,8 +168,6 @@ extension DiscoveryForYouViewController: ProfileCardTableViewCellDelegate { // MARK: ScrollViewContainer extension DiscoveryForYouViewController: ScrollViewContainer { - var scrollView: UIScrollView? { - tableView - } + var scrollView: UIScrollView { tableView } } diff --git a/Mastodon/Scene/Discovery/Hashtags/DiscoveryHashtagsViewController.swift b/Mastodon/Scene/Discovery/Hashtags/DiscoveryHashtagsViewController.swift index 6e6d96924..20ad408a2 100644 --- a/Mastodon/Scene/Discovery/Hashtags/DiscoveryHashtagsViewController.swift +++ b/Mastodon/Scene/Discovery/Hashtags/DiscoveryHashtagsViewController.swift @@ -127,9 +127,7 @@ extension DiscoveryHashtagsViewController: UITableViewDelegate { // MARK: ScrollViewContainer extension DiscoveryHashtagsViewController: ScrollViewContainer { - var scrollView: UIScrollView? { - tableView - } + var scrollView: UIScrollView { tableView } } extension DiscoveryHashtagsViewController { diff --git a/Mastodon/Scene/Discovery/News/DiscoveryNewsViewController.swift b/Mastodon/Scene/Discovery/News/DiscoveryNewsViewController.swift index f73602ae4..d2415145c 100644 --- a/Mastodon/Scene/Discovery/News/DiscoveryNewsViewController.swift +++ b/Mastodon/Scene/Discovery/News/DiscoveryNewsViewController.swift @@ -127,9 +127,7 @@ extension DiscoveryNewsViewController: UITableViewDelegate { // MARK: ScrollViewContainer extension DiscoveryNewsViewController: ScrollViewContainer { - var scrollView: UIScrollView? { - tableView - } + var scrollView: UIScrollView { tableView } } extension DiscoveryNewsViewController { diff --git a/Mastodon/Scene/Discovery/Posts/DiscoveryPostsViewController.swift b/Mastodon/Scene/Discovery/Posts/DiscoveryPostsViewController.swift index a1d5b5e76..537ca1c58 100644 --- a/Mastodon/Scene/Discovery/Posts/DiscoveryPostsViewController.swift +++ b/Mastodon/Scene/Discovery/Posts/DiscoveryPostsViewController.swift @@ -160,9 +160,7 @@ extension DiscoveryPostsViewController: StatusTableViewCellDelegate { } // MARK: ScrollViewContainer extension DiscoveryPostsViewController: ScrollViewContainer { - var scrollView: UIScrollView? { - tableView - } + var scrollView: UIScrollView { tableView } } // MARK: - DiscoveryIntroBannerViewDelegate diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift index 64d3d5941..871d47c28 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift @@ -537,13 +537,9 @@ extension HomeTimelineViewController: TimelineMiddleLoaderTableViewCellDelegate // MARK: - ScrollViewContainer extension HomeTimelineViewController: ScrollViewContainer { - var scrollView: UIScrollView? { return tableView } + var scrollView: UIScrollView { return tableView } func scrollToTop(animated: Bool) { - guard let scrollView = scrollView else { - return - } - if scrollView.contentOffset.y < scrollView.frame.height, viewModel.loadLatestStateMachine.canEnterState(HomeTimelineViewModel.LoadLatestState.Loading.self), (scrollView.contentOffset.y + scrollView.adjustedContentInset.top) == 0.0, diff --git a/Mastodon/Scene/MediaPreview/MediaPreviewViewController.swift b/Mastodon/Scene/MediaPreview/MediaPreviewViewController.swift index ae55134c4..e1e367e37 100644 --- a/Mastodon/Scene/MediaPreview/MediaPreviewViewController.swift +++ b/Mastodon/Scene/MediaPreview/MediaPreviewViewController.swift @@ -135,6 +135,18 @@ extension MediaPreviewViewController { } } .store(in: &disposeBag) + +// viewModel.$isPoping +// .receive(on: DispatchQueue.main) +// .removeDuplicates() +// .sink { [weak self] _ in +// guard let self = self else { return } +// // statusBar style update with animation +// self.setNeedsStatusBarAppearanceUpdate() +// UIView.animate(withDuration: 0.3) { +// } +// } +// .store(in: &disposeBag) } } diff --git a/Mastodon/Scene/Notification/NotificationTimeline/NotificationTimelineViewController.swift b/Mastodon/Scene/Notification/NotificationTimeline/NotificationTimelineViewController.swift index 16130251c..300b9165d 100644 --- a/Mastodon/Scene/Notification/NotificationTimeline/NotificationTimelineViewController.swift +++ b/Mastodon/Scene/Notification/NotificationTimeline/NotificationTimelineViewController.swift @@ -203,9 +203,7 @@ extension NotificationTimelineViewController: NotificationTableViewCellDelegate // MARK: - ScrollViewContainer extension NotificationTimelineViewController: ScrollViewContainer { - - var scrollView: UIScrollView? { tableView } - + var scrollView: UIScrollView { tableView } } extension NotificationTimelineViewController { diff --git a/Mastodon/Scene/Notification/NotificationTimeline/NotificationTimelineViewModel+LoadOldestState.swift b/Mastodon/Scene/Notification/NotificationTimeline/NotificationTimelineViewModel+LoadOldestState.swift index bc67a6304..5461223cb 100644 --- a/Mastodon/Scene/Notification/NotificationTimeline/NotificationTimelineViewModel+LoadOldestState.swift +++ b/Mastodon/Scene/Notification/NotificationTimeline/NotificationTimelineViewModel+LoadOldestState.swift @@ -108,7 +108,7 @@ extension NotificationTimelineViewModel.LoadOldestState { logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): fetch statues failed: \(error.localizedDescription)") await self.enter(state: Fail.self) } - } // Task + } // end Task } } diff --git a/Mastodon/Scene/Notification/NotificationTimeline/NotificationTimelineViewModel.swift b/Mastodon/Scene/Notification/NotificationTimeline/NotificationTimelineViewModel.swift index ee2ac8a0e..c48ed1199 100644 --- a/Mastodon/Scene/Notification/NotificationTimeline/NotificationTimelineViewModel.swift +++ b/Mastodon/Scene/Notification/NotificationTimeline/NotificationTimelineViewModel.swift @@ -88,7 +88,6 @@ extension NotificationTimelineViewModel { } } - var excludeTypes: [MastodonNotificationType]? { switch self { case .everything: return nil diff --git a/Mastodon/Scene/Notification/NotificationViewController.swift b/Mastodon/Scene/Notification/NotificationViewController.swift index dd4d97047..0935c9967 100644 --- a/Mastodon/Scene/Notification/NotificationViewController.swift +++ b/Mastodon/Scene/Notification/NotificationViewController.swift @@ -170,9 +170,9 @@ extension NotificationViewController { // MARK: - ScrollViewContainer extension NotificationViewController: ScrollViewContainer { - var scrollView: UIScrollView? { + var scrollView: UIScrollView { guard let viewController = currentViewController as? NotificationTimelineViewController else { - return nil + return UIScrollView() } return viewController.scrollView } diff --git a/Mastodon/Scene/Profile/About/ProfileAboutViewController.swift b/Mastodon/Scene/Profile/About/ProfileAboutViewController.swift index 4879be744..47385813d 100644 --- a/Mastodon/Scene/Profile/About/ProfileAboutViewController.swift +++ b/Mastodon/Scene/Profile/About/ProfileAboutViewController.swift @@ -9,6 +9,9 @@ import os.log import UIKit import Combine import MetaTextKit +import MastodonLocalization +import TabBarPager +import XLPagerTabStrip protocol ProfileAboutViewControllerDelegate: AnyObject { func profileAboutViewController(_ viewController: ProfileAboutViewController, profileFieldCollectionViewCell: ProfileFieldCollectionViewCell, metaLabel: MetaLabel, didSelectMeta meta: Meta) @@ -162,7 +165,17 @@ extension ProfileAboutViewController: ProfileFieldEditCollectionViewCellDelegate // MARK: - ScrollViewContainer extension ProfileAboutViewController: ScrollViewContainer { - var scrollView: UIScrollView? { - collectionView + var scrollView: UIScrollView { collectionView } +} + +// MARK: - TabBarPage +extension ProfileAboutViewController: TabBarPage { + var pageScrollView: UIScrollView { scrollView } +} + +// MARK: - IndicatorInfoProvider +extension ProfileAboutViewController: IndicatorInfoProvider { + func indicatorInfo(for pagerTabStripController: PagerTabStripViewController) -> IndicatorInfo { + return IndicatorInfo(title: L10n.Scene.Profile.SegmentedControl.about) } } diff --git a/Mastodon/Scene/Profile/About/ProfileAboutViewModel+Diffable.swift b/Mastodon/Scene/Profile/About/ProfileAboutViewModel+Diffable.swift index 259cad12d..0a11a71f6 100644 --- a/Mastodon/Scene/Profile/About/ProfileAboutViewModel+Diffable.swift +++ b/Mastodon/Scene/Profile/About/ProfileAboutViewModel+Diffable.swift @@ -25,7 +25,8 @@ extension ProfileAboutViewModel { profileFieldEditCollectionViewCellDelegate: profileFieldEditCollectionViewCellDelegate ) ) - + self.diffableDataSource = diffableDataSource + diffableDataSource.reorderingHandlers.canReorderItem = { item -> Bool in switch item { case .editField: return true @@ -42,22 +43,25 @@ extension ProfileAboutViewModel { guard case let .editField(field) = item else { continue } fields.append(field) } - self.editProfileInfo.fields = fields + self.profileInfoEditing.fields = fields } - self.diffableDataSource = diffableDataSource + + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([.main]) + diffableDataSource.apply(snapshot) Publishers.CombineLatest4( $isEditing.removeDuplicates(), - displayProfileInfo.$fields.removeDuplicates(), - editProfileInfo.$fields.removeDuplicates(), + profileInfo.$fields.removeDuplicates(), + profileInfoEditing.$fields.removeDuplicates(), $emojiMeta.removeDuplicates() ) .throttle(for: 0.3, scheduler: DispatchQueue.main, latest: true) .sink { [weak self] isEditing, displayFields, editingFields, emojiMeta in guard let self = self else { return } guard let diffableDataSource = self.diffableDataSource else { return } - + var snapshot = NSDiffableDataSourceSnapshot() snapshot.appendSections([.main]) @@ -69,17 +73,17 @@ extension ProfileAboutViewModel { return ProfileFieldItem.field(field: field) } } - + if isEditing, fields.count < ProfileHeaderViewModel.maxProfileFieldCount { items.append(.addEntry) } - + if !isEditing, items.isEmpty { items.append(.noResult) } - + snapshot.appendItems(items, toSection: .main) - + diffableDataSource.apply(snapshot, animatingDifferences: false, completion: nil) } .store(in: &disposeBag) diff --git a/Mastodon/Scene/Profile/About/ProfileAboutViewModel.swift b/Mastodon/Scene/Profile/About/ProfileAboutViewModel.swift index c7ef895dd..8498c6866 100644 --- a/Mastodon/Scene/Profile/About/ProfileAboutViewModel.swift +++ b/Mastodon/Scene/Profile/About/ProfileAboutViewModel.swift @@ -8,6 +8,7 @@ import os.log import UIKit import Combine +import CoreDataStack import MastodonSDK import MastodonMeta import Kanna @@ -18,41 +19,69 @@ final class ProfileAboutViewModel { // input let context: AppContext + @Published var user: MastodonUser? @Published var isEditing = false @Published var accountForEdit: Mastodon.Entity.Account? - @Published var emojiMeta: MastodonContent.Emojis = [:] // output var diffableDataSource: UICollectionViewDiffableDataSource? + let profileInfo = ProfileInfo() + let profileInfoEditing = ProfileInfo() - let displayProfileInfo = ProfileInfo() - let editProfileInfo = ProfileInfo() - let editProfileInfoDidInitialized = CurrentValueSubject(Void()) // needs trigger initial event + @Published var fields: [MastodonField] = [] + @Published var emojiMeta: MastodonContent.Emojis = [:] init(context: AppContext) { self.context = context // end init + $user + .compactMap { $0 } + .flatMap { $0.publisher(for: \.emojis) } + .map { $0.asDictionary } + .assign(to: &$emojiMeta) + + $user + .compactMap { $0 } + .flatMap { $0.publisher(for: \.fields) } + .assign(to: &$fields) + Publishers.CombineLatest( - $isEditing.removeDuplicates(), // only trigger when value toggle - $accountForEdit + $fields, + $emojiMeta + ) + .map { fields, emojiMeta in + fields.map { ProfileFieldItem.FieldValue(name: $0.name, value: $0.value, emojiMeta: emojiMeta) } + } + .assign(to: &profileInfo.$fields) + + Publishers.CombineLatest( + $accountForEdit, + $emojiMeta ) .receive(on: DispatchQueue.main) - .sink { [weak self] isEditing, account in + .sink { [weak self] account, emojiMeta in guard let self = self else { return } - guard isEditing else { return } + guard let account = account else { return } - // setup editing value when toggle to editing - self.editProfileInfo.fields = account?.source?.fields?.compactMap { field in + self.profileInfo.fields = account.source?.fields?.compactMap { field in + ProfileFieldItem.FieldValue( + name: field.name, + value: field.value, + emojiMeta: emojiMeta + ) + } ?? [] + + self.profileInfoEditing.fields = account.source?.fields?.compactMap { field in ProfileFieldItem.FieldValue( name: field.name, value: field.value, emojiMeta: [:] // no use for editing ) } ?? [] - self.editProfileInfoDidInitialized.send() } .store(in: &disposeBag) + } } @@ -65,31 +94,31 @@ extension ProfileAboutViewModel { extension ProfileAboutViewModel { func appendFieldItem() { - var fields = editProfileInfo.fields + var fields = profileInfoEditing.fields guard fields.count < ProfileHeaderViewModel.maxProfileFieldCount else { return } fields.append(ProfileFieldItem.FieldValue(name: "", value: "", emojiMeta: [:])) - editProfileInfo.fields = fields + profileInfoEditing.fields = fields } func removeFieldItem(item: ProfileFieldItem) { - var fields = editProfileInfo.fields + var fields = profileInfoEditing.fields guard case let .editField(field) = item else { return } guard let removeIndex = fields.firstIndex(of: field) else { return } fields.remove(at: removeIndex) - editProfileInfo.fields = fields + profileInfoEditing.fields = fields } } // MARK: - ProfileViewModelEditable extension ProfileAboutViewModel: ProfileViewModelEditable { - func isEdited() -> Bool { + var isEdited: Bool { guard isEditing else { return false } let isFieldsEqual: Bool = { let originalFields = self.accountForEdit?.source?.fields?.compactMap { field in ProfileFieldItem.FieldValue(name: field.name, value: field.value, emojiMeta: [:]) } ?? [] - let editFields = editProfileInfo.fields + let editFields = profileInfoEditing.fields guard editFields.count == originalFields.count else { return false } for (editField, originalField) in zip(editFields, originalFields) { guard editField.name.value == originalField.name.value, diff --git a/Mastodon/Scene/Profile/Header/ProfileHeaderViewController.swift b/Mastodon/Scene/Profile/Header/ProfileHeaderViewController.swift index de6ad5415..f35ac6aa4 100644 --- a/Mastodon/Scene/Profile/Header/ProfileHeaderViewController.swift +++ b/Mastodon/Scene/Profile/Header/ProfileHeaderViewController.swift @@ -8,6 +8,7 @@ import os.log import UIKit import Combine +import CoreDataStack import PhotosUI import AlamofireImage import CropViewController @@ -15,22 +16,31 @@ import MastodonMeta import MetaTextKit import MastodonAsset import MastodonLocalization -import Tabman +import TabBarPager protocol ProfileHeaderViewControllerDelegate: AnyObject { - func profileHeaderViewController(_ viewController: ProfileHeaderViewController, viewLayoutDidUpdate view: UIView) + func profileHeaderViewController(_ profileHeaderViewController: ProfileHeaderViewController, profileHeaderView: ProfileHeaderView, relationshipButtonDidPressed button: ProfileRelationshipActionButton) + func profileHeaderViewController(_ profileHeaderViewController: ProfileHeaderViewController, profileHeaderView: ProfileHeaderView, metaTextView: MetaTextView, metaDidPressed meta: Meta) } -final class ProfileHeaderViewController: UIViewController { +final class ProfileHeaderViewController: UIViewController, NeedsDependency, MediaPreviewableViewController { + + let logger = Logger(subsystem: "ProfileHeaderViewController", category: "ViewController") static let segmentedControlHeight: CGFloat = 50 static let headerMinHeight: CGFloat = segmentedControlHeight - var disposeBag = Set() - weak var delegate: ProfileHeaderViewControllerDelegate? + weak var context: AppContext! { willSet { precondition(!isViewLoaded) } } + weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } } + var disposeBag = Set() var viewModel: ProfileHeaderViewModel! + weak var delegate: ProfileHeaderViewControllerDelegate? + weak var headerDelegate: TabBarPagerHeaderDelegate? + + let mediaPreviewTransitionController = MediaPreviewTransitionController() + let titleView: DoubleTitleLabelNavigationBarTitleView = { let titleView = DoubleTitleLabelNavigationBarTitleView() titleView.titleLabel.textColor = .white @@ -43,39 +53,8 @@ final class ProfileHeaderViewController: UIViewController { }() let profileHeaderView = ProfileHeaderView() - - let buttonBar: TMBar.ButtonBar = { - let buttonBar = TMBar.ButtonBar() - buttonBar.indicator.backgroundColor = Asset.Colors.Label.primary.color - buttonBar.backgroundView.style = .clear - buttonBar.layout.contentInset = .zero - return buttonBar - }() - func customizeButtonBarAppearance() { - // The implmention use CATextlayer. Adapt for Dark Mode without dynamic colors - // Needs trigger update when `userInterfaceStyle` chagnes - let userInterfaceStyle = traitCollection.userInterfaceStyle - buttonBar.buttons.customize { button in - switch userInterfaceStyle { - case .dark: - // Asset.Colors.Label.primary.color - button.selectedTintColor = UIColor(red: 238.0/255.0, green: 238.0/255.0, blue: 238.0/255.0, alpha: 1.0) - // Asset.Colors.Label.secondary.color - button.tintColor = UIColor(red: 151.0/255.0, green: 157.0/255.0, blue: 173.0/255.0, alpha: 1.0) - default: - // Asset.Colors.Label.primary.color - button.selectedTintColor = UIColor(red: 40.0/255.0, green: 44.0/255.0, blue: 55.0/255.0, alpha: 1.0) - // Asset.Colors.Label.secondary.color - button.tintColor = UIColor(red: 60.0/255.0, green: 60.0/255.0, blue: 67.0/255.0, alpha: 0.6) - } - - button.backgroundColor = .clear - } - } - - private var isBannerPinned = false - private var bottomShadowAlpha: CGFloat = 0.0 +// private var isBannerPinned = false // private var isAdjustBannerImageViewForSafeAreaInset = false private var containerSafeAreaInset: UIEdgeInsets = .zero @@ -103,7 +82,7 @@ final class ProfileHeaderViewController: UIViewController { }() deinit { - os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) } } @@ -113,7 +92,7 @@ extension ProfileHeaderViewController { override func viewDidLoad() { super.viewDidLoad() - customizeButtonBarAppearance() + view.setContentHuggingPriority(.required - 1, for: .vertical) view.backgroundColor = ThemeService.shared.currentTheme.value.systemBackgroundColor ThemeService.shared.currentTheme @@ -124,137 +103,73 @@ extension ProfileHeaderViewController { } .store(in: &disposeBag) +// profileHeaderView.preservesSuperviewLayoutMargins = true profileHeaderView.translatesAutoresizingMaskIntoConstraints = false view.addSubview(profileHeaderView) NSLayoutConstraint.activate([ profileHeaderView.topAnchor.constraint(equalTo: view.topAnchor), profileHeaderView.leadingAnchor.constraint(equalTo: view.leadingAnchor), profileHeaderView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + view.bottomAnchor.constraint(equalTo: profileHeaderView.bottomAnchor), ]) - profileHeaderView.preservesSuperviewLayoutMargins = true - - Publishers.CombineLatest( - viewModel.viewDidAppear.eraseToAnyPublisher(), - viewModel.isTitleViewContentOffsetSet.eraseToAnyPublisher() - ) - .receive(on: DispatchQueue.main) - .sink { [weak self] viewDidAppear, isTitleViewContentOffsetDidSet in - guard let self = self else { return } - self.titleView.titleLabel.alpha = viewDidAppear && isTitleViewContentOffsetDidSet ? 1 : 0 - self.titleView.subtitleLabel.alpha = viewDidAppear && isTitleViewContentOffsetDidSet ? 1 : 0 - } - .store(in: &disposeBag) - - viewModel.needsSetupBottomShadow - .receive(on: DispatchQueue.main) - .sink { [weak self] needsSetupBottomShadow in - guard let self = self else { return } - self.setupBottomShadow() - } - .store(in: &disposeBag) - - Publishers.CombineLatest4( - viewModel.$isEditing.eraseToAnyPublisher(), - viewModel.displayProfileInfo.$avatarImageResource.eraseToAnyPublisher(), - viewModel.editProfileInfo.$avatarImageResource.eraseToAnyPublisher(), - viewModel.viewDidAppear.eraseToAnyPublisher() - ) - .receive(on: DispatchQueue.main) - .sink { [weak self] isEditing, displayResource, editingResource, _ in - guard let self = self else { return } - - let url = displayResource.url - let image = editingResource.image - - self.profileHeaderView.avatarButton.avatarImageView.configure( - configuration: AvatarImageView.Configuration( - url: isEditing && image != nil ? nil : url, - placeholder: image ?? UIImage.placeholder(color: Asset.Theme.Mastodon.systemGroupedBackground.color) - ) - ) - } - .store(in: &disposeBag) - Publishers.CombineLatest4( - viewModel.$isEditing, - viewModel.displayProfileInfo.$name.removeDuplicates(), - viewModel.editProfileInfo.$name.removeDuplicates(), - viewModel.$emojiMeta - ) - .receive(on: DispatchQueue.main) - .sink { [weak self] isEditing, name, editingName, emojiMeta in - guard let self = self else { return } - do { - let mastodonContent = MastodonContent(content: name ?? " ", emojis: emojiMeta) - let metaContent = try MastodonMetaContent.convert(document: mastodonContent) - self.profileHeaderView.nameMetaText.configure(content: metaContent) - } catch { - assertionFailure() - } - self.profileHeaderView.nameTextField.text = isEditing ? editingName : name - } - .store(in: &disposeBag) - - let profileNote = Publishers.CombineLatest3( - viewModel.$isEditing.removeDuplicates(), - viewModel.displayProfileInfo.$note.removeDuplicates(), - viewModel.editProfileInfoDidInitialized - ) - .map { isEditing, displayNote, _ -> String? in - if isEditing { - return self.viewModel.editProfileInfo.note - } else { - return displayNote - } - } - .eraseToAnyPublisher() - - Publishers.CombineLatest3( - viewModel.$isEditing.removeDuplicates(), - profileNote.removeDuplicates(), - viewModel.$emojiMeta.removeDuplicates() - ) - .receive(on: DispatchQueue.main) - .sink { [weak self] isEditing, note, emojiMeta in - guard let self = self else { return } - - self.profileHeaderView.bioMetaText.textView.isEditable = isEditing - - if isEditing { - let metaContent = PlaintextMetaContent(string: note ?? "") - self.profileHeaderView.bioMetaText.configure(content: metaContent) - } else { - let mastodonContent = MastodonContent(content: note ?? "", emojis: emojiMeta) - do { - let metaContent = try MastodonMetaContent.convert(document: mastodonContent) - self.profileHeaderView.bioMetaText.configure(content: metaContent) - } catch { - assertionFailure() - self.profileHeaderView.bioMetaText.reset() - } - } - } - .store(in: &disposeBag) - profileHeaderView.bioMetaText.delegate = self - + NotificationCenter.default.publisher(for: UITextField.textDidChangeNotification, object: profileHeaderView.nameTextField) .receive(on: DispatchQueue.main) .sink { [weak self] notification in guard let self = self else { return } guard let textField = notification.object as? UITextField else { return } - self.viewModel.editProfileInfo.name = textField.text + self.viewModel.profileInfoEditing.name = textField.text } .store(in: &disposeBag) - profileHeaderView.editAvatarButton.menu = createAvatarContextMenu() - profileHeaderView.editAvatarButton.showsMenuAsPrimaryAction = true + profileHeaderView.editAvatarButtonOverlayIndicatorView.menu = createAvatarContextMenu() + profileHeaderView.editAvatarButtonOverlayIndicatorView.showsMenuAsPrimaryAction = true + profileHeaderView.delegate = self + + // bind viewModel + viewModel.$isTitleViewContentOffsetSet + .receive(on: DispatchQueue.main) + .sink { [weak self] isTitleViewContentOffsetDidSet in + guard let self = self else { return } + self.titleView.titleLabel.alpha = isTitleViewContentOffsetDidSet ? 1 : 0 + self.titleView.subtitleLabel.alpha = isTitleViewContentOffsetDidSet ? 1 : 0 + } + .store(in: &disposeBag) + viewModel.$user + .receive(on: DispatchQueue.main) + .sink { [weak self] user in + guard let self = self else { return } + guard let user = user else { return } + self.profileHeaderView.prepareForReuse() + self.profileHeaderView.configuration(user: user) + } + .store(in: &disposeBag) + viewModel.$relationshipActionOptionSet + .assign(to: \.relationshipActionOptionSet, on: profileHeaderView.viewModel) + .store(in: &disposeBag) + viewModel.$isEditing + .assign(to: \.isEditing, on: profileHeaderView.viewModel) + .store(in: &disposeBag) + viewModel.$isUpdating + .assign(to: \.isUpdating, on: profileHeaderView.viewModel) + .store(in: &disposeBag) + viewModel.profileInfoEditing.$avatar + .assign(to: \.avatarImageEditing, on: profileHeaderView.viewModel) + .store(in: &disposeBag) + viewModel.profileInfoEditing.$name + .assign(to: \.nameEditing, on: profileHeaderView.viewModel) + .store(in: &disposeBag) + viewModel.profileInfoEditing.$note + .assign(to: \.noteEditing, on: profileHeaderView.viewModel) + .store(in: &disposeBag) } override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) - viewModel.viewDidAppear.value = true - + profileHeaderView.viewModel.viewDidAppear.send() + // set display after view appear profileHeaderView.setupAvatarOverlayViews() } @@ -262,14 +177,7 @@ extension ProfileHeaderViewController { override func viewDidLayoutSubviews() { super.viewDidLayoutSubviews() - delegate?.profileHeaderViewController(self, viewLayoutDidUpdate: view) - setupBottomShadow() - } - - override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { - super.traitCollectionDidChange(previousTraitCollection) - - customizeButtonBarAppearance() + headerDelegate?.viewLayoutDidUpdate(self) } } @@ -321,56 +229,8 @@ extension ProfileHeaderViewController { containerSafeAreaInset = inset } - func setupBottomShadow() { - guard viewModel.needsSetupBottomShadow.value else { - view.layer.shadowColor = nil - view.layer.shadowRadius = 0 - return - } - view.layer.setupShadow(color: UIColor.black.withAlphaComponent(0.12), alpha: Float(bottomShadowAlpha), x: 0, y: 2, blur: 2, spread: 0, roundedRect: view.bounds, byRoundingCorners: .allCorners, cornerRadii: .zero) - } - - private func updateHeaderBottomShadow(progress: CGFloat) { - let alpha = min(max(0, 10 * progress - 9), 1) - if bottomShadowAlpha != alpha { - bottomShadowAlpha = alpha - view.setNeedsLayout() - } - } - func updateHeaderScrollProgress(_ progress: CGFloat, throttle: CGFloat) { - // os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: progress: %.2f", ((#file as NSString).lastPathComponent), #line, #function, progress) - updateHeaderBottomShadow(progress: progress) - - let bannerImageView = profileHeaderView.bannerImageView - guard bannerImageView.bounds != .zero else { - // wait layout finish - return - } - - let bannerContainerInWindow = profileHeaderView.convert(profileHeaderView.bannerContainerView.frame, to: nil) - let bannerContainerBottomOffset = bannerContainerInWindow.origin.y + bannerContainerInWindow.height - - // scroll from bottom to top: 1 -> 2 -> 3 - if bannerContainerInWindow.origin.y > containerSafeAreaInset.top { - // 1 - // banner top pin to window top and expand - bannerImageView.frame.origin.y = -bannerContainerInWindow.origin.y - bannerImageView.frame.size.height = bannerContainerInWindow.origin.y + bannerContainerInWindow.size.height - } else if bannerContainerBottomOffset < containerSafeAreaInset.top { - // 3 - // banner bottom pin to navigation bar bottom and - // the `progress` growth to 1 then segmented control pin to top - bannerImageView.frame.origin.y = -containerSafeAreaInset.top - let bannerImageHeight = bannerContainerInWindow.size.height + containerSafeAreaInset.top + (containerSafeAreaInset.top - bannerContainerBottomOffset) - bannerImageView.frame.size.height = bannerImageHeight - } else { - // 2 - // banner move with scrolling from bottom to top until the - // banner bottom higher than navigation bar bottom - bannerImageView.frame.origin.y = -containerSafeAreaInset.top - bannerImageView.frame.size.height = bannerContainerInWindow.size.height + containerSafeAreaInset.top - } + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: progress: %.2f", ((#file as NSString).lastPathComponent), #line, #function, progress) // set title view offset let nameTextFieldInWindow = profileHeaderView.nameTextField.superview!.convert(profileHeaderView.nameTextField.frame, to: nil) @@ -378,18 +238,14 @@ extension ProfileHeaderViewController { let titleViewContentOffset: CGFloat = titleView.frame.height - nameTextFieldTopToNavigationBarBottomOffset let transformY = max(0, titleViewContentOffset) titleView.containerView.transform = CGAffineTransform(translationX: 0, y: transformY) - viewModel.isTitleViewDisplaying.value = transformY < titleView.containerView.frame.height + viewModel.isTitleViewDisplaying = transformY < titleView.containerView.frame.height + viewModel.isTitleViewContentOffsetSet = true - if viewModel.viewDidAppear.value { - viewModel.isTitleViewContentOffsetSet.value = true - } - - // set avatar fade - if progress > 0 { - setProfileAvatar(alpha: 0) - } else if progress > -abs(throttle) { - // y = -(1/0.8T)x - let alpha = -1 / abs(0.8 * throttle) * progress + if progress > 0, throttle > 0 { + // y = 1 - (x/t) + // give: x = 0, y = 1 + // x = t, y = 0 + let alpha = 1 - progress/throttle setProfileAvatar(alpha: alpha) } else { setProfileAvatar(alpha: 1) @@ -404,6 +260,103 @@ extension ProfileHeaderViewController { } +// MARK: - ProfileHeaderViewDelegate +extension ProfileHeaderViewController: ProfileHeaderViewDelegate { + func profileHeaderView(_ profileHeaderView: ProfileHeaderView, avatarButtonDidPressed button: AvatarButton) { + guard let user = viewModel.user else { return } + let record: ManagedObjectRecord = .init(objectID: user.objectID) + + Task { + try await DataSourceFacade.coordinateToMediaPreviewScene( + dependency: self, + user: record, + previewContext: DataSourceFacade.ImagePreviewContext( + imageView: button.avatarImageView, + containerView: .profileAvatar(profileHeaderView) + ) + ) + } // end Task + } + + func profileHeaderView(_ profileHeaderView: ProfileHeaderView, bannerImageViewDidPressed imageView: UIImageView) { + guard let user = viewModel.user else { return } + let record: ManagedObjectRecord = .init(objectID: user.objectID) + + Task { + try await DataSourceFacade.coordinateToMediaPreviewScene( + dependency: self, + user: record, + previewContext: DataSourceFacade.ImagePreviewContext( + imageView: imageView, + containerView: .profileBanner(profileHeaderView) + ) + ) + } // end Task + } + + func profileHeaderView( + _ profileHeaderView: ProfileHeaderView, + relationshipButtonDidPressed button: ProfileRelationshipActionButton + ) { + delegate?.profileHeaderViewController( + self, + profileHeaderView: profileHeaderView, + relationshipButtonDidPressed: button + ) + } + + func profileHeaderView(_ profileHeaderView: ProfileHeaderView, metaTextView: MetaTextView, metaDidPressed meta: Meta) { + delegate?.profileHeaderViewController( + self, + profileHeaderView: profileHeaderView, + metaTextView: metaTextView, + metaDidPressed: meta + ) + } + + func profileHeaderView( + _ profileHeaderView: ProfileHeaderView, + profileStatusDashboardView dashboardView: ProfileStatusDashboardView, + dashboardMeterViewDidPressed dashboardMeterView: ProfileStatusDashboardMeterView, + meter: ProfileStatusDashboardView.Meter + ) { + switch meter { + case .post: + // do nothing + break + case .follower: + guard let domain = viewModel.user?.domain, + let userID = viewModel.user?.id + else { return } + let followerListViewModel = FollowerListViewModel( + context: context, + domain: domain, + userID: userID + ) + coordinator.present( + scene: .follower(viewModel: followerListViewModel), + from: self, + transition: .show + ) + case .following: + guard let domain = viewModel.user?.domain, + let userID = viewModel.user?.id + else { return } + let followingListViewModel = FollowingListViewModel( + context: context, + domain: domain, + userID: userID + ) + coordinator.present( + scene: .following(viewModel: followingListViewModel), + from: self, + transition: .show + ) + } + } + +} + // MARK: - MetaTextDelegate extension ProfileHeaderViewController: MetaTextDelegate { func metaText(_ metaText: MetaText, processEditing textStorage: MetaTextStorage) -> MetaContent? { @@ -412,7 +365,9 @@ extension ProfileHeaderViewController: MetaTextDelegate { switch metaText { case profileHeaderView.bioMetaText: guard viewModel.isEditing else { break } - viewModel.editProfileInfo.note = metaText.backedString + defer { + viewModel.profileInfoEditing.note = metaText.backedString + } let metaContent = PlaintextMetaContent(string: metaText.backedString) return metaContent default: @@ -484,7 +439,10 @@ extension ProfileHeaderViewController: UIDocumentPickerDelegate { // MARK: - CropViewControllerDelegate extension ProfileHeaderViewController: CropViewControllerDelegate { public func cropViewController(_ cropViewController: CropViewController, didCropToImage image: UIImage, withRect cropRect: CGRect, angle: Int) { - viewModel.editProfileInfo.avatarImage = image + viewModel.profileInfoEditing.avatar = image cropViewController.dismiss(animated: true, completion: nil) } } + +// MARK: - TabBarPagerHeader +extension ProfileHeaderViewController: TabBarPagerHeader { } diff --git a/Mastodon/Scene/Profile/Header/ProfileHeaderViewModel.swift b/Mastodon/Scene/Profile/Header/ProfileHeaderViewModel.swift index 8bdce2a6d..e28b250cf 100644 --- a/Mastodon/Scene/Profile/Header/ProfileHeaderViewModel.swift +++ b/Mastodon/Scene/Profile/Header/ProfileHeaderViewModel.swift @@ -8,9 +8,11 @@ import os.log import UIKit import Combine +import CoreDataStack import Kanna import MastodonSDK import MastodonMeta +import MastodonUI final class ProfileHeaderViewModel { @@ -21,39 +23,44 @@ final class ProfileHeaderViewModel { // input let context: AppContext + @Published var user: MastodonUser? + @Published var relationshipActionOptionSet: RelationshipActionOptionSet = .none + @Published var isEditing = false - @Published var accountForEdit: Mastodon.Entity.Account? - @Published var emojiMeta: MastodonContent.Emojis = [:] + @Published var isUpdating = false - let viewDidAppear = CurrentValueSubject(false) - let needsSetupBottomShadow = CurrentValueSubject(true) - let needsFiledCollectionViewHidden = CurrentValueSubject(false) - let isTitleViewContentOffsetSet = CurrentValueSubject(false) + @Published var accountForEdit: Mastodon.Entity.Account? + +// let needsFiledCollectionViewHidden = CurrentValueSubject(false) // output - let isTitleViewDisplaying = CurrentValueSubject(false) - let displayProfileInfo = ProfileInfo() - let editProfileInfo = ProfileInfo() - let editProfileInfoDidInitialized = CurrentValueSubject(Void()) // needs trigger initial event + let profileInfo = ProfileInfo() + let profileInfoEditing = ProfileInfo() + + @Published var isTitleViewDisplaying = false + @Published var isTitleViewContentOffsetSet = false init(context: AppContext) { self.context = context - Publishers.CombineLatest( - $isEditing.removeDuplicates(), // only trigger when value toggle - $accountForEdit - ) - .receive(on: DispatchQueue.main) - .sink { [weak self] isEditing, account in - guard let self = self else { return } - guard isEditing else { return } - // setup editing value when toggle to editing - self.editProfileInfo.name = self.displayProfileInfo.name // set to name - self.editProfileInfo.avatarImage = nil // set to empty - self.editProfileInfo.note = ProfileHeaderViewModel.normalize(note: self.displayProfileInfo.note) - self.editProfileInfoDidInitialized.send() - } - .store(in: &disposeBag) + $accountForEdit + .receive(on: DispatchQueue.main) + .sink { [weak self] account in + guard let self = self else { return } + guard let account = account else { return } + // avatar + self.profileInfo.avatar = nil + self.profileInfoEditing.avatar = nil + // name + let name = account.displayNameWithFallback + self.profileInfo.name = name + self.profileInfoEditing.name = name + // bio + let note = ProfileHeaderViewModel.normalize(note: account.note) + self.profileInfo.note = note + self.profileInfoEditing.note = note + } + .store(in: &disposeBag) } } @@ -61,29 +68,9 @@ final class ProfileHeaderViewModel { extension ProfileHeaderViewModel { class ProfileInfo { // input + @Published var avatar: UIImage? @Published var name: String? - @Published var avatarImageURL: URL? - @Published var avatarImage: UIImage? @Published var note: String? - - // output - @Published var avatarImageResource = ImageResource(url: nil, image: nil) - - struct ImageResource { - let url: URL? - let image: UIImage? - } - - init() { - Publishers.CombineLatest( - $avatarImageURL, - $avatarImage - ) - .map { url, image in - ImageResource(url: url, image: image) - } - .assign(to: &$avatarImageResource) - } } } @@ -103,15 +90,14 @@ extension ProfileHeaderViewModel { } - // MARK: - ProfileViewModelEditable extension ProfileHeaderViewModel: ProfileViewModelEditable { - func isEdited() -> Bool { + var isEdited: Bool { guard isEditing else { return false } - guard editProfileInfo.name == displayProfileInfo.name else { return true } - guard editProfileInfo.avatarImage == nil else { return true } - guard editProfileInfo.note == ProfileHeaderViewModel.normalize(note: displayProfileInfo.note) else { return true } + guard profileInfoEditing.avatar == nil else { return true } + guard profileInfo.name == profileInfoEditing.name else { return true } + guard profileInfo.note == profileInfoEditing.note else { return true } return false } diff --git a/Mastodon/Scene/Profile/Header/View/ProfileHeaderView+Configuration.swift b/Mastodon/Scene/Profile/Header/View/ProfileHeaderView+Configuration.swift new file mode 100644 index 000000000..ac33227cc --- /dev/null +++ b/Mastodon/Scene/Profile/Header/View/ProfileHeaderView+Configuration.swift @@ -0,0 +1,56 @@ +// +// ProfileHeaderView+Configuration.swift +// Mastodon +// +// Created by MainasuK on 2022-5-26. +// + +import os.log +import UIKit +import Combine +import CoreDataStack + +extension ProfileHeaderView { + func configuration(user: MastodonUser) { + // header + user.publisher(for: \.header) + .map { _ in user.headerImageURL() } + .assign(to: \.headerImageURL, on: viewModel) + .store(in: &disposeBag) + // avatar + user.publisher(for: \.avatar) + .map { _ in user.avatarImageURL() } + .assign(to: \.avatarImageURL, on: viewModel) + .store(in: &disposeBag) + // emojiMeta + user.publisher(for: \.emojis) + .map { $0.asDictionary } + .assign(to: \.emojiMeta, on: viewModel) + .store(in: &disposeBag) + // name + user.publisher(for: \.displayName) + .map { _ in user.displayNameWithFallback } + .assign(to: \.name, on: viewModel) + .store(in: &disposeBag) + // username + viewModel.acct = user.acctWithDomain + // bio + user.publisher(for: \.note) + .assign(to: \.note, on: viewModel) + .store(in: &disposeBag) + // 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/Mastodon/Scene/Profile/Header/View/ProfileHeaderView+ViewModel.swift b/Mastodon/Scene/Profile/Header/View/ProfileHeaderView+ViewModel.swift new file mode 100644 index 000000000..808e1d7ba --- /dev/null +++ b/Mastodon/Scene/Profile/Header/View/ProfileHeaderView+ViewModel.swift @@ -0,0 +1,280 @@ +// +// ProfileHeaderView+ViewModel.swift +// Mastodon +// +// Created by MainasuK on 2022-5-26. +// + +import os.log +import UIKit +import Combine +import CoreDataStack +import MetaTextKit +import MastodonMeta +import MastodonUI +import MastodonAsset +import MastodonLocalization + +extension ProfileHeaderView { + class ViewModel: ObservableObject { + var disposeBag = Set() + + let viewDidAppear = PassthroughSubject() + + @Published var state: State? + @Published var isEditing = false + @Published var isUpdating = false + + @Published var emojiMeta: MastodonContent.Emojis = [:] + @Published var headerImageURL: URL? + @Published var avatarImageURL: URL? + @Published var avatarImageEditing: UIImage? + + @Published var name: String? + @Published var nameEditing: String? + + @Published var acct: String? + + @Published var note: String? + @Published var noteEditing: String? + + @Published var statusesCount: Int? + @Published var followingCount: Int? + @Published var followersCount: Int? + + @Published var fields: [MastodonField] = [] + + @Published var relationshipActionOptionSet: RelationshipActionOptionSet = .none + @Published var isRelationshipActionButtonHidden = false + + init() { + $relationshipActionOptionSet + .compactMap { $0.highPriorityAction(except: []) } + .map { $0 == .none } + .assign(to: &$isRelationshipActionButtonHidden) + } + } +} + +extension ProfileHeaderView.ViewModel { + + func bind(view: ProfileHeaderView) { + // header + Publishers.CombineLatest( + $headerImageURL, + viewDidAppear + ) + .sink { headerImageURL, _ in + view.bannerImageView.af.cancelImageRequest() + let placeholder = UIImage.placeholder(color: ProfileHeaderView.bannerImageViewPlaceholderColor) + guard let bannerImageURL = headerImageURL else { + view.bannerImageView.image = placeholder + return + } + view.bannerImageView.af.setImage( + withURL: bannerImageURL, + placeholderImage: placeholder, + imageTransition: .crossDissolve(0.3), + runImageTransitionIfCached: false, + completion: { [weak view] response in + guard let view = view else { return } + guard let image = response.value else { return } + guard image.size.width > 1 && image.size.height > 1 else { + // restore to placeholder when image invalid + view.bannerImageView.image = placeholder + return + } + } + ) + } + .store(in: &disposeBag) + // avatar + Publishers.CombineLatest4( + $avatarImageURL, + $avatarImageEditing, + $isEditing, + viewDidAppear + ) + .sink { avatarImageURL, avatarImageEditing, isEditing, _ in + view.avatarButton.avatarImageView.configure(configuration: .init( + url: (!isEditing || avatarImageEditing == nil) ? avatarImageURL : nil, + placeholder: isEditing ? (avatarImageEditing ?? AvatarImageView.placeholder) : AvatarImageView.placeholder + )) + } + .store(in: &disposeBag) + // blur + $relationshipActionOptionSet + .map { $0.contains(.blocking) || $0.contains(.blockingBy) } + .sink { needsImageOverlayBlurred in + UIView.animate(withDuration: 0.33) { + let bannerEffect: UIVisualEffect? = needsImageOverlayBlurred ? ProfileHeaderView.bannerImageViewOverlayBlurEffect : nil + view.bannerImageViewOverlayVisualEffectView.effect = bannerEffect + let avatarEffect: UIVisualEffect? = needsImageOverlayBlurred ? ProfileHeaderView.avatarImageViewOverlayBlurEffect : nil + view.avatarImageViewOverlayVisualEffectView.effect = avatarEffect + } + } + .store(in: &disposeBag) + // name + Publishers.CombineLatest4( + $isEditing.removeDuplicates(), + $name.removeDuplicates(), + $nameEditing.removeDuplicates(), + $emojiMeta.removeDuplicates() + ) + .sink { isEditing, name, nameEditing, emojiMeta in + do { + let mastodonContent = MastodonContent(content: name ?? " ", emojis: emojiMeta) + let metaContent = try MastodonMetaContent.convert(document: mastodonContent) + view.nameMetaText.configure(content: metaContent) + } catch { + assertionFailure() + } + view.nameTextField.text = isEditing ? nameEditing : name + } + .store(in: &disposeBag) + // username + $acct + .map { acct in acct.flatMap { "@" + $0 } ?? " " } + .assign(to: \.text, on: view.usernameLabel) + .store(in: &disposeBag) + // bio + Publishers.CombineLatest4( + $isEditing.removeDuplicates(), + $emojiMeta.removeDuplicates(), + $note.removeDuplicates(), + $noteEditing.removeDuplicates() + ) + .sink { isEditing, emojiMeta, note, noteEditing in + view.bioMetaText.textView.isEditable = isEditing + + let metaContent: MetaContent = { + if isEditing { + return PlaintextMetaContent(string: noteEditing ?? "") + } else { + do { + let mastodonContent = MastodonContent(content: note ?? "", emojis: emojiMeta) + return try MastodonMetaContent.convert(document: mastodonContent) + } catch { + assertionFailure() + return PlaintextMetaContent(string: note ?? "") + } + } + }() + + guard metaContent.string != view.bioMetaText.textStorage.string else { return } + view.bioMetaText.configure(content: metaContent) + } + .store(in: &disposeBag) + $relationshipActionOptionSet + .sink { optionSet in + let isBlocking = optionSet.contains(.blocking) + let isBlockedBy = optionSet.contains(.blockingBy) + let isSuspended = optionSet.contains(.suspended) + let isNeedsHidden = isBlocking || isBlockedBy || isSuspended + view.bioMetaText.textView.isHidden = isNeedsHidden + } + .store(in: &disposeBag) + // dashboard + $statusesCount + .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 + .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 + .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) + $isEditing + .sink { isEditing in + let animator = UIViewPropertyAnimator(duration: 0.33, curve: .easeInOut) + animator.addAnimations { + view.statusDashboardView.alpha = isEditing ? 0.2 : 1.0 + } + animator.startAnimation() + } + .store(in: &disposeBag) + // relationship + $isRelationshipActionButtonHidden + .assign(to: \.isHidden, on: view.relationshipActionButtonShadowContainer) + .store(in: &disposeBag) + Publishers.CombineLatest3( + $relationshipActionOptionSet, + $isEditing, + $isUpdating + ) + .sink { relationshipActionOptionSet, isEditing, isUpdating in + if relationshipActionOptionSet.contains(.edit) { + // check .edit state and set .editing when isEditing + view.relationshipActionButton.configure(actionOptionSet: isUpdating ? .updating : (isEditing ? .editing : .edit)) + view.configure(state: isEditing ? .editing : .normal) + } else { + view.relationshipActionButton.configure(actionOptionSet: relationshipActionOptionSet) + } + } + .store(in: &disposeBag) + } + +} + + +extension ProfileHeaderView { + enum State { + case normal + case editing + } + + func configure(state: State) { + guard viewModel.state != state else { return } // avoid redundant animation + viewModel.state = state + + let animator = UIViewPropertyAnimator(duration: 0.33, curve: .easeInOut) + + switch state { + case .normal: + nameMetaText.textView.alpha = 1 + nameTextField.alpha = 0 + nameTextField.isEnabled = false + bioMetaText.textView.backgroundColor = .clear + + animator.addAnimations { + self.bannerImageViewOverlayVisualEffectView.backgroundColor = ProfileHeaderView.bannerImageViewOverlayViewBackgroundNormalColor + self.nameTextFieldBackgroundView.backgroundColor = .clear + self.editAvatarBackgroundView.alpha = 0 + } + animator.addCompletion { _ in + self.editAvatarBackgroundView.isHidden = true + } + case .editing: + nameMetaText.textView.alpha = 0 + nameTextField.isEnabled = true + nameTextField.alpha = 1 + + editAvatarBackgroundView.isHidden = false + editAvatarBackgroundView.alpha = 0 + bioMetaText.textView.backgroundColor = .clear + animator.addAnimations { + self.bannerImageViewOverlayVisualEffectView.backgroundColor = ProfileHeaderView.bannerImageViewOverlayViewBackgroundEditingColor + self.nameTextFieldBackgroundView.backgroundColor = Asset.Scene.Profile.Banner.nameEditBackgroundGray.color + self.editAvatarBackgroundView.alpha = 1 + self.bioMetaText.textView.backgroundColor = Asset.Scene.Profile.Banner.bioEditBackgroundGray.color + } + } + + animator.startAnimation() + } +} diff --git a/Mastodon/Scene/Profile/Header/View/ProfileHeaderView.swift b/Mastodon/Scene/Profile/Header/View/ProfileHeaderView.swift index 1a6e10537..7257333d0 100644 --- a/Mastodon/Scene/Profile/Header/View/ProfileHeaderView.swift +++ b/Mastodon/Scene/Profile/Header/View/ProfileHeaderView.swift @@ -38,8 +38,16 @@ final class ProfileHeaderView: UIView { weak var delegate: ProfileHeaderViewDelegate? var disposeBag = Set() - var state: State? + func prepareForReuse() { + disposeBag.removeAll() + } + private(set) lazy var viewModel: ViewModel = { + let viewModel = ViewModel() + viewModel.bind(view: self) + return viewModel + }() + let bannerContainerView = UIView() let bannerImageView: UIImageView = { let imageView = UIImageView() @@ -61,6 +69,8 @@ final class ProfileHeaderView: UIView { overlayView.backgroundColor = ProfileHeaderView.bannerImageViewOverlayViewBackgroundNormalColor return overlayView }() + var bannerImageViewTopLayoutConstraint: NSLayoutConstraint! + var bannerImageViewBottomLayoutConstraint: NSLayoutConstraint! let avatarImageViewBackgroundView: UIView = { let view = UIView() @@ -81,7 +91,7 @@ final class ProfileHeaderView: UIView { func setupAvatarOverlayViews() { editAvatarBackgroundView.backgroundColor = UIColor.black.withAlphaComponent(0.6) - editAvatarButton.tintColor = .white + editAvatarButtonOverlayIndicatorView.tintColor = .white } static let avatarImageViewOverlayBlurEffect = UIBlurEffect(style: .systemUltraThinMaterialDark) @@ -101,7 +111,7 @@ final class ProfileHeaderView: UIView { return view }() - let editAvatarButton: HighlightDimmableButton = { + let editAvatarButtonOverlayIndicatorView: HighlightDimmableButton = { let button = HighlightDimmableButton() button.setImage(UIImage(systemName: "photo", withConfiguration: UIImage.SymbolConfiguration(pointSize: 28)), for: .normal) button.tintColor = .clear @@ -136,7 +146,7 @@ final class ProfileHeaderView: UIView { let nameTextField: UITextField = { let textField = UITextField() textField.font = UIFontMetrics(forTextStyle: .title2).scaledFont(for: .systemFont(ofSize: 22, weight: .bold)) - textField.textColor = Asset.Colors.Label.secondary.color + textField.textColor = Asset.Colors.Label.primary.color textField.text = "Alice" textField.autocorrectionType = .no textField.autocapitalizationType = .none @@ -164,8 +174,8 @@ final class ProfileHeaderView: UIView { return button }() - let bioContainerView = UIView() - let fieldContainerStackView = UIStackView() + // let bioContainerView = UIView() + // let fieldContainerStackView = UIStackView() let bioMetaText: MetaText = { let metaText = MetaText() @@ -230,12 +240,19 @@ extension ProfileHeaderView { bannerContainerView.topAnchor.constraint(equalTo: topAnchor), bannerContainerView.leadingAnchor.constraint(equalTo: leadingAnchor), trailingAnchor.constraint(equalTo: bannerContainerView.trailingAnchor), - readableContentGuide.widthAnchor.constraint(equalTo: bannerContainerView.heightAnchor, multiplier: 3), // set height to 1/3 of readable frame width + bannerContainerView.widthAnchor.constraint(equalTo: bannerContainerView.heightAnchor, multiplier: 3), // aspectRatio 1 : 3 ]) - bannerImageView.autoresizingMask = [.flexibleWidth, .flexibleHeight] - bannerImageView.frame = bannerContainerView.bounds + bannerImageView.translatesAutoresizingMaskIntoConstraints = false bannerContainerView.addSubview(bannerImageView) + bannerImageViewTopLayoutConstraint = bannerImageView.topAnchor.constraint(equalTo: bannerContainerView.topAnchor) + bannerImageViewBottomLayoutConstraint = bannerContainerView.bottomAnchor.constraint(equalTo: bannerImageView.bottomAnchor) + NSLayoutConstraint.activate([ + bannerImageViewTopLayoutConstraint, + bannerImageView.leadingAnchor.constraint(equalTo: bannerContainerView.leadingAnchor), + bannerImageView.trailingAnchor.constraint(equalTo: bannerContainerView.trailingAnchor), + bannerImageViewBottomLayoutConstraint, + ]) bannerImageViewOverlayVisualEffectView.translatesAutoresizingMaskIntoConstraints = false bannerImageView.addSubview(bannerImageViewOverlayVisualEffectView) @@ -283,13 +300,13 @@ extension ProfileHeaderView { editAvatarBackgroundView.bottomAnchor.constraint(equalTo: avatarButton.bottomAnchor), ]) - editAvatarButton.translatesAutoresizingMaskIntoConstraints = false - editAvatarBackgroundView.addSubview(editAvatarButton) + editAvatarButtonOverlayIndicatorView.translatesAutoresizingMaskIntoConstraints = false + editAvatarBackgroundView.addSubview(editAvatarButtonOverlayIndicatorView) NSLayoutConstraint.activate([ - editAvatarButton.topAnchor.constraint(equalTo: editAvatarBackgroundView.topAnchor), - editAvatarButton.leadingAnchor.constraint(equalTo: editAvatarBackgroundView.leadingAnchor), - editAvatarButton.trailingAnchor.constraint(equalTo: editAvatarBackgroundView.trailingAnchor), - editAvatarButton.bottomAnchor.constraint(equalTo: editAvatarBackgroundView.bottomAnchor), + editAvatarButtonOverlayIndicatorView.topAnchor.constraint(equalTo: editAvatarBackgroundView.topAnchor), + editAvatarButtonOverlayIndicatorView.leadingAnchor.constraint(equalTo: editAvatarBackgroundView.leadingAnchor), + editAvatarButtonOverlayIndicatorView.trailingAnchor.constraint(equalTo: editAvatarBackgroundView.trailingAnchor), + editAvatarButtonOverlayIndicatorView.bottomAnchor.constraint(equalTo: editAvatarBackgroundView.bottomAnchor), ]) editAvatarBackgroundView.isUserInteractionEnabled = true avatarButton.isUserInteractionEnabled = true @@ -297,6 +314,7 @@ extension ProfileHeaderView { // container: V - [ dashboard container | author container | bio ] let container = UIStackView() container.axis = .vertical + container.distribution = .fill container.spacing = 8 container.preservesSuperviewLayoutMargins = true container.isLayoutMarginsRelativeArrangement = true @@ -310,7 +328,7 @@ extension ProfileHeaderView { layoutMarginsGuide.trailingAnchor.constraint(equalTo: container.trailingAnchor), container.bottomAnchor.constraint(equalTo: bottomAnchor), ]) - + // dashboardContainer: H - [ padding | statusDashboardView ] let dashboardContainer = UIStackView() dashboardContainer.axis = .horizontal @@ -364,6 +382,7 @@ extension ProfileHeaderView { nameTextFieldBackgroundView.trailingAnchor.constraint(equalTo: nameMetaText.textView.trailingAnchor, constant: 5), nameMetaText.textView.bottomAnchor.constraint(equalTo: nameTextFieldBackgroundView.bottomAnchor), ]) + // nameMetaText.textView.setContentHuggingPriority(, for: <#T##NSLayoutConstraint.Axis#>) nameContainerStackView.addArrangedSubview(displayNameStackView) nameContainerStackView.addArrangedSubview(usernameLabel) @@ -438,53 +457,6 @@ extension ProfileHeaderView { } -extension ProfileHeaderView { - enum State { - case normal - case editing - } - - func configure(state: State) { - guard self.state != state else { return } // avoid redundant animation - self.state = state - - let animator = UIViewPropertyAnimator(duration: 0.33, curve: .easeInOut) - - switch state { - case .normal: - nameMetaText.textView.alpha = 1 - nameTextField.alpha = 0 - nameTextField.isEnabled = false - bioMetaText.textView.backgroundColor = .clear - - animator.addAnimations { - self.bannerImageViewOverlayVisualEffectView.backgroundColor = ProfileHeaderView.bannerImageViewOverlayViewBackgroundNormalColor - self.nameTextFieldBackgroundView.backgroundColor = .clear - self.editAvatarBackgroundView.alpha = 0 - } - animator.addCompletion { _ in - self.editAvatarBackgroundView.isHidden = true - } - case .editing: - nameMetaText.textView.alpha = 0 - nameTextField.isEnabled = true - nameTextField.alpha = 1 - - editAvatarBackgroundView.isHidden = false - editAvatarBackgroundView.alpha = 0 - bioMetaText.textView.backgroundColor = .clear - animator.addAnimations { - self.bannerImageViewOverlayVisualEffectView.backgroundColor = ProfileHeaderView.bannerImageViewOverlayViewBackgroundEditingColor - self.nameTextFieldBackgroundView.backgroundColor = Asset.Scene.Profile.Banner.nameEditBackgroundGray.color - self.editAvatarBackgroundView.alpha = 1 - self.bioMetaText.textView.backgroundColor = Asset.Scene.Profile.Banner.bioEditBackgroundGray.color - } - } - - animator.startAnimation() - } -} - extension ProfileHeaderView { @objc private func relationshipActionButtonDidPressed(_ sender: UIButton) { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) diff --git a/Mastodon/Scene/Profile/Paging/ProfilePagingViewController.swift b/Mastodon/Scene/Profile/Paging/ProfilePagingViewController.swift new file mode 100644 index 000000000..bfbe45471 --- /dev/null +++ b/Mastodon/Scene/Profile/Paging/ProfilePagingViewController.swift @@ -0,0 +1,217 @@ +// +// ProfilePagingViewController.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-29. +// + +import os.log +import UIKit +import Combine +import XLPagerTabStrip +import TabBarPager +import MastodonAsset + +protocol ProfilePagingViewControllerDelegate: AnyObject { + func profilePagingViewController(_ viewController: ProfilePagingViewController, didScrollToPostCustomScrollViewContainerController customScrollViewContainerController: ScrollViewContainer, atIndex index: Int) +} + +final class ProfilePagingViewController: ButtonBarPagerTabStripViewController, TabBarPageViewController { + + weak var tabBarPageViewDelegate: TabBarPageViewDelegate? + weak var pagingDelegate: ProfilePagingViewControllerDelegate? + + var disposeBag = Set() + var viewModel: ProfilePagingViewModel! + + let buttonBarShadowView = UIView() + private var buttonBarShadowAlpha: CGFloat = 0.0 + + // MARK: - TabBarPageViewController + + var currentPage: TabBarPage? { + return viewModel.viewControllers[currentIndex] + } + + var currentPageIndex: Int? { + currentIndex + } + + // MARK: - ButtonBarPagerTabStripViewController + + override func viewControllers(for pagerTabStripController: PagerTabStripViewController) -> [UIViewController] { + return viewModel.viewControllers + } + + override func updateIndicator(for viewController: PagerTabStripViewController, fromIndex: Int, toIndex: Int, withProgressPercentage progressPercentage: CGFloat, indexWasChanged: Bool) { + super.updateIndicator(for: viewController, fromIndex: fromIndex, toIndex: toIndex, withProgressPercentage: progressPercentage, indexWasChanged: indexWasChanged) + + guard indexWasChanged else { return } + let page = viewModel.viewControllers[toIndex] + tabBarPageViewDelegate?.pageViewController(self, didPresentingTabBarPage: page, at: toIndex) + } + + // make key commands works + override var canBecomeFirstResponder: Bool { + return true + } + + deinit { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + } + +} + +extension ProfilePagingViewController { + + override func viewDidLoad() { + // configure style before viewDidLoad + settings.style.buttonBarBackgroundColor = ThemeService.shared.currentTheme.value.systemBackgroundColor + settings.style.buttonBarItemBackgroundColor = .clear + settings.style.buttonBarItemsShouldFillAvailableWidth = false // alignment from leading to trailing + settings.style.selectedBarHeight = 3 + settings.style.selectedBarBackgroundColor = Asset.Colors.Label.primary.color + settings.style.buttonBarItemFont = UIFont.systemFont(ofSize: 17, weight: .semibold) + + changeCurrentIndexProgressive = { [weak self] (oldCell: ButtonBarViewCell?, newCell: ButtonBarViewCell?, progressPercentage: CGFloat, changeCurrentIndex: Bool, animated: Bool) -> Void in + guard let _ = self else { return } + guard changeCurrentIndex == true else { return } + oldCell?.label.textColor = Asset.Colors.Label.secondary.color + newCell?.label.textColor = Asset.Colors.Label.primary.color + } + + super.viewDidLoad() + + ThemeService.shared.currentTheme + .receive(on: DispatchQueue.main) + .sink { [weak self] theme in + guard let self = self else { return } + self.settings.style.buttonBarBackgroundColor = theme.systemBackgroundColor + self.barButtonLayout?.invalidateLayout() + } + .store(in: &disposeBag) + + updateBarButtonInsets() + + if let buttonBarView = self.buttonBarView { + buttonBarShadowView.translatesAutoresizingMaskIntoConstraints = false + view.insertSubview(buttonBarShadowView, belowSubview: buttonBarView) + NSLayoutConstraint.activate([ + buttonBarShadowView.topAnchor.constraint(equalTo: buttonBarView.topAnchor), + buttonBarShadowView.leadingAnchor.constraint(equalTo: buttonBarView.leadingAnchor), + buttonBarShadowView.trailingAnchor.constraint(equalTo: buttonBarView.trailingAnchor), + buttonBarShadowView.bottomAnchor.constraint(equalTo: buttonBarView.bottomAnchor), + ]) + + viewModel.$needsSetupBottomShadow + .receive(on: DispatchQueue.main) + .sink { [weak self] needsSetupBottomShadow in + guard let self = self else { return } + self.setupBottomShadow() + } + .store(in: &disposeBag) + } + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + + becomeFirstResponder() + } + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + + setupBottomShadow() + } + + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + + updateBarButtonInsets() + } + +} + +extension ProfilePagingViewController { + + private func updateBarButtonInsets() { + let margin: CGFloat = { + switch traitCollection.userInterfaceIdiom { + case .phone: + return ProfileViewController.containerViewMarginForCompactHorizontalSizeClass + default: + return traitCollection.horizontalSizeClass == .regular ? + ProfileViewController.containerViewMarginForRegularHorizontalSizeClass : + ProfileViewController.containerViewMarginForCompactHorizontalSizeClass + } + }() + + settings.style.buttonBarLeftContentInset = margin + settings.style.buttonBarRightContentInset = margin + barButtonLayout?.sectionInset.left = margin + barButtonLayout?.sectionInset.right = margin + barButtonLayout?.invalidateLayout() + } + + private var barButtonLayout: UICollectionViewFlowLayout? { + let layout = buttonBarView.collectionViewLayout as? UICollectionViewFlowLayout + return layout + } + + func setupBottomShadow() { + guard viewModel.needsSetupBottomShadow else { + buttonBarShadowView.layer.shadowColor = nil + buttonBarShadowView.layer.shadowRadius = 0 + return + } + buttonBarShadowView.layer.setupShadow( + color: UIColor.black.withAlphaComponent(0.12), + alpha: Float(buttonBarShadowAlpha), + x: 0, + y: 2, + blur: 2, + spread: 0, + roundedRect: buttonBarShadowView.bounds, + byRoundingCorners: .allCorners, + cornerRadii: .zero + ) + } + + func updateButtonBarShadow(progress: CGFloat) { + let alpha = min(max(0, 10 * progress - 9), 1) + if buttonBarShadowAlpha != alpha { + buttonBarShadowAlpha = alpha + setupBottomShadow() + buttonBarShadowView.setNeedsLayout() + } + } +} + +extension ProfilePagingViewController { + + var currentViewController: (UIViewController & TabBarPage)? { + guard !viewModel.viewControllers.isEmpty, + currentIndex < viewModel.viewControllers.count + else { return nil } + return viewModel.viewControllers[currentIndex] + } + +} + +// workaround to fix tab man responder chain issue +extension ProfilePagingViewController { + + override var keyCommands: [UIKeyCommand]? { + return currentViewController?.keyCommands + } + + @objc func navigateKeyCommandHandlerRelay(_ sender: UIKeyCommand) { + (currentViewController as? StatusTableViewControllerNavigateable)?.navigateKeyCommandHandlerRelay(sender) + } + + @objc func statusKeyCommandHandlerRelay(_ sender: UIKeyCommand) { + (currentViewController as? StatusTableViewControllerNavigateable)?.statusKeyCommandHandlerRelay(sender) + } + +} diff --git a/Mastodon/Scene/Profile/Paging/ProfilePagingViewModel.swift b/Mastodon/Scene/Profile/Paging/ProfilePagingViewModel.swift new file mode 100644 index 000000000..9b9e78d98 --- /dev/null +++ b/Mastodon/Scene/Profile/Paging/ProfilePagingViewModel.swift @@ -0,0 +1,50 @@ +// +// ProfilePagingViewModel.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-29. +// + +import os.log +import UIKit +import MastodonAsset +import MastodonLocalization +import TabBarPager + +final class ProfilePagingViewModel: NSObject { + + let postUserTimelineViewController = UserTimelineViewController() + let repliesUserTimelineViewController = UserTimelineViewController() + let mediaUserTimelineViewController = UserTimelineViewController() + let profileAboutViewController = ProfileAboutViewController() + + // input + @Published var needsSetupBottomShadow = true + + init( + postsUserTimelineViewModel: UserTimelineViewModel, + repliesUserTimelineViewModel: UserTimelineViewModel, + mediaUserTimelineViewModel: UserTimelineViewModel, + profileAboutViewModel: ProfileAboutViewModel + ) { + postUserTimelineViewController.viewModel = postsUserTimelineViewModel + repliesUserTimelineViewController.viewModel = repliesUserTimelineViewModel + mediaUserTimelineViewController.viewModel = mediaUserTimelineViewModel + profileAboutViewController.viewModel = profileAboutViewModel + super.init() + } + + var viewControllers: [UIViewController & TabBarPage] { + return [ + postUserTimelineViewController, + repliesUserTimelineViewController, + mediaUserTimelineViewController, + profileAboutViewController, + ] + } + + deinit { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + } + +} diff --git a/Mastodon/Scene/Profile/ProfileViewController.swift b/Mastodon/Scene/Profile/ProfileViewController.swift index 4c3f9820a..6a51ff599 100644 --- a/Mastodon/Scene/Profile/ProfileViewController.swift +++ b/Mastodon/Scene/Profile/ProfileViewController.swift @@ -14,11 +14,11 @@ import MastodonAsset import MastodonLocalization import MastodonUI import CoreDataStack -import Tabman -import Pageboy +import TabBarPager +import XLPagerTabStrip protocol ProfileViewModelEditable { - func isEdited() -> Bool + var isEdited: Bool { get } } final class ProfileViewController: UIViewController, NeedsDependency, MediaPreviewableViewController { @@ -41,7 +41,7 @@ final class ProfileViewController: UIViewController, NeedsDependency, MediaPrevi barButtonItem.tintColor = .white return barButtonItem }() - + private(set) lazy var settingBarButtonItem: UIBarButtonItem = { let barButtonItem = UIBarButtonItem( image: Asset.ObjectsAndTools.gear.image.withRenderingMode(.alwaysTemplate), @@ -52,7 +52,7 @@ final class ProfileViewController: UIViewController, NeedsDependency, MediaPrevi barButtonItem.tintColor = .white return barButtonItem }() - + private(set) lazy var shareBarButtonItem: UIBarButtonItem = { let barButtonItem = UIBarButtonItem( image: Asset.Arrow.squareAndArrowUp.image.withRenderingMode(.alwaysTemplate), @@ -63,7 +63,7 @@ final class ProfileViewController: UIViewController, NeedsDependency, MediaPrevi barButtonItem.tintColor = .white return barButtonItem }() - + private(set) lazy var favoriteBarButtonItem: UIBarButtonItem = { let barButtonItem = UIBarButtonItem( image: Asset.ObjectsAndTools.star.image.withRenderingMode(.alwaysTemplate), @@ -74,53 +74,55 @@ final class ProfileViewController: UIViewController, NeedsDependency, MediaPrevi barButtonItem.tintColor = .white return barButtonItem }() - + private(set) lazy var replyBarButtonItem: UIBarButtonItem = { let barButtonItem = UIBarButtonItem(image: UIImage(systemName: "arrowshape.turn.up.left"), style: .plain, target: self, action: #selector(ProfileViewController.replyBarButtonItemPressed(_:))) barButtonItem.tintColor = .white return barButtonItem }() - + let moreMenuBarButtonItem: UIBarButtonItem = { let barButtonItem = UIBarButtonItem(image: UIImage(systemName: "ellipsis.circle"), style: .plain, target: nil, action: nil) barButtonItem.tintColor = .white return barButtonItem }() - + let refreshControl: UIRefreshControl = { let refreshControl = UIRefreshControl() refreshControl.tintColor = .white return refreshControl }() - let containerScrollView: UIScrollView = { - let scrollView = UIScrollView() - scrollView.scrollsToTop = false - scrollView.showsVerticalScrollIndicator = false - scrollView.preservesSuperviewLayoutMargins = true - scrollView.delaysContentTouches = false - return scrollView - }() - - let overlayScrollView: UIScrollView = { - let scrollView = UIScrollView() - scrollView.showsVerticalScrollIndicator = false - scrollView.backgroundColor = .clear - scrollView.delaysContentTouches = false - return scrollView - }() - - private(set) lazy var profileSegmentedViewController = ProfileSegmentedViewController() + private(set) lazy var tabBarPagerController = TabBarPagerController() + private(set) lazy var profileHeaderViewController: ProfileHeaderViewController = { let viewController = ProfileHeaderViewController() + viewController.context = context + viewController.coordinator = coordinator viewController.viewModel = ProfileHeaderViewModel(context: context) return viewController }() - private var profileBannerImageViewLayoutConstraint: NSLayoutConstraint! - - private var contentOffsets: [Int: CGFloat] = [:] - var currentPostTimelineTableViewContentSizeObservation: NSKeyValueObservation? + private(set) lazy var profilePagingViewController: ProfilePagingViewController = { + let profilePagingViewController = ProfilePagingViewController() + profilePagingViewController.viewModel = { + let profilePagingViewModel = ProfilePagingViewModel( + postsUserTimelineViewModel: viewModel.postsUserTimelineViewModel, + repliesUserTimelineViewModel: viewModel.repliesUserTimelineViewModel, + mediaUserTimelineViewModel: viewModel.mediaUserTimelineViewModel, + profileAboutViewModel: viewModel.profileAboutViewModel + ) + profilePagingViewModel.viewControllers.forEach { viewController in + if let viewController = viewController as? NeedsDependency { + viewController.context = context + viewController.coordinator = coordinator + } + } + return profilePagingViewModel + }() + return profilePagingViewController + }() + // title view nested in header var titleView: DoubleTitleLabelNavigationBarTitleView { profileHeaderViewController.titleView @@ -132,44 +134,18 @@ final class ProfileViewController: UIViewController, NeedsDependency, MediaPrevi } -extension ProfileViewController { - - func observeTableViewContentSize(scrollView: UIScrollView) -> NSKeyValueObservation { - updateOverlayScrollViewContentSize(scrollView: scrollView) - return scrollView.observe(\.contentSize, options: .new) { scrollView, change in - self.updateOverlayScrollViewContentSize(scrollView: scrollView) - } - } - - func updateOverlayScrollViewContentSize(scrollView: UIScrollView) { - let bottomPageHeight = max(scrollView.contentSize.height, self.containerScrollView.frame.height - ProfileHeaderViewController.headerMinHeight - self.containerScrollView.safeAreaInsets.bottom) - let headerViewHeight: CGFloat = profileHeaderViewController.view.frame.height - let contentSize = CGSize( - width: self.containerScrollView.contentSize.width, - height: bottomPageHeight + headerViewHeight - ) - self.overlayScrollView.contentSize = contentSize - // os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: contentSize: %s", ((#file as NSString).lastPathComponent), #line, #function, contentSize.debugDescription) - } - -} - extension ProfileViewController { override var preferredStatusBarStyle: UIStatusBarStyle { return .lightContent } - + override func viewSafeAreaInsetsDidChange() { super.viewSafeAreaInsetsDidChange() - + profileHeaderViewController.updateHeaderContainerSafeAreaInset(view.safeAreaInsets) } - override var isViewLoaded: Bool { - return super.isViewLoaded - } - override func viewDidLoad() { super.viewDidLoad() @@ -191,21 +167,21 @@ extension ProfileViewController { navigationItem.standardAppearance = barAppearance navigationItem.compactAppearance = barAppearance navigationItem.scrollEdgeAppearance = barAppearance - + navigationItem.titleView = titleView let editingAndUpdatingPublisher = Publishers.CombineLatest( - viewModel.isEditing.eraseToAnyPublisher(), - viewModel.isUpdating.eraseToAnyPublisher() + viewModel.$isEditing, + viewModel.$isUpdating ) // note: not add .share() here - + let barButtonItemHiddenPublisher = Publishers.CombineLatest3( - viewModel.isMeBarButtonItemsHidden.eraseToAnyPublisher(), - viewModel.isReplyBarButtonItemHidden.eraseToAnyPublisher(), - viewModel.isMoreMenuBarButtonItemHidden.eraseToAnyPublisher() + viewModel.$isMeBarButtonItemsHidden, + viewModel.$isReplyBarButtonItemHidden, + viewModel.$isMoreMenuBarButtonItemHidden ) - + editingAndUpdatingPublisher .receive(on: DispatchQueue.main) .sink { [weak self] isEditing, isUpdating in @@ -213,44 +189,44 @@ extension ProfileViewController { self.cancelEditingBarButtonItem.isEnabled = !isUpdating } .store(in: &disposeBag) - + Publishers.CombineLatest4 ( - viewModel.suspended.eraseToAnyPublisher(), - profileHeaderViewController.viewModel.isTitleViewDisplaying.eraseToAnyPublisher(), + viewModel.relationshipViewModel.$isSuspended, + profileHeaderViewController.viewModel.$isTitleViewDisplaying, editingAndUpdatingPublisher.eraseToAnyPublisher(), barButtonItemHiddenPublisher.eraseToAnyPublisher() ) .receive(on: DispatchQueue.main) - .sink { [weak self] suspended, isTitleViewDisplaying, tuple1, tuple2 in + .sink { [weak self] isSuspended, isTitleViewDisplaying, tuple1, tuple2 in guard let self = self else { return } let (isEditing, _) = tuple1 let (isMeBarButtonItemsHidden, isReplyBarButtonItemHidden, isMoreMenuBarButtonItemHidden) = tuple2 - + var items: [UIBarButtonItem] = [] defer { self.navigationItem.rightBarButtonItems = !items.isEmpty ? items : nil } - guard !suspended else { + guard !isSuspended else { return } - + guard !isEditing else { items.append(self.cancelEditingBarButtonItem) return } - + guard !isTitleViewDisplaying else { return } - + guard isMeBarButtonItemsHidden else { items.append(self.settingBarButtonItem) items.append(self.shareBarButtonItem) items.append(self.favoriteBarButtonItem) return } - + if !isMoreMenuBarButtonItemHidden { items.append(self.moreMenuBarButtonItem) } @@ -259,254 +235,90 @@ extension ProfileViewController { } } .store(in: &disposeBag) + + addChild(tabBarPagerController) + tabBarPagerController.view.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(tabBarPagerController.view) + tabBarPagerController.didMove(toParent: self) + NSLayoutConstraint.activate([ + tabBarPagerController.view.topAnchor.constraint(equalTo: view.topAnchor), + tabBarPagerController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), + tabBarPagerController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor), + tabBarPagerController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor), + ]) - overlayScrollView.refreshControl = refreshControl + tabBarPagerController.delegate = self + tabBarPagerController.dataSource = self + + tabBarPagerController.relayScrollView.refreshControl = refreshControl refreshControl.addTarget(self, action: #selector(ProfileViewController.refreshControlValueChanged(_:)), for: .valueChanged) - - let postsUserTimelineViewModel = UserTimelineViewModel(context: context, domain: viewModel.domain.value, userID: viewModel.userID.value, queryFilter: UserTimelineViewModel.QueryFilter(excludeReplies: true)) - bind(userTimelineViewModel: postsUserTimelineViewModel) - - let repliesUserTimelineViewModel = UserTimelineViewModel(context: context, domain: viewModel.domain.value, userID: viewModel.userID.value, queryFilter: UserTimelineViewModel.QueryFilter(excludeReplies: false)) - bind(userTimelineViewModel: repliesUserTimelineViewModel) - - let mediaUserTimelineViewModel = UserTimelineViewModel(context: context, domain: viewModel.domain.value, userID: viewModel.userID.value, queryFilter: UserTimelineViewModel.QueryFilter(onlyMedia: true)) - bind(userTimelineViewModel: mediaUserTimelineViewModel) - - let profileAboutViewModel = ProfileAboutViewModel(context: context) - - profileSegmentedViewController.pagingViewController.viewModel = { - let profilePagingViewModel = ProfilePagingViewModel( - postsUserTimelineViewModel: postsUserTimelineViewModel, - repliesUserTimelineViewModel: repliesUserTimelineViewModel, - mediaUserTimelineViewModel: mediaUserTimelineViewModel, - profileAboutViewModel: profileAboutViewModel - ) - profilePagingViewModel.viewControllers.forEach { viewController in - if let viewController = viewController as? NeedsDependency { - viewController.context = context - viewController.coordinator = coordinator - } - } - return profilePagingViewModel - }() - - profileSegmentedViewController.pagingViewController.addBar( - profileHeaderViewController.buttonBar, - dataSource: profileSegmentedViewController.pagingViewController.viewModel, - at: .custom(view: profileHeaderViewController.view, layout: { buttonBar in - buttonBar.translatesAutoresizingMaskIntoConstraints = false - self.profileHeaderViewController.view.addSubview(buttonBar) - NSLayoutConstraint.activate([ - buttonBar.topAnchor.constraint(equalTo: self.profileHeaderViewController.profileHeaderView.bottomAnchor), - buttonBar.leadingAnchor.constraint(equalTo: self.profileHeaderViewController.view.leadingAnchor), - buttonBar.trailingAnchor.constraint(equalTo: self.profileHeaderViewController.view.trailingAnchor), - buttonBar.bottomAnchor.constraint(equalTo: self.profileHeaderViewController.view.bottomAnchor), - buttonBar.heightAnchor.constraint(equalToConstant: ProfileHeaderViewController.segmentedControlHeight).priority(.required - 1), - ]) - }) - ) - updateBarButtonInsets() - - overlayScrollView.translatesAutoresizingMaskIntoConstraints = false - view.addSubview(overlayScrollView) - NSLayoutConstraint.activate([ - overlayScrollView.frameLayoutGuide.topAnchor.constraint(equalTo: view.topAnchor), - overlayScrollView.frameLayoutGuide.leadingAnchor.constraint(equalTo: view.leadingAnchor), - view.trailingAnchor.constraint(equalTo: overlayScrollView.frameLayoutGuide.trailingAnchor), - view.bottomAnchor.constraint(equalTo: overlayScrollView.frameLayoutGuide.bottomAnchor), - overlayScrollView.contentLayoutGuide.widthAnchor.constraint(equalTo: view.widthAnchor), - ]) - - containerScrollView.translatesAutoresizingMaskIntoConstraints = false - view.addSubview(containerScrollView) - NSLayoutConstraint.activate([ - containerScrollView.frameLayoutGuide.topAnchor.constraint(equalTo: view.topAnchor), - containerScrollView.frameLayoutGuide.leadingAnchor.constraint(equalTo: view.leadingAnchor), - view.trailingAnchor.constraint(equalTo: containerScrollView.frameLayoutGuide.trailingAnchor), - view.bottomAnchor.constraint(equalTo: containerScrollView.frameLayoutGuide.bottomAnchor), - containerScrollView.contentLayoutGuide.widthAnchor.constraint(equalTo: view.widthAnchor), - ]) - - // add segmented list - addChild(profileSegmentedViewController) - profileSegmentedViewController.view.translatesAutoresizingMaskIntoConstraints = false - containerScrollView.addSubview(profileSegmentedViewController.view) - profileSegmentedViewController.didMove(toParent: self) - NSLayoutConstraint.activate([ - profileSegmentedViewController.view.leadingAnchor.constraint(equalTo: containerScrollView.contentLayoutGuide.leadingAnchor), - profileSegmentedViewController.view.trailingAnchor.constraint(equalTo: containerScrollView.contentLayoutGuide.trailingAnchor), - profileSegmentedViewController.view.bottomAnchor.constraint(equalTo: containerScrollView.contentLayoutGuide.bottomAnchor), - profileSegmentedViewController.view.heightAnchor.constraint(equalTo: containerScrollView.frameLayoutGuide.heightAnchor), - ]) - - // add header - addChild(profileHeaderViewController) - profileHeaderViewController.view.translatesAutoresizingMaskIntoConstraints = false - containerScrollView.addSubview(profileHeaderViewController.view) - profileHeaderViewController.didMove(toParent: self) - NSLayoutConstraint.activate([ - profileHeaderViewController.view.topAnchor.constraint(equalTo: containerScrollView.topAnchor), - profileHeaderViewController.view.leadingAnchor.constraint(equalTo: containerScrollView.contentLayoutGuide.leadingAnchor), - containerScrollView.contentLayoutGuide.trailingAnchor.constraint(equalTo: profileHeaderViewController.view.trailingAnchor), - profileSegmentedViewController.view.topAnchor.constraint(equalTo: profileHeaderViewController.view.bottomAnchor), - ]) - - containerScrollView.addGestureRecognizer(overlayScrollView.panGestureRecognizer) - overlayScrollView.layer.zPosition = .greatestFiniteMagnitude // make vision top-most - overlayScrollView.delegate = self + + // setup delegate profileHeaderViewController.delegate = self - profileSegmentedViewController.pagingViewController.viewModel.profileAboutViewController.delegate = self - profileSegmentedViewController.pagingViewController.pagingDelegate = self - - // bind view model - bindProfile( - headerViewModel: profileHeaderViewController.viewModel, - aboutViewModel: profileAboutViewModel - ) - + profilePagingViewController.viewModel.profileAboutViewController.delegate = self + + bindViewModel() bindTitleView() - bindHeader() - bindProfileRelationship() - bindProfileDashboard() - - viewModel.needsPagingEnabled - .receive(on: DispatchQueue.main) - .sink { [weak self] needsPaingEnabled in - guard let self = self else { return } - self.profileSegmentedViewController.pagingViewController.isScrollEnabled = needsPaingEnabled - } - .store(in: &disposeBag) - - profileHeaderViewController.profileHeaderView.delegate = self - } - - override func viewWillAppear(_ animated: Bool) { - super.viewWillAppear(animated) - - // set back button tint color in SceneCoordinator.present(scene:from:transition:) - - // force layout to make banner image tweak take effect - view.layoutIfNeeded() + bindMoreBarButtonItem() + bindPager() } override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) - viewModel.viewDidAppear.send() - - // set overlay scroll view initial content size - guard let currentViewController = profileSegmentedViewController.pagingViewController.currentViewController as? ScrollViewContainer, - let scrollView = currentViewController.scrollView - else { return } - - currentPostTimelineTableViewContentSizeObservation = observeTableViewContentSize(scrollView: scrollView) - scrollView.panGestureRecognizer.require(toFail: overlayScrollView.panGestureRecognizer) + setNeedsStatusBarAppearanceUpdate() } - override func viewDidDisappear(_ animated: Bool) { - super.viewDidDisappear(animated) - - currentPostTimelineTableViewContentSizeObservation = nil - } - - override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { - super.traitCollectionDidChange(previousTraitCollection) - - updateBarButtonInsets() - } - } extension ProfileViewController { - private func updateBarButtonInsets() { - let margin: CGFloat = { - switch traitCollection.userInterfaceIdiom { - case .phone: - return ProfileViewController.containerViewMarginForCompactHorizontalSizeClass - default: - return traitCollection.horizontalSizeClass == .regular ? - ProfileViewController.containerViewMarginForRegularHorizontalSizeClass : - ProfileViewController.containerViewMarginForCompactHorizontalSizeClass - } - }() - - profileHeaderViewController.buttonBar.layout.contentInset.left = margin - profileHeaderViewController.buttonBar.layout.contentInset.right = margin - } - -} - -extension ProfileViewController { - - private func bind(userTimelineViewModel: UserTimelineViewModel) { - viewModel.domain.assign(to: \.domain, on: userTimelineViewModel).store(in: &disposeBag) - viewModel.userID.assign(to: \.userID, on: userTimelineViewModel).store(in: &disposeBag) - viewModel.isBlocking.assign(to: \.value, on: userTimelineViewModel.isBlocking).store(in: &disposeBag) - viewModel.isBlockedBy.assign(to: \.value, on: userTimelineViewModel.isBlockedBy).store(in: &disposeBag) - viewModel.suspended.assign(to: \.value, on: userTimelineViewModel.isSuspended).store(in: &disposeBag) - viewModel.name.assign(to: \.value, on: userTimelineViewModel.userDisplayName).store(in: &disposeBag) - } - - private func bindProfile( - headerViewModel: ProfileHeaderViewModel, - aboutViewModel: ProfileAboutViewModel - ) { + private func bindViewModel() { // header - viewModel.avatarImageURL - .receive(on: DispatchQueue.main) - .assign(to: \.avatarImageURL, on: headerViewModel.displayProfileInfo) + let headerViewModel = profileHeaderViewController.viewModel! + viewModel.$user + .assign(to: \.user, on: headerViewModel) .store(in: &disposeBag) - viewModel.name - .map { $0 ?? "" } - .receive(on: DispatchQueue.main) - .assign(to: \.name, on: headerViewModel.displayProfileInfo) - .store(in: &disposeBag) - viewModel.bioDescription - .receive(on: DispatchQueue.main) - .assign(to: \.note, on: headerViewModel.displayProfileInfo) - .store(in: &disposeBag) - - // about - Publishers.CombineLatest( - viewModel.fields.removeDuplicates(), - viewModel.emojiMeta.removeDuplicates() - ) - .map { fields, emojiMeta -> [ProfileFieldItem.FieldValue] in - fields.map { ProfileFieldItem.FieldValue(name: $0.name, value: $0.value, emojiMeta: emojiMeta) } - } - .receive(on: DispatchQueue.main) - .assign(to: \.fields, on: aboutViewModel.displayProfileInfo) - .store(in: &disposeBag) - - // common - viewModel.accountForEdit - .assign(to: \.accountForEdit, on: headerViewModel) - .store(in: &disposeBag) - viewModel.accountForEdit - .assign(to: \.accountForEdit, on: aboutViewModel) - .store(in: &disposeBag) - viewModel.emojiMeta - .receive(on: DispatchQueue.main) - .assign(to: \.emojiMeta, on: headerViewModel) - .store(in: &disposeBag) - viewModel.emojiMeta - .receive(on: DispatchQueue.main) - .assign(to: \.emojiMeta, on: aboutViewModel) - .store(in: &disposeBag) - viewModel.isEditing + viewModel.$isEditing .assign(to: \.isEditing, on: headerViewModel) .store(in: &disposeBag) - viewModel.isEditing + viewModel.$isUpdating + .assign(to: \.isUpdating, on: headerViewModel) + .store(in: &disposeBag) + viewModel.relationshipViewModel.$optionSet + .map { $0 ?? .none } + .assign(to: \.relationshipActionOptionSet, on: headerViewModel) + .store(in: &disposeBag) + viewModel.$accountForEdit + .assign(to: \.accountForEdit, on: headerViewModel) + .store(in: &disposeBag) + + // timeline + [ + viewModel.postsUserTimelineViewModel, + viewModel.repliesUserTimelineViewModel, + viewModel.mediaUserTimelineViewModel, + ].forEach { userTimelineViewModel in + viewModel.relationshipViewModel.$isBlocking.assign(to: \.isBlocking, on: userTimelineViewModel).store(in: &disposeBag) + viewModel.relationshipViewModel.$isBlockingBy.assign(to: \.isBlockedBy, on: userTimelineViewModel).store(in: &disposeBag) + viewModel.relationshipViewModel.$isSuspended.assign(to: \.isSuspended, on: userTimelineViewModel).store(in: &disposeBag) + } + + // about + let aboutViewModel = profilePagingViewController.viewModel.profileAboutViewController.viewModel! + viewModel.$isEditing .assign(to: \.isEditing, on: aboutViewModel) .store(in: &disposeBag) + viewModel.$accountForEdit + .assign(to: \.accountForEdit, on: aboutViewModel) + .store(in: &disposeBag) } - + private func bindTitleView() { Publishers.CombineLatest3( - viewModel.name, - viewModel.emojiMeta, - viewModel.statusesCount + profileHeaderViewController.profileHeaderView.viewModel.$name, + profileHeaderViewController.profileHeaderView.viewModel.$emojiMeta, + profileHeaderViewController.profileHeaderView.viewModel.$statusesCount ) .receive(on: DispatchQueue.main) .sink { [weak self] name, emojiMeta, statusesCount in @@ -527,7 +339,7 @@ extension ProfileViewController { } } .store(in: &disposeBag) - viewModel.name + profileHeaderViewController.profileHeaderView.viewModel.$name .receive(on: DispatchQueue.main) .sink { [weak self] name in guard let self = self else { return } @@ -535,99 +347,11 @@ extension ProfileViewController { } .store(in: &disposeBag) } - - private func bindHeader() { - // heaer UI - Publishers.CombineLatest( - viewModel.bannerImageURL.eraseToAnyPublisher(), - viewModel.viewDidAppear.eraseToAnyPublisher() - ) - .receive(on: DispatchQueue.main) - .sink { [weak self] bannerImageURL, _ in - guard let self = self else { return } - self.profileHeaderViewController.profileHeaderView.bannerImageView.af.cancelImageRequest() - let placeholder = UIImage.placeholder(color: ProfileHeaderView.bannerImageViewPlaceholderColor) - guard let bannerImageURL = bannerImageURL else { - self.profileHeaderViewController.profileHeaderView.bannerImageView.image = placeholder - return - } - self.profileHeaderViewController.profileHeaderView.bannerImageView.af.setImage( - withURL: bannerImageURL, - placeholderImage: placeholder, - imageTransition: .crossDissolve(0.3), - runImageTransitionIfCached: false, - completion: { [weak self] response in - guard let self = self else { return } - guard let image = response.value else { return } - guard image.size.width > 1 && image.size.height > 1 else { - // restore to placeholder when image invalid - self.profileHeaderViewController.profileHeaderView.bannerImageView.image = placeholder - return - } - } - ) - } - .store(in: &disposeBag) - - viewModel.username - .map { username in username.flatMap { "@" + $0 } ?? " " } - .receive(on: DispatchQueue.main) - .assign(to: \.text, on: profileHeaderViewController.profileHeaderView.usernameLabel) - .store(in: &disposeBag) - - viewModel.isEditing - .receive(on: DispatchQueue.main) - .sink { [weak self] isEditing in - guard let self = self else { return } - // set first responder for key command - if !isEditing { - DispatchQueue.main.asyncAfter(deadline: .now() + 1) { - self.profileSegmentedViewController.pagingViewController.becomeFirstResponder() - } - } - - // dismiss keyboard if needs - if !isEditing { self.view.endEditing(true) } - - self.profileHeaderViewController.buttonBar.isUserInteractionEnabled = !isEditing - if isEditing { - // scroll to About page - self.profileSegmentedViewController.pagingViewController.scrollToPage( - .last, - animated: true, - completion: nil - ) - self.profileSegmentedViewController.pagingViewController.isScrollEnabled = false - } else { - self.profileSegmentedViewController.pagingViewController.isScrollEnabled = true - } - - let animator = UIViewPropertyAnimator(duration: 0.33, curve: .easeInOut) - animator.addAnimations { - self.profileHeaderViewController.profileHeaderView.statusDashboardView.alpha = isEditing ? 0.2 : 1.0 - } - animator.startAnimation() - } - .store(in: &disposeBag) - - viewModel.needsImageOverlayBlurred - .receive(on: DispatchQueue.main) - .sink { [weak self] needsImageOverlayBlurred in - guard let self = self else { return } - UIView.animate(withDuration: 0.33) { - let bannerEffect: UIVisualEffect? = needsImageOverlayBlurred ? ProfileHeaderView.bannerImageViewOverlayBlurEffect : nil - self.profileHeaderViewController.profileHeaderView.bannerImageViewOverlayVisualEffectView.effect = bannerEffect - let avatarEffect: UIVisualEffect? = needsImageOverlayBlurred ? ProfileHeaderView.avatarImageViewOverlayBlurEffect : nil - self.profileHeaderViewController.profileHeaderView.avatarImageViewOverlayVisualEffectView.effect = avatarEffect - } - } - .store(in: &disposeBag) - } - - private func bindProfileRelationship() { + + private func bindMoreBarButtonItem() { Publishers.CombineLatest( viewModel.$user, - viewModel.relationshipActionOptionSet + viewModel.relationshipViewModel.$optionSet ) .asyncMap { [weak self] user, relationshipSet -> UIMenu? in guard let self = self else { return nil } @@ -638,8 +362,8 @@ extension ProfileViewController { let _ = ManagedObjectRecord(objectID: user.objectID) let menu = MastodonMenu.setupMenu( actions: [ - .muteUser(.init(name: name, isMuting: self.viewModel.isMuting.value)), - .blockUser(.init(name: name, isBlocking: self.viewModel.isBlocking.value)), + .muteUser(.init(name: name, isMuting: self.viewModel.relationshipViewModel.isMuting)), + .blockUser(.init(name: name, isBlocking: self.viewModel.relationshipViewModel.isBlocking)), .reportUser(.init(name: name)), .shareUser(.init(name: name)), ], @@ -660,85 +384,62 @@ extension ProfileViewController { self.moreMenuBarButtonItem.menu = menu } .store(in: &disposeBag) - - viewModel.isRelationshipActionButtonHidden - .receive(on: DispatchQueue.main) - .sink { [weak self] isHidden in - guard let self = self else { return } - self.profileHeaderViewController.profileHeaderView.relationshipActionButtonShadowContainer.isHidden = isHidden - } - .store(in: &disposeBag) - - Publishers.CombineLatest3( - viewModel.relationshipActionOptionSet.eraseToAnyPublisher(), - viewModel.isEditing.eraseToAnyPublisher(), - viewModel.isUpdating.eraseToAnyPublisher() - ) - .receive(on: DispatchQueue.main) - .sink { [weak self] relationshipActionSet, isEditing, isUpdating in - guard let self = self else { return } - let friendshipButton = self.profileHeaderViewController.profileHeaderView.relationshipActionButton - if relationshipActionSet.contains(.edit) { - // check .edit state and set .editing when isEditing - friendshipButton.configure(actionOptionSet: isUpdating ? .updating : (isEditing ? .editing : .edit)) - self.profileHeaderViewController.profileHeaderView.configure(state: isEditing ? .editing : .normal) - } else { - friendshipButton.configure(actionOptionSet: relationshipActionSet) - } - } - .store(in: &disposeBag) - - Publishers.CombineLatest3( - viewModel.isBlocking.eraseToAnyPublisher(), - viewModel.isBlockedBy.eraseToAnyPublisher(), - viewModel.suspended.eraseToAnyPublisher() - ) - .receive(on: DispatchQueue.main) - .sink { [weak self] isBlocking, isBlockedBy, suspended in - guard let self = self else { return } - let isNeedSetHidden = isBlocking || isBlockedBy || suspended - self.profileHeaderViewController.viewModel.needsSetupBottomShadow.value = !isNeedSetHidden - self.profileHeaderViewController.profileHeaderView.bioContainerView.isHidden = isNeedSetHidden - self.profileHeaderViewController.viewModel.needsFiledCollectionViewHidden.value = isNeedSetHidden - self.profileHeaderViewController.buttonBar.isUserInteractionEnabled = !isNeedSetHidden - self.viewModel.needsPagePinToTop.value = isNeedSetHidden - } - .store(in: &disposeBag) - } // end func bindProfileRelationship + } - private func bindProfileDashboard() { - viewModel.statusesCount + private func bindPager() { + viewModel.$isPagingEnabled .receive(on: DispatchQueue.main) - .sink { [weak self] count in + .sink { [weak self] isPagingEnabled in guard let self = self else { return } - let text = count.flatMap { MastodonMetricFormatter().string(from: $0) } ?? "-" - self.profileHeaderViewController.profileHeaderView.statusDashboardView.postDashboardMeterView.numberLabel.text = text - self.profileHeaderViewController.profileHeaderView.statusDashboardView.postDashboardMeterView.isAccessibilityElement = true - self.profileHeaderViewController.profileHeaderView.statusDashboardView.postDashboardMeterView.accessibilityLabel = L10n.Plural.Count.post(count ?? 0) + self.profilePagingViewController.containerView.isScrollEnabled = isPagingEnabled + self.profilePagingViewController.buttonBarView.isUserInteractionEnabled = isPagingEnabled } .store(in: &disposeBag) - viewModel.followingCount + + viewModel.$isEditing .receive(on: DispatchQueue.main) - .sink { [weak self] count in + .sink { [weak self] isEditing in guard let self = self else { return } - let text = count.flatMap { MastodonMetricFormatter().string(from: $0) } ?? "-" - self.profileHeaderViewController.profileHeaderView.statusDashboardView.followingDashboardMeterView.numberLabel.text = text - self.profileHeaderViewController.profileHeaderView.statusDashboardView.followingDashboardMeterView.isAccessibilityElement = true - self.profileHeaderViewController.profileHeaderView.statusDashboardView.followingDashboardMeterView.accessibilityLabel = L10n.Plural.Count.following(count ?? 0) - } - .store(in: &disposeBag) - viewModel.followersCount - .receive(on: DispatchQueue.main) - .sink { [weak self] count in - guard let self = self else { return } - let text = count.flatMap { MastodonMetricFormatter().string(from: $0) } ?? "-" - self.profileHeaderViewController.profileHeaderView.statusDashboardView.followersDashboardMeterView.numberLabel.text = text - self.profileHeaderViewController.profileHeaderView.statusDashboardView.followersDashboardMeterView.isAccessibilityElement = true - self.profileHeaderViewController.profileHeaderView.statusDashboardView.followersDashboardMeterView.accessibilityLabel = L10n.Plural.Count.follower(count ?? 0) + // set first responder for key command + if !isEditing { + DispatchQueue.main.asyncAfter(deadline: .now() + 1) { + self.profilePagingViewController.becomeFirstResponder() + } + } + + // dismiss keyboard if needs + if !isEditing { self.view.endEditing(true) } + + if isEditing, + let index = self.profilePagingViewController.viewControllers.firstIndex(where: { type(of: $0) is ProfileAboutViewController.Type }), + self.profilePagingViewController.canMoveTo(index: index) + { + self.profilePagingViewController.moveToViewController(at: index) + } } .store(in: &disposeBag) } - + +// private func bindProfileRelationship() { +// +// Publishers.CombineLatest3( +// viewModel.isBlocking.eraseToAnyPublisher(), +// viewModel.isBlockedBy.eraseToAnyPublisher(), +// viewModel.suspended.eraseToAnyPublisher() +// ) +// .receive(on: DispatchQueue.main) +// .sink { [weak self] isBlocking, isBlockedBy, suspended in +// guard let self = self else { return } +// let isNeedSetHidden = isBlocking || isBlockedBy || suspended +// self.profileHeaderViewController.viewModel.needsSetupBottomShadow.value = !isNeedSetHidden +// self.profileHeaderViewController.profileHeaderView.bioContainerView.isHidden = isNeedSetHidden +// self.profileHeaderViewController.viewModel.needsFiledCollectionViewHidden.value = isNeedSetHidden +// self.profileHeaderViewController.buttonBar.isUserInteractionEnabled = !isNeedSetHidden +// self.viewModel.needsPagePinToTop.value = isNeedSetHidden +// } +// .store(in: &disposeBag) +// } // end func bindProfileRelationship + private func handleMetaPress(_ meta: Meta) { switch meta { case .url(_, _, let url, _): @@ -759,19 +460,19 @@ extension ProfileViewController { } extension ProfileViewController { - + @objc private func cancelEditingBarButtonItemPressed(_ sender: UIBarButtonItem) { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) - viewModel.isEditing.value = false + viewModel.isEditing = false } - + @objc private func settingBarButtonItemPressed(_ sender: UIBarButtonItem) { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) guard let setting = context.settingService.currentSetting.value else { return } let settingsViewModel = SettingsViewModel(context: context, setting: setting) coordinator.present(scene: .settings(viewModel: settingsViewModel), from: self, transition: .modal(animated: true, completion: nil)) } - + @objc private func shareBarButtonItemPressed(_ sender: UIBarButtonItem) { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) guard let user = viewModel.user else { return } @@ -793,13 +494,13 @@ extension ProfileViewController { ) } // end Task } - + @objc private func favoriteBarButtonItemPressed(_ sender: UIBarButtonItem) { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) let favoriteViewModel = FavoriteViewModel(context: context) coordinator.present(scene: .favorite(viewModel: favoriteViewModel), from: self, transition: .show) } - + @objc private func replyBarButtonItemPressed(_ sender: UIBarButtonItem) { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) guard let authenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { return } @@ -811,174 +512,185 @@ extension ProfileViewController { ) coordinator.present(scene: .compose(viewModel: composeViewModel), from: self, transition: .modal(animated: true, completion: nil)) } - + @objc private func refreshControlValueChanged(_ sender: UIRefreshControl) { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) - let currentViewController = profileSegmentedViewController.pagingViewController.currentViewController - if let currentViewController = currentViewController as? UserTimelineViewController { - currentViewController.viewModel.stateMachine.enter(UserTimelineViewModel.State.Reloading.self) + if let userTimelineViewController = profilePagingViewController.currentViewController as? UserTimelineViewController { + userTimelineViewController.viewModel.stateMachine.enter(UserTimelineViewModel.State.Reloading.self) } DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { sender.endRefreshing() } } - + } -// MARK: - UIScrollViewDelegate -extension ProfileViewController: UIScrollViewDelegate { +// MARK: - TabBarPagerDelegate +extension ProfileViewController: TabBarPagerDelegate { - func scrollViewDidScroll(_ scrollView: UIScrollView) { - contentOffsets[profileSegmentedViewController.pagingViewController.currentIndex!] = scrollView.contentOffset.y - let topMaxContentOffsetY = profileSegmentedViewController.view.frame.minY - ProfileHeaderViewController.headerMinHeight - containerScrollView.safeAreaInsets.top - if scrollView.contentOffset.y < topMaxContentOffsetY { - self.containerScrollView.contentOffset.y = scrollView.contentOffset.y - for postTimelineView in profileSegmentedViewController.pagingViewController.viewModel.viewControllers { - postTimelineView.scrollView?.contentOffset.y = 0 - } - contentOffsets.removeAll() - } else { - containerScrollView.contentOffset.y = topMaxContentOffsetY - if viewModel.needsPagePinToTop.value { - // do nothing + func tabBarMinimalHeight() -> CGFloat { + return ProfileHeaderViewController.headerMinHeight + } + + func resetPageContentOffset(_ tabBarPagerController: TabBarPagerController) { + for viewController in profilePagingViewController.viewModel.viewControllers { + viewController.pageScrollView.contentOffset = .zero + } + } + + func tabBarPagerController(_ tabBarPagerController: TabBarPagerController, didScroll scrollView: UIScrollView) { + // try to find some patterns: + // print(""" + // ----- + // headerMinHeight: \(ProfileHeaderViewController.headerMinHeight) + // scrollView.contentOffset.y: \(scrollView.contentOffset.y) + // scrollView.contentSize.height: \(scrollView.contentSize.height) + // scrollView.frame: \(scrollView.frame) + // scrollView.adjustedContentInset.top: \(scrollView.adjustedContentInset.top) + // scrollView.adjustedContentInset.bottom: \(scrollView.adjustedContentInset.bottom) + // """ + // ) + + + // elastically banner + + // make banner top snap to window top + // do not rely on the view frame becase the header frame is .zero during the initial call + profileHeaderViewController.profileHeaderView.bannerImageViewTopLayoutConstraint.constant = min(0, scrollView.contentOffset.y) + + if profileHeaderViewController.profileHeaderView.frame != .zero { + // make banner bottom not higher than navigation bar bottom + let bannerContainerInWindow = profileHeaderViewController.profileHeaderView.convert( + profileHeaderViewController.profileHeaderView.bannerContainerView.frame, + to: nil + ) + let bannerContainerBottomOffset = bannerContainerInWindow.origin.y + bannerContainerInWindow.height + // print("bannerContainerBottomOffset: \(bannerContainerBottomOffset)") + + let height = profileHeaderViewController.view.frame.height - bannerContainerInWindow.height + // make avata hidden when scroll 0.5x avatar height + let throttle = height != .zero ? 0.5 * ProfileHeaderView.avatarImageViewSize.height / height : 0 + let progress: CGFloat + + if bannerContainerBottomOffset < tabBarPagerController.containerScrollView.safeAreaInsets.top { + let offset = bannerContainerBottomOffset - tabBarPagerController.containerScrollView.safeAreaInsets.top + profileHeaderViewController.profileHeaderView.bannerImageViewBottomLayoutConstraint.constant = offset + // the progress for header move from banner bottom to header bottom (from 0 to 1) + progress = height != .zero ? abs(offset) / height : 0 } else { - if let customScrollViewContainerController = profileSegmentedViewController.pagingViewController.currentViewController as? ScrollViewContainer { - let contentOffsetY = scrollView.contentOffset.y - containerScrollView.contentOffset.y - customScrollViewContainerController.scrollView?.contentOffset.y = contentOffsetY - } + profileHeaderViewController.profileHeaderView.bannerImageViewBottomLayoutConstraint.constant = 0 + progress = 0 } + // setup titleView offset and fade avatar + profileHeaderViewController.updateHeaderScrollProgress(progress, throttle: throttle) + + // setup buttonBar shadow + profilePagingViewController.updateButtonBarShadow(progress: progress) } - - // elastically banner image - let headerScrollProgress = (containerScrollView.contentOffset.y - containerScrollView.safeAreaInsets.top) / topMaxContentOffsetY - let throttle = ProfileHeaderViewController.headerMinHeight / topMaxContentOffsetY - profileHeaderViewController.updateHeaderScrollProgress(headerScrollProgress, throttle: throttle) } - + } +// MARK: - TabBarPagerDataSource +extension ProfileViewController: TabBarPagerDataSource { + func headerViewController() -> UIViewController & TabBarPagerHeader { + return profileHeaderViewController + } + + func pageViewController() -> UIViewController & TabBarPageViewController { + return profilePagingViewController + } +} + +//// MARK: - UIScrollViewDelegate +//extension ProfileViewController: UIScrollViewDelegate { +// +// func scrollViewDidScroll(_ scrollView: UIScrollView) { +// contentOffsets[profileSegmentedViewController.pagingViewController.currentIndex!] = scrollView.contentOffset.y +// let topMaxContentOffsetY = profileSegmentedViewController.view.frame.minY - ProfileHeaderViewController.headerMinHeight - containerScrollView.safeAreaInsets.top +// if scrollView.contentOffset.y < topMaxContentOffsetY { +// self.containerScrollView.contentOffset.y = scrollView.contentOffset.y +// for postTimelineView in profileSegmentedViewController.pagingViewController.viewModel.viewControllers { +// postTimelineView.scrollView?.contentOffset.y = 0 +// } +// contentOffsets.removeAll() +// } else { +// containerScrollView.contentOffset.y = topMaxContentOffsetY +// if viewModel.needsPagePinToTop.value { +// // do nothing +// } else { +// if let customScrollViewContainerController = profileSegmentedViewController.pagingViewController.currentViewController as? ScrollViewContainer { +// let contentOffsetY = scrollView.contentOffset.y - containerScrollView.contentOffset.y +// customScrollViewContainerController.scrollView?.contentOffset.y = contentOffsetY +// } +// } +// +// } +// } +// +//} + // MARK: - ProfileHeaderViewControllerDelegate extension ProfileViewController: ProfileHeaderViewControllerDelegate { - - func profileHeaderViewController(_ viewController: ProfileHeaderViewController, viewLayoutDidUpdate view: UIView) { - guard let scrollView = (profileSegmentedViewController.pagingViewController.currentViewController as? UserTimelineViewController)?.scrollView else { - // assertionFailure() - return - } - - updateOverlayScrollViewContentSize(scrollView: scrollView) - } - -} - -// MARK: - ProfilePagingViewControllerDelegate -extension ProfileViewController: ProfilePagingViewControllerDelegate { - - func profilePagingViewController(_ viewController: ProfilePagingViewController, didScrollToPostCustomScrollViewContainerController postTimelineViewController: ScrollViewContainer, atIndex index: Int) { - os_log("%{public}s[%{public}ld], %{public}s: select at index: %ld", ((#file as NSString).lastPathComponent), #line, #function, index) - -// // update segemented control -// if index < profileHeaderViewController.pageSegmentedControl.numberOfSegments { -// profileHeaderViewController.pageSegmentedControl.selectedSegmentIndex = index -// } - - // save content offset - overlayScrollView.contentOffset.y = contentOffsets[index] ?? containerScrollView.contentOffset.y - - // setup observer and gesture fallback - if let scrollView = postTimelineViewController.scrollView { - currentPostTimelineTableViewContentSizeObservation = observeTableViewContentSize(scrollView: scrollView) - scrollView.panGestureRecognizer.require(toFail: overlayScrollView.panGestureRecognizer) - } - } - -} - -// MARK: - ProfileHeaderViewDelegate -extension ProfileViewController: ProfileHeaderViewDelegate { - func profileHeaderView(_ profileHeaderView: ProfileHeaderView, avatarButtonDidPressed button: AvatarButton) { - guard let user = viewModel.user else { return } - let record: ManagedObjectRecord = .init(objectID: user.objectID) - - Task { - try await DataSourceFacade.coordinateToMediaPreviewScene( - dependency: self, - user: record, - previewContext: DataSourceFacade.ImagePreviewContext( - imageView: button.avatarImageView, - containerView: .profileAvatar(profileHeaderView) - ) - ) - } // end Task - } - - func profileHeaderView(_ profileHeaderView: ProfileHeaderView, bannerImageViewDidPressed imageView: UIImageView) { - guard let user = viewModel.user else { return } - let record: ManagedObjectRecord = .init(objectID: user.objectID) - - Task { - try await DataSourceFacade.coordinateToMediaPreviewScene( - dependency: self, - user: record, - previewContext: DataSourceFacade.ImagePreviewContext( - imageView: imageView, - containerView: .profileBanner(profileHeaderView) - ) - ) - } // end Task - } - - func profileHeaderView( - _ profileHeaderView: ProfileHeaderView, + func profileHeaderViewController( + _ profileHeaderViewController: ProfileHeaderViewController, + profileHeaderView: ProfileHeaderView, relationshipButtonDidPressed button: ProfileRelationshipActionButton ) { - let relationshipActionSet = viewModel.relationshipActionOptionSet.value - + let relationshipActionSet = viewModel.relationshipViewModel.optionSet ?? .none + // handle edit logic for editable profile // handle relationship logic for non-editable profile if relationshipActionSet.contains(.edit) { // do nothing when updating - guard !viewModel.isUpdating.value else { return } - + guard !viewModel.isUpdating else { return } + guard let profileHeaderViewModel = profileHeaderViewController.viewModel else { return } - guard let profileAboutViewModel = profileSegmentedViewController.pagingViewController.viewModel.profileAboutViewController.viewModel else { return } + guard let profileAboutViewModel = profilePagingViewController.viewModel.profileAboutViewController.viewModel else { return } - let isEdited = profileHeaderViewModel.isEdited() - || profileAboutViewModel.isEdited() + let isEdited = profileHeaderViewModel.isEdited || profileAboutViewModel.isEdited if isEdited { - // update profile if changed - viewModel.isUpdating.value = true - Task { + // update profile when edited + viewModel.isUpdating = true + Task { @MainActor in do { // TODO: handle error _ = try await viewModel.updateProfileInfo( - headerProfileInfo: profileHeaderViewModel.editProfileInfo, - aboutProfileInfo: profileAboutViewModel.editProfileInfo + headerProfileInfo: profileHeaderViewModel.profileInfoEditing, + aboutProfileInfo: profileAboutViewModel.profileInfoEditing ) self.logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): update profile info success") - self.viewModel.isEditing.value = false + self.viewModel.isEditing = false } catch { self.logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): update profile info fail: \(error.localizedDescription)") + let alertController = UIAlertController( + for: error, + title: L10n.Common.Alerts.EditProfileFailure.title, + preferredStyle: .alert + ) + let okAction = UIAlertAction(title: L10n.Common.Controls.Actions.ok, style: .default) + alertController.addAction(okAction) + self.present(alertController, animated: true) } // finish updating - self.viewModel.isUpdating.value = false - } + self.viewModel.isUpdating = false + } // end Task } else { // set `updating` then toggle `edit` state - viewModel.isUpdating.value = true + viewModel.isUpdating = true viewModel.fetchEditProfileInfo() .receive(on: DispatchQueue.main) .sink { [weak self] completion in guard let self = self else { return } defer { // finish updating - self.viewModel.isUpdating.value = false + self.viewModel.isUpdating = false } switch completion { case .failure(let error): @@ -994,11 +706,11 @@ extension ProfileViewController: ProfileHeaderViewDelegate { case .finished: os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: fetch profile info for edit success", ((#file as NSString).lastPathComponent), #line, #function) // enter editing mode - self.viewModel.isEditing.value.toggle() + self.viewModel.isEditing.toggle() } } receiveValue: { [weak self] response in guard let self = self else { return } - self.viewModel.accountForEdit.value = response.value + self.viewModel.accountForEdit = response.value } .store(in: &disposeBag) } @@ -1074,53 +786,27 @@ extension ProfileViewController: ProfileHeaderViewDelegate { assertionFailure() } } + } - - func profileHeaderView(_ profileHeaderView: ProfileHeaderView, metaTextView: MetaTextView, metaDidPressed meta: Meta) { + + func profileHeaderViewController( + _ profileHeaderViewController: ProfileHeaderViewController, + profileHeaderView: ProfileHeaderView, + metaTextView: MetaTextView, + metaDidPressed meta: Meta + ) { handleMetaPress(meta) } - - func profileHeaderView(_ profileHeaderView: ProfileHeaderView, profileStatusDashboardView dashboardView: ProfileStatusDashboardView, dashboardMeterViewDidPressed dashboardMeterView: ProfileStatusDashboardMeterView, meter: ProfileStatusDashboardView.Meter) { - switch meter { - case .post: - // do nothing - break - case .follower: - guard let domain = viewModel.domain.value, - let userID = viewModel.userID.value - else { return } - let followerListViewModel = FollowerListViewModel( - context: context, - domain: domain, - userID: userID - ) - coordinator.present( - scene: .follower(viewModel: followerListViewModel), - from: self, - transition: .show - ) - case .following: - guard let domain = viewModel.domain.value, - let userID = viewModel.userID.value - else { return } - let followingListViewModel = FollowingListViewModel( - context: context, - domain: domain, - userID: userID - ) - coordinator.present( - scene: .following(viewModel: followingListViewModel), - from: self, - transition: .show - ) - } - } - } // MARK: - ProfileAboutViewControllerDelegate extension ProfileViewController: ProfileAboutViewControllerDelegate { - func profileAboutViewController(_ viewController: ProfileAboutViewController, profileFieldCollectionViewCell: ProfileFieldCollectionViewCell, metaLabel: MetaLabel, didSelectMeta meta: Meta) { + func profileAboutViewController( + _ viewController: ProfileAboutViewController, + profileFieldCollectionViewCell: ProfileFieldCollectionViewCell, + metaLabel: MetaLabel, + didSelectMeta meta: Meta + ) { handleMetaPress(meta) } } @@ -1130,9 +816,9 @@ extension ProfileViewController: MastodonMenuDelegate { func menuAction(_ action: MastodonMenu.Action) { guard let authenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { return } guard let user = viewModel.user else { return } - + let userRecord: ManagedObjectRecord = .init(objectID: user.objectID) - + Task { try await DataSourceFacade.responseToMenuAction( dependency: self, @@ -1151,16 +837,16 @@ extension ProfileViewController: MastodonMenuDelegate { // MARK: - ScrollViewContainer extension ProfileViewController: ScrollViewContainer { - var scrollView: UIScrollView? { - return overlayScrollView + var scrollView: UIScrollView { + return tabBarPagerController.containerScrollView } } extension ProfileViewController { override var keyCommands: [UIKeyCommand]? { - if !viewModel.isEditing.value { - return pageboyNavigateKeyCommands + if !viewModel.isEditing { + return pagerTabStripNavigateKeyCommands } return nil @@ -1168,16 +854,16 @@ extension ProfileViewController { } -// MARK: - PageboyNavigateable -extension ProfileViewController: PageboyNavigateable { - - var navigateablePageViewController: PageboyViewController { - return profileSegmentedViewController.pagingViewController +// MARK: - PagerTabStripNavigateable +extension ProfileViewController: PagerTabStripNavigateable { + + var navigateablePageViewController: PagerTabStripViewController { + return profilePagingViewController } - - @objc func pageboyNavigateKeyCommandHandlerRelay(_ sender: UIKeyCommand) { - pageboyNavigateKeyCommandHandler(sender) + + @objc func pagerTabStripNavigateKeyCommandHandlerRelay(_ sender: UIKeyCommand) { + pagerTabStripNavigateKeyCommandHandler(sender) } - + } diff --git a/Mastodon/Scene/Profile/ProfileViewModel.swift b/Mastodon/Scene/Profile/ProfileViewModel.swift index ac8c12e98..91866b851 100644 --- a/Mastodon/Scene/Profile/ProfileViewModel.swift +++ b/Mastodon/Scene/Profile/ProfileViewModel.swift @@ -27,97 +27,109 @@ class ProfileViewModel: NSObject { private var mastodonUserObserver: AnyCancellable? private var currentMastodonUserObserver: AnyCancellable? + let postsUserTimelineViewModel: UserTimelineViewModel + let repliesUserTimelineViewModel: UserTimelineViewModel + let mediaUserTimelineViewModel: UserTimelineViewModel + let profileAboutViewModel: ProfileAboutViewModel + // input let context: AppContext @Published var me: MastodonUser? @Published var user: MastodonUser? + let viewDidAppear = PassthroughSubject() + + @Published var isEditing = false + @Published var isUpdating = false + @Published var accountForEdit: Mastodon.Entity.Account? // output - let domain: CurrentValueSubject - let userID: CurrentValueSubject - let bannerImageURL: CurrentValueSubject - let avatarImageURL: CurrentValueSubject - let name: CurrentValueSubject - let username: CurrentValueSubject - let bioDescription: CurrentValueSubject - let url: CurrentValueSubject - let statusesCount: CurrentValueSubject - let followingCount: CurrentValueSubject - let followersCount: CurrentValueSubject - let fields: CurrentValueSubject<[MastodonField], Never> - let emojiMeta: CurrentValueSubject - - // fulfill this before editing - let accountForEdit = CurrentValueSubject(nil) - - let protected: CurrentValueSubject - let suspended: CurrentValueSubject - - let isEditing = CurrentValueSubject(false) - let isUpdating = CurrentValueSubject(false) + let relationshipViewModel = RelationshipViewModel() - let relationshipActionOptionSet = CurrentValueSubject(.none) - let isFollowedBy = CurrentValueSubject(false) - let isMuting = CurrentValueSubject(false) - let isBlocking = CurrentValueSubject(false) - let isBlockedBy = CurrentValueSubject(false) + @Published var userIdentifier: UserIdentifier? = nil - let isRelationshipActionButtonHidden = CurrentValueSubject(true) - let isReplyBarButtonItemHidden = CurrentValueSubject(true) - let isMoreMenuBarButtonItemHidden = CurrentValueSubject(true) - let isMeBarButtonItemsHidden = CurrentValueSubject(true) + @Published var isRelationshipActionButtonHidden: Bool = true + @Published var isReplyBarButtonItemHidden: Bool = true + @Published var isMoreMenuBarButtonItemHidden: Bool = true + @Published var isMeBarButtonItemsHidden: Bool = true + @Published var isPagingEnabled = true - let needsPagePinToTop = CurrentValueSubject(false) - let needsPagingEnabled = CurrentValueSubject(true) - let needsImageOverlayBlurred = CurrentValueSubject(false) + // @Published var protected: Bool? = nil + // let needsPagePinToTop = CurrentValueSubject(false) init(context: AppContext, optionalMastodonUser mastodonUser: MastodonUser?) { self.context = context self.user = mastodonUser - self.domain = CurrentValueSubject(context.authenticationService.activeMastodonAuthenticationBox.value?.domain) - self.userID = CurrentValueSubject(mastodonUser?.id) - self.bannerImageURL = CurrentValueSubject(mastodonUser?.headerImageURL()) - self.avatarImageURL = CurrentValueSubject(mastodonUser?.avatarImageURL()) - self.name = CurrentValueSubject(mastodonUser?.displayNameWithFallback) - self.username = CurrentValueSubject(mastodonUser?.acctWithDomain) - self.bioDescription = CurrentValueSubject(mastodonUser?.note) - self.url = CurrentValueSubject(mastodonUser?.url) - self.statusesCount = CurrentValueSubject(mastodonUser.flatMap { Int($0.statusesCount) }) - self.followingCount = CurrentValueSubject(mastodonUser.flatMap { Int($0.followingCount) }) - self.followersCount = CurrentValueSubject(mastodonUser.flatMap { Int($0.followersCount) }) - self.protected = CurrentValueSubject(mastodonUser?.locked) - self.suspended = CurrentValueSubject(mastodonUser?.suspended ?? false) - self.fields = CurrentValueSubject(mastodonUser?.fields ?? []) - self.emojiMeta = CurrentValueSubject(mastodonUser?.emojis.asDictionary ?? [:]) + self.postsUserTimelineViewModel = UserTimelineViewModel( + context: context, + title: L10n.Scene.Profile.SegmentedControl.posts, + queryFilter: .init(excludeReplies: true) + ) + self.repliesUserTimelineViewModel = UserTimelineViewModel( + context: context, + title: L10n.Scene.Profile.SegmentedControl.postsAndReplies, + queryFilter: .init(excludeReplies: true) + ) + self.mediaUserTimelineViewModel = UserTimelineViewModel( + context: context, + title: L10n.Scene.Profile.SegmentedControl.media, + queryFilter: .init(onlyMedia: true) + ) + self.profileAboutViewModel = ProfileAboutViewModel(context: context) super.init() - relationshipActionOptionSet - .compactMap { $0.highPriorityAction(except: []) } - .map { $0 == .none } - .assign(to: \.value, on: isRelationshipActionButtonHidden) - .store(in: &disposeBag) - - // bind active authentication + // bind me context.authenticationService.activeMastodonAuthenticationBox + .receive(on: DispatchQueue.main) .sink { [weak self] authenticationBox in guard let self = self else { return } - guard let authenticationBox = authenticationBox else { - self.domain.value = nil - self.me = nil - return - } - self.domain.value = authenticationBox.domain - self.me = authenticationBox.authenticationRecord.object(in: context.managedObjectContext)?.user + self.me = authenticationBox?.authenticationRecord.object(in: context.managedObjectContext)?.user } .store(in: &disposeBag) + $me + .assign(to: \.me, on: relationshipViewModel) + .store(in: &disposeBag) + + // bind user + $user + .map { user -> UserIdentifier? in + guard let user = user else { return nil } + return MastodonUserIdentifier(domain: user.domain, userID: user.id) + } + .assign(to: &$userIdentifier) + $user + .assign(to: \.user, on: relationshipViewModel) + .store(in: &disposeBag) + // bind userIdentifier + $userIdentifier.assign(to: &postsUserTimelineViewModel.$userIdentifier) + $userIdentifier.assign(to: &repliesUserTimelineViewModel.$userIdentifier) + $userIdentifier.assign(to: &mediaUserTimelineViewModel.$userIdentifier) + + // bind bar button items + relationshipViewModel.$optionSet + .sink { [weak self] optionSet in + guard let self = self else { return } + guard let optionSet = optionSet, !optionSet.contains(.none) else { + self.isReplyBarButtonItemHidden = true + self.isMoreMenuBarButtonItemHidden = true + self.isMeBarButtonItemsHidden = true + return + } + + let isMyself = optionSet.contains(.isMyself) + self.isReplyBarButtonItemHidden = isMyself + self.isMoreMenuBarButtonItemHidden = isMyself + self.isMeBarButtonItemsHidden = !isMyself + } + .store(in: &disposeBag) + // query relationship let userRecord = $user.map { user -> ManagedObjectRecord? in user.flatMap { ManagedObjectRecord(objectID: $0.objectID) } } let pendingRetryPublisher = CurrentValueSubject(1) - + // observe friendship Publishers.CombineLatest3( userRecord, @@ -148,200 +160,25 @@ class ProfileViewModel: NSObject { } catch { self.logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): [Relationship] update user relationship failure: \(error.localizedDescription)") } - } + } // end Task } .store(in: &disposeBag) - +// let isBlockingOrBlocked = Publishers.CombineLatest( - isBlocking, - isBlockedBy + relationshipViewModel.$isBlocking, + relationshipViewModel.$isBlockingBy ) .map { $0 || $1 } .share() - - isBlockingOrBlocked - .map { !$0 } - .assign(to: \.value, on: needsPagingEnabled) - .store(in: &disposeBag) - - isBlockingOrBlocked - .map { $0 } - .assign(to: \.value, on: needsImageOverlayBlurred) - .store(in: &disposeBag) - - setup() - } - -} - -extension ProfileViewModel { - private func setup() { - Publishers.CombineLatest( - $user, - $me - ) - .receive(on: DispatchQueue.main) - .sink { [weak self] user, me in - guard let self = self else { return } - // Update view model attribute - self.update(mastodonUser: user) - self.update(mastodonUser: user, currentMastodonUser: me) - - // Setup observer for user - if let mastodonUser = user { - // setup observer - self.mastodonUserObserver = ManagedObjectObserver.observe(object: mastodonUser) - .sink { completion in - switch completion { - case .failure(let error): - assertionFailure(error.localizedDescription) - case .finished: - assertionFailure() - } - } receiveValue: { [weak self] change in - guard let self = self else { return } - guard let changeType = change.changeType else { return } - switch changeType { - case .update: - self.update(mastodonUser: mastodonUser) - self.update(mastodonUser: mastodonUser, currentMastodonUser: me) - case .delete: - // TODO: - break - } - } - - } else { - self.mastodonUserObserver = nil - } - - // Setup observer for user - if let currentMastodonUser = me { - // setup observer - self.currentMastodonUserObserver = ManagedObjectObserver.observe(object: currentMastodonUser) - .sink { completion in - switch completion { - case .failure(let error): - assertionFailure(error.localizedDescription) - case .finished: - assertionFailure() - } - } receiveValue: { [weak self] change in - guard let self = self else { return } - guard let changeType = change.changeType else { return } - switch changeType { - case .update: - self.update(mastodonUser: user, currentMastodonUser: currentMastodonUser) - case .delete: - // TODO: - break - } - } - } else { - self.currentMastodonUserObserver = nil - } - } - .store(in: &disposeBag) - } - - private func update(mastodonUser: MastodonUser?) { - self.userID.value = mastodonUser?.id - self.bannerImageURL.value = mastodonUser?.headerImageURL() - self.avatarImageURL.value = mastodonUser?.avatarImageURL() - self.name.value = mastodonUser?.displayNameWithFallback - self.username.value = mastodonUser?.acctWithDomain - self.bioDescription.value = mastodonUser?.note - self.url.value = mastodonUser?.url - self.statusesCount.value = mastodonUser.flatMap { Int($0.statusesCount) } - self.followingCount.value = mastodonUser.flatMap { Int($0.followingCount) } - self.followersCount.value = mastodonUser.flatMap { Int($0.followersCount) } - self.protected.value = mastodonUser?.locked - self.suspended.value = mastodonUser?.suspended ?? false - self.fields.value = mastodonUser?.fields ?? [] - self.emojiMeta.value = mastodonUser?.emojis.asDictionary ?? [:] - } - - private func update(mastodonUser: MastodonUser?, currentMastodonUser: MastodonUser?) { - guard let mastodonUser = mastodonUser, - let currentMastodonUser = currentMastodonUser else { - // set relationship - self.relationshipActionOptionSet.value = .none - self.isFollowedBy.value = false - self.isMuting.value = false - self.isBlocking.value = false - self.isBlockedBy.value = false - - // set bar button item state - self.isReplyBarButtonItemHidden.value = true - self.isMoreMenuBarButtonItemHidden.value = true - self.isMeBarButtonItemsHidden.value = true - return - } - if mastodonUser == currentMastodonUser { - self.relationshipActionOptionSet.value = [.edit] - // set bar button item state - self.isReplyBarButtonItemHidden.value = true - self.isMoreMenuBarButtonItemHidden.value = true - self.isMeBarButtonItemsHidden.value = false - } else { - // set with follow action default - var relationshipActionSet = RelationshipActionOptionSet([.follow]) - - if mastodonUser.locked { - relationshipActionSet.insert(.request) - } - - if mastodonUser.suspended { - relationshipActionSet.insert(.suspended) - } - - let isFollowing = mastodonUser.followingBy.contains(currentMastodonUser) - if isFollowing { - relationshipActionSet.insert(.following) - } - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Relationship] update %s isFollowing: %s", ((#file as NSString).lastPathComponent), #line, #function, mastodonUser.id, isFollowing.description) - - let isPending = mastodonUser.followRequestedBy.contains(currentMastodonUser) - if isPending { - relationshipActionSet.insert(.pending) - } - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Relationship] update %s isPending: %s", ((#file as NSString).lastPathComponent), #line, #function, mastodonUser.id, isPending.description) - - let isFollowedBy = currentMastodonUser.followingBy.contains(mastodonUser) - self.isFollowedBy.value = isFollowedBy - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Relationship] update %s isFollowedBy: %s", ((#file as NSString).lastPathComponent), #line, #function, mastodonUser.id, isFollowedBy.description) - - let isMuting = mastodonUser.mutingBy.contains(currentMastodonUser) - if isMuting { - relationshipActionSet.insert(.muting) - } - self.isMuting.value = isMuting - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Relationship] update %s isMuting: %s", ((#file as NSString).lastPathComponent), #line, #function, mastodonUser.id, isMuting.description) - - let isBlocking = mastodonUser.blockingBy.contains(currentMastodonUser) - if isBlocking { - relationshipActionSet.insert(.blocking) - } - self.isBlocking.value = isBlocking - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Relationship] update %s isBlocking: %s", ((#file as NSString).lastPathComponent), #line, #function, mastodonUser.id, isBlocking.description) - - let isBlockedBy = currentMastodonUser.blockingBy.contains(mastodonUser) - if isBlockedBy { - relationshipActionSet.insert(.blocked) - } - self.isBlockedBy.value = isBlockedBy - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Relationship] update %s isBlockedBy: %s", ((#file as NSString).lastPathComponent), #line, #function, mastodonUser.id, isBlockedBy.description) - - self.relationshipActionOptionSet.value = relationshipActionSet - - // set bar button item state - self.isReplyBarButtonItemHidden.value = isBlocking || isBlockedBy - self.isMoreMenuBarButtonItemHidden.value = false - self.isMeBarButtonItemsHidden.value = true - } + Publishers.CombineLatest( + isBlockingOrBlocked, + $isEditing + ) + .map { !$0 && !$1 } + .assign(to: &$isPagingEnabled) } - + } extension ProfileViewModel { @@ -386,7 +223,7 @@ extension ProfileViewModel { let authorization = authenticationBox.userAuthorization let _image: UIImage? = { - guard let image = headerProfileInfo.avatarImage else { return nil } + guard let image = headerProfileInfo.avatar else { return nil } guard image.size.width <= ProfileHeaderViewModel.avatarImageMaxSizeInPixel.width else { return image.af.imageScaled(to: ProfileHeaderViewModel.avatarImageMaxSizeInPixel) } diff --git a/Mastodon/Scene/Profile/Segmented/Paging/ProfilePagingViewController.swift b/Mastodon/Scene/Profile/Segmented/Paging/ProfilePagingViewController.swift deleted file mode 100644 index 23630741f..000000000 --- a/Mastodon/Scene/Profile/Segmented/Paging/ProfilePagingViewController.swift +++ /dev/null @@ -1,90 +0,0 @@ -// -// ProfilePagingViewController.swift -// Mastodon -// -// Created by MainasuK Cirno on 2021-3-29. -// - -import os.log -import UIKit -import Pageboy -import Tabman - -protocol ProfilePagingViewControllerDelegate: AnyObject { - func profilePagingViewController(_ viewController: ProfilePagingViewController, didScrollToPostCustomScrollViewContainerController customScrollViewContainerController: ScrollViewContainer, atIndex index: Int) -} - -final class ProfilePagingViewController: TabmanViewController { - - weak var pagingDelegate: ProfilePagingViewControllerDelegate? - var viewModel: ProfilePagingViewModel! - - - // MARK: - PageboyViewControllerDelegate - override func pageboyViewController(_ pageboyViewController: PageboyViewController, didCancelScrollToPageAt index: PageboyViewController.PageIndex, returnToPageAt previousIndex: PageboyViewController.PageIndex) { - super.pageboyViewController(pageboyViewController, didCancelScrollToPageAt: index, returnToPageAt: previousIndex) - - // Fix the SDK bug for table view get row selected during swipe but cancel paging - guard previousIndex < viewModel.viewControllers.count else { return } - let viewController = viewModel.viewControllers[previousIndex] - - if let tableView = viewController.scrollView as? UITableView { - for cell in tableView.visibleCells { - cell.setHighlighted(false, animated: false) - } - } - } - - override func pageboyViewController(_ pageboyViewController: PageboyViewController, didScrollToPageAt index: TabmanViewController.PageIndex, direction: PageboyViewController.NavigationDirection, animated: Bool) { - super.pageboyViewController(pageboyViewController, didScrollToPageAt: index, direction: direction, animated: animated) - - let viewController = viewModel.viewControllers[index] - (viewController as? StatusTableViewControllerNavigateable)?.overrideNavigationScrollPosition = .top - pagingDelegate?.profilePagingViewController(self, didScrollToPostCustomScrollViewContainerController: viewController, atIndex: index) - } - - // make key commands works - override var canBecomeFirstResponder: Bool { - return true - } - - deinit { - os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) - } - -} - -extension ProfilePagingViewController { - - override func viewDidLoad() { - super.viewDidLoad() - - view.backgroundColor = .clear - dataSource = viewModel - } - - override func viewDidAppear(_ animated: Bool) { - super.viewDidAppear(animated) - - becomeFirstResponder() - } - -} - -// workaround to fix tab man responder chain issue -extension ProfilePagingViewController { - - override var keyCommands: [UIKeyCommand]? { - return currentViewController?.keyCommands - } - - @objc func navigateKeyCommandHandlerRelay(_ sender: UIKeyCommand) { - (currentViewController as? StatusTableViewControllerNavigateable)?.navigateKeyCommandHandlerRelay(sender) - - } - - @objc func statusKeyCommandHandlerRelay(_ sender: UIKeyCommand) { - (currentViewController as? StatusTableViewControllerNavigateable)?.statusKeyCommandHandlerRelay(sender) - } - -} diff --git a/Mastodon/Scene/Profile/Segmented/Paging/ProfilePagingViewModel.swift b/Mastodon/Scene/Profile/Segmented/Paging/ProfilePagingViewModel.swift deleted file mode 100644 index 67a0ca93d..000000000 --- a/Mastodon/Scene/Profile/Segmented/Paging/ProfilePagingViewModel.swift +++ /dev/null @@ -1,82 +0,0 @@ -// -// ProfilePagingViewModel.swift -// Mastodon -// -// Created by MainasuK Cirno on 2021-3-29. -// - -import os.log -import UIKit -import Pageboy -import Tabman -import MastodonAsset -import MastodonLocalization - -final class ProfilePagingViewModel: NSObject { - - let postUserTimelineViewController = UserTimelineViewController() - let repliesUserTimelineViewController = UserTimelineViewController() - let mediaUserTimelineViewController = UserTimelineViewController() - let profileAboutViewController = ProfileAboutViewController() - - init( - postsUserTimelineViewModel: UserTimelineViewModel, - repliesUserTimelineViewModel: UserTimelineViewModel, - mediaUserTimelineViewModel: UserTimelineViewModel, - profileAboutViewModel: ProfileAboutViewModel - ) { - postUserTimelineViewController.viewModel = postsUserTimelineViewModel - repliesUserTimelineViewController.viewModel = repliesUserTimelineViewModel - mediaUserTimelineViewController.viewModel = mediaUserTimelineViewModel - profileAboutViewController.viewModel = profileAboutViewModel - super.init() - } - - var viewControllers: [ScrollViewContainer] { - return [ - postUserTimelineViewController, - repliesUserTimelineViewController, - mediaUserTimelineViewController, - profileAboutViewController, - ] - } - - let barItems: [TMBarItemable] = { - let items = [ - TMBarItem(title: L10n.Scene.Profile.SegmentedControl.posts), - TMBarItem(title: L10n.Scene.Profile.SegmentedControl.postsAndReplies), - TMBarItem(title: L10n.Scene.Profile.SegmentedControl.media), - TMBarItem(title: L10n.Scene.Profile.SegmentedControl.about), - ] - return items - }() - - deinit { - os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) - } - -} - -// MARK: - PageboyViewControllerDataSource -extension ProfilePagingViewModel: PageboyViewControllerDataSource { - - func numberOfViewControllers(in pageboyViewController: PageboyViewController) -> Int { - return viewControllers.count - } - - func viewController(for pageboyViewController: PageboyViewController, at index: PageboyViewController.PageIndex) -> UIViewController? { - return viewControllers[index] - } - - func defaultPage(for pageboyViewController: PageboyViewController) -> PageboyViewController.Page? { - return .first - } - -} - -// MARK: - TMBarDataSource -extension ProfilePagingViewModel: TMBarDataSource { - func barItem(for bar: TMBar, at index: Int) -> TMBarItemable { - return barItems[index] - } -} diff --git a/Mastodon/Scene/Profile/Segmented/ProfileSegmentedViewController.swift b/Mastodon/Scene/Profile/Segmented/ProfileSegmentedViewController.swift deleted file mode 100644 index 5d5241c56..000000000 --- a/Mastodon/Scene/Profile/Segmented/ProfileSegmentedViewController.swift +++ /dev/null @@ -1,39 +0,0 @@ -// -// ProfileSegmentedViewController.swift -// Mastodon -// -// Created by MainasuK Cirno on 2021-3-29. -// - -import os.log -import UIKit - -final class ProfileSegmentedViewController: UIViewController { - let pagingViewController = ProfilePagingViewController() - - deinit { - os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) - } - -} - -extension ProfileSegmentedViewController { - - override func viewDidLoad() { - super.viewDidLoad() - - view.backgroundColor = .clear - - addChild(pagingViewController) - pagingViewController.view.translatesAutoresizingMaskIntoConstraints = false - view.addSubview(pagingViewController.view) - pagingViewController.didMove(toParent: self) - NSLayoutConstraint.activate([ - pagingViewController.view.topAnchor.constraint(equalTo: view.topAnchor), - pagingViewController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), - view.trailingAnchor.constraint(equalTo: pagingViewController.view.trailingAnchor), - view.bottomAnchor.constraint(equalTo: pagingViewController.view.bottomAnchor), - ]) - } - -} diff --git a/Mastodon/Scene/Profile/Timeline/UserTimelineViewController.swift b/Mastodon/Scene/Profile/Timeline/UserTimelineViewController.swift index d9e52a8c7..fb42b81b8 100644 --- a/Mastodon/Scene/Profile/Timeline/UserTimelineViewController.swift +++ b/Mastodon/Scene/Profile/Timeline/UserTimelineViewController.swift @@ -11,6 +11,8 @@ import AVKit import Combine import CoreDataStack import GameplayKit +import TabBarPager +import XLPagerTabStrip final class UserTimelineViewController: UIViewController, NeedsDependency, MediaPreviewableViewController { @@ -143,7 +145,14 @@ extension UserTimelineViewController: UITableViewDelegate, AutoGenerateTableView // MARK: - CustomScrollViewContainerController extension UserTimelineViewController: ScrollViewContainer { - var scrollView: UIScrollView? { return tableView } + var scrollView: UIScrollView { return tableView } +} + +// MARK: - TabBarPage +extension UserTimelineViewController: TabBarPage { + var pageScrollView: UIScrollView { + scrollView + } } // MARK: - StatusTableViewCellDelegate @@ -165,3 +174,10 @@ extension UserTimelineViewController: StatusTableViewControllerNavigateable { statusKeyCommandHandler(sender) } } + +// MARK: - IndicatorInfoProvider +extension UserTimelineViewController: IndicatorInfoProvider { + func indicatorInfo(for pagerTabStripController: PagerTabStripViewController) -> IndicatorInfo { + return IndicatorInfo(title: viewModel.title) + } +} diff --git a/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel+Diffable.swift b/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel+Diffable.swift index a0a1f52cd..7f7341aa6 100644 --- a/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel+Diffable.swift +++ b/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel+Diffable.swift @@ -30,22 +30,19 @@ extension UserTimelineViewModel { snapshot.appendSections([.main]) diffableDataSource?.apply(snapshot) - // trigger user timeline loading - Publishers.CombineLatest( - $domain.removeDuplicates(), - $userID.removeDuplicates() - ) - .receive(on: DispatchQueue.main) - .sink { [weak self] _ in - guard let self = self else { return } - self.stateMachine.enter(UserTimelineViewModel.State.Reloading.self) - } - .store(in: &disposeBag) + // trigger timeline reloading + $userIdentifier + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + guard let self = self else { return } + self.stateMachine.enter(UserTimelineViewModel.State.Reloading.self) + } + .store(in: &disposeBag) let needsTimelineHidden = Publishers.CombineLatest3( - isBlocking, - isBlockedBy, - isSuspended + $isBlocking, + $isBlockedBy, + $isSuspended ).map { $0 || $1 || $2 } Publishers.CombineLatest( diff --git a/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel+State.swift b/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel+State.swift index ae870f7b5..ca798fa0b 100644 --- a/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel+State.swift +++ b/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel+State.swift @@ -50,7 +50,7 @@ extension UserTimelineViewModel.State { guard let viewModel = viewModel else { return false } switch stateClass { case is Reloading.Type: - return viewModel.userID != nil + return viewModel.userIdentifier != nil default: return false } @@ -132,7 +132,7 @@ extension UserTimelineViewModel.State { let maxID = viewModel.statusFetchedResultsController.statusIDs.value.last - guard let userID = viewModel.userID, !userID.isEmpty else { + guard let userID = viewModel.userIdentifier?.userID, !userID.isEmpty else { stateMachine.enter(Fail.self) return } @@ -194,7 +194,7 @@ extension UserTimelineViewModel.State { guard let viewModel = viewModel, let _ = stateMachine else { return } // trigger data source update. otherwise, spinner always display - viewModel.isSuspended.value = viewModel.isSuspended.value + viewModel.isSuspended = viewModel.isSuspended // remove bottom loader guard let diffableDataSource = viewModel.diffableDataSource else { return } diff --git a/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel.swift b/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel.swift index 9701ba480..2d350fb0b 100644 --- a/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel.swift +++ b/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel.swift @@ -19,17 +19,18 @@ final class UserTimelineViewModel { // input let context: AppContext - @Published var domain: String? - @Published var userID: String? - @Published var queryFilter: QueryFilter + let title: String let statusFetchedResultsController: StatusFetchedResultsController let listBatchFetchViewModel = ListBatchFetchViewModel() + @Published var userIdentifier: UserIdentifier? + @Published var queryFilter: QueryFilter - let isBlocking = CurrentValueSubject(false) - let isBlockedBy = CurrentValueSubject(false) - let isSuspended = CurrentValueSubject(false) - let userDisplayName = CurrentValueSubject(nil) // for suspended prompt label - var dataSourceDidUpdate = PassthroughSubject() + @Published var isBlocking = false + @Published var isBlockedBy = false + @Published var isSuspended = false + + // let userDisplayName = CurrentValueSubject(nil) // for suspended prompt label + // var dataSourceDidUpdate = PassthroughSubject() // output var diffableDataSource: UITableViewDiffableDataSource? @@ -48,30 +49,27 @@ final class UserTimelineViewModel { init( context: AppContext, - domain: String?, - userID: String?, + title: String, queryFilter: QueryFilter ) { self.context = context + self.title = title self.statusFetchedResultsController = StatusFetchedResultsController( managedObjectContext: context.managedObjectContext, - domain: domain, - additionalTweetPredicate: Status.notDeleted() + domain: nil, + additionalTweetPredicate: nil ) - self.domain = domain - self.userID = userID self.queryFilter = queryFilter // super.init() - $domain + context.authenticationService.activeMastodonAuthenticationBox + .map { $0?.domain } .assign(to: \.value, on: statusFetchedResultsController.domain) .store(in: &disposeBag) - - } deinit { - os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) } } @@ -92,5 +90,4 @@ extension UserTimelineViewModel { self.onlyMedia = onlyMedia } } - } diff --git a/Mastodon/Scene/Share/NavigationController/AdaptiveStatusBarStyleNavigationController.swift b/Mastodon/Scene/Share/NavigationController/AdaptiveStatusBarStyleNavigationController.swift index ec145f86d..80df8938e 100644 --- a/Mastodon/Scene/Share/NavigationController/AdaptiveStatusBarStyleNavigationController.swift +++ b/Mastodon/Scene/Share/NavigationController/AdaptiveStatusBarStyleNavigationController.swift @@ -7,12 +7,12 @@ import UIKit -// Make status bar style adaptive for child view controller -// SeeAlso: `modalPresentationCapturesStatusBarAppearance` class AdaptiveStatusBarStyleNavigationController: UINavigationController { private lazy var fullWidthBackGestureRecognizer = UIPanGestureRecognizer() + // Make status bar style adaptive for child view controller + // SeeAlso: `modalPresentationCapturesStatusBarAppearance` override var childForStatusBarStyle: UIViewController? { visibleViewController } diff --git a/Mastodon/Scene/Share/View/Content/PollOptionView+Configuration.swift b/Mastodon/Scene/Share/View/Content/PollOptionView+Configuration.swift index 717c35f82..99b0f3b6b 100644 --- a/Mastodon/Scene/Share/View/Content/PollOptionView+Configuration.swift +++ b/Mastodon/Scene/Share/View/Content/PollOptionView+Configuration.swift @@ -101,7 +101,9 @@ extension PollOptionView { .receive(on: DispatchQueue.main) .sink { [weak self] theme in guard let self = self else { return } - self.checkmarkBackgroundView.backgroundColor = theme.tertiarySystemBackgroundColor + self.checkmarkBackgroundView.backgroundColor = UIColor(dynamicProvider: { trailtCollection in + return trailtCollection.userInterfaceStyle == .light ? .white : theme.tableViewCellSelectionBackgroundColor + }) } .store(in: &disposeBag) } diff --git a/Mastodon/Service/APIService/APIService+Notification.swift b/Mastodon/Service/APIService/APIService+Notification.swift index 6cc0dbba3..921f75d01 100644 --- a/Mastodon/Service/APIService/APIService+Notification.swift +++ b/Mastodon/Service/APIService/APIService+Notification.swift @@ -23,6 +23,28 @@ extension APIService { let query = Mastodon.API.Notifications.Query( maxID: maxID, + types: { + switch scope { + case .everything: + return [ + .follow, + .followRequest, + .mention, + .reblog, + .favourite, + .poll, + .status, + ] + case .mentions: + return [ + .follow, + .followRequest, + .reblog, + .favourite, + .poll + ] + } + }(), excludeTypes: { switch scope { case .everything: diff --git a/MastodonIntent/Info.plist b/MastodonIntent/Info.plist index 1a6ccd5fb..2f2b230f0 100644 --- a/MastodonIntent/Info.plist +++ b/MastodonIntent/Info.plist @@ -17,9 +17,9 @@ CFBundlePackageType $(PRODUCT_BUNDLE_PACKAGE_TYPE) CFBundleShortVersionString - 1.4.2 + 1.4.3 CFBundleVersion - 133 + 138 NSExtension NSExtensionAttributes diff --git a/MastodonSDK/Package.swift b/MastodonSDK/Package.swift index c963be72d..aa349f8e0 100644 --- a/MastodonSDK/Package.swift +++ b/MastodonSDK/Package.swift @@ -27,7 +27,7 @@ let package = Package( .package(url: "https://github.com/apple/swift-nio.git", from: "1.0.0"), .package(url: "https://github.com/kean/Nuke.git", from: "10.3.1"), .package(url: "https://github.com/Flipboard/FLAnimatedImage.git", from: "1.0.0"), - .package(url: "https://github.com/TwidereProject/MetaTextKit.git", .exact("2.2.3")), + .package(url: "https://github.com/TwidereProject/MetaTextKit.git", .exact("2.2.5")), .package(url: "https://github.com/Alamofire/Alamofire.git", from: "5.4.0"), .package(url: "https://github.com/Alamofire/AlamofireImage.git", from: "4.1.0"), .package(name: "NukeFLAnimatedImagePlugin", url: "https://github.com/kean/Nuke-FLAnimatedImage-Plugin.git", from: "8.0.0"), diff --git a/MastodonSDK/Sources/MastodonLocalization/Resources/fr.lproj/Localizable.strings b/MastodonSDK/Sources/MastodonLocalization/Resources/fr.lproj/Localizable.strings index 1706ad363..b7a5071dd 100644 --- a/MastodonSDK/Sources/MastodonLocalization/Resources/fr.lproj/Localizable.strings +++ b/MastodonSDK/Sources/MastodonLocalization/Resources/fr.lproj/Localizable.strings @@ -305,12 +305,12 @@ téléversé sur Mastodon."; "Scene.Report.StepFinal.BlockUser" = "Bloquer %@"; "Scene.Report.StepFinal.DontWantToSeeThis" = "Vous ne voulez pas voir cela ?"; "Scene.Report.StepFinal.MuteUser" = "Masquer %@"; -"Scene.Report.StepFinal.TheyWillNoLongerBeAbleToFollowOrSeeYourPostsButTheyCanSeeIfTheyveBeenBlocked" = "They will no longer be able to follow or see your posts, but they can see if they’ve been blocked."; +"Scene.Report.StepFinal.TheyWillNoLongerBeAbleToFollowOrSeeYourPostsButTheyCanSeeIfTheyveBeenBlocked" = "Ils ne seront plus en mesure de suivre ou de voir vos messages, mais iels peuvent voir s’iels ont été bloqué·e·s."; "Scene.Report.StepFinal.Unfollow" = "Se désabonner"; "Scene.Report.StepFinal.UnfollowUser" = "Ne plus suivre %@"; "Scene.Report.StepFinal.Unfollowed" = "Unfollowed"; "Scene.Report.StepFinal.WhenYouSeeSomethingYouDontLikeOnMastodonYouCanRemoveThePersonFromYourExperience." = "Quand vous voyez quelque chose que vous n’aimez pas sur Mastodon, vous pouvez retirer la personne de votre expérience."; -"Scene.Report.StepFinal.WhileWeReviewThisYouCanTakeActionAgainstUser" = "While we review this, you can take action against %@"; +"Scene.Report.StepFinal.WhileWeReviewThisYouCanTakeActionAgainstUser" = "Pendant que nous étudions votre requête, vous pouvez prendre des mesures contre %@"; "Scene.Report.StepFinal.YouWontSeeTheirPostsOrReblogsInYourHomeFeedTheyWontKnowTheyVeBeenMuted" = "Vous ne verrez plus leurs messages ou leurs partages dans votre flux personnel. Iels ne sauront pas qu’iels ont été mis en sourdine."; "Scene.Report.StepFour.IsThereAnythingElseWeShouldKnow" = "Y a-t-il autre chose que nous devrions savoir ?"; "Scene.Report.StepFour.Step4Of4" = "Étape 4 sur 4"; @@ -320,7 +320,7 @@ téléversé sur Mastodon."; "Scene.Report.StepOne.ItsSomethingElse" = "Pour une autre raison"; "Scene.Report.StepOne.ItsSpam" = "C’est du spam"; "Scene.Report.StepOne.MaliciousLinksFakeEngagementOrRepetetiveReplies" = "Liens malveillants, engagement mensonger ou réponses répétitives"; -"Scene.Report.StepOne.SelectTheBestMatch" = "Select the best match"; +"Scene.Report.StepOne.SelectTheBestMatch" = "Sélectionnez ce qui correspond le mieux"; "Scene.Report.StepOne.Step1Of4" = "Étape 1 sur 4"; "Scene.Report.StepOne.TheIssueDoesNotFitIntoOtherCategories" = "Le problème ne correspond à aucune des catégories"; "Scene.Report.StepOne.WhatsWrongWithThisAccount" = "Qu’est-ce qui ne va pas avec ce compte ?"; @@ -374,7 +374,7 @@ téléversé sur Mastodon."; "Scene.ServerPicker.EmptyState.FindingServers" = "Recherche des serveurs disponibles..."; "Scene.ServerPicker.EmptyState.NoResults" = "Aucun résultat"; "Scene.ServerPicker.Input.Placeholder" = "Trouvez un serveur ou rejoignez le vôtre..."; -"Scene.ServerPicker.Input.SearchServersOrEnterUrl" = "Search servers or enter URL"; +"Scene.ServerPicker.Input.SearchServersOrEnterUrl" = "Rechercher des serveurs ou entrer une URL"; "Scene.ServerPicker.Label.Category" = "CATÉGORIE"; "Scene.ServerPicker.Label.Language" = "LANGUE"; "Scene.ServerPicker.Label.Users" = "UTILISATEUR·RICE·S"; diff --git a/MastodonSDK/Sources/MastodonLocalization/Resources/gl.lproj/Localizable.strings b/MastodonSDK/Sources/MastodonLocalization/Resources/gl.lproj/Localizable.strings index d5ddb239d..a34df4ae8 100644 --- a/MastodonSDK/Sources/MastodonLocalization/Resources/gl.lproj/Localizable.strings +++ b/MastodonSDK/Sources/MastodonLocalization/Resources/gl.lproj/Localizable.strings @@ -208,14 +208,14 @@ ser subido a Mastodon."; "Scene.Discovery.Tabs.Hashtags" = "Cancelos"; "Scene.Discovery.Tabs.News" = "Novas"; "Scene.Discovery.Tabs.Posts" = "Publicacións"; -"Scene.Familiarfollowers.FollowedByNames" = "Followed by %@"; -"Scene.Familiarfollowers.Title" = "Followers you familiar"; +"Scene.Familiarfollowers.FollowedByNames" = "Seguimentos de %@"; +"Scene.Familiarfollowers.Title" = "Seguimentos próximos"; "Scene.Favorite.Title" = "Publicacións Favoritas"; -"Scene.FavoritedBy.Title" = "Favorited By"; +"Scene.FavoritedBy.Title" = "Favorecido por"; "Scene.Follower.Footer" = "Non se mostran seguidoras desde outros servidores."; -"Scene.Follower.Title" = "follower"; +"Scene.Follower.Title" = "seguidora"; "Scene.Following.Footer" = "Non se mostran os seguimentos desde outros servidores."; -"Scene.Following.Title" = "following"; +"Scene.Following.Title" = "seguindo"; "Scene.HomeTimeline.NavigationBarState.Accessibility.LogoHint" = "Toca para ir arriba e toca outra vez para volver ao mesmo lugar"; "Scene.HomeTimeline.NavigationBarState.Accessibility.LogoLabel" = "Botón do logo"; "Scene.HomeTimeline.NavigationBarState.NewPosts" = "Novas publicacións"; @@ -259,7 +259,7 @@ ser subido a Mastodon."; "Scene.Profile.SegmentedControl.Posts" = "Publicacións"; "Scene.Profile.SegmentedControl.PostsAndReplies" = "Publicacións e respostas"; "Scene.Profile.SegmentedControl.Replies" = "Respostas"; -"Scene.RebloggedBy.Title" = "Reblogged By"; +"Scene.RebloggedBy.Title" = "Promovido por"; "Scene.Register.Error.Item.Agreement" = "Acordo"; "Scene.Register.Error.Item.Email" = "Email"; "Scene.Register.Error.Item.Locale" = "Locale"; @@ -374,7 +374,7 @@ ser subido a Mastodon."; "Scene.ServerPicker.EmptyState.FindingServers" = "Buscando servidores dispoñibles..."; "Scene.ServerPicker.EmptyState.NoResults" = "Sen resultados"; "Scene.ServerPicker.Input.Placeholder" = "Buscar comunidades"; -"Scene.ServerPicker.Input.SearchServersOrEnterUrl" = "Search servers or enter URL"; +"Scene.ServerPicker.Input.SearchServersOrEnterUrl" = "Busca un servidor ou escribe URL"; "Scene.ServerPicker.Label.Category" = "CATEGORÍA"; "Scene.ServerPicker.Label.Language" = "IDIOMA"; "Scene.ServerPicker.Label.Users" = "USUARIAS"; diff --git a/MastodonSDK/Sources/MastodonLocalization/Resources/it.lproj/Localizable.strings b/MastodonSDK/Sources/MastodonLocalization/Resources/it.lproj/Localizable.strings index 6e4e5589f..19e098e0f 100644 --- a/MastodonSDK/Sources/MastodonLocalization/Resources/it.lproj/Localizable.strings +++ b/MastodonSDK/Sources/MastodonLocalization/Resources/it.lproj/Localizable.strings @@ -118,8 +118,8 @@ Per favore verifica la tua connessione internet."; "Common.Controls.Status.Tag.Mention" = "Menzione"; "Common.Controls.Status.Tag.Url" = "URL"; "Common.Controls.Status.TapToReveal" = "Tocca per rivelare"; -"Common.Controls.Status.UserReblogged" = "%@ hanno condiviso"; -"Common.Controls.Status.UserRepliedTo" = "Rispondi a %@"; +"Common.Controls.Status.UserReblogged" = "%@ ha condiviso"; +"Common.Controls.Status.UserRepliedTo" = "Risposta a %@"; "Common.Controls.Status.Visibility.Direct" = "Solo l'utente menzionato può vedere questo post."; "Common.Controls.Status.Visibility.Private" = "Solo i loro seguaci possono vedere questo post."; "Common.Controls.Status.Visibility.PrivateFromMe" = "Solo i miei seguaci possono vedere questo post."; @@ -218,7 +218,7 @@ caricato su Mastodon."; "Scene.Following.Title" = "seguendo"; "Scene.HomeTimeline.NavigationBarState.Accessibility.LogoHint" = "Tocca per scorrere verso l'alto e tocca di nuovo verso la posizione precedente"; "Scene.HomeTimeline.NavigationBarState.Accessibility.LogoLabel" = "Pulsante Logo"; -"Scene.HomeTimeline.NavigationBarState.NewPosts" = "Vedi nuovi post"; +"Scene.HomeTimeline.NavigationBarState.NewPosts" = "Vedi i nuovi post"; "Scene.HomeTimeline.NavigationBarState.Offline" = "Non in linea"; "Scene.HomeTimeline.NavigationBarState.Published" = "Pubblicato!"; "Scene.HomeTimeline.NavigationBarState.Publishing" = "Pubblicazione post..."; @@ -229,7 +229,7 @@ caricato su Mastodon."; "Scene.Notification.NotificationDescription.FollowedYou" = "ti ha seguito"; "Scene.Notification.NotificationDescription.MentionedYou" = "ti ha menzionato"; "Scene.Notification.NotificationDescription.PollHasEnded" = "sondaggio terminato"; -"Scene.Notification.NotificationDescription.RebloggedYourPost" = "ha ripostato il tuo post"; +"Scene.Notification.NotificationDescription.RebloggedYourPost" = "ha condiviso il tuo post"; "Scene.Notification.NotificationDescription.RequestToFollowYou" = "richiesta di seguirti"; "Scene.Notification.Title.Everything" = "Tutto"; "Scene.Notification.Title.Mentions" = "Menzioni"; diff --git a/MastodonSDK/Sources/MastodonLocalization/Resources/ja.lproj/Localizable.strings b/MastodonSDK/Sources/MastodonLocalization/Resources/ja.lproj/Localizable.strings index ea59763b1..fb4f5d9d6 100644 --- a/MastodonSDK/Sources/MastodonLocalization/Resources/ja.lproj/Localizable.strings +++ b/MastodonSDK/Sources/MastodonLocalization/Resources/ja.lproj/Localizable.strings @@ -297,38 +297,38 @@ "Scene.Report.SkipToSend" = "コメントなしで送信"; "Scene.Report.Step1" = "ステップ 1/2"; "Scene.Report.Step2" = "ステップ 2/2"; -"Scene.Report.StepFinal.BlockUser" = "Block %@"; +"Scene.Report.StepFinal.BlockUser" = "%@をブロック"; "Scene.Report.StepFinal.DontWantToSeeThis" = "Don’t want to see this?"; -"Scene.Report.StepFinal.MuteUser" = "Mute %@"; +"Scene.Report.StepFinal.MuteUser" = "%@をミュート"; "Scene.Report.StepFinal.TheyWillNoLongerBeAbleToFollowOrSeeYourPostsButTheyCanSeeIfTheyveBeenBlocked" = "They will no longer be able to follow or see your posts, but they can see if they’ve been blocked."; -"Scene.Report.StepFinal.Unfollow" = "Unfollow"; -"Scene.Report.StepFinal.UnfollowUser" = "Unfollow %@"; -"Scene.Report.StepFinal.Unfollowed" = "Unfollowed"; +"Scene.Report.StepFinal.Unfollow" = "フォロー解除"; +"Scene.Report.StepFinal.UnfollowUser" = "%@をフォロー解除"; +"Scene.Report.StepFinal.Unfollowed" = "フォロー解除しました"; "Scene.Report.StepFinal.WhenYouSeeSomethingYouDontLikeOnMastodonYouCanRemoveThePersonFromYourExperience." = "When you see something you don’t like on Mastodon, you can remove the person from your experience."; -"Scene.Report.StepFinal.WhileWeReviewThisYouCanTakeActionAgainstUser" = "While we review this, you can take action against %@"; +"Scene.Report.StepFinal.WhileWeReviewThisYouCanTakeActionAgainstUser" = "私たちが確認している間でも、あなたは%@さんに対して対応することができます。"; "Scene.Report.StepFinal.YouWontSeeTheirPostsOrReblogsInYourHomeFeedTheyWontKnowTheyVeBeenMuted" = "You won’t see their posts or reblogs in your home feed. They won’t know they’ve been muted."; -"Scene.Report.StepFour.IsThereAnythingElseWeShouldKnow" = "Is there anything else we should know?"; -"Scene.Report.StepFour.Step4Of4" = "Step 4 of 4"; -"Scene.Report.StepOne.IDontLikeIt" = "I don’t like it"; -"Scene.Report.StepOne.ItIsNotSomethingYouWantToSee" = "It is not something you want to see"; -"Scene.Report.StepOne.ItViolatesServerRules" = "It violates server rules"; -"Scene.Report.StepOne.ItsSomethingElse" = "It’s something else"; -"Scene.Report.StepOne.ItsSpam" = "It’s spam"; -"Scene.Report.StepOne.MaliciousLinksFakeEngagementOrRepetetiveReplies" = "Malicious links, fake engagement, or repetetive replies"; -"Scene.Report.StepOne.SelectTheBestMatch" = "Select the best match"; -"Scene.Report.StepOne.Step1Of4" = "Step 1 of 4"; -"Scene.Report.StepOne.TheIssueDoesNotFitIntoOtherCategories" = "The issue does not fit into other categories"; -"Scene.Report.StepOne.WhatsWrongWithThisAccount" = "What's wrong with this account?"; -"Scene.Report.StepOne.WhatsWrongWithThisPost" = "What's wrong with this post?"; -"Scene.Report.StepOne.WhatsWrongWithThisUsername" = "What's wrong with %@?"; -"Scene.Report.StepOne.YouAreAwareThatItBreaksSpecificRules" = "You are aware that it breaks specific rules"; +"Scene.Report.StepFour.IsThereAnythingElseWeShouldKnow" = "その他に私たちに伝えておくべき事はありますか?"; +"Scene.Report.StepFour.Step4Of4" = "ステップ 4/4"; +"Scene.Report.StepOne.IDontLikeIt" = "興味がありません"; +"Scene.Report.StepOne.ItIsNotSomethingYouWantToSee" = "見たくない内容の場合"; +"Scene.Report.StepOne.ItViolatesServerRules" = "サーバーのルールに違反しています"; +"Scene.Report.StepOne.ItsSomethingElse" = "その他"; +"Scene.Report.StepOne.ItsSpam" = "これはスパムです"; +"Scene.Report.StepOne.MaliciousLinksFakeEngagementOrRepetetiveReplies" = "悪意あるリンクや虚偽の情報、執拗な返信など"; +"Scene.Report.StepOne.SelectTheBestMatch" = "最も近いものを選んでください"; +"Scene.Report.StepOne.Step1Of4" = "ステップ 1/4"; +"Scene.Report.StepOne.TheIssueDoesNotFitIntoOtherCategories" = "当てはまる選択肢がない場合"; +"Scene.Report.StepOne.WhatsWrongWithThisAccount" = "このアカウントのどこが問題ですか?"; +"Scene.Report.StepOne.WhatsWrongWithThisPost" = "この投稿のどこが問題ですか?"; +"Scene.Report.StepOne.WhatsWrongWithThisUsername" = "%@さんのどこが問題ですか?"; +"Scene.Report.StepOne.YouAreAwareThatItBreaksSpecificRules" = "ルールに違反しているのを見つけた場合"; "Scene.Report.StepThree.AreThereAnyPostsThatBackUpThisReport" = "Are there any posts that back up this report?"; "Scene.Report.StepThree.SelectAllThatApply" = "Select all that apply"; -"Scene.Report.StepThree.Step3Of4" = "Step 3 of 4"; +"Scene.Report.StepThree.Step3Of4" = "ステップ 3/4"; "Scene.Report.StepTwo.IJustDon’tLikeIt" = "I just don’t like it"; "Scene.Report.StepTwo.SelectAllThatApply" = "Select all that apply"; -"Scene.Report.StepTwo.Step2Of4" = "Step 2 of 4"; -"Scene.Report.StepTwo.WhichRulesAreBeingViolated" = "Which rules are being violated?"; +"Scene.Report.StepTwo.Step2Of4" = "ステップ 2/4"; +"Scene.Report.StepTwo.WhichRulesAreBeingViolated" = "どのルールに違反していますか?"; "Scene.Report.TextPlaceholder" = "追加コメントを入力"; "Scene.Report.Title" = "%@を通報"; "Scene.Report.TitleReport" = "報告する"; @@ -369,7 +369,7 @@ "Scene.ServerPicker.EmptyState.FindingServers" = "利用可能なサーバーの検索..."; "Scene.ServerPicker.EmptyState.NoResults" = "なし"; "Scene.ServerPicker.Input.Placeholder" = "サーバーを探す"; -"Scene.ServerPicker.Input.SearchServersOrEnterUrl" = "Search servers or enter URL"; +"Scene.ServerPicker.Input.SearchServersOrEnterUrl" = "サーバーを検索またはURLを入力"; "Scene.ServerPicker.Label.Category" = "カテゴリー"; "Scene.ServerPicker.Label.Language" = "言語"; "Scene.ServerPicker.Label.Users" = "ユーザー"; diff --git a/MastodonSDK/Sources/MastodonLocalization/Resources/kab.lproj/Localizable.strings b/MastodonSDK/Sources/MastodonLocalization/Resources/kab.lproj/Localizable.strings index fc7c720ae..d9dc29100 100644 --- a/MastodonSDK/Sources/MastodonLocalization/Resources/kab.lproj/Localizable.strings +++ b/MastodonSDK/Sources/MastodonLocalization/Resources/kab.lproj/Localizable.strings @@ -200,10 +200,10 @@ Ad d-yettwasali ɣef Mastodon."; "Scene.ConfirmEmail.OpenEmailApp.OpenEmailClient" = "Ldi amsaɣ n yimayl"; "Scene.ConfirmEmail.OpenEmailApp.Title" = "Sefqed Tanaka-inek."; "Scene.ConfirmEmail.Subtitle" = "Sit ɣef useɣwen i ak-n-uznen i wakken ad tesneqdeḍ amiḍan-ik."; -"Scene.ConfirmEmail.TapTheLinkWeEmailedToYouToVerifyYourAccount" = "Tap the link we emailed to you to verify your account"; +"Scene.ConfirmEmail.TapTheLinkWeEmailedToYouToVerifyYourAccount" = "Sit ɣef useɣwen i ak-n-uznen i wakken ad tesneqdeḍ amiḍan-ik"; "Scene.ConfirmEmail.Title" = "Taɣawsa taneggarut."; "Scene.Discovery.Intro" = "Tigi d tisuffaɣ i d-ijebbden s waṭas deg tama-inek•inem n Mastodon."; -"Scene.Discovery.Tabs.Community" = "Community"; +"Scene.Discovery.Tabs.Community" = "Tamɣiwent"; "Scene.Discovery.Tabs.ForYou" = "I kečč·kem"; "Scene.Discovery.Tabs.Hashtags" = "Ihacṭagen"; "Scene.Discovery.Tabs.News" = "Isallen"; @@ -213,11 +213,11 @@ Ad d-yettwasali ɣef Mastodon."; "Scene.Favorite.Title" = "Ismenyifen-ik·im"; "Scene.FavoritedBy.Title" = "Favorited By"; "Scene.Follower.Footer" = "Ineḍfaren seg yiqeddacen-nniḍen ur d-ttwaskanen ara."; -"Scene.Follower.Title" = "follower"; +"Scene.Follower.Title" = "aneḍfar"; "Scene.Following.Footer" = "Ineḍfaren seg yiqeddacen-nniḍen ur d-ttwaskanen ara."; -"Scene.Following.Title" = "following"; +"Scene.Following.Title" = "yeṭṭafar"; "Scene.HomeTimeline.NavigationBarState.Accessibility.LogoHint" = "Tap to scroll to top and tap again to previous location"; -"Scene.HomeTimeline.NavigationBarState.Accessibility.LogoLabel" = "Logo Button"; +"Scene.HomeTimeline.NavigationBarState.Accessibility.LogoLabel" = "Taqeffalt n ulugu"; "Scene.HomeTimeline.NavigationBarState.NewPosts" = "Tissufaɣ timaynutin"; "Scene.HomeTimeline.NavigationBarState.Offline" = "Beṛṛa n tuqqna"; "Scene.HomeTimeline.NavigationBarState.Published" = "Yettwasuffeɣ!"; @@ -292,7 +292,7 @@ Ad d-yettwasali ɣef Mastodon."; "Scene.Register.Input.Password.Require" = "Awal-ik uffir yesra ma drus:"; "Scene.Register.Input.Username.DuplicatePrompt" = "Isem-ayi n umseqdac yettwaṭṭef yakan."; "Scene.Register.Input.Username.Placeholder" = "isem n useqdac"; -"Scene.Register.LetsGetYouSetUpOnDomain" = "Let’s get you set up on %@"; +"Scene.Register.LetsGetYouSetUpOnDomain" = "Aha ad nebdu asbadu ɣef %@"; "Scene.Register.Title" = "Aha ad nebdu asbadu ɣef %@"; "Scene.Report.Content1" = "Tebɣiḍ ad ternuḍ tisuffaɣ-nniḍen ɣer uneqqis?"; "Scene.Report.Content2" = "Yella wayen i ilaqen ad teẓren yimḍebbren ɣef uneqqis-a?"; @@ -302,38 +302,38 @@ Ad d-yettwasali ɣef Mastodon."; "Scene.Report.SkipToSend" = "Azen s war awennit"; "Scene.Report.Step1" = "Aḥric 1 seg 2"; "Scene.Report.Step2" = "Aḥric 2 seg 2"; -"Scene.Report.StepFinal.BlockUser" = "Block %@"; -"Scene.Report.StepFinal.DontWantToSeeThis" = "Don’t want to see this?"; -"Scene.Report.StepFinal.MuteUser" = "Mute %@"; -"Scene.Report.StepFinal.TheyWillNoLongerBeAbleToFollowOrSeeYourPostsButTheyCanSeeIfTheyveBeenBlocked" = "They will no longer be able to follow or see your posts, but they can see if they’ve been blocked."; -"Scene.Report.StepFinal.Unfollow" = "Unfollow"; -"Scene.Report.StepFinal.UnfollowUser" = "Unfollow %@"; -"Scene.Report.StepFinal.Unfollowed" = "Unfollowed"; -"Scene.Report.StepFinal.WhenYouSeeSomethingYouDontLikeOnMastodonYouCanRemoveThePersonFromYourExperience." = "When you see something you don’t like on Mastodon, you can remove the person from your experience."; -"Scene.Report.StepFinal.WhileWeReviewThisYouCanTakeActionAgainstUser" = "While we review this, you can take action against %@"; -"Scene.Report.StepFinal.YouWontSeeTheirPostsOrReblogsInYourHomeFeedTheyWontKnowTheyVeBeenMuted" = "You won’t see their posts or reblogs in your home feed. They won’t know they’ve been muted."; -"Scene.Report.StepFour.IsThereAnythingElseWeShouldKnow" = "Is there anything else we should know?"; -"Scene.Report.StepFour.Step4Of4" = "Step 4 of 4"; -"Scene.Report.StepOne.IDontLikeIt" = "I don’t like it"; -"Scene.Report.StepOne.ItIsNotSomethingYouWantToSee" = "It is not something you want to see"; -"Scene.Report.StepOne.ItViolatesServerRules" = "It violates server rules"; -"Scene.Report.StepOne.ItsSomethingElse" = "It’s something else"; -"Scene.Report.StepOne.ItsSpam" = "It’s spam"; -"Scene.Report.StepOne.MaliciousLinksFakeEngagementOrRepetetiveReplies" = "Malicious links, fake engagement, or repetetive replies"; -"Scene.Report.StepOne.SelectTheBestMatch" = "Select the best match"; -"Scene.Report.StepOne.Step1Of4" = "Step 1 of 4"; -"Scene.Report.StepOne.TheIssueDoesNotFitIntoOtherCategories" = "The issue does not fit into other categories"; +"Scene.Report.StepFinal.BlockUser" = "Sewḥel %@"; +"Scene.Report.StepFinal.DontWantToSeeThis" = "Ur tebɣiḍ ara ad twaliḍ aya?"; +"Scene.Report.StepFinal.MuteUser" = "Sgugem %@"; +"Scene.Report.StepFinal.TheyWillNoLongerBeAbleToFollowOrSeeYourPostsButTheyCanSeeIfTheyveBeenBlocked" = "Ur ttuɣalen ara ad izmiren ad ak•akem-ḍefren neɣ ad walin tisuffaɣ-inek•inem, maca ad walin ma yella ttusweḥlen."; +"Scene.Report.StepFinal.Unfollow" = "Ur ṭṭafaṛ ara"; +"Scene.Report.StepFinal.UnfollowUser" = "Y•Teḥbes aḍfar n %@"; +"Scene.Report.StepFinal.Unfollowed" = "Y•Teḥbes aḍfar n"; +"Scene.Report.StepFinal.WhenYouSeeSomethingYouDontLikeOnMastodonYouCanRemoveThePersonFromYourExperience." = "Mi ara twaliḍ kra ur ak•am-neɛǧib ara ɣef Mastodon, tzemreḍ ad tekkseḍ amdan-nni seg tirmit-ik•im."; +"Scene.Report.StepFinal.WhileWeReviewThisYouCanTakeActionAgainstUser" = "Ideg nekkni nessenqad tuttra-inek•inem, tzemreḍ ad tḥadreḍ mgal %@"; +"Scene.Report.StepFinal.YouWontSeeTheirPostsOrReblogsInYourHomeFeedTheyWontKnowTheyVeBeenMuted" = "Ur tettwaliḍ ara tisuffaɣ-nsen neɣ iriblugen-nsen deg usuddem-inek•inem agejdan. Ur ẓerren ara belli tesgugmeḍ-ten."; +"Scene.Report.StepFour.IsThereAnythingElseWeShouldKnow" = "Yella wayen-nniḍen i ilaqen ad t-nẓer?"; +"Scene.Report.StepFour.Step4Of4" = "Aḥric 4 seg 4"; +"Scene.Report.StepOne.IDontLikeIt" = "Ur ḥemmleɣ ara aya"; +"Scene.Report.StepOne.ItIsNotSomethingYouWantToSee" = "D ayen akk ur bɣiɣ ara ad waliɣ"; +"Scene.Report.StepOne.ItViolatesServerRules" = "Truẓi n yilugan n uqeddac"; +"Scene.Report.StepOne.ItsSomethingElse" = "Ɣef ssebba-nniḍen"; +"Scene.Report.StepOne.ItsSpam" = "D aspam"; +"Scene.Report.StepOne.MaliciousLinksFakeEngagementOrRepetetiveReplies" = "Yir iseɣwan, yir agman d tririyin i d-yettuɣalen"; +"Scene.Report.StepOne.SelectTheBestMatch" = "Fren amṣada akk igerrzen"; +"Scene.Report.StepOne.Step1Of4" = "Aḥric 1 seg 4"; +"Scene.Report.StepOne.TheIssueDoesNotFitIntoOtherCategories" = "Ugur ur yemṣada ara akk d taggayin-nniḍen"; "Scene.Report.StepOne.WhatsWrongWithThisAccount" = "What's wrong with this account?"; -"Scene.Report.StepOne.WhatsWrongWithThisPost" = "What's wrong with this post?"; -"Scene.Report.StepOne.WhatsWrongWithThisUsername" = "What's wrong with %@?"; -"Scene.Report.StepOne.YouAreAwareThatItBreaksSpecificRules" = "You are aware that it breaks specific rules"; -"Scene.Report.StepThree.AreThereAnyPostsThatBackUpThisReport" = "Are there any posts that back up this report?"; -"Scene.Report.StepThree.SelectAllThatApply" = "Select all that apply"; -"Scene.Report.StepThree.Step3Of4" = "Step 3 of 4"; -"Scene.Report.StepTwo.IJustDon’tLikeIt" = "I just don’t like it"; -"Scene.Report.StepTwo.SelectAllThatApply" = "Select all that apply"; -"Scene.Report.StepTwo.Step2Of4" = "Step 2 of 4"; -"Scene.Report.StepTwo.WhichRulesAreBeingViolated" = "Which rules are being violated?"; +"Scene.Report.StepOne.WhatsWrongWithThisPost" = "Acu n wugur yellan d tsuffeɣt-a?"; +"Scene.Report.StepOne.WhatsWrongWithThisUsername" = "Acu n wugur yellan d %@?"; +"Scene.Report.StepOne.YouAreAwareThatItBreaksSpecificRules" = "Teẓriḍ y•tettruẓu kra n yilugan"; +"Scene.Report.StepThree.AreThereAnyPostsThatBackUpThisReport" = "Llant tsuffaɣ ara isdemren aneqqis-a?"; +"Scene.Report.StepThree.SelectAllThatApply" = "Fren akk tifrat ara yettusnasen"; +"Scene.Report.StepThree.Step3Of4" = "Aḥric 3 seg 4"; +"Scene.Report.StepTwo.IJustDon’tLikeIt" = "Ur ḥemmleɣ ara kan aya"; +"Scene.Report.StepTwo.SelectAllThatApply" = "Fren akk tifrat ara yettusnasen"; +"Scene.Report.StepTwo.Step2Of4" = "Aḥric 2 seg 4"; +"Scene.Report.StepTwo.WhichRulesAreBeingViolated" = "Acu n yilugan i yettwarẓan?"; "Scene.Report.TextPlaceholder" = "Aru neɣ senteḍ iwenniten-nniḍen"; "Scene.Report.Title" = "Aneqqis %@"; "Scene.Report.TitleReport" = "Aneqqis"; @@ -374,7 +374,7 @@ Ad d-yettwasali ɣef Mastodon."; "Scene.ServerPicker.EmptyState.FindingServers" = "Tifin n yiqeddacen yellan..."; "Scene.ServerPicker.EmptyState.NoResults" = "Ulac igemmaḍ"; "Scene.ServerPicker.Input.Placeholder" = "Nadi timɣiwnin"; -"Scene.ServerPicker.Input.SearchServersOrEnterUrl" = "Search servers or enter URL"; +"Scene.ServerPicker.Input.SearchServersOrEnterUrl" = "Nadi timɣiwnin neɣ sekcem URL"; "Scene.ServerPicker.Label.Category" = "TAGGAYT"; "Scene.ServerPicker.Label.Language" = "TUTLAYT"; "Scene.ServerPicker.Label.Users" = "ISEQDACEN"; diff --git a/MastodonSDK/Sources/MastodonLocalization/Resources/ku.lproj/Localizable.strings b/MastodonSDK/Sources/MastodonLocalization/Resources/ku.lproj/Localizable.strings index 472bc9403..08f531c7e 100644 --- a/MastodonSDK/Sources/MastodonLocalization/Resources/ku.lproj/Localizable.strings +++ b/MastodonSDK/Sources/MastodonLocalization/Resources/ku.lproj/Localizable.strings @@ -375,7 +375,7 @@ girêdanê bitikne da ku ajimêra xwe bidî piştrastkirin."; "Scene.ServerPicker.EmptyState.FindingServers" = "Peydakirina rajekarên berdest..."; "Scene.ServerPicker.EmptyState.NoResults" = "Encam tune"; "Scene.ServerPicker.Input.Placeholder" = "Li rajekaran bigere"; -"Scene.ServerPicker.Input.SearchServersOrEnterUrl" = "Search servers or enter URL"; +"Scene.ServerPicker.Input.SearchServersOrEnterUrl" = "Li rajekaran bigere an jî girêdanê têxe"; "Scene.ServerPicker.Label.Category" = "BEŞ"; "Scene.ServerPicker.Label.Language" = "ZIMAN"; "Scene.ServerPicker.Label.Users" = "BIKARHÊNER"; diff --git a/MastodonSDK/Sources/MastodonLocalization/Resources/th.lproj/Localizable.strings b/MastodonSDK/Sources/MastodonLocalization/Resources/th.lproj/Localizable.strings index 9ae1abc81..89c05abb6 100644 --- a/MastodonSDK/Sources/MastodonLocalization/Resources/th.lproj/Localizable.strings +++ b/MastodonSDK/Sources/MastodonLocalization/Resources/th.lproj/Localizable.strings @@ -374,7 +374,7 @@ "Scene.ServerPicker.EmptyState.FindingServers" = "กำลังค้นหาเซิร์ฟเวอร์ที่พร้อมใช้งาน..."; "Scene.ServerPicker.EmptyState.NoResults" = "ไม่มีผลลัพธ์"; "Scene.ServerPicker.Input.Placeholder" = "ค้นหาเซิร์ฟเวอร์"; -"Scene.ServerPicker.Input.SearchServersOrEnterUrl" = "Search servers or enter URL"; +"Scene.ServerPicker.Input.SearchServersOrEnterUrl" = "ค้นหาเซิร์ฟเวอร์หรือป้อน URL"; "Scene.ServerPicker.Label.Category" = "หมวดหมู่"; "Scene.ServerPicker.Label.Language" = "ภาษา"; "Scene.ServerPicker.Label.Users" = "ผู้ใช้"; diff --git a/MastodonSDK/Sources/MastodonLocalization/Resources/vi.lproj/Localizable.strings b/MastodonSDK/Sources/MastodonLocalization/Resources/vi.lproj/Localizable.strings index 9978b2ea9..25b63dd88 100644 --- a/MastodonSDK/Sources/MastodonLocalization/Resources/vi.lproj/Localizable.strings +++ b/MastodonSDK/Sources/MastodonLocalization/Resources/vi.lproj/Localizable.strings @@ -374,7 +374,7 @@ tải lên Mastodon."; "Scene.ServerPicker.EmptyState.FindingServers" = "Đang tìm máy chủ hoạt động..."; "Scene.ServerPicker.EmptyState.NoResults" = "Không có kết quả"; "Scene.ServerPicker.Input.Placeholder" = "Tìm máy chủ"; -"Scene.ServerPicker.Input.SearchServersOrEnterUrl" = "Search servers or enter URL"; +"Scene.ServerPicker.Input.SearchServersOrEnterUrl" = "Tìm máy chủ hoặc nhập URL"; "Scene.ServerPicker.Label.Category" = "PHÂN LOẠI"; "Scene.ServerPicker.Label.Language" = "NGÔN NGỮ"; "Scene.ServerPicker.Label.Users" = "NGƯỜI DÙNG"; diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Notifications.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Notifications.swift index c6b56c9e9..f70caaa1d 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Notifications.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Notifications.swift @@ -90,6 +90,7 @@ extension Mastodon.API.Notifications { public let sinceID: Mastodon.Entity.Status.ID? public let minID: Mastodon.Entity.Status.ID? public let limit: Int? + public let types: [Mastodon.Entity.Notification.NotificationType]? public let excludeTypes: [Mastodon.Entity.Notification.NotificationType]? public let accountID: String? @@ -98,6 +99,7 @@ extension Mastodon.API.Notifications { sinceID: Mastodon.Entity.Status.ID? = nil, minID: Mastodon.Entity.Status.ID? = nil, limit: Int? = nil, + types: [Mastodon.Entity.Notification.NotificationType]? = nil, excludeTypes: [Mastodon.Entity.Notification.NotificationType]? = nil, accountID: String? = nil ) { @@ -105,6 +107,7 @@ extension Mastodon.API.Notifications { self.sinceID = sinceID self.minID = minID self.limit = limit + self.types = types self.excludeTypes = excludeTypes self.accountID = accountID } @@ -115,6 +118,11 @@ extension Mastodon.API.Notifications { sinceID.flatMap { items.append(URLQueryItem(name: "since_id", value: $0)) } minID.flatMap { items.append(URLQueryItem(name: "min_id", value: $0)) } limit.flatMap { items.append(URLQueryItem(name: "limit", value: String($0))) } + if let types = types { + types.forEach { + items.append(URLQueryItem(name: "types[]", value: $0.rawValue)) + } + } if let excludeTypes = excludeTypes { excludeTypes.forEach { items.append(URLQueryItem(name: "exclude_types[]", value: $0.rawValue)) diff --git a/MastodonSDK/Sources/MastodonUI/DateTimeProvider.swift b/MastodonSDK/Sources/MastodonUI/Helper/DateTimeProvider.swift similarity index 100% rename from MastodonSDK/Sources/MastodonUI/DateTimeProvider.swift rename to MastodonSDK/Sources/MastodonUI/Helper/DateTimeProvider.swift diff --git a/MastodonSDK/Sources/MastodonUI/Model/UserIdentifier.swift b/MastodonSDK/Sources/MastodonUI/Model/UserIdentifier.swift new file mode 100644 index 000000000..6db7499c6 --- /dev/null +++ b/MastodonSDK/Sources/MastodonUI/Model/UserIdentifier.swift @@ -0,0 +1,28 @@ +// +// UserIdentifier.swift +// +// +// Created by MainasuK on 2022-5-13. +// + +import Foundation +import MastodonSDK + +public protocol UserIdentifier { + var domain: String { get } + var userID: Mastodon.Entity.Account.ID { get } +} + +public struct MastodonUserIdentifier: UserIdentifier { + public let domain: String + public var userID: Mastodon.Entity.Account.ID + + + public init( + domain: String, + userID: Mastodon.Entity.Account.ID + ) { + self.domain = domain + self.userID = userID + } +} diff --git a/MastodonSDK/Sources/MastodonUI/UserIdentifier.swift b/MastodonSDK/Sources/MastodonUI/UserIdentifier.swift deleted file mode 100644 index ecde41d32..000000000 --- a/MastodonSDK/Sources/MastodonUI/UserIdentifier.swift +++ /dev/null @@ -1,14 +0,0 @@ -// -// UserIdentifier.swift -// -// -// Created by MainasuK on 2022-1-12. -// - -import Foundation -import MastodonSDK - -public protocol UserIdentifier { - var domain: String { get } - var userID: Mastodon.Entity.Account.ID { get } -} diff --git a/MastodonSDK/Sources/MastodonUI/ViewModel/RelationshipViewModel.swift b/MastodonSDK/Sources/MastodonUI/ViewModel/RelationshipViewModel.swift index cee31c14a..a19de5138 100644 --- a/MastodonSDK/Sources/MastodonUI/ViewModel/RelationshipViewModel.swift +++ b/MastodonSDK/Sources/MastodonUI/ViewModel/RelationshipViewModel.swift @@ -84,7 +84,7 @@ public struct RelationshipActionOptionSet: OptionSet { 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 .blocked: return L10n.Common.Controls.Friendship.follow // blocked by user (deprecated) case .blocking: return L10n.Common.Controls.Friendship.blocked case .suspended: return L10n.Common.Controls.Friendship.follow case .edit: return L10n.Common.Controls.Friendship.editInfo @@ -116,6 +116,7 @@ public final class RelationshipViewModel { @Published public var isMuting = false @Published public var isBlocking = false @Published public var isBlockingBy = false + @Published public var isSuspended = false public init() { Publishers.CombineLatest3( @@ -182,8 +183,8 @@ extension RelationshipViewModel { self.isMuting = optionSet.contains(.muting) self.isBlockingBy = optionSet.contains(.blockingBy) self.isBlocking = optionSet.contains(.blocking) + self.isSuspended = optionSet.contains(.suspended) - self.optionSet = optionSet } @@ -203,7 +204,7 @@ 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] + return [.isMyself, .edit] } let isProtected = user.locked @@ -247,6 +248,10 @@ extension RelationshipViewModel { if isBlocking { optionSet.insert(.blocking) } + + if user.suspended { + optionSet.insert(.suspended) + } return optionSet } diff --git a/MastodonTests/Info.plist b/MastodonTests/Info.plist index bc0be73ac..094d6d538 100644 --- a/MastodonTests/Info.plist +++ b/MastodonTests/Info.plist @@ -15,8 +15,8 @@ CFBundlePackageType $(PRODUCT_BUNDLE_PACKAGE_TYPE) CFBundleShortVersionString - 1.4.2 + 1.4.3 CFBundleVersion - 133 + 138 diff --git a/MastodonUITests/Info.plist b/MastodonUITests/Info.plist index bc0be73ac..094d6d538 100644 --- a/MastodonUITests/Info.plist +++ b/MastodonUITests/Info.plist @@ -15,8 +15,8 @@ CFBundlePackageType $(PRODUCT_BUNDLE_PACKAGE_TYPE) CFBundleShortVersionString - 1.4.2 + 1.4.3 CFBundleVersion - 133 + 138 diff --git a/NotificationService/Info.plist b/NotificationService/Info.plist index 31a95bf2b..ffeca43f4 100644 --- a/NotificationService/Info.plist +++ b/NotificationService/Info.plist @@ -17,9 +17,9 @@ CFBundlePackageType $(PRODUCT_BUNDLE_PACKAGE_TYPE) CFBundleShortVersionString - 1.4.2 + 1.4.3 CFBundleVersion - 133 + 138 NSExtension NSExtensionPointIdentifier diff --git a/Podfile b/Podfile index 30f90a05e..a64cd0e55 100644 --- a/Podfile +++ b/Podfile @@ -8,6 +8,7 @@ target 'Mastodon' do # UI pod 'UITextField+Shake', '~> 1.2' + pod 'XLPagerTabStrip', '~> 9.0.0' # misc pod 'SwiftGen', '~> 6.4.0' diff --git a/Podfile.lock b/Podfile.lock index 0c156eadc..629a48a87 100644 --- a/Podfile.lock +++ b/Podfile.lock @@ -8,6 +8,7 @@ PODS: - Sourcery/CLI-Only (1.6.1) - SwiftGen (6.4.0) - "UITextField+Shake (1.2.1)" + - XLPagerTabStrip (9.0.0) DEPENDENCIES: - DateToolsSwift (~> 5.0.0) @@ -17,6 +18,7 @@ DEPENDENCIES: - Sourcery (~> 1.6.1) - SwiftGen (~> 6.4.0) - "UITextField+Shake (~> 1.2)" + - XLPagerTabStrip (~> 9.0.0) SPEC REPOS: trunk: @@ -26,6 +28,7 @@ SPEC REPOS: - Sourcery - SwiftGen - "UITextField+Shake" + - XLPagerTabStrip EXTERNAL SOURCES: Keys: @@ -39,7 +42,8 @@ SPEC CHECKSUMS: Sourcery: f3759f803bd0739f74fc92a4341eed0473ce61ac SwiftGen: 67860cc7c3cfc2ed25b9b74cfd55495fc89f9108 "UITextField+Shake": 298ac5a0f239d731bdab999b19b628c956ca0ac3 + XLPagerTabStrip: 61c57fd61f611ee5f01ff1495ad6fbee8bf496c5 -PODFILE CHECKSUM: 335d0ca70493d4c280d0f8fd7f26fe9be6a4e289 +PODFILE CHECKSUM: 1ac960a2c981ef98f7c24a3bba57bdabc1f66103 COCOAPODS: 1.11.3 diff --git a/ShareActionExtension/Info.plist b/ShareActionExtension/Info.plist index 7df1564a3..77557d6b7 100644 --- a/ShareActionExtension/Info.plist +++ b/ShareActionExtension/Info.plist @@ -17,9 +17,9 @@ CFBundlePackageType $(PRODUCT_BUNDLE_PACKAGE_TYPE) CFBundleShortVersionString - 1.4.2 + 1.4.3 CFBundleVersion - 133 + 138 NSExtension NSExtensionAttributes