From 3ac65b78b1967ff8be889738f07e1db1901305eb Mon Sep 17 00:00:00 2001 From: Takayuki KUSANO <65759+tkusano@users.noreply.github.com> Date: Tue, 17 Sep 2024 11:31:22 +0900 Subject: [PATCH 01/30] Update translation --- src/locale/locales/ja/messages.po | 38 +++++++++++++++++++++++++++++-- 1 file changed, 36 insertions(+), 2 deletions(-) diff --git a/src/locale/locales/ja/messages.po b/src/locale/locales/ja/messages.po index d369bec348..e29f5f43a5 100644 --- a/src/locale/locales/ja/messages.po +++ b/src/locale/locales/ja/messages.po @@ -8,7 +8,7 @@ msgstr "" "Language: ja\n" "Project-Id-Version: \n" "Report-Msgid-Bugs-To: \n" -"PO-Revision-Date: 2024-09-14 17:17+0900\n" +"PO-Revision-Date: 2024-09-17 11:17+0900\n" "Last-Translator: tkusano\n" "Language-Team: Hima-Zinn, tkusano, dolciss, oboenikui, noritada, middlingphys, hibiki, reindex-ot, haoyayoi, vyv03354\n" "Plural-Forms: \n" @@ -848,9 +848,14 @@ msgstr "Bluesky は、ホスティング プロバイダーを選択できるオ msgid "Bluesky is better with friends!" msgstr "Blueskyは友達と一緒のほうが楽しい!" +#: src/components/dialogs/nuxs/TenMillion/Trigger.tsx:43 +#: src/components/dialogs/nuxs/TenMillion/Trigger.tsx:59 +msgid "Bluesky is celebrating 10 million users!" +msgstr "Blueskyは1,000万ユーザーを祝っています!" + #: src/components/dialogs/nuxs/TenMillion/index.tsx:206 msgid "Bluesky now has over 10 million users, and I was #{0}!" -msgstr "Bluesky のユーザー数は現在 1,000 万人を超えており、私は #{0} 番目でした。" +msgstr "Bluesky のユーザー数は現在 1,000 万人を超えており、私は {0} 番目でした。" #: src/components/StarterPack/ProfileStarterPacks.tsx:282 msgid "Bluesky will choose a set of recommended accounts from people in your network." @@ -3890,6 +3895,11 @@ msgstr "おすすめのGIFが見つかりません。Tenorに問題があるか msgid "No feeds found. Try searching for something else." msgstr "フィードが見つかりませんでした。他を探してみて。" +#: src/components/LikedByList.tsx:78 +#: src/view/com/post-thread/PostLikedBy.tsx:85 +msgid "No likes yet" +msgstr "いいねはありません" + #: src/components/ProfileCard.tsx:336 #: src/screens/Profile/Header/ProfileHeaderStandard.tsx:116 msgid "No longer following {0}" @@ -3926,6 +3936,14 @@ msgstr "投稿主だけがこの投稿を引用できます。" msgid "No posts yet." msgstr "まだ投稿がありません。" +#: src/view/com/post-thread/PostQuotes.tsx:106 +msgid "No quotes yet" +msgstr "まだ引用がありません" + +#: src/view/com/post-thread/PostRepostedBy.tsx:78 +msgid "No reposts yet" +msgstr "まだリポストがありません" + #: src/view/com/composer/text-input/mobile/Autocomplete.tsx:101 #: src/view/com/composer/text-input/web/Autocomplete.tsx:195 msgid "No result" @@ -3969,6 +3987,14 @@ msgstr "返信不可" msgid "Nobody has liked this yet. Maybe you should be the first!" msgstr "まだ誰もこれをいいねしていません。あなたが最初になるべきかもしれません!" +#: src/view/com/post-thread/PostQuotes.tsx:108 +msgid "Nobody has quoted this yet. Maybe you should be the first!" +msgstr "まだ誰もこれを引用していません。あなたが最初になるべきかもしれません!" + +#: src/view/com/post-thread/PostRepostedBy.tsx:80 +msgid "Nobody has reposted this yet. Maybe you should be the first!" +msgstr "まだ誰もこれをリポストしていません。あなたが最初になるべきかもしれません!" + #: src/screens/StarterPack/Wizard/StepProfiles.tsx:103 msgid "Nobody was found. Try searching for someone else." msgstr "誰も見つかりませんでした。他を探してみて。" @@ -6648,6 +6674,10 @@ msgstr "スレッドの設定" msgid "To disable the email 2FA method, please verify your access to the email address." msgstr "メールでの2要素認証を無効にするには、メールアドレスにアクセスできるか確認してください。" +#: src/components/dialogs/nuxs/TenMillion/Trigger.tsx:69 +msgid "To learn more, <0>check out our post." +msgstr "詳しくは、<0>私達のこの投稿をチェックして。" + #: src/components/dms/ReportConversationPrompt.tsx:20 msgid "To report a conversation, please report one of its messages via the conversation screen. This lets our moderators understand the context of your issue." msgstr "会話を報告するには、会話の画面からメッセージのうちの一つを報告してください。それによって問題の文脈をモデレーターが理解できるようになります。" @@ -7154,6 +7184,10 @@ msgstr "スレッドをすべて表示" msgid "View information about these labels" msgstr "これらのラベルに関する情報を見る" +#: src/components/dialogs/nuxs/TenMillion/Trigger.tsx:72 +msgid "View our post" +msgstr "投稿を表示" + #: src/components/ProfileHoverCard/index.web.tsx:418 #: src/components/ProfileHoverCard/index.web.tsx:436 #: src/components/ProfileHoverCard/index.web.tsx:463 From aa50e214100fd05fb001064c5d3d455331184814 Mon Sep 17 00:00:00 2001 From: Takayuki KUSANO <65759+tkusano@users.noreply.github.com> Date: Tue, 17 Sep 2024 14:17:12 +0900 Subject: [PATCH 02/30] Fixed --- src/locale/locales/ja/messages.po | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/locale/locales/ja/messages.po b/src/locale/locales/ja/messages.po index e29f5f43a5..09ad6e6f54 100644 --- a/src/locale/locales/ja/messages.po +++ b/src/locale/locales/ja/messages.po @@ -8,7 +8,7 @@ msgstr "" "Language: ja\n" "Project-Id-Version: \n" "Report-Msgid-Bugs-To: \n" -"PO-Revision-Date: 2024-09-17 11:17+0900\n" +"PO-Revision-Date: 2024-09-17 14:16+0900\n" "Last-Translator: tkusano\n" "Language-Team: Hima-Zinn, tkusano, dolciss, oboenikui, noritada, middlingphys, hibiki, reindex-ot, haoyayoi, vyv03354\n" "Plural-Forms: \n" @@ -6676,7 +6676,7 @@ msgstr "メールでの2要素認証を無効にするには、メールアド #: src/components/dialogs/nuxs/TenMillion/Trigger.tsx:69 msgid "To learn more, <0>check out our post." -msgstr "詳しくは、<0>私達のこの投稿をチェックして。" +msgstr "詳しくは、<0>私達のこの投稿をチェックしてください。" #: src/components/dms/ReportConversationPrompt.tsx:20 msgid "To report a conversation, please report one of its messages via the conversation screen. This lets our moderators understand the context of your issue." From cd020bce839bc7d1d1a2a1c6f6981172ce1cd1a7 Mon Sep 17 00:00:00 2001 From: Takayuki KUSANO <65759+tkusano@users.noreply.github.com> Date: Wed, 18 Sep 2024 11:02:24 +0900 Subject: [PATCH 03/30] Update translation --- src/locale/locales/ja/messages.po | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/locale/locales/ja/messages.po b/src/locale/locales/ja/messages.po index 09ad6e6f54..07fc3b1348 100644 --- a/src/locale/locales/ja/messages.po +++ b/src/locale/locales/ja/messages.po @@ -8,7 +8,7 @@ msgstr "" "Language: ja\n" "Project-Id-Version: \n" "Report-Msgid-Bugs-To: \n" -"PO-Revision-Date: 2024-09-17 14:16+0900\n" +"PO-Revision-Date: 2024-09-18 11:01+0900\n" "Last-Translator: tkusano\n" "Language-Team: Hima-Zinn, tkusano, dolciss, oboenikui, noritada, middlingphys, hibiki, reindex-ot, haoyayoi, vyv03354\n" "Plural-Forms: \n" @@ -262,6 +262,10 @@ msgstr "30日" msgid "7 days" msgstr "7日" +#: src/components/dialogs/nuxs/TenMillion/index.tsx:266 +msgid "A virtual certificate with text \"Celebrating 10M users on Bluesky, #{0}, {displayName} {handle}, joined on {joinedDate}\"" +msgstr "「Blueskyの1,000万ユーザーを祝福し、{0} 番目に、{displayName} {handle} が {joinedDate} に 参加した」というテキストが書かれた仮想証明書" + #: src/view/com/util/ViewHeader.tsx:92 #: src/view/screens/Search/Search.tsx:684 msgid "Access navigation links and settings" @@ -3138,8 +3142,8 @@ msgstr "Blueskyに参加" msgid "Join the conversation" msgstr "会話に参加" -#: src/components/dialogs/nuxs/TenMillion/index.tsx:492 -msgid "Joined {0}" +#: src/components/dialogs/nuxs/TenMillion/index.tsx:559 +msgid "Joined on {joinedDate}" msgstr "{0} に参加" #: src/screens/Onboarding/index.tsx:21 From 699a26321b87f54f5b7249be9c5eaff98b3600bf Mon Sep 17 00:00:00 2001 From: Takayuki KUSANO <65759+tkusano@users.noreply.github.com> Date: Thu, 19 Sep 2024 15:38:48 +0900 Subject: [PATCH 04/30] Update translation --- src/locale/locales/ja/messages.po | 69 +++++++++++++++++++++++++++---- 1 file changed, 60 insertions(+), 9 deletions(-) diff --git a/src/locale/locales/ja/messages.po b/src/locale/locales/ja/messages.po index 07fc3b1348..d8ccebabd3 100644 --- a/src/locale/locales/ja/messages.po +++ b/src/locale/locales/ja/messages.po @@ -8,7 +8,7 @@ msgstr "" "Language: ja\n" "Project-Id-Version: \n" "Report-Msgid-Bugs-To: \n" -"PO-Revision-Date: 2024-09-18 11:01+0900\n" +"PO-Revision-Date: 2024-09-19 15:32+0900\n" "Last-Translator: tkusano\n" "Language-Team: Hima-Zinn, tkusano, dolciss, oboenikui, noritada, middlingphys, hibiki, reindex-ot, haoyayoi, vyv03354\n" "Plural-Forms: \n" @@ -671,15 +671,15 @@ msgstr "この決定に異議を申し立てる" #: src/screens/Settings/AppearanceSettings.tsx:69 #: src/view/screens/Settings/index.tsx:484 msgid "Appearance" -msgstr "背景" +msgstr "外観" #: src/view/screens/Settings/index.tsx:475 msgid "Appearance settings" -msgstr "背景の設定" +msgstr "外観の設定" #: src/Navigation.tsx:326 msgid "Appearance Settings" -msgstr "背景の設定" +msgstr "外観の設定" #: src/screens/Feeds/NoSavedFeedsOfAnyType.tsx:47 #: src/screens/Home/NoFeedsPinned.tsx:93 @@ -1271,6 +1271,10 @@ msgstr "ユーザーリストを折りたたむ" msgid "Collapses list of users for a given notification" msgstr "指定した通知のユーザーリストを折りたたむ" +#: src/screens/Settings/AppearanceSettings.tsx:97 +msgid "Color mode" +msgstr "カラーモード" + #: src/screens/Onboarding/index.tsx:38 #: src/screens/Onboarding/state.ts:82 msgid "Comedy" @@ -1642,6 +1646,15 @@ msgstr "モデレーションをデバッグ" msgid "Debug panel" msgstr "デバッグパネル" +#: src/components/dialogs/nuxs/NeueTypography.tsx:101 +#: src/screens/Settings/AppearanceSettings.tsx:169 +msgid "Default" +msgstr "デフォルト" + +#: src/components/dialogs/nuxs/NeueTypography.tsx:62 +msgid "Defaults are shown below. You can edit these in your Appearance Settings later." +msgstr "デフォルトは以下のとおり。後で外観の設定で編集できます。" + #: src/components/dms/MessageMenu.tsx:151 #: src/screens/StarterPack/StarterPackScreen.tsx:573 #: src/screens/StarterPack/StarterPackScreen.tsx:652 @@ -2647,6 +2660,16 @@ msgstr "あなたをフォロー" msgid "Follows You" msgstr "あなたをフォロー" +#: src/components/dialogs/nuxs/NeueTypography.tsx:73 +#: src/screens/Settings/AppearanceSettings.tsx:141 +msgid "Font" +msgstr "フォント" + +#: src/components/dialogs/nuxs/NeueTypography.tsx:93 +#: src/screens/Settings/AppearanceSettings.tsx:161 +msgid "Font size" +msgstr "フォントサイズ" + #: src/screens/Onboarding/index.tsx:40 #: src/screens/Onboarding/state.ts:87 msgid "Food" @@ -2660,6 +2683,11 @@ msgstr "セキュリティ上の理由から、あなたのメールアドレス msgid "For security reasons, you won't be able to view this again. If you lose this password, you'll need to generate a new one." msgstr "セキュリティ上の理由から、これを再度表示することはできません。このパスワードを紛失した場合は、新しいパスワードを生成する必要があります。" +#: src/components/dialogs/nuxs/NeueTypography.tsx:75 +#: src/screens/Settings/AppearanceSettings.tsx:143 +msgid "For the best experience, we recommend using the theme font." +msgstr "ベストな体験のために、テーマフォントの使用をお勧めします。" + #: src/components/dialogs/MutedWords.tsx:178 msgid "Forever" msgstr "永久" @@ -3069,6 +3097,14 @@ msgstr "反応が制限されています" msgid "Introducing Direct Messages" msgstr "ダイレクトメッセージの紹介" +#: src/components/dialogs/nuxs/NeueTypography.tsx:48 +msgid "Introducing new font settings" +msgstr "新しいフォント設定の紹介" + +#: src/components/dialogs/nuxs/NeueTypography.tsx:52 +msgid "Introducing new font settings ✨" +msgstr "新しいフォント設定の紹介 ✨" + #: src/screens/Login/LoginForm.tsx:145 #: src/view/screens/Settings/DisableEmail2FADialog.tsx:70 msgid "Invalid 2FA confirmation code." @@ -3192,6 +3228,11 @@ msgstr "言語の設定" msgid "Languages" msgstr "言語" +#: src/components/dialogs/nuxs/NeueTypography.tsx:105 +#: src/screens/Settings/AppearanceSettings.tsx:173 +msgid "Larger" +msgstr "大きく" + #: src/screens/Hashtag.tsx:97 #: src/view/screens/Search/Search.tsx:359 msgid "Latest" @@ -3519,10 +3560,6 @@ msgstr "誤解を招くアカウント" msgid "Misleading Post" msgstr "誤解を招く投稿" -#: src/screens/Settings/AppearanceSettings.tsx:78 -msgid "Mode" -msgstr "モード" - #: src/Navigation.tsx:135 #: src/screens/Moderation/index.tsx:105 #: src/view/screens/Settings/index.tsx:527 @@ -4228,7 +4265,7 @@ msgstr "デバッグエントリーの追加詳細を開く" #: src/view/screens/Settings/index.tsx:476 msgid "Opens appearance settings" -msgstr "背景の設定を開く" +msgstr "外観の設定を開く" #: src/view/com/composer/photos/OpenCameraBtn.tsx:74 msgid "Opens camera on device" @@ -5979,6 +6016,11 @@ msgstr "スキップ" msgid "Skip this flow" msgstr "この手順をスキップする" +#: src/components/dialogs/nuxs/NeueTypography.tsx:97 +#: src/screens/Settings/AppearanceSettings.tsx:165 +msgid "Smaller" +msgstr "小さく" + #: src/screens/Onboarding/index.tsx:37 #: src/screens/Onboarding/state.ts:85 msgid "Software Dev" @@ -6365,6 +6407,11 @@ msgstr "サービス規約は移動しました" msgid "The verification code you have provided is invalid. Please make sure that you have used the correct verification link or request a new one." msgstr "入力された確認コードが正しくありません。正しいリンクを使用したかを確認するか、新しい確認コードを要求してください。" +#: src/components/dialogs/nuxs/NeueTypography.tsx:84 +#: src/screens/Settings/AppearanceSettings.tsx:152 +msgid "Theme" +msgstr "テーマ" + #: src/screens/Settings/components/DeactivateAccountDialog.tsx:86 msgid "There is no time limit for account deactivation, come back any time." msgstr "アカウントの無効化に期限はありません。いつでも戻ってこられます。" @@ -6686,6 +6733,10 @@ msgstr "詳しくは、<0>私達のこの投稿をチェックしてください msgid "To report a conversation, please report one of its messages via the conversation screen. This lets our moderators understand the context of your issue." msgstr "会話を報告するには、会話の画面からメッセージのうちの一つを報告してください。それによって問題の文脈をモデレーターが理解できるようになります。" +#: src/components/dialogs/nuxs/NeueTypography.tsx:55 +msgid "To the ensure the best possible experience, we're introducing a new theme font, along with adjustable font sizing settings." +msgstr "可能な限りの最高の体験を得られるように、新しいテーマフォントと調整可能なフォントサイズ設定を導入しました。" + #: src/view/com/composer/videos/SelectVideoBtn.tsx:120 msgid "To upload videos to Bluesky, you must first verify your email." msgstr "Blueskyにビデオをアップロードするには、まずメールアドレスを確認しなくてはなりません。" From 73f7ecec9c1261f6428652082c05335aa07d3064 Mon Sep 17 00:00:00 2001 From: Takayuki KUSANO <65759+tkusano@users.noreply.github.com> Date: Fri, 20 Sep 2024 11:01:43 +0900 Subject: [PATCH 05/30] Update translation --- src/locale/locales/ja/messages.po | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/src/locale/locales/ja/messages.po b/src/locale/locales/ja/messages.po index d8ccebabd3..71b8f79a1a 100644 --- a/src/locale/locales/ja/messages.po +++ b/src/locale/locales/ja/messages.po @@ -8,7 +8,7 @@ msgstr "" "Language: ja\n" "Project-Id-Version: \n" "Report-Msgid-Bugs-To: \n" -"PO-Revision-Date: 2024-09-19 15:32+0900\n" +"PO-Revision-Date: 2024-09-20 11:01+0900\n" "Last-Translator: tkusano\n" "Language-Team: Hima-Zinn, tkusano, dolciss, oboenikui, noritada, middlingphys, hibiki, reindex-ot, haoyayoi, vyv03354\n" "Plural-Forms: \n" @@ -1651,10 +1651,6 @@ msgstr "デバッグパネル" msgid "Default" msgstr "デフォルト" -#: src/components/dialogs/nuxs/NeueTypography.tsx:62 -msgid "Defaults are shown below. You can edit these in your Appearance Settings later." -msgstr "デフォルトは以下のとおり。後で外観の設定で編集できます。" - #: src/components/dms/MessageMenu.tsx:151 #: src/screens/StarterPack/StarterPackScreen.tsx:573 #: src/screens/StarterPack/StarterPackScreen.tsx:652 @@ -3101,10 +3097,6 @@ msgstr "ダイレクトメッセージの紹介" msgid "Introducing new font settings" msgstr "新しいフォント設定の紹介" -#: src/components/dialogs/nuxs/NeueTypography.tsx:52 -msgid "Introducing new font settings ✨" -msgstr "新しいフォント設定の紹介 ✨" - #: src/screens/Login/LoginForm.tsx:145 #: src/view/screens/Settings/DisableEmail2FADialog.tsx:70 msgid "Invalid 2FA confirmation code." @@ -3836,6 +3828,10 @@ msgstr "新規" msgid "New chat" msgstr "新しいチャット" +#: src/components/dialogs/nuxs/NeueTypography.tsx:52 +msgid "New font settings ✨" +msgstr "新しいフォント設定 ✨" + #: src/components/dms/NewMessagesPill.tsx:92 msgid "New messages" msgstr "新しいメッセージ" @@ -6733,10 +6729,6 @@ msgstr "詳しくは、<0>私達のこの投稿をチェックしてください msgid "To report a conversation, please report one of its messages via the conversation screen. This lets our moderators understand the context of your issue." msgstr "会話を報告するには、会話の画面からメッセージのうちの一つを報告してください。それによって問題の文脈をモデレーターが理解できるようになります。" -#: src/components/dialogs/nuxs/NeueTypography.tsx:55 -msgid "To the ensure the best possible experience, we're introducing a new theme font, along with adjustable font sizing settings." -msgstr "可能な限りの最高の体験を得られるように、新しいテーマフォントと調整可能なフォントサイズ設定を導入しました。" - #: src/view/com/composer/videos/SelectVideoBtn.tsx:120 msgid "To upload videos to Bluesky, you must first verify your email." msgstr "Blueskyにビデオをアップロードするには、まずメールアドレスを確認しなくてはなりません。" @@ -7351,6 +7343,10 @@ msgstr "これはあなたの体験をカスタマイズするために使用さ msgid "We're having network issues, try again" msgstr "ネットワークで問題が発生しています。もう一度試してください" +#: src/components/dialogs/nuxs/NeueTypography.tsx:55 +msgid "We're introducing a new theme font, along with adjustable font sizing." +msgstr "新しいテーマフォントを導入し、フォントサイズを調整可能にしました。" + #: src/screens/Signup/index.tsx:100 msgid "We're so excited to have you join us!" msgstr "私たちはあなたが参加してくれることをとても楽しみにしています!" @@ -7535,6 +7531,10 @@ msgstr "あなたはビデオのアップロードを許可されていません msgid "You are not following anyone." msgstr "あなたはまだだれもフォローしていません。" +#: src/components/dialogs/nuxs/NeueTypography.tsx:62 +msgid "You can adjust these in your Appearance Settings later." +msgstr "これらは後で外観の設定で調整できます。" + #: src/view/com/posts/FollowingEmptyState.tsx:63 #: src/view/com/posts/FollowingEndOfFeed.tsx:64 msgid "You can also discover new Custom Feeds to follow." From 64727e397744a058c578f5062a25226961b8bf70 Mon Sep 17 00:00:00 2001 From: Takayuki KUSANO <65759+tkusano@users.noreply.github.com> Date: Sat, 21 Sep 2024 16:45:07 +0900 Subject: [PATCH 06/30] Update translation --- src/locale/locales/ja/messages.po | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/locale/locales/ja/messages.po b/src/locale/locales/ja/messages.po index 71b8f79a1a..29fc41e9cc 100644 --- a/src/locale/locales/ja/messages.po +++ b/src/locale/locales/ja/messages.po @@ -8,7 +8,7 @@ msgstr "" "Language: ja\n" "Project-Id-Version: \n" "Report-Msgid-Bugs-To: \n" -"PO-Revision-Date: 2024-09-20 11:01+0900\n" +"PO-Revision-Date: 2024-09-21 16:44+0900\n" "Last-Translator: tkusano\n" "Language-Team: Hima-Zinn, tkusano, dolciss, oboenikui, noritada, middlingphys, hibiki, reindex-ot, haoyayoi, vyv03354\n" "Plural-Forms: \n" @@ -3147,6 +3147,14 @@ msgstr "お気に入りのフィードやユーザーをフォローするよう msgid "Invites, but personal" msgstr "招待、ただし個人的なもの" +#: src/screens/Signup/StepInfo/index.tsx:77 +msgid "It looks like you may have entered your email address incorrectly. Are you sure it's right?" +msgstr "入力したメールアドレスは間違ってるようです。本当にそれで合ってますか?" + +#: src/screens/Signup/StepInfo/index.tsx:238 +msgid "It's correct" +msgstr "合ってます" + #: src/screens/StarterPack/Wizard/index.tsx:452 msgid "It's just you right now! Add more people to your starter pack by searching above." msgstr "今はあなただけ!上で検索してスターターパックにより多くのユーザーを追加してください。" @@ -5245,6 +5253,10 @@ msgstr "アカウントにログインする時にメールのコードを必須 msgid "Required for this provider" msgstr "このプロバイダーに必要" +#: src/components/LabelingServiceCard/index.tsx:75 +msgid "Required in your region" +msgstr "あなたの地域では必要" + #: src/view/screens/Settings/DisableEmail2FADialog.tsx:168 #: src/view/screens/Settings/DisableEmail2FADialog.tsx:171 msgid "Resend email" From 9679949dbeb9ad34528bf9aa3d0e1f15f6e55df8 Mon Sep 17 00:00:00 2001 From: Takayuki KUSANO <65759+tkusano@users.noreply.github.com> Date: Sat, 21 Sep 2024 17:25:31 +0900 Subject: [PATCH 07/30] Suggested fix by Hima-Zinn --- src/locale/locales/ja/messages.po | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/locale/locales/ja/messages.po b/src/locale/locales/ja/messages.po index 29fc41e9cc..c054594d11 100644 --- a/src/locale/locales/ja/messages.po +++ b/src/locale/locales/ja/messages.po @@ -8,7 +8,7 @@ msgstr "" "Language: ja\n" "Project-Id-Version: \n" "Report-Msgid-Bugs-To: \n" -"PO-Revision-Date: 2024-09-21 16:44+0900\n" +"PO-Revision-Date: 2024-09-21 17:24+0900\n" "Last-Translator: tkusano\n" "Language-Team: Hima-Zinn, tkusano, dolciss, oboenikui, noritada, middlingphys, hibiki, reindex-ot, haoyayoi, vyv03354\n" "Plural-Forms: \n" @@ -3180,7 +3180,7 @@ msgstr "会話に参加" #: src/components/dialogs/nuxs/TenMillion/index.tsx:559 msgid "Joined on {joinedDate}" -msgstr "{0} に参加" +msgstr "{joinedDate} に参加" #: src/screens/Onboarding/index.tsx:21 #: src/screens/Onboarding/state.ts:89 @@ -3231,7 +3231,7 @@ msgstr "言語" #: src/components/dialogs/nuxs/NeueTypography.tsx:105 #: src/screens/Settings/AppearanceSettings.tsx:173 msgid "Larger" -msgstr "大きく" +msgstr "大きい" #: src/screens/Hashtag.tsx:97 #: src/view/screens/Search/Search.tsx:359 @@ -6027,7 +6027,7 @@ msgstr "この手順をスキップする" #: src/components/dialogs/nuxs/NeueTypography.tsx:97 #: src/screens/Settings/AppearanceSettings.tsx:165 msgid "Smaller" -msgstr "小さく" +msgstr "小さい" #: src/screens/Onboarding/index.tsx:37 #: src/screens/Onboarding/state.ts:85 From 277e5867b3e9c477f5ceda05169ca464321b975b Mon Sep 17 00:00:00 2001 From: Takayuki KUSANO <65759+tkusano@users.noreply.github.com> Date: Wed, 25 Sep 2024 13:19:39 +0900 Subject: [PATCH 08/30] Update translation --- src/locale/locales/ja/messages.po | 69 +++++++------------------------ 1 file changed, 14 insertions(+), 55 deletions(-) diff --git a/src/locale/locales/ja/messages.po b/src/locale/locales/ja/messages.po index c054594d11..624d3ebe7c 100644 --- a/src/locale/locales/ja/messages.po +++ b/src/locale/locales/ja/messages.po @@ -8,7 +8,7 @@ msgstr "" "Language: ja\n" "Project-Id-Version: \n" "Report-Msgid-Bugs-To: \n" -"PO-Revision-Date: 2024-09-21 17:24+0900\n" +"PO-Revision-Date: 2024-09-25 13:18+0900\n" "Last-Translator: tkusano\n" "Language-Team: Hima-Zinn, tkusano, dolciss, oboenikui, noritada, middlingphys, hibiki, reindex-ot, haoyayoi, vyv03354\n" "Plural-Forms: \n" @@ -844,9 +844,9 @@ msgstr "ブログ" msgid "Bluesky" msgstr "Bluesky" -#: src/view/com/auth/server-input/index.tsx:154 -msgid "Bluesky is an open network where you can choose your hosting provider. Custom hosting is now available in beta for developers." -msgstr "Bluesky は、ホスティング プロバイダーを選択できるオープン ネットワークです。 カスタムホスティングは、開発者向けのベータ版で利用できるようになりました。" +#: src/view/com/auth/server-input/index.tsx:155 +msgid "Bluesky is an open network where you can choose your hosting provider. If you're a developer, you can host your own server." +msgstr "Bluesky は、ホスティング プロバイダーを選択できるオープン ネットワークです。 あなたが開発者であれば、自分のサーバーをホストできます。" #: src/components/ProgressGuide/List.tsx:55 msgid "Bluesky is better with friends!" @@ -2514,15 +2514,6 @@ msgstr "フィットネス" msgid "Flexible" msgstr "柔軟です" -#: src/view/com/modals/EditImage.tsx:116 -msgid "Flip horizontal" -msgstr "水平方向に反転" - -#: src/view/com/modals/EditImage.tsx:121 -#: src/view/com/modals/EditImage.tsx:288 -msgid "Flip vertically" -msgstr "垂直方向に反転" - #. User is not following this account, click to follow #: src/components/ProfileCard.tsx:356 #: src/components/ProfileHoverCard/index.web.tsx:446 @@ -3024,10 +3015,6 @@ msgstr "違法かつ緊急" msgid "Image" msgstr "画像" -#: src/view/com/modals/AltImage.tsx:122 -msgid "Image alt text" -msgstr "画像のALTテキスト" - #: src/components/dialogs/nuxs/TenMillion/index.tsx:247 #: src/components/StarterPack/ShareDialog.tsx:76 msgid "Image saved to your camera roll!" @@ -3246,6 +3233,10 @@ msgstr "詳細" msgid "Learn more about Bluesky" msgstr "Blueskyについての詳細" +#: src/view/com/auth/server-input/index.tsx:160 +msgid "Learn more about self hosting your PDS." +msgstr "自分用のPDSをホスティングする方法を学ぶ。" + #: src/components/moderation/ContentHider.tsx:66 #: src/components/moderation/ContentHider.tsx:131 msgid "Learn more about the moderation applied to this content." @@ -4860,10 +4851,6 @@ msgstr "この投稿の引用" msgid "Random (aka \"Poster's Roulette\")" msgstr "ランダムな順番で表示(別名「投稿者のルーレット」)" -#: src/view/com/modals/EditImage.tsx:237 -msgid "Ratios" -msgstr "比率" - #: src/view/com/util/forms/PostDropdownBtn.tsx:543 #: src/view/com/util/forms/PostDropdownBtn.tsx:553 msgid "Re-attach quote" @@ -5360,10 +5347,6 @@ msgctxt "action" msgid "Save" msgstr "保存" -#: src/view/com/modals/AltImage.tsx:132 -msgid "Save alt text" -msgstr "ALTテキストを保存" - #: src/components/dialogs/BirthDateSettings.tsx:119 msgid "Save birthday" msgstr "生年月日を保存" @@ -5717,18 +5700,6 @@ msgstr "Blueskyのユーザーネームを設定" msgid "Sets email for password reset" msgstr "パスワードをリセットするためのメールアドレスを入力" -#: src/view/com/modals/crop-image/CropImage.web.tsx:146 -msgid "Sets image aspect ratio to square" -msgstr "画像のアスペクト比を正方形に設定" - -#: src/view/com/modals/crop-image/CropImage.web.tsx:136 -msgid "Sets image aspect ratio to tall" -msgstr "画像のアスペクト比を縦長に設定" - -#: src/view/com/modals/crop-image/CropImage.web.tsx:126 -msgid "Sets image aspect ratio to wide" -msgstr "画像のアスペクト比をワイドに設定" - #: src/Navigation.tsx:155 #: src/view/screens/Settings/index.tsx:302 #: src/view/shell/desktop/LeftNav.tsx:395 @@ -6093,10 +6064,6 @@ msgstr "スパム、過剰なメンションや返信" msgid "Sports" msgstr "スポーツ" -#: src/view/com/modals/crop-image/CropImage.web.tsx:145 -msgid "Square" -msgstr "正方形" - #: src/components/dms/dialogs/NewChatDialog.tsx:63 msgid "Start a new chat" msgstr "新しいチャットを開始" @@ -6119,6 +6086,10 @@ msgstr "スターターパック" msgid "Starter pack by {0}" msgstr "{0}によるスターターパック" +#: src/components/StarterPack/StarterPackCard.tsx:74 +msgid "Starter pack by you" +msgstr "自分のスターターパック" + #: src/screens/StarterPack/StarterPackScreen.tsx:703 msgid "Starter pack is invalid" msgstr "スターターパックが無効です" @@ -6223,10 +6194,6 @@ msgstr "タグメニュー:{displayTag}" msgid "Tags only" msgstr "タグのみ" -#: src/view/com/modals/crop-image/CropImage.web.tsx:135 -msgid "Tall" -msgstr "トール" - #: src/components/ProgressGuide/Toast.tsx:150 msgid "Tap to dismiss" msgstr "タップして消す" @@ -6766,10 +6733,6 @@ msgstr "成人向けコンテンツの有効もしくは無効の切り替え" msgid "Top" msgstr "トップ" -#: src/view/com/modals/EditImage.tsx:272 -msgid "Transformations" -msgstr "変換" - #: src/components/dms/MessageMenu.tsx:103 #: src/components/dms/MessageMenu.tsx:105 #: src/view/com/post-thread/PostThreadItem.tsx:746 @@ -6957,8 +6920,8 @@ msgid "Unwanted Sexual Content" msgstr "望まない性的なコンテンツ" #: src/view/com/modals/UserAddRemoveLists.tsx:82 -msgid "Update {displayName} in Lists" -msgstr "リストの{displayName}を更新" +msgid "Update <0>{displayName} in Lists" +msgstr "リストの<0>{displayName}を更新" #: src/view/com/modals/ChangeHandle.tsx:502 msgid "Update to {handle}" @@ -7464,10 +7427,6 @@ msgstr "なぜこのスターターパックをレビューする必要があり msgid "Why should this user be reviewed?" msgstr "なぜこのユーザーをレビューする必要がありますか?" -#: src/view/com/modals/crop-image/CropImage.web.tsx:125 -msgid "Wide" -msgstr "ワイド" - #: src/screens/Messages/Conversation/MessageInput.tsx:142 #: src/screens/Messages/Conversation/MessageInput.web.tsx:198 msgid "Write a message" From 8da1072f23fd104d1ec2ffd80286ca046774d4ae Mon Sep 17 00:00:00 2001 From: Takayuki KUSANO <65759+tkusano@users.noreply.github.com> Date: Thu, 26 Sep 2024 10:37:22 +0900 Subject: [PATCH 09/30] Update translation --- src/locale/locales/ja/messages.po | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/locale/locales/ja/messages.po b/src/locale/locales/ja/messages.po index 624d3ebe7c..638a0ddd9a 100644 --- a/src/locale/locales/ja/messages.po +++ b/src/locale/locales/ja/messages.po @@ -8,7 +8,7 @@ msgstr "" "Language: ja\n" "Project-Id-Version: \n" "Report-Msgid-Bugs-To: \n" -"PO-Revision-Date: 2024-09-25 13:18+0900\n" +"PO-Revision-Date: 2024-09-26 10:27+0900\n" "Last-Translator: tkusano\n" "Language-Team: Hima-Zinn, tkusano, dolciss, oboenikui, noritada, middlingphys, hibiki, reindex-ot, haoyayoi, vyv03354\n" "Plural-Forms: \n" @@ -5808,6 +5808,10 @@ msgstr "リンクしたウェブサイトを共有" msgid "Show" msgstr "表示" +#: src/view/screens/Search/Search.tsx:883 +msgid "Show advanced filters" +msgstr "高度なフィルターを表示" + #: src/view/com/util/post-embeds/GifEmbed.tsx:169 msgid "Show alt text" msgstr "ALTテキストを表示" From 7e3a4580fd6ee741136853b64ce77c30ef015926 Mon Sep 17 00:00:00 2001 From: Takayuki KUSANO <65759+tkusano@users.noreply.github.com> Date: Fri, 27 Sep 2024 10:10:00 +0900 Subject: [PATCH 10/30] Update translation --- src/locale/locales/ja/messages.po | 77 +++---------------------------- 1 file changed, 6 insertions(+), 71 deletions(-) diff --git a/src/locale/locales/ja/messages.po b/src/locale/locales/ja/messages.po index 638a0ddd9a..9e207c2559 100644 --- a/src/locale/locales/ja/messages.po +++ b/src/locale/locales/ja/messages.po @@ -8,7 +8,7 @@ msgstr "" "Language: ja\n" "Project-Id-Version: \n" "Report-Msgid-Bugs-To: \n" -"PO-Revision-Date: 2024-09-26 10:27+0900\n" +"PO-Revision-Date: 2024-09-27 10:09+0900\n" "Last-Translator: tkusano\n" "Language-Team: Hima-Zinn, tkusano, dolciss, oboenikui, noritada, middlingphys, hibiki, reindex-ot, haoyayoi, vyv03354\n" "Plural-Forms: \n" @@ -262,10 +262,6 @@ msgstr "30日" msgid "7 days" msgstr "7日" -#: src/components/dialogs/nuxs/TenMillion/index.tsx:266 -msgid "A virtual certificate with text \"Celebrating 10M users on Bluesky, #{0}, {displayName} {handle}, joined on {joinedDate}\"" -msgstr "「Blueskyの1,000万ユーザーを祝福し、{0} 番目に、{displayName} {handle} が {joinedDate} に 参加した」というテキストが書かれた仮想証明書" - #: src/view/com/util/ViewHeader.tsx:92 #: src/view/screens/Search/Search.tsx:684 msgid "Access navigation links and settings" @@ -546,10 +542,6 @@ msgstr "ビデオの読み込み時にエラーが発生しました。時間を msgid "An error occurred while loading the video. Please try again." msgstr "ビデオの読み込み時にエラーが発生しました。もう一度お試しください。" -#: src/components/dialogs/nuxs/TenMillion/index.tsx:250 -msgid "An error occurred while saving the image!" -msgstr "画像の保存中にエラーが発生しました!" - #: src/components/StarterPack/QrCodeDialog.tsx:71 #: src/components/StarterPack/ShareDialog.tsx:79 msgid "An error occurred while saving the QR code!" @@ -852,15 +844,6 @@ msgstr "Bluesky は、ホスティング プロバイダーを選択できるオ msgid "Bluesky is better with friends!" msgstr "Blueskyは友達と一緒のほうが楽しい!" -#: src/components/dialogs/nuxs/TenMillion/Trigger.tsx:43 -#: src/components/dialogs/nuxs/TenMillion/Trigger.tsx:59 -msgid "Bluesky is celebrating 10 million users!" -msgstr "Blueskyは1,000万ユーザーを祝っています!" - -#: src/components/dialogs/nuxs/TenMillion/index.tsx:206 -msgid "Bluesky now has over 10 million users, and I was #{0}!" -msgstr "Bluesky のユーザー数は現在 1,000 万人を超えており、私は {0} 番目でした。" - #: src/components/StarterPack/ProfileStarterPacks.tsx:282 msgid "Bluesky will choose a set of recommended accounts from people in your network." msgstr "Blueskyはあなたのつながっているユーザーからおすすめのアカウントを選びます。" @@ -882,10 +865,6 @@ msgstr "画像のぼかしとフィードからのフィルタリング" msgid "Books" msgstr "書籍" -#: src/components/dialogs/nuxs/TenMillion/index.tsx:614 -msgid "Brag a little!" -msgstr "ちょっと自慢してみよう!" - #: src/components/FeedInterstitials.tsx:350 msgid "Browse more accounts on the Explore page" msgstr "検索ページでさらにアカウントを見る" @@ -1025,10 +1004,6 @@ msgstr "キャプション(.vtt)" msgid "Captions & alt text" msgstr "キャプション&ALTテキスト" -#: src/components/dialogs/nuxs/TenMillion/index.tsx:368 -msgid "Celebrating {0} users" -msgstr "{0} 人のユーザーを祝福" - #: src/view/com/modals/VerifyEmail.tsx:160 msgid "Change" msgstr "変更" @@ -1524,11 +1499,6 @@ msgstr "ビデオを処理できませんでした" msgid "Create" msgstr "作成" -#: src/view/com/auth/SplashScreen.tsx:57 -#: src/view/com/auth/SplashScreen.web.tsx:106 -msgid "Create a new account" -msgstr "新しいアカウントを作成" - #: src/view/screens/Settings/index.tsx:402 msgid "Create a new Bluesky account" msgstr "新しいBlueskyアカウントを作成" @@ -1547,6 +1517,11 @@ msgstr "スターターパックを作成" msgid "Create a starter pack for me" msgstr "私向けのスターターパックを作成" +#: src/view/com/auth/SplashScreen.tsx:56 +#: src/view/com/auth/SplashScreen.web.tsx:100 +msgid "Create account" +msgstr "アカウントを作成" + #: src/screens/Signup/index.tsx:99 msgid "Create Account" msgstr "アカウントを作成" @@ -1913,10 +1888,6 @@ msgstr "Blueskyをダウンロード" msgid "Download CAR file" msgstr "CARファイルをダウンロード" -#: src/components/dialogs/nuxs/TenMillion/index.tsx:622 -msgid "Download image" -msgstr "画像をダウンロード" - #: src/view/com/composer/text-input/TextInput.web.tsx:269 msgid "Drop to add images" msgstr "ドロップして画像を追加する" @@ -3165,10 +3136,6 @@ msgstr "Blueskyに参加" msgid "Join the conversation" msgstr "会話に参加" -#: src/components/dialogs/nuxs/TenMillion/index.tsx:559 -msgid "Joined on {joinedDate}" -msgstr "{joinedDate} に参加" - #: src/screens/Onboarding/index.tsx:21 #: src/screens/Onboarding/state.ts:89 msgid "Journalism" @@ -4125,10 +4092,6 @@ msgstr "ちょっと!" msgid "Oh no! Something went wrong." msgstr "ちょっと!何らかの問題が発生したようです。" -#: src/components/dialogs/nuxs/TenMillion/index.tsx:175 -msgid "Oh no! We weren't able to generate an image for you to share. Rest assured, we're glad you're here 🦋" -msgstr "ああ、残念!共有するための画像を生成できませんでした。ご安心ください、ここに来てくれて嬉しいです🦋" - #: src/screens/Profile/Header/ProfileHeaderLabeler.tsx:339 msgid "OK" msgstr "OK" @@ -5753,14 +5716,6 @@ msgstr "とにかく共有" msgid "Share feed" msgstr "フィードを共有" -#: src/components/dialogs/nuxs/TenMillion/index.tsx:621 -msgid "Share image externally" -msgstr "画像を外部に共有する" - -#: src/components/dialogs/nuxs/TenMillion/index.tsx:639 -msgid "Share image in post" -msgstr "投稿で画像を共有" - #: src/components/StarterPack/ShareDialog.tsx:124 #: src/components/StarterPack/ShareDialog.tsx:131 #: src/screens/StarterPack/StarterPackScreen.tsx:586 @@ -6240,10 +6195,6 @@ msgstr "ジョークを言って!" msgid "Tell us a little more" msgstr "もう少し教えて" -#: src/components/dialogs/nuxs/TenMillion/index.tsx:518 -msgid "Ten Million" -msgstr "1,000万" - #: src/view/shell/desktop/RightNav.tsx:90 msgid "Terms" msgstr "条件" @@ -6277,10 +6228,6 @@ msgstr "テキストの入力フィールド" msgid "Thank you. Your report has been sent." msgstr "ありがとうございます。あなたの報告は送信されました。" -#: src/components/dialogs/nuxs/TenMillion/index.tsx:593 -msgid "Thanks for being one of our first 10 million users." -msgstr "最初の 1,000 万人のユーザーの 1 人になっていただきありがとうございます。" - #: src/components/intents/VerifyEmailIntentDialog.tsx:74 msgid "Thanks, you have successfully verified your email address." msgstr "ありがとう、メールアドレスの確認に成功しました。" @@ -6704,10 +6651,6 @@ msgstr "スレッドの設定" msgid "To disable the email 2FA method, please verify your access to the email address." msgstr "メールでの2要素認証を無効にするには、メールアドレスにアクセスできるか確認してください。" -#: src/components/dialogs/nuxs/TenMillion/Trigger.tsx:69 -msgid "To learn more, <0>check out our post." -msgstr "詳しくは、<0>私達のこの投稿をチェックしてください。" - #: src/components/dms/ReportConversationPrompt.tsx:20 msgid "To report a conversation, please report one of its messages via the conversation screen. This lets our moderators understand the context of your issue." msgstr "会話を報告するには、会話の画面からメッセージのうちの一つを報告してください。それによって問題の文脈をモデレーターが理解できるようになります。" @@ -6720,10 +6663,6 @@ msgstr "Blueskyにビデオをアップロードするには、まずメール msgid "To whom would you like to send this report?" msgstr "この報告を誰に送りたいですか?" -#: src/components/dialogs/nuxs/TenMillion/index.tsx:597 -msgid "Together, we're rebuilding the social internet. We're glad you're here." -msgstr "私たちは一緒にソーシャル インターネットを再構築しています。ご参加いただきありがとうございます。" - #: src/view/com/util/forms/DropdownButton.tsx:255 msgid "Toggle dropdown" msgstr "ドロップダウンを切り替え" @@ -7210,10 +7149,6 @@ msgstr "スレッドをすべて表示" msgid "View information about these labels" msgstr "これらのラベルに関する情報を見る" -#: src/components/dialogs/nuxs/TenMillion/Trigger.tsx:72 -msgid "View our post" -msgstr "投稿を表示" - #: src/components/ProfileHoverCard/index.web.tsx:418 #: src/components/ProfileHoverCard/index.web.tsx:436 #: src/components/ProfileHoverCard/index.web.tsx:463 From b675f0051a28f8fe08aa25c5e2d1de1ae4d35f6d Mon Sep 17 00:00:00 2001 From: Takayuki KUSANO <65759+tkusano@users.noreply.github.com> Date: Sat, 28 Sep 2024 08:49:35 +0900 Subject: [PATCH 11/30] Translate "pinned" --- src/locale/locales/ja/messages.po | 28 +++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/src/locale/locales/ja/messages.po b/src/locale/locales/ja/messages.po index 9e207c2559..e2c3ee8f23 100644 --- a/src/locale/locales/ja/messages.po +++ b/src/locale/locales/ja/messages.po @@ -8,7 +8,7 @@ msgstr "" "Language: ja\n" "Project-Id-Version: \n" "Report-Msgid-Bugs-To: \n" -"PO-Revision-Date: 2024-09-27 10:09+0900\n" +"PO-Revision-Date: 2024-09-28 08:43+0900\n" "Last-Translator: tkusano\n" "Language-Team: Hima-Zinn, tkusano, dolciss, oboenikui, noritada, middlingphys, hibiki, reindex-ot, haoyayoi, vyv03354\n" "Plural-Forms: \n" @@ -2364,6 +2364,10 @@ msgstr "おすすめのフィードの読み込みに失敗しました" msgid "Failed to load suggested follows" msgstr "おすすめのフォローの読み込みに失敗しました" +#: src/state/queries/pinned-post.ts:75 +msgid "Failed to pin post" +msgstr "投稿の固定に失敗しました" + #: src/view/com/lightbox/Lightbox.tsx:90 msgid "Failed to save image: {0}" msgstr "画像の保存に失敗しました:{0}" @@ -4471,6 +4475,15 @@ msgstr "ホームにピン留め" msgid "Pin to Home" msgstr "ホームにピン留め" +#: src/view/com/util/forms/PostDropdownBtn.tsx:396 +#: src/view/com/util/forms/PostDropdownBtn.tsx:403 +msgid "Pin to your profile" +msgstr "プロフィールに固定" + +#: src/view/com/posts/FeedItem.tsx:354 +msgid "Pinned" +msgstr "固定" + #: src/view/screens/SavedFeeds.tsx:103 msgid "Pinned Feeds" msgstr "ピン留めされたフィード" @@ -4636,6 +4649,14 @@ msgstr "投稿の言語" msgid "Post not found" msgstr "投稿が見つかりません" +#: src/state/queries/pinned-post.ts:59 +msgid "Post pinned" +msgstr "投稿を固定しました" + +#: src/state/queries/pinned-post.ts:61 +msgid "Post unpinned" +msgstr "投稿の固定を解除しました" + #: src/components/TagMenu/index.tsx:267 msgid "posts" msgstr "投稿" @@ -6828,6 +6849,11 @@ msgstr "ピン留めを解除" msgid "Unpin from home" msgstr "ホームからピン留めを解除" +#: src/view/com/util/forms/PostDropdownBtn.tsx:395 +#: src/view/com/util/forms/PostDropdownBtn.tsx:402 +msgid "Unpin from profile" +msgstr "プロフィールから固定を解除" + #: src/view/screens/ProfileList.tsx:556 msgid "Unpin moderation list" msgstr "モデレーションリストのピン留めを解除" From ceac53c0ab6ae86fcf4baa037a3d923d35a9cb81 Mon Sep 17 00:00:00 2001 From: Takayuki KUSANO <65759+tkusano@users.noreply.github.com> Date: Thu, 3 Oct 2024 10:51:04 +0900 Subject: [PATCH 12/30] Update translation --- src/locale/locales/ja/messages.po | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/locale/locales/ja/messages.po b/src/locale/locales/ja/messages.po index e2c3ee8f23..0948345dba 100644 --- a/src/locale/locales/ja/messages.po +++ b/src/locale/locales/ja/messages.po @@ -8,7 +8,7 @@ msgstr "" "Language: ja\n" "Project-Id-Version: \n" "Report-Msgid-Bugs-To: \n" -"PO-Revision-Date: 2024-09-28 08:43+0900\n" +"PO-Revision-Date: 2024-10-03 10:49+0900\n" "Last-Translator: tkusano\n" "Language-Team: Hima-Zinn, tkusano, dolciss, oboenikui, noritada, middlingphys, hibiki, reindex-ot, haoyayoi, vyv03354\n" "Plural-Forms: \n" @@ -6249,9 +6249,9 @@ msgstr "テキストの入力フィールド" msgid "Thank you. Your report has been sent." msgstr "ありがとうございます。あなたの報告は送信されました。" -#: src/components/intents/VerifyEmailIntentDialog.tsx:74 -msgid "Thanks, you have successfully verified your email address." -msgstr "ありがとう、メールアドレスの確認に成功しました。" +#: src/components/intents/VerifyEmailIntentDialog.tsx:82 +msgid "Thanks, you have successfully verified your email address. You can close this dialog." +msgstr "ありがとう、メールアドレスの確認に成功しました。このダイアログを閉じても大丈夫です。" #: src/view/com/modals/ChangeHandle.tsx:459 msgid "That contains the following:" From f74f9bc1d93e9e52e39e1082e55e4172c6d18301 Mon Sep 17 00:00:00 2001 From: dan Date: Thu, 3 Oct 2024 11:41:23 +0900 Subject: [PATCH 13/30] Refactor video uploads (#5570) * Remove unused video field * Stop exposing video dispatch * Move cancellation out of the reducer * Make useUploadStatusQuery controlled by jobId * Rename SetStatus to SetProcessing This action only has one callsite and it's always passing "processing". * Move jobId into video reducer state * Make cancellation scoped * Inline useCompressVideoMutation * Move processVideo down the file * Extract getErrorMessage * useServiceAuthToken -> getServiceAuthToken * useVideoAgent -> createVideoAgent * useVideoUploadLimits -> getVideoUploadLimits * useUploadVideoMutation -> uploadVideo * Use async/await in processVideo * Inline onVideoCompressed into processVideo * Use async/await for uploadVideo * Factor out error messages * Guard dispatch with signal This lets us remove the scattered signal checks around dispatch. * Move job polling out of RQ * Handle poll failures * Remove unnecessary guards * Slightly more accurate condition * Move initVideoUri handling out of the hook * Remove dead argument It wasn't being used before either. * Remove unused detailed status This isn't being used because we're only respecting that state variable when isProcessing=true, but isProcessing is always false during video upload. If we want to re-add this later, it should really just be derived from the reducer state. * Harden the video reducer * Tie all spawned work to a signal * Preserve asset/media for nicer error state * Rename actions to match states * Inline useUploadVideo This abstraction is getting in the way of some future work. * Move MIME check to the only place that handles it --- src/state/queries/video/compress-video.ts | 39 -- src/state/queries/video/util.ts | 11 +- .../queries/video/video-upload.shared.ts | 88 ++- src/state/queries/video/video-upload.ts | 111 +-- src/state/queries/video/video-upload.web.ts | 137 ++-- src/state/queries/video/video.ts | 646 ++++++++++-------- src/view/com/composer/Composer.tsx | 124 ++-- .../com/composer/videos/SelectVideoBtn.tsx | 47 +- 8 files changed, 644 insertions(+), 559 deletions(-) delete mode 100644 src/state/queries/video/compress-video.ts diff --git a/src/state/queries/video/compress-video.ts b/src/state/queries/video/compress-video.ts deleted file mode 100644 index cefbf94066..0000000000 --- a/src/state/queries/video/compress-video.ts +++ /dev/null @@ -1,39 +0,0 @@ -import {ImagePickerAsset} from 'expo-image-picker' -import {useMutation} from '@tanstack/react-query' - -import {cancelable} from '#/lib/async/cancelable' -import {CompressedVideo} from '#/lib/media/video/types' -import {compressVideo} from 'lib/media/video/compress' - -export function useCompressVideoMutation({ - onProgress, - onSuccess, - onError, - signal, -}: { - onProgress: (progress: number) => void - onError: (e: any) => void - onSuccess: (video: CompressedVideo) => void - signal: AbortSignal -}) { - return useMutation({ - mutationKey: ['video', 'compress'], - mutationFn: cancelable( - (asset: ImagePickerAsset) => - compressVideo(asset, { - onProgress: num => onProgress(trunc2dp(num)), - signal, - }), - signal, - ), - onError, - onSuccess, - onMutate: () => { - onProgress(0) - }, - }) -} - -function trunc2dp(num: number) { - return Math.trunc(num * 100) / 100 -} diff --git a/src/state/queries/video/util.ts b/src/state/queries/video/util.ts index 2c1298ab63..87b422c2c9 100644 --- a/src/state/queries/video/util.ts +++ b/src/state/queries/video/util.ts @@ -1,4 +1,3 @@ -import {useMemo} from 'react' import {AtpAgent} from '@atproto/api' import {SupportedMimeTypes, VIDEO_SERVICE} from '#/lib/constants' @@ -17,12 +16,10 @@ export const createVideoEndpointUrl = ( return url.href } -export function useVideoAgent() { - return useMemo(() => { - return new AtpAgent({ - service: VIDEO_SERVICE, - }) - }, []) +export function createVideoAgent() { + return new AtpAgent({ + service: VIDEO_SERVICE, + }) } export function mimeToExt(mimeType: SupportedMimeTypes | (string & {})) { diff --git a/src/state/queries/video/video-upload.shared.ts b/src/state/queries/video/video-upload.shared.ts index 6b633bf213..8c217eadcf 100644 --- a/src/state/queries/video/video-upload.shared.ts +++ b/src/state/queries/video/video-upload.shared.ts @@ -1,73 +1,61 @@ -import {useCallback} from 'react' +import {BskyAgent} from '@atproto/api' +import {I18n} from '@lingui/core' import {msg} from '@lingui/macro' -import {useLingui} from '@lingui/react' import {VIDEO_SERVICE_DID} from '#/lib/constants' import {UploadLimitError} from '#/lib/media/video/errors' import {getServiceAuthAudFromUrl} from '#/lib/strings/url-helpers' -import {useAgent} from '#/state/session' -import {useVideoAgent} from './util' +import {createVideoAgent} from './util' -export function useServiceAuthToken({ +export async function getServiceAuthToken({ + agent, aud, lxm, exp, }: { + agent: BskyAgent aud?: string lxm: string exp?: number }) { - const agent = useAgent() - - return useCallback(async () => { - const pdsAud = getServiceAuthAudFromUrl(agent.dispatchUrl) - - if (!pdsAud) { - throw new Error('Agent does not have a PDS URL') - } - - const {data: serviceAuth} = await agent.com.atproto.server.getServiceAuth({ - aud: aud ?? pdsAud, - lxm, - exp, - }) - - return serviceAuth.token - }, [agent, aud, lxm, exp]) + const pdsAud = getServiceAuthAudFromUrl(agent.dispatchUrl) + if (!pdsAud) { + throw new Error('Agent does not have a PDS URL') + } + const {data: serviceAuth} = await agent.com.atproto.server.getServiceAuth({ + aud: aud ?? pdsAud, + lxm, + exp, + }) + return serviceAuth.token } -export function useVideoUploadLimits() { - const agent = useVideoAgent() - const getToken = useServiceAuthToken({ +export async function getVideoUploadLimits(agent: BskyAgent, _: I18n['_']) { + const token = await getServiceAuthToken({ + agent, lxm: 'app.bsky.video.getUploadLimits', aud: VIDEO_SERVICE_DID, }) - const {_} = useLingui() - - return useCallback(async () => { - const {data: limits} = await agent.app.bsky.video - .getUploadLimits( - {}, - {headers: {Authorization: `Bearer ${await getToken()}`}}, - ) - .catch(err => { - if (err instanceof Error) { - throw new UploadLimitError(err.message) - } else { - throw err - } - }) - - if (!limits.canUpload) { - if (limits.message) { - throw new UploadLimitError(limits.message) + const videoAgent = createVideoAgent() + const {data: limits} = await videoAgent.app.bsky.video + .getUploadLimits({}, {headers: {Authorization: `Bearer ${token}`}}) + .catch(err => { + if (err instanceof Error) { + throw new UploadLimitError(err.message) } else { - throw new UploadLimitError( - _( - msg`You have temporarily reached the limit for video uploads. Please try again later.`, - ), - ) + throw err } + }) + + if (!limits.canUpload) { + if (limits.message) { + throw new UploadLimitError(limits.message) + } else { + throw new UploadLimitError( + _( + msg`You have temporarily reached the limit for video uploads. Please try again later.`, + ), + ) } - }, [agent, _, getToken]) + } } diff --git a/src/state/queries/video/video-upload.ts b/src/state/queries/video/video-upload.ts index 170b538901..46f24a58b1 100644 --- a/src/state/queries/video/video-upload.ts +++ b/src/state/queries/video/video-upload.ts @@ -1,76 +1,79 @@ import {createUploadTask, FileSystemUploadType} from 'expo-file-system' -import {AppBskyVideoDefs} from '@atproto/api' +import {AppBskyVideoDefs, BskyAgent} from '@atproto/api' +import {I18n} from '@lingui/core' import {msg} from '@lingui/macro' -import {useLingui} from '@lingui/react' -import {useMutation} from '@tanstack/react-query' import {nanoid} from 'nanoid/non-secure' -import {cancelable} from '#/lib/async/cancelable' +import {AbortError} from '#/lib/async/cancelable' import {ServerError} from '#/lib/media/video/errors' import {CompressedVideo} from '#/lib/media/video/types' import {createVideoEndpointUrl, mimeToExt} from '#/state/queries/video/util' -import {useSession} from '#/state/session' -import {useServiceAuthToken, useVideoUploadLimits} from './video-upload.shared' +import {getServiceAuthToken, getVideoUploadLimits} from './video-upload.shared' -export const useUploadVideoMutation = ({ - onSuccess, - onError, +export async function uploadVideo({ + video, + agent, + did, setProgress, signal, + _, }: { - onSuccess: (response: AppBskyVideoDefs.JobStatus) => void - onError: (e: any) => void + video: CompressedVideo + agent: BskyAgent + did: string setProgress: (progress: number) => void signal: AbortSignal -}) => { - const {currentAccount} = useSession() - const getToken = useServiceAuthToken({ + _: I18n['_'] +}) { + if (signal.aborted) { + throw new AbortError() + } + await getVideoUploadLimits(agent, _) + + const uri = createVideoEndpointUrl('/xrpc/app.bsky.video.uploadVideo', { + did, + name: `${nanoid(12)}.${mimeToExt(video.mimeType)}`, + }) + + if (signal.aborted) { + throw new AbortError() + } + const token = await getServiceAuthToken({ + agent, lxm: 'com.atproto.repo.uploadBlob', exp: Date.now() / 1000 + 60 * 30, // 30 minutes }) - const checkLimits = useVideoUploadLimits() - const {_} = useLingui() + const uploadTask = createUploadTask( + uri, + video.uri, + { + headers: { + 'content-type': video.mimeType, + Authorization: `Bearer ${token}`, + }, + httpMethod: 'POST', + uploadType: FileSystemUploadType.BINARY_CONTENT, + }, + p => setProgress(p.totalBytesSent / p.totalBytesExpectedToSend), + ) - return useMutation({ - mutationKey: ['video', 'upload'], - mutationFn: cancelable(async (video: CompressedVideo) => { - await checkLimits() + if (signal.aborted) { + throw new AbortError() + } + const res = await uploadTask.uploadAsync() - const uri = createVideoEndpointUrl('/xrpc/app.bsky.video.uploadVideo', { - did: currentAccount!.did, - name: `${nanoid(12)}.${mimeToExt(video.mimeType)}`, - }) + if (!res?.body) { + throw new Error('No response') + } - const uploadTask = createUploadTask( - uri, - video.uri, - { - headers: { - 'content-type': video.mimeType, - Authorization: `Bearer ${await getToken()}`, - }, - httpMethod: 'POST', - uploadType: FileSystemUploadType.BINARY_CONTENT, - }, - p => setProgress(p.totalBytesSent / p.totalBytesExpectedToSend), - ) - const res = await uploadTask.uploadAsync() + const responseBody = JSON.parse(res.body) as AppBskyVideoDefs.JobStatus - if (!res?.body) { - throw new Error('No response') - } + if (!responseBody.jobId) { + throw new ServerError(responseBody.error || _(msg`Failed to upload video`)) + } - const responseBody = JSON.parse(res.body) as AppBskyVideoDefs.JobStatus - - if (!responseBody.jobId) { - throw new ServerError( - responseBody.error || _(msg`Failed to upload video`), - ) - } - - return responseBody - }, signal), - onError, - onSuccess, - }) + if (signal.aborted) { + throw new AbortError() + } + return responseBody } diff --git a/src/state/queries/video/video-upload.web.ts b/src/state/queries/video/video-upload.web.ts index c93e206030..bbae641999 100644 --- a/src/state/queries/video/video-upload.web.ts +++ b/src/state/queries/video/video-upload.web.ts @@ -1,86 +1,95 @@ import {AppBskyVideoDefs} from '@atproto/api' +import {BskyAgent} from '@atproto/api' +import {I18n} from '@lingui/core' import {msg} from '@lingui/macro' -import {useLingui} from '@lingui/react' -import {useMutation} from '@tanstack/react-query' import {nanoid} from 'nanoid/non-secure' -import {cancelable} from '#/lib/async/cancelable' +import {AbortError} from '#/lib/async/cancelable' import {ServerError} from '#/lib/media/video/errors' import {CompressedVideo} from '#/lib/media/video/types' import {createVideoEndpointUrl, mimeToExt} from '#/state/queries/video/util' -import {useSession} from '#/state/session' -import {useServiceAuthToken, useVideoUploadLimits} from './video-upload.shared' +import {getServiceAuthToken, getVideoUploadLimits} from './video-upload.shared' -export const useUploadVideoMutation = ({ - onSuccess, - onError, +export async function uploadVideo({ + video, + agent, + did, setProgress, signal, + _, }: { - onSuccess: (response: AppBskyVideoDefs.JobStatus) => void - onError: (e: any) => void + video: CompressedVideo + agent: BskyAgent + did: string setProgress: (progress: number) => void signal: AbortSignal -}) => { - const {currentAccount} = useSession() - const getToken = useServiceAuthToken({ + _: I18n['_'] +}) { + if (signal.aborted) { + throw new AbortError() + } + await getVideoUploadLimits(agent, _) + + const uri = createVideoEndpointUrl('/xrpc/app.bsky.video.uploadVideo', { + did, + name: `${nanoid(12)}.${mimeToExt(video.mimeType)}`, + }) + + let bytes = video.bytes + if (!bytes) { + if (signal.aborted) { + throw new AbortError() + } + bytes = await fetch(video.uri).then(res => res.arrayBuffer()) + } + + if (signal.aborted) { + throw new AbortError() + } + const token = await getServiceAuthToken({ + agent, lxm: 'com.atproto.repo.uploadBlob', exp: Date.now() / 1000 + 60 * 30, // 30 minutes }) - const checkLimits = useVideoUploadLimits() - const {_} = useLingui() - - return useMutation({ - mutationKey: ['video', 'upload'], - mutationFn: cancelable(async (video: CompressedVideo) => { - await checkLimits() - const uri = createVideoEndpointUrl('/xrpc/app.bsky.video.uploadVideo', { - did: currentAccount!.did, - name: `${nanoid(12)}.${mimeToExt(video.mimeType)}`, + if (signal.aborted) { + throw new AbortError() + } + const xhr = new XMLHttpRequest() + const res = await new Promise( + (resolve, reject) => { + xhr.upload.addEventListener('progress', e => { + const progress = e.loaded / e.total + setProgress(progress) }) - - let bytes = video.bytes - if (!bytes) { - bytes = await fetch(video.uri).then(res => res.arrayBuffer()) + xhr.onloadend = () => { + if (signal.aborted) { + reject(new AbortError()) + } else if (xhr.readyState === 4) { + const uploadRes = JSON.parse( + xhr.responseText, + ) as AppBskyVideoDefs.JobStatus + resolve(uploadRes) + } else { + reject(new ServerError(_(msg`Failed to upload video`))) + } } - - const token = await getToken() - - const xhr = new XMLHttpRequest() - const res = await new Promise( - (resolve, reject) => { - xhr.upload.addEventListener('progress', e => { - const progress = e.loaded / e.total - setProgress(progress) - }) - xhr.onloadend = () => { - if (xhr.readyState === 4) { - const uploadRes = JSON.parse( - xhr.responseText, - ) as AppBskyVideoDefs.JobStatus - resolve(uploadRes) - } else { - reject(new ServerError(_(msg`Failed to upload video`))) - } - } - xhr.onerror = () => { - reject(new ServerError(_(msg`Failed to upload video`))) - } - xhr.open('POST', uri) - xhr.setRequestHeader('Content-Type', video.mimeType) - xhr.setRequestHeader('Authorization', `Bearer ${token}`) - xhr.send(bytes) - }, - ) - - if (!res.jobId) { - throw new ServerError(res.error || _(msg`Failed to upload video`)) + xhr.onerror = () => { + reject(new ServerError(_(msg`Failed to upload video`))) } + xhr.open('POST', uri) + xhr.setRequestHeader('Content-Type', video.mimeType) + xhr.setRequestHeader('Authorization', `Bearer ${token}`) + xhr.send(bytes) + }, + ) - return res - }, signal), - onError, - onSuccess, - }) + if (!res.jobId) { + throw new ServerError(res.error || _(msg`Failed to upload video`)) + } + + if (signal.aborted) { + throw new AbortError() + } + return res } diff --git a/src/state/queries/video/video.ts b/src/state/queries/video/video.ts index 0d77935da9..fabee6ad1a 100644 --- a/src/state/queries/video/video.ts +++ b/src/state/queries/video/video.ts @@ -1,12 +1,11 @@ -import React, {useCallback, useEffect} from 'react' import {ImagePickerAsset} from 'expo-image-picker' -import {AppBskyVideoDefs, BlobRef} from '@atproto/api' +import {AppBskyVideoDefs, BlobRef, BskyAgent} from '@atproto/api' +import {JobStatus} from '@atproto/api/dist/client/types/app/bsky/video/defs' +import {I18n} from '@lingui/core' import {msg} from '@lingui/macro' -import {useLingui} from '@lingui/react' -import {QueryClient, useQuery, useQueryClient} from '@tanstack/react-query' import {AbortError} from '#/lib/async/cancelable' -import {SUPPORTED_MIME_TYPES, SupportedMimeTypes} from '#/lib/constants' +import {compressVideo} from '#/lib/media/video/compress' import { ServerError, UploadLimitError, @@ -14,338 +13,409 @@ import { } from '#/lib/media/video/errors' import {CompressedVideo} from '#/lib/media/video/types' import {logger} from '#/logger' -import {isWeb} from '#/platform/detection' -import {useCompressVideoMutation} from '#/state/queries/video/compress-video' -import {useVideoAgent} from '#/state/queries/video/util' -import {useUploadVideoMutation} from '#/state/queries/video/video-upload' - -type Status = 'idle' | 'compressing' | 'processing' | 'uploading' | 'done' +import {createVideoAgent} from '#/state/queries/video/util' +import {uploadVideo} from '#/state/queries/video/video-upload' type Action = - | {type: 'SetStatus'; status: Status} - | {type: 'SetProgress'; progress: number} - | {type: 'SetError'; error: string | undefined} - | {type: 'Reset'} - | {type: 'SetAsset'; asset: ImagePickerAsset} - | {type: 'SetDimensions'; width: number; height: number} - | {type: 'SetVideo'; video: CompressedVideo} - | {type: 'SetJobStatus'; jobStatus: AppBskyVideoDefs.JobStatus} - | {type: 'SetComplete'; blobRef: BlobRef} + | {type: 'to_idle'; nextController: AbortController} + | { + type: 'idle_to_compressing' + asset: ImagePickerAsset + signal: AbortSignal + } + | { + type: 'compressing_to_uploading' + video: CompressedVideo + signal: AbortSignal + } + | { + type: 'uploading_to_processing' + jobId: string + signal: AbortSignal + } + | {type: 'to_error'; error: string; signal: AbortSignal} + | { + type: 'to_done' + blobRef: BlobRef + signal: AbortSignal + } + | {type: 'update_progress'; progress: number; signal: AbortSignal} + | { + type: 'update_dimensions' + width: number + height: number + signal: AbortSignal + } + | { + type: 'update_job_status' + jobStatus: AppBskyVideoDefs.JobStatus + signal: AbortSignal + } -export interface State { - status: Status - progress: number - asset?: ImagePickerAsset +type IdleState = { + status: 'idle' + progress: 0 + abortController: AbortController + asset?: undefined + video?: undefined + jobId?: undefined + pendingPublish?: undefined +} + +type ErrorState = { + status: 'error' + progress: 100 + abortController: AbortController + asset: ImagePickerAsset | null video: CompressedVideo | null - jobStatus?: AppBskyVideoDefs.JobStatus - blobRef?: BlobRef - error?: string + jobId: string | null + error: string + pendingPublish?: undefined +} + +type CompressingState = { + status: 'compressing' + progress: number abortController: AbortController - pendingPublish?: {blobRef: BlobRef; mutableProcessed: boolean} + asset: ImagePickerAsset + video?: undefined + jobId?: undefined + pendingPublish?: undefined } -export type VideoUploadDispatch = (action: Action) => void +type UploadingState = { + status: 'uploading' + progress: number + abortController: AbortController + asset: ImagePickerAsset + video: CompressedVideo + jobId?: undefined + pendingPublish?: undefined +} -function reducer(queryClient: QueryClient) { - return (state: State, action: Action): State => { - let updatedState = state - if (action.type === 'SetStatus') { - updatedState = {...state, status: action.status} - } else if (action.type === 'SetProgress') { - updatedState = {...state, progress: action.progress} - } else if (action.type === 'SetError') { - updatedState = {...state, error: action.error} - } else if (action.type === 'Reset') { - state.abortController.abort() - queryClient.cancelQueries({ - queryKey: ['video'], - }) - updatedState = { - status: 'idle', - progress: 0, - video: null, - blobRef: undefined, - abortController: new AbortController(), - } - } else if (action.type === 'SetAsset') { - updatedState = { +type ProcessingState = { + status: 'processing' + progress: number + abortController: AbortController + asset: ImagePickerAsset + video: CompressedVideo + jobId: string + jobStatus: AppBskyVideoDefs.JobStatus | null + pendingPublish?: undefined +} + +type DoneState = { + status: 'done' + progress: 100 + abortController: AbortController + asset: ImagePickerAsset + video: CompressedVideo + jobId?: undefined + pendingPublish: {blobRef: BlobRef; mutableProcessed: boolean} +} + +export type State = + | IdleState + | ErrorState + | CompressingState + | UploadingState + | ProcessingState + | DoneState + +export function createVideoState( + abortController: AbortController = new AbortController(), +): IdleState { + return { + status: 'idle', + progress: 0, + abortController, + } +} + +export function videoReducer(state: State, action: Action): State { + if (action.type === 'to_idle') { + return createVideoState(action.nextController) + } + if (action.signal.aborted || action.signal !== state.abortController.signal) { + // This action is stale and the process that spawned it is no longer relevant. + return state + } + if (action.type === 'to_error') { + return { + status: 'error', + progress: 100, + abortController: state.abortController, + error: action.error, + asset: state.asset ?? null, + video: state.video ?? null, + jobId: state.jobId ?? null, + } + } else if (action.type === 'update_progress') { + if (state.status === 'compressing' || state.status === 'uploading') { + return { ...state, - asset: action.asset, + progress: action.progress, + } + } + } else if (action.type === 'idle_to_compressing') { + if (state.status === 'idle') { + return { status: 'compressing', - error: undefined, + progress: 0, + abortController: state.abortController, + asset: action.asset, } - } else if (action.type === 'SetDimensions') { - updatedState = { + } + } else if (action.type === 'update_dimensions') { + if (state.asset) { + return { ...state, - asset: state.asset - ? {...state.asset, width: action.width, height: action.height} - : undefined, + asset: {...state.asset, width: action.width, height: action.height}, } - } else if (action.type === 'SetVideo') { - updatedState = {...state, video: action.video, status: 'uploading'} - } else if (action.type === 'SetJobStatus') { - updatedState = {...state, jobStatus: action.jobStatus} - } else if (action.type === 'SetComplete') { - updatedState = { + } + } else if (action.type === 'compressing_to_uploading') { + if (state.status === 'compressing') { + return { + status: 'uploading', + progress: 0, + abortController: state.abortController, + asset: state.asset, + video: action.video, + } + } + return state + } else if (action.type === 'uploading_to_processing') { + if (state.status === 'uploading') { + return { + status: 'processing', + progress: 0, + abortController: state.abortController, + asset: state.asset, + video: state.video, + jobId: action.jobId, + jobStatus: null, + } + } + } else if (action.type === 'update_job_status') { + if (state.status === 'processing') { + return { ...state, + jobStatus: action.jobStatus, + progress: + action.jobStatus.progress !== undefined + ? action.jobStatus.progress / 100 + : state.progress, + } + } + } else if (action.type === 'to_done') { + if (state.status === 'processing') { + return { + status: 'done', + progress: 100, + abortController: state.abortController, + asset: state.asset, + video: state.video, pendingPublish: { blobRef: action.blobRef, mutableProcessed: false, }, - status: 'done', } } - return updatedState } + console.error( + 'Unexpected video action (' + + action.type + + ') while in ' + + state.status + + ' state', + ) + return state } -export function useUploadVideo({ - setStatus, - initialVideoUri, -}: { - setStatus: (status: string) => void - onSuccess: () => void - initialVideoUri?: string -}) { - const {_} = useLingui() - const queryClient = useQueryClient() - const [state, dispatch] = React.useReducer(reducer(queryClient), { - status: 'idle', - progress: 0, - video: null, - abortController: new AbortController(), - }) - - const {setJobId} = useUploadStatusQuery({ - onStatusChange: (status: AppBskyVideoDefs.JobStatus) => { - // This might prove unuseful, most of the job status steps happen too quickly to even be displayed to the user - // Leaving it for now though - dispatch({ - type: 'SetJobStatus', - jobStatus: status, - }) - setStatus(status.state.toString()) - }, - onSuccess: blobRef => { - dispatch({ - type: 'SetComplete', - blobRef, - }) - }, - onError: useCallback( - error => { - logger.error('Error processing video', {safeMessage: error}) - dispatch({ - type: 'SetError', - error: _(msg`Video failed to process`), - }) - }, - [_], - ), - }) +function trunc2dp(num: number) { + return Math.trunc(num * 100) / 100 +} - const {mutate: onVideoCompressed} = useUploadVideoMutation({ - onSuccess: response => { - dispatch({ - type: 'SetStatus', - status: 'processing', - }) - setJobId(response.jobId) - }, - onError: e => { - if (e instanceof AbortError) { - return - } else if (e instanceof ServerError || e instanceof UploadLimitError) { - let message - // https://github.com/bluesky-social/tango/blob/lumi/lumi/worker/permissions.go#L77 - switch (e.message) { - case 'User is not allowed to upload videos': - message = _(msg`You are not allowed to upload videos.`) - break - case 'Uploading is disabled at the moment': - message = _( - msg`Hold up! We’re gradually giving access to video, and you’re still waiting in line. Check back soon!`, - ) - break - case "Failed to get user's upload stats": - message = _( - msg`We were unable to determine if you are allowed to upload videos. Please try again.`, - ) - break - case 'User has exceeded daily upload bytes limit': - message = _( - msg`You've reached your daily limit for video uploads (too many bytes)`, - ) - break - case 'User has exceeded daily upload videos limit': - message = _( - msg`You've reached your daily limit for video uploads (too many videos)`, - ) - break - case 'Account is not old enough to upload videos': - message = _( - msg`Your account is not yet old enough to upload videos. Please try again later.`, - ) - break - default: - message = e.message - break - } - dispatch({ - type: 'SetError', - error: message, - }) - } else { - dispatch({ - type: 'SetError', - error: _(msg`An error occurred while uploading the video.`), - }) - } - logger.error('Error uploading video', {safeMessage: e}) - }, - setProgress: p => { - dispatch({type: 'SetProgress', progress: p}) - }, - signal: state.abortController.signal, +export async function processVideo( + asset: ImagePickerAsset, + dispatch: (action: Action) => void, + agent: BskyAgent, + did: string, + signal: AbortSignal, + _: I18n['_'], +) { + dispatch({ + type: 'idle_to_compressing', + asset, + signal, }) - const {mutate: onSelectVideo} = useCompressVideoMutation({ - onProgress: p => { - dispatch({type: 'SetProgress', progress: p}) - }, - onSuccess: (video: CompressedVideo) => { + let video: CompressedVideo | undefined + try { + video = await compressVideo(asset, { + onProgress: num => { + dispatch({type: 'update_progress', progress: trunc2dp(num), signal}) + }, + signal, + }) + } catch (e) { + const message = getCompressErrorMessage(e, _) + if (message !== null) { dispatch({ - type: 'SetVideo', - video, + type: 'to_error', + error: message, + signal, }) - onVideoCompressed(video) - }, - onError: e => { - if (e instanceof AbortError) { - return - } else if (e instanceof VideoTooLargeError) { - dispatch({ - type: 'SetError', - error: _(msg`The selected video is larger than 50MB.`), - }) - } else { - dispatch({ - type: 'SetError', - error: _(msg`An error occurred while compressing the video.`), - }) - logger.error('Error compressing video', {safeMessage: e}) - } - }, - signal: state.abortController.signal, + } + return + } + dispatch({ + type: 'compressing_to_uploading', + video, + signal, }) - const selectVideo = React.useCallback( - (asset: ImagePickerAsset) => { - // compression step on native converts to mp4, so no need to check there - if (isWeb) { - const mimeType = getMimeType(asset) - if (!SUPPORTED_MIME_TYPES.includes(mimeType as SupportedMimeTypes)) { - throw new Error(_(msg`Unsupported video type: ${mimeType}`)) - } - } - + let uploadResponse: AppBskyVideoDefs.JobStatus | undefined + try { + uploadResponse = await uploadVideo({ + video, + agent, + did, + signal, + _, + setProgress: p => { + dispatch({type: 'update_progress', progress: p, signal}) + }, + }) + } catch (e) { + const message = getUploadErrorMessage(e, _) + if (message !== null) { dispatch({ - type: 'SetAsset', - asset, + type: 'to_error', + error: message, + signal, }) - onSelectVideo(asset) - }, - [_, onSelectVideo], - ) - - const clearVideo = () => { - dispatch({type: 'Reset'}) + } + return } - const updateVideoDimensions = useCallback((width: number, height: number) => { - dispatch({ - type: 'SetDimensions', - width, - height, - }) - }, []) + const jobId = uploadResponse.jobId + dispatch({ + type: 'uploading_to_processing', + jobId, + signal, + }) - // Whenever we receive an initial video uri, we should immediately run compression if necessary - useEffect(() => { - if (initialVideoUri) { - selectVideo({uri: initialVideoUri} as ImagePickerAsset) + let pollFailures = 0 + while (true) { + if (signal.aborted) { + return // Exit async loop } - }, [initialVideoUri, selectVideo]) - - return { - state, - dispatch, - selectVideo, - clearVideo, - updateVideoDimensions, - } -} -const useUploadStatusQuery = ({ - onStatusChange, - onSuccess, - onError, -}: { - onStatusChange: (status: AppBskyVideoDefs.JobStatus) => void - onSuccess: (blobRef: BlobRef) => void - onError: (error: Error) => void -}) => { - const videoAgent = useVideoAgent() - const [enabled, setEnabled] = React.useState(true) - const [jobId, setJobId] = React.useState() + const videoAgent = createVideoAgent() + let status: JobStatus | undefined + let blob: BlobRef | undefined + try { + const response = await videoAgent.app.bsky.video.getJobStatus({jobId}) + status = response.data.jobStatus + pollFailures = 0 - const {error} = useQuery({ - queryKey: ['video', 'upload status', jobId], - queryFn: async () => { - if (!jobId) return // this won't happen, can ignore - - const {data} = await videoAgent.app.bsky.video.getJobStatus({jobId}) - const status = data.jobStatus if (status.state === 'JOB_STATE_COMPLETED') { - setEnabled(false) - if (!status.blob) + blob = status.blob + if (!blob) { throw new Error('Job completed, but did not return a blob') - onSuccess(status.blob) + } } else if (status.state === 'JOB_STATE_FAILED') { throw new Error(status.error ?? 'Job failed to process') } - onStatusChange(status) - return status - }, - enabled: Boolean(jobId && enabled), - refetchInterval: 1500, - }) + } catch (e) { + if (!status) { + pollFailures++ + if (pollFailures < 50) { + await new Promise(resolve => setTimeout(resolve, 5000)) + continue // Continue async loop + } + } - useEffect(() => { - if (error) { - onError(error) - setEnabled(false) + logger.error('Error processing video', {safeMessage: e}) + dispatch({ + type: 'to_error', + error: _(msg`Video failed to process`), + signal, + }) + return // Exit async loop } - }, [error, onError]) - return { - setJobId: (_jobId: string) => { - setJobId(_jobId) - setEnabled(true) - }, + if (blob) { + dispatch({ + type: 'to_done', + blobRef: blob, + signal, + }) + } else { + dispatch({ + type: 'update_job_status', + jobStatus: status, + signal, + }) + } + + if ( + status.state !== 'JOB_STATE_COMPLETED' && + status.state !== 'JOB_STATE_FAILED' + ) { + await new Promise(resolve => setTimeout(resolve, 1500)) + continue // Continue async loop + } + + return // Exit async loop } } -function getMimeType(asset: ImagePickerAsset) { - if (isWeb) { - const [mimeType] = asset.uri.slice('data:'.length).split(';base64,') - if (!mimeType) { - throw new Error('Could not determine mime type') - } - return mimeType +function getCompressErrorMessage(e: unknown, _: I18n['_']): string | null { + if (e instanceof AbortError) { + return null } - if (!asset.mimeType) { - throw new Error('Could not determine mime type') + if (e instanceof VideoTooLargeError) { + return _(msg`The selected video is larger than 50MB.`) + } + logger.error('Error compressing video', {safeMessage: e}) + return _(msg`An error occurred while compressing the video.`) +} + +function getUploadErrorMessage(e: unknown, _: I18n['_']): string | null { + if (e instanceof AbortError) { + return null + } + logger.error('Error uploading video', {safeMessage: e}) + if (e instanceof ServerError || e instanceof UploadLimitError) { + // https://github.com/bluesky-social/tango/blob/lumi/lumi/worker/permissions.go#L77 + switch (e.message) { + case 'User is not allowed to upload videos': + return _(msg`You are not allowed to upload videos.`) + case 'Uploading is disabled at the moment': + return _( + msg`Hold up! We’re gradually giving access to video, and you’re still waiting in line. Check back soon!`, + ) + case "Failed to get user's upload stats": + return _( + msg`We were unable to determine if you are allowed to upload videos. Please try again.`, + ) + case 'User has exceeded daily upload bytes limit': + return _( + msg`You've reached your daily limit for video uploads (too many bytes)`, + ) + case 'User has exceeded daily upload videos limit': + return _( + msg`You've reached your daily limit for video uploads (too many videos)`, + ) + case 'Account is not old enough to upload videos': + return _( + msg`Your account is not yet old enough to upload videos. Please try again later.`, + ) + default: + return e.message + } } - return asset.mimeType + return _(msg`An error occurred while uploading the video.`) } diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx index f354f0f0dc..185a57fc35 100644 --- a/src/view/com/composer/Composer.tsx +++ b/src/view/com/composer/Composer.tsx @@ -36,6 +36,7 @@ import Animated, { ZoomOut, } from 'react-native-reanimated' import {useSafeAreaInsets} from 'react-native-safe-area-context' +import {ImagePickerAsset} from 'expo-image-picker' import { AppBskyFeedDefs, AppBskyFeedGetPostThread, @@ -82,9 +83,10 @@ import {Gif} from '#/state/queries/tenor' import {ThreadgateAllowUISetting} from '#/state/queries/threadgate' import {threadgateViewToAllowUISetting} from '#/state/queries/threadgate/util' import { + createVideoState, + processVideo, State as VideoUploadState, - useUploadVideo, - VideoUploadDispatch, + videoReducer, } from '#/state/queries/video/video' import {useAgent, useSession} from '#/state/session' import {useComposerControls} from '#/state/shell/composer' @@ -147,7 +149,8 @@ export const ComposePost = ({ }) => { const {currentAccount} = useSession() const agent = useAgent() - const {data: currentProfile} = useProfileQuery({did: currentAccount!.did}) + const currentDid = currentAccount!.did + const {data: currentProfile} = useProfileQuery({did: currentDid}) const {isModalActive} = useModals() const {closeComposer} = useComposerControls() const pal = usePalette('default') @@ -189,21 +192,50 @@ export const ComposePost = ({ const [videoAltText, setVideoAltText] = useState('') const [captions, setCaptions] = useState<{lang: string; file: File}[]>([]) - const { - selectVideo, - clearVideo, - state: videoUploadState, - updateVideoDimensions, - dispatch: videoUploadDispatch, - } = useUploadVideo({ - setStatus: setProcessingState, - onSuccess: () => { - if (publishOnUpload) { - onPressPublish(true) - } + const [videoUploadState, videoDispatch] = useReducer( + videoReducer, + undefined, + createVideoState, + ) + + const selectVideo = React.useCallback( + (asset: ImagePickerAsset) => { + processVideo( + asset, + videoDispatch, + agent, + currentDid, + videoUploadState.abortController.signal, + _, + ) }, - initialVideoUri: initVideoUri, - }) + [_, videoUploadState.abortController, videoDispatch, agent, currentDid], + ) + + // Whenever we receive an initial video uri, we should immediately run compression if necessary + useEffect(() => { + if (initVideoUri) { + selectVideo({uri: initVideoUri} as ImagePickerAsset) + } + }, [initVideoUri, selectVideo]) + + const clearVideo = React.useCallback(() => { + videoUploadState.abortController.abort() + videoDispatch({type: 'to_idle', nextController: new AbortController()}) + }, [videoUploadState.abortController, videoDispatch]) + + const updateVideoDimensions = useCallback( + (width: number, height: number) => { + videoDispatch({ + type: 'update_dimensions', + width, + height, + signal: videoUploadState.abortController.signal, + }) + }, + [videoUploadState.abortController], + ) + const hasVideo = Boolean(videoUploadState.asset || videoUploadState.video) const [publishOnUpload, setPublishOnUpload] = useState(false) @@ -400,19 +432,18 @@ export const ComposePost = ({ postgate, onStateChange: setProcessingState, langs: toPostLanguages(langPrefs.postLanguage), - video: videoUploadState.pendingPublish?.blobRef - ? { - blobRef: videoUploadState.pendingPublish.blobRef, - altText: videoAltText, - captions: captions, - aspectRatio: videoUploadState.asset - ? { - width: videoUploadState.asset?.width, - height: videoUploadState.asset?.height, - } - : undefined, - } - : undefined, + video: + videoUploadState.status === 'done' + ? { + blobRef: videoUploadState.pendingPublish.blobRef, + altText: videoAltText, + captions: captions, + aspectRatio: { + width: videoUploadState.asset.width, + height: videoUploadState.asset.height, + }, + } + : undefined, }) ).uri try { @@ -694,7 +725,7 @@ export const ComposePost = ({ error={error} videoUploadState={videoUploadState} clearError={() => setError('')} - videoUploadDispatch={videoUploadDispatch} + clearVideo={clearVideo} /> void - videoUploadDispatch: VideoUploadDispatch + clearVideo: () => void }) { const t = useTheme() const {_} = useLingui() const videoError = - videoUploadState.status !== 'idle' ? videoUploadState.error : undefined + videoUploadState.status === 'error' ? videoUploadState.error : undefined const error = standardError || videoError const onClearError = () => { if (standardError) { clearError() } else { - videoUploadDispatch({type: 'Reset'}) + clearVideo() } } @@ -1136,7 +1167,7 @@ function ErrorBanner({ - {videoError && videoUploadState.jobStatus?.jobId && ( + {videoError && videoUploadState.jobId && ( - Job ID: {videoUploadState.jobStatus.jobId} + Job ID: {videoUploadState.jobId} )} @@ -1174,9 +1205,7 @@ function ToolbarWrapper({ function VideoUploadToolbar({state}: {state: VideoUploadState}) { const t = useTheme() const {_} = useLingui() - const progress = state.jobStatus?.progress - ? state.jobStatus.progress / 100 - : state.progress + const progress = state.progress const shouldRotate = state.status === 'processing' && (progress === 0 || progress === 1) let wheelProgress = shouldRotate ? 0.33 : progress @@ -1212,16 +1241,15 @@ function VideoUploadToolbar({state}: {state: VideoUploadState}) { case 'processing': text = _('Processing video...') break + case 'error': + text = _('Error') + wheelProgress = 100 + break case 'done': text = _('Video uploaded') break } - if (state.error) { - text = _('Error') - wheelProgress = 100 - } - return ( @@ -1229,7 +1257,11 @@ function VideoUploadToolbar({state}: {state: VideoUploadState}) { size={30} borderWidth={1} borderColor={t.atoms.border_contrast_low.borderColor} - color={state.error ? t.palette.negative_500 : t.palette.primary_500} + color={ + state.status === 'error' + ? t.palette.negative_500 + : t.palette.primary_500 + } progress={wheelProgress} /> diff --git a/src/view/com/composer/videos/SelectVideoBtn.tsx b/src/view/com/composer/videos/SelectVideoBtn.tsx index 2f2b4c3e7a..bbb3d95f2b 100644 --- a/src/view/com/composer/videos/SelectVideoBtn.tsx +++ b/src/view/com/composer/videos/SelectVideoBtn.tsx @@ -9,12 +9,14 @@ import { import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' +import {SUPPORTED_MIME_TYPES, SupportedMimeTypes} from '#/lib/constants' +import {BSKY_SERVICE} from '#/lib/constants' import {useVideoLibraryPermission} from '#/lib/hooks/usePermissions' +import {getHostnameFromUrl} from '#/lib/strings/url-helpers' +import {isWeb} from '#/platform/detection' import {isNative} from '#/platform/detection' import {useModalControls} from '#/state/modals' import {useSession} from '#/state/session' -import {BSKY_SERVICE} from 'lib/constants' -import {getHostnameFromUrl} from 'lib/strings/url-helpers' import {atoms as a, useTheme} from '#/alf' import {Button} from '#/components/Button' import {VideoClip_Stroke2_Corner0_Rounded as VideoClipIcon} from '#/components/icons/VideoClip' @@ -58,16 +60,25 @@ export function SelectVideoBtn({onSelectVideo, disabled, setError}: Props) { UIImagePickerPreferredAssetRepresentationMode.Current, }) if (response.assets && response.assets.length > 0) { - if (isNative) { - if (typeof response.assets[0].duration !== 'number') - throw Error('Asset is not a video') - if (response.assets[0].duration > VIDEO_MAX_DURATION) { - setError(_(msg`Videos must be less than 60 seconds long`)) - return - } - } + const asset = response.assets[0] try { - onSelectVideo(response.assets[0]) + if (isWeb) { + // compression step on native converts to mp4, so no need to check there + const mimeType = getMimeType(asset) + if ( + !SUPPORTED_MIME_TYPES.includes(mimeType as SupportedMimeTypes) + ) { + throw Error(_(msg`Unsupported video type: ${mimeType}`)) + } + } else { + if (typeof asset.duration !== 'number') { + throw Error('Asset is not a video') + } + if (asset.duration > VIDEO_MAX_DURATION) { + throw Error(_(msg`Videos must be less than 60 seconds long`)) + } + } + onSelectVideo(asset) } catch (err) { if (err instanceof Error) { setError(err.message) @@ -132,3 +143,17 @@ function VerifyEmailPrompt({control}: {control: Prompt.PromptControlProps}) { /> ) } + +function getMimeType(asset: ImagePickerAsset) { + if (isWeb) { + const [mimeType] = asset.uri.slice('data:'.length).split(';base64,') + if (!mimeType) { + throw new Error('Could not determine mime type') + } + return mimeType + } + if (!asset.mimeType) { + throw new Error('Could not determine mime type') + } + return asset.mimeType +} From 59589e34a375e15c3a802c3dd18170fde913c9cd Mon Sep 17 00:00:00 2001 From: dan Date: Thu, 3 Oct 2024 14:26:38 +0900 Subject: [PATCH 14/30] Manage video reducer from composer reducer (#5573) * Move video state into composer state * Represent video as embed This is slightly broken. In particular, we can't remove video yet because there's no action that results in video embed being removed. * Properly represent video as embed This aligns the video state lifetime with the embed lifetime. Video can now be properly added and removed. * Disable Add Video when we have images * Ignore empty image pick --- src/state/queries/video/video.ts | 67 +++++++++++--------------- src/view/com/composer/Composer.tsx | 49 +++++++++++-------- src/view/com/composer/state.ts | 75 +++++++++++++++++++++++++++++- 3 files changed, 129 insertions(+), 62 deletions(-) diff --git a/src/state/queries/video/video.ts b/src/state/queries/video/video.ts index fabee6ad1a..dbbb6c2026 100644 --- a/src/state/queries/video/video.ts +++ b/src/state/queries/video/video.ts @@ -16,13 +16,7 @@ import {logger} from '#/logger' import {createVideoAgent} from '#/state/queries/video/util' import {uploadVideo} from '#/state/queries/video/video-upload' -type Action = - | {type: 'to_idle'; nextController: AbortController} - | { - type: 'idle_to_compressing' - asset: ImagePickerAsset - signal: AbortSignal - } +export type VideoAction = | { type: 'compressing_to_uploading' video: CompressedVideo @@ -52,15 +46,20 @@ type Action = signal: AbortSignal } -type IdleState = { - status: 'idle' - progress: 0 - abortController: AbortController - asset?: undefined - video?: undefined - jobId?: undefined - pendingPublish?: undefined -} +const noopController = new AbortController() +noopController.abort() + +export const NO_VIDEO = Object.freeze({ + status: 'idle', + progress: 0, + abortController: noopController, + asset: undefined, + video: undefined, + jobId: undefined, + pendingPublish: undefined, +}) + +export type NoVideoState = typeof NO_VIDEO type ErrorState = { status: 'error' @@ -114,8 +113,7 @@ type DoneState = { pendingPublish: {blobRef: BlobRef; mutableProcessed: boolean} } -export type State = - | IdleState +export type VideoState = | ErrorState | CompressingState | UploadingState @@ -123,19 +121,21 @@ export type State = | DoneState export function createVideoState( - abortController: AbortController = new AbortController(), -): IdleState { + asset: ImagePickerAsset, + abortController: AbortController, +): CompressingState { return { - status: 'idle', + status: 'compressing', progress: 0, abortController, + asset, } } -export function videoReducer(state: State, action: Action): State { - if (action.type === 'to_idle') { - return createVideoState(action.nextController) - } +export function videoReducer( + state: VideoState, + action: VideoAction, +): VideoState { if (action.signal.aborted || action.signal !== state.abortController.signal) { // This action is stale and the process that spawned it is no longer relevant. return state @@ -157,15 +157,6 @@ export function videoReducer(state: State, action: Action): State { progress: action.progress, } } - } else if (action.type === 'idle_to_compressing') { - if (state.status === 'idle') { - return { - status: 'compressing', - progress: 0, - abortController: state.abortController, - asset: action.asset, - } - } } else if (action.type === 'update_dimensions') { if (state.asset) { return { @@ -238,18 +229,12 @@ function trunc2dp(num: number) { export async function processVideo( asset: ImagePickerAsset, - dispatch: (action: Action) => void, + dispatch: (action: VideoAction) => void, agent: BskyAgent, did: string, signal: AbortSignal, _: I18n['_'], ) { - dispatch({ - type: 'idle_to_compressing', - asset, - signal, - }) - let video: CompressedVideo | undefined try { video = await compressVideo(asset, { diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx index 185a57fc35..59aae29516 100644 --- a/src/view/com/composer/Composer.tsx +++ b/src/view/com/composer/Composer.tsx @@ -82,11 +82,12 @@ import {useProfileQuery} from '#/state/queries/profile' import {Gif} from '#/state/queries/tenor' import {ThreadgateAllowUISetting} from '#/state/queries/threadgate' import {threadgateViewToAllowUISetting} from '#/state/queries/threadgate/util' +import {NO_VIDEO, NoVideoState} from '#/state/queries/video/video' import { - createVideoState, processVideo, - State as VideoUploadState, - videoReducer, + VideoAction, + VideoState, + VideoState as VideoUploadState, } from '#/state/queries/video/video' import {useAgent, useSession} from '#/state/session' import {useComposerControls} from '#/state/shell/composer' @@ -192,24 +193,38 @@ export const ComposePost = ({ const [videoAltText, setVideoAltText] = useState('') const [captions, setCaptions] = useState<{lang: string; file: File}[]>([]) - const [videoUploadState, videoDispatch] = useReducer( - videoReducer, - undefined, - createVideoState, + // TODO: Move more state here. + const [composerState, dispatch] = useReducer( + composerReducer, + {initImageUris}, + createComposerState, + ) + + let videoUploadState: VideoState | NoVideoState = NO_VIDEO + if (composerState.embed.media?.type === 'video') { + videoUploadState = composerState.embed.media.video + } + const videoDispatch = useCallback( + (videoAction: VideoAction) => { + dispatch({type: 'embed_update_video', videoAction}) + }, + [dispatch], ) const selectVideo = React.useCallback( (asset: ImagePickerAsset) => { + const abortController = new AbortController() + dispatch({type: 'embed_add_video', asset, abortController}) processVideo( asset, videoDispatch, agent, currentDid, - videoUploadState.abortController.signal, + abortController.signal, _, ) }, - [_, videoUploadState.abortController, videoDispatch, agent, currentDid], + [_, videoDispatch, agent, currentDid], ) // Whenever we receive an initial video uri, we should immediately run compression if necessary @@ -221,8 +236,8 @@ export const ComposePost = ({ const clearVideo = React.useCallback(() => { videoUploadState.abortController.abort() - videoDispatch({type: 'to_idle', nextController: new AbortController()}) - }, [videoUploadState.abortController, videoDispatch]) + dispatch({type: 'embed_remove_video'}) + }, [videoUploadState.abortController, dispatch]) const updateVideoDimensions = useCallback( (width: number, height: number) => { @@ -233,7 +248,7 @@ export const ComposePost = ({ signal: videoUploadState.abortController.signal, }) }, - [videoUploadState.abortController], + [videoUploadState.abortController, videoDispatch], ) const hasVideo = Boolean(videoUploadState.asset || videoUploadState.video) @@ -249,12 +264,6 @@ export const ComposePost = ({ ) const [postgate, setPostgate] = useState(createPostgateRecord({post: ''})) - // TODO: Move more state here. - const [composerState, dispatch] = useReducer( - composerReducer, - {initImageUris}, - createComposerState, - ) let images = NO_IMAGES if (composerState.embed.media?.type === 'images') { images = composerState.embed.media.images @@ -857,7 +866,7 @@ export const ComposePost = ({ /> 0} setError={setError} /> @@ -1117,7 +1126,7 @@ function ErrorBanner({ clearVideo, }: { error: string - videoUploadState: VideoUploadState + videoUploadState: VideoUploadState | NoVideoState clearError: () => void clearVideo: () => void }) { diff --git a/src/view/com/composer/state.ts b/src/view/com/composer/state.ts index 5588de1aa3..8e974ad7a5 100644 --- a/src/view/com/composer/state.ts +++ b/src/view/com/composer/state.ts @@ -1,4 +1,12 @@ +import {ImagePickerAsset} from 'expo-image-picker' + import {ComposerImage, createInitialImages} from '#/state/gallery' +import { + createVideoState, + VideoAction, + videoReducer, + VideoState, +} from '#/state/queries/video/video' import {ComposerOpts} from '#/state/shell/composer' type PostRecord = { @@ -11,11 +19,16 @@ type ImagesMedia = { labels: string[] } +type VideoMedia = { + type: 'video' + video: VideoState +} + type ComposerEmbed = { // TODO: Other record types. record: PostRecord | undefined // TODO: Other media types. - media: ImagesMedia | undefined + media: ImagesMedia | VideoMedia | undefined } export type ComposerState = { @@ -27,6 +40,13 @@ export type ComposerAction = | {type: 'embed_add_images'; images: ComposerImage[]} | {type: 'embed_update_image'; image: ComposerImage} | {type: 'embed_remove_image'; image: ComposerImage} + | { + type: 'embed_add_video' + asset: ImagePickerAsset + abortController: AbortController + } + | {type: 'embed_remove_video'} + | {type: 'embed_update_video'; videoAction: VideoAction} const MAX_IMAGES = 4 @@ -36,6 +56,9 @@ export function composerReducer( ): ComposerState { switch (action.type) { case 'embed_add_images': { + if (action.images.length === 0) { + return state + } const prevMedia = state.embed.media let nextMedia = prevMedia if (!prevMedia) { @@ -104,6 +127,55 @@ export function composerReducer( } return state } + case 'embed_add_video': { + const prevMedia = state.embed.media + let nextMedia = prevMedia + if (!prevMedia) { + nextMedia = { + type: 'video', + video: createVideoState(action.asset, action.abortController), + } + } + return { + ...state, + embed: { + ...state.embed, + media: nextMedia, + }, + } + } + case 'embed_update_video': { + const videoAction = action.videoAction + const prevMedia = state.embed.media + let nextMedia = prevMedia + if (prevMedia?.type === 'video') { + nextMedia = { + ...prevMedia, + video: videoReducer(prevMedia.video, videoAction), + } + } + return { + ...state, + embed: { + ...state.embed, + media: nextMedia, + }, + } + } + case 'embed_remove_video': { + const prevMedia = state.embed.media + let nextMedia = prevMedia + if (prevMedia?.type === 'video') { + nextMedia = undefined + } + return { + ...state, + embed: { + ...state.embed, + media: nextMedia, + }, + } + } default: return state } @@ -122,6 +194,7 @@ export function createComposerState({ labels: [], } } + // TODO: initial video. return { embed: { record: undefined, From 475708ea30dd367c5d79f524bec907a356b6155a Mon Sep 17 00:00:00 2001 From: dan Date: Thu, 3 Oct 2024 14:57:48 +0900 Subject: [PATCH 15/30] Rename some files and variables (#5587) * Move composer reducers together * videoUploadState -> videoState * Inline videoDispatch --- src/lib/api/index.ts | 2 +- src/lib/media/video/compress.ts | 2 +- .../media/video/upload.shared.ts} | 0 .../media/video/upload.ts} | 4 +- .../media/video/upload.web.ts} | 4 +- .../queries => lib/media}/video/util.ts | 0 src/view/com/composer/Composer.tsx | 120 ++++++++---------- src/view/com/composer/photos/Gallery.tsx | 2 +- .../composer/{state.ts => state/composer.ts} | 7 +- .../com/composer/state}/video.ts | 4 +- 10 files changed, 64 insertions(+), 81 deletions(-) rename src/{state/queries/video/video-upload.shared.ts => lib/media/video/upload.shared.ts} (100%) rename src/{state/queries/video/video-upload.ts => lib/media/video/upload.ts} (92%) rename src/{state/queries/video/video-upload.web.ts => lib/media/video/upload.web.ts} (93%) rename src/{state/queries => lib/media}/video/util.ts (100%) rename src/view/com/composer/{state.ts => state/composer.ts} (97%) rename src/{state/queries/video => view/com/composer/state}/video.ts (98%) diff --git a/src/lib/api/index.ts b/src/lib/api/index.ts index 51bf51ffff..8b79250042 100644 --- a/src/lib/api/index.ts +++ b/src/lib/api/index.ts @@ -24,7 +24,7 @@ import { threadgateAllowUISettingToAllowRecordValue, writeThreadgateRecord, } from '#/state/queries/threadgate' -import {ComposerState} from '#/view/com/composer/state' +import {ComposerState} from '#/view/com/composer/state/composer' import {LinkMeta} from '../link-meta/link-meta' import {uploadBlob} from './upload-blob' diff --git a/src/lib/media/video/compress.ts b/src/lib/media/video/compress.ts index dec9032a34..c2d1470c63 100644 --- a/src/lib/media/video/compress.ts +++ b/src/lib/media/video/compress.ts @@ -2,8 +2,8 @@ import {getVideoMetaData, Video} from 'react-native-compressor' import {ImagePickerAsset} from 'expo-image-picker' import {SUPPORTED_MIME_TYPES, SupportedMimeTypes} from '#/lib/constants' -import {extToMime} from '#/state/queries/video/util' import {CompressedVideo} from './types' +import {extToMime} from './util' const MIN_SIZE_FOR_COMPRESSION = 25 // 25mb diff --git a/src/state/queries/video/video-upload.shared.ts b/src/lib/media/video/upload.shared.ts similarity index 100% rename from src/state/queries/video/video-upload.shared.ts rename to src/lib/media/video/upload.shared.ts diff --git a/src/state/queries/video/video-upload.ts b/src/lib/media/video/upload.ts similarity index 92% rename from src/state/queries/video/video-upload.ts rename to src/lib/media/video/upload.ts index 46f24a58b1..3330370b3e 100644 --- a/src/state/queries/video/video-upload.ts +++ b/src/lib/media/video/upload.ts @@ -7,8 +7,8 @@ import {nanoid} from 'nanoid/non-secure' import {AbortError} from '#/lib/async/cancelable' import {ServerError} from '#/lib/media/video/errors' import {CompressedVideo} from '#/lib/media/video/types' -import {createVideoEndpointUrl, mimeToExt} from '#/state/queries/video/util' -import {getServiceAuthToken, getVideoUploadLimits} from './video-upload.shared' +import {createVideoEndpointUrl, mimeToExt} from './util' +import {getServiceAuthToken, getVideoUploadLimits} from './upload.shared' export async function uploadVideo({ video, diff --git a/src/state/queries/video/video-upload.web.ts b/src/lib/media/video/upload.web.ts similarity index 93% rename from src/state/queries/video/video-upload.web.ts rename to src/lib/media/video/upload.web.ts index bbae641999..ec65f96c97 100644 --- a/src/state/queries/video/video-upload.web.ts +++ b/src/lib/media/video/upload.web.ts @@ -7,8 +7,8 @@ import {nanoid} from 'nanoid/non-secure' import {AbortError} from '#/lib/async/cancelable' import {ServerError} from '#/lib/media/video/errors' import {CompressedVideo} from '#/lib/media/video/types' -import {createVideoEndpointUrl, mimeToExt} from '#/state/queries/video/util' -import {getServiceAuthToken, getVideoUploadLimits} from './video-upload.shared' +import {createVideoEndpointUrl, mimeToExt} from './util' +import {getServiceAuthToken, getVideoUploadLimits} from './upload.shared' export async function uploadVideo({ video, diff --git a/src/state/queries/video/util.ts b/src/lib/media/video/util.ts similarity index 100% rename from src/state/queries/video/util.ts rename to src/lib/media/video/util.ts diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx index 59aae29516..f4e290ca8d 100644 --- a/src/view/com/composer/Composer.tsx +++ b/src/view/com/composer/Composer.tsx @@ -82,13 +82,6 @@ import {useProfileQuery} from '#/state/queries/profile' import {Gif} from '#/state/queries/tenor' import {ThreadgateAllowUISetting} from '#/state/queries/threadgate' import {threadgateViewToAllowUISetting} from '#/state/queries/threadgate/util' -import {NO_VIDEO, NoVideoState} from '#/state/queries/video/video' -import { - processVideo, - VideoAction, - VideoState, - VideoState as VideoUploadState, -} from '#/state/queries/video/video' import {useAgent, useSession} from '#/state/session' import {useComposerControls} from '#/state/shell/composer' import {ComposerOpts} from '#/state/shell/composer' @@ -123,7 +116,8 @@ import {EmojiArc_Stroke2_Corner0_Rounded as EmojiSmile} from '#/components/icons import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times' import * as Prompt from '#/components/Prompt' import {Text as NewText} from '#/components/Typography' -import {composerReducer, createComposerState} from './state' +import {composerReducer, createComposerState} from './state/composer' +import {NO_VIDEO, NoVideoState, processVideo, VideoState} from './state/video' const MAX_IMAGES = 4 @@ -200,16 +194,10 @@ export const ComposePost = ({ createComposerState, ) - let videoUploadState: VideoState | NoVideoState = NO_VIDEO + let videoState: VideoState | NoVideoState = NO_VIDEO if (composerState.embed.media?.type === 'video') { - videoUploadState = composerState.embed.media.video + videoState = composerState.embed.media.video } - const videoDispatch = useCallback( - (videoAction: VideoAction) => { - dispatch({type: 'embed_update_video', videoAction}) - }, - [dispatch], - ) const selectVideo = React.useCallback( (asset: ImagePickerAsset) => { @@ -217,14 +205,14 @@ export const ComposePost = ({ dispatch({type: 'embed_add_video', asset, abortController}) processVideo( asset, - videoDispatch, + videoAction => dispatch({type: 'embed_update_video', videoAction}), agent, currentDid, abortController.signal, _, ) }, - [_, videoDispatch, agent, currentDid], + [_, agent, currentDid], ) // Whenever we receive an initial video uri, we should immediately run compression if necessary @@ -235,23 +223,26 @@ export const ComposePost = ({ }, [initVideoUri, selectVideo]) const clearVideo = React.useCallback(() => { - videoUploadState.abortController.abort() + videoState.abortController.abort() dispatch({type: 'embed_remove_video'}) - }, [videoUploadState.abortController, dispatch]) + }, [videoState.abortController, dispatch]) const updateVideoDimensions = useCallback( (width: number, height: number) => { - videoDispatch({ - type: 'update_dimensions', - width, - height, - signal: videoUploadState.abortController.signal, + dispatch({ + type: 'embed_update_video', + videoAction: { + type: 'update_dimensions', + width, + height, + signal: videoState.abortController.signal, + }, }) }, - [videoUploadState.abortController, videoDispatch], + [videoState.abortController], ) - const hasVideo = Boolean(videoUploadState.asset || videoUploadState.video) + const hasVideo = Boolean(videoState.asset || videoState.video) const [publishOnUpload, setPublishOnUpload] = useState(false) @@ -288,7 +279,7 @@ export const ComposePost = ({ graphemeLength > 0 || images.length !== 0 || extGif || - videoUploadState.status !== 'idle' + videoState.status !== 'idle' ) { closeAllDialogs() Keyboard.dismiss() @@ -303,7 +294,7 @@ export const ComposePost = ({ closeAllDialogs, discardPromptControl, onClose, - videoUploadState.status, + videoState.status, ]) useImperativeHandle(cancelRef, () => ({onPressCancel})) @@ -400,8 +391,8 @@ export const ComposePost = ({ if ( !finishedUploading && - videoUploadState.asset && - videoUploadState.status !== 'done' + videoState.asset && + videoState.status !== 'done' ) { setPublishOnUpload(true) return @@ -414,7 +405,7 @@ export const ComposePost = ({ images.length === 0 && !extLink && !quote && - videoUploadState.status === 'idle' + videoState.status === 'idle' ) { setError(_(msg`Did you want to say anything?`)) return @@ -442,14 +433,14 @@ export const ComposePost = ({ onStateChange: setProcessingState, langs: toPostLanguages(langPrefs.postLanguage), video: - videoUploadState.status === 'done' + videoState.status === 'done' ? { - blobRef: videoUploadState.pendingPublish.blobRef, + blobRef: videoState.pendingPublish.blobRef, altText: videoAltText, captions: captions, aspectRatio: { - width: videoUploadState.asset.width, - height: videoUploadState.asset.height, + width: videoState.asset.width, + height: videoState.asset.height, }, } : undefined, @@ -550,20 +541,20 @@ export const ComposePost = ({ setLangPrefs, threadgateAllowUISettings, videoAltText, - videoUploadState.asset, - videoUploadState.pendingPublish, - videoUploadState.status, + videoState.asset, + videoState.pendingPublish, + videoState.status, ], ) React.useEffect(() => { - if (videoUploadState.pendingPublish && publishOnUpload) { - if (!videoUploadState.pendingPublish.mutableProcessed) { - videoUploadState.pendingPublish.mutableProcessed = true + if (videoState.pendingPublish && publishOnUpload) { + if (!videoState.pendingPublish.mutableProcessed) { + videoState.pendingPublish.mutableProcessed = true onPressPublish(true) } } - }, [onPressPublish, publishOnUpload, videoUploadState.pendingPublish]) + }, [onPressPublish, publishOnUpload, videoState.pendingPublish]) const canPost = useMemo( () => graphemeLength <= MAX_GRAPHEME_LENGTH && !isAltTextRequiredAndMissing, @@ -576,10 +567,10 @@ export const ComposePost = ({ const canSelectImages = images.length < MAX_IMAGES && !extLink && - videoUploadState.status === 'idle' && - !videoUploadState.video + videoState.status === 'idle' && + !videoState.video const hasMedia = - images.length > 0 || Boolean(extLink) || Boolean(videoUploadState.video) + images.length > 0 || Boolean(extLink) || Boolean(videoState.video) const onEmojiButtonPress = useCallback(() => { openEmojiPicker?.(textInput.current?.getCursorPosition()) @@ -694,9 +685,7 @@ export const ComposePost = ({ size="small" style={[a.rounded_full, a.py_sm]} onPress={() => onPressPublish()} - disabled={ - videoUploadState.status !== 'idle' && publishOnUpload - }> + disabled={videoState.status !== 'idle' && publishOnUpload}> {replyTo ? ( Reply @@ -732,7 +721,7 @@ export const ComposePost = ({ )} setError('')} clearVideo={clearVideo} /> @@ -798,17 +787,17 @@ export const ComposePost = ({ style={[a.w_full, a.mt_lg]} entering={native(ZoomIn)} exiting={native(ZoomOut)}> - {videoUploadState.asset && - (videoUploadState.status === 'compressing' ? ( + {videoState.asset && + (videoState.status === 'compressing' ? ( - ) : videoUploadState.video ? ( + ) : videoState.video ? ( @@ -854,9 +843,8 @@ export const ComposePost = ({ t.atoms.border_contrast_medium, styles.bottomBar, ]}> - {videoUploadState.status !== 'idle' && - videoUploadState.status !== 'done' ? ( - + {videoState.status !== 'idle' && videoState.status !== 'done' ? ( + ) : ( void clearVideo: () => void }) { @@ -1134,7 +1122,7 @@ function ErrorBanner({ const {_} = useLingui() const videoError = - videoUploadState.status === 'error' ? videoUploadState.error : undefined + videoState.status === 'error' ? videoState.error : undefined const error = standardError || videoError const onClearError = () => { @@ -1176,7 +1164,7 @@ function ErrorBanner({ - {videoError && videoUploadState.jobId && ( + {videoError && videoState.jobId && ( - Job ID: {videoUploadState.jobId} + Job ID: {videoState.jobId} )} @@ -1211,7 +1199,7 @@ function ToolbarWrapper({ ) } -function VideoUploadToolbar({state}: {state: VideoUploadState}) { +function VideoUploadToolbar({state}: {state: VideoState}) { const t = useTheme() const {_} = useLingui() const progress = state.progress diff --git a/src/view/com/composer/photos/Gallery.tsx b/src/view/com/composer/photos/Gallery.tsx index 5692f3d2c9..5ff7042bc1 100644 --- a/src/view/com/composer/photos/Gallery.tsx +++ b/src/view/com/composer/photos/Gallery.tsx @@ -21,7 +21,7 @@ import {ComposerImage, cropImage} from '#/state/gallery' import {Text} from '#/view/com/util/text/Text' import {useTheme} from '#/alf' import * as Dialog from '#/components/Dialog' -import {ComposerAction} from '../state' +import {ComposerAction} from '../state/composer' import {EditImageDialog} from './EditImageDialog' import {ImageAltTextDialog} from './ImageAltTextDialog' diff --git a/src/view/com/composer/state.ts b/src/view/com/composer/state/composer.ts similarity index 97% rename from src/view/com/composer/state.ts rename to src/view/com/composer/state/composer.ts index 8e974ad7a5..a23a5d8c86 100644 --- a/src/view/com/composer/state.ts +++ b/src/view/com/composer/state/composer.ts @@ -1,13 +1,8 @@ import {ImagePickerAsset} from 'expo-image-picker' import {ComposerImage, createInitialImages} from '#/state/gallery' -import { - createVideoState, - VideoAction, - videoReducer, - VideoState, -} from '#/state/queries/video/video' import {ComposerOpts} from '#/state/shell/composer' +import {createVideoState, VideoAction, videoReducer, VideoState} from './video' type PostRecord = { uri: string diff --git a/src/state/queries/video/video.ts b/src/view/com/composer/state/video.ts similarity index 98% rename from src/state/queries/video/video.ts rename to src/view/com/composer/state/video.ts index dbbb6c2026..2695056579 100644 --- a/src/state/queries/video/video.ts +++ b/src/view/com/composer/state/video.ts @@ -4,6 +4,8 @@ import {JobStatus} from '@atproto/api/dist/client/types/app/bsky/video/defs' import {I18n} from '@lingui/core' import {msg} from '@lingui/macro' +import {createVideoAgent} from '#/lib/media/video/util' +import {uploadVideo} from '#/lib/media/video/upload' import {AbortError} from '#/lib/async/cancelable' import {compressVideo} from '#/lib/media/video/compress' import { @@ -13,8 +15,6 @@ import { } from '#/lib/media/video/errors' import {CompressedVideo} from '#/lib/media/video/types' import {logger} from '#/logger' -import {createVideoAgent} from '#/state/queries/video/util' -import {uploadVideo} from '#/state/queries/video/video-upload' export type VideoAction = | { From 3ab5190aca767b9ed1900a84eab538f41000526c Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Thu, 3 Oct 2024 17:28:13 +0300 Subject: [PATCH 16/30] =?UTF-8?q?=F0=9F=AA=B5=F0=9F=93=8C=20(#5594)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/lib/statsig/events.ts | 2 ++ src/view/com/util/forms/PostDropdownBtn.tsx | 2 ++ 2 files changed, 4 insertions(+) diff --git a/src/lib/statsig/events.ts b/src/lib/statsig/events.ts index c9bc8fefb2..9a306ee4f4 100644 --- a/src/lib/statsig/events.ts +++ b/src/lib/statsig/events.ts @@ -145,6 +145,8 @@ export type LogEvents = { } 'post:mute': {} 'post:unmute': {} + 'post:pin': {} + 'post:unpin': {} 'profile:follow:sampled': { didBecomeMutual: boolean | undefined followeeClout: number | undefined diff --git a/src/view/com/util/forms/PostDropdownBtn.tsx b/src/view/com/util/forms/PostDropdownBtn.tsx index fe6efc02fa..33287564a7 100644 --- a/src/view/com/util/forms/PostDropdownBtn.tsx +++ b/src/view/com/util/forms/PostDropdownBtn.tsx @@ -22,6 +22,7 @@ import {getCurrentRoute} from '#/lib/routes/helpers' import {makeProfileLink} from '#/lib/routes/links' import {CommonNavigatorParams, NavigationProp} from '#/lib/routes/types' import {shareUrl} from '#/lib/sharing' +import {logEvent} from '#/lib/statsig/statsig' import {richTextToString} from '#/lib/strings/rich-text-helpers' import {toShareUrl} from '#/lib/strings/url-helpers' import {useTheme} from '#/lib/ThemeContext' @@ -350,6 +351,7 @@ let PostDropdownBtn = ({ ]) const onPressPin = useCallback(() => { + logEvent(isPinned ? 'post:unpin' : 'post:pin', {}) pinPostMutate({ postUri, postCid, From af8e3422a6cb2131f5386fbdc94e9b158e5562b6 Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Thu, 3 Oct 2024 18:19:38 +0300 Subject: [PATCH 17/30] =?UTF-8?q?[=F0=9F=90=B4]=20Reduce=20amount=20that?= =?UTF-8?q?=20message=20sent=20date=20is=20shown=20(#4228)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/dms/DateDivider.tsx | 80 +++++++++++++ src/components/dms/MessageItem.tsx | 178 ++++++++++++++-------------- src/components/dms/ReportDialog.tsx | 1 + src/components/dms/util.ts | 9 ++ src/state/messages/convo/agent.ts | 23 +++- src/state/messages/convo/types.ts | 12 ++ 6 files changed, 209 insertions(+), 94 deletions(-) create mode 100644 src/components/dms/DateDivider.tsx diff --git a/src/components/dms/DateDivider.tsx b/src/components/dms/DateDivider.tsx new file mode 100644 index 0000000000..a9c82e8ea2 --- /dev/null +++ b/src/components/dms/DateDivider.tsx @@ -0,0 +1,80 @@ +import React from 'react' +import {View} from 'react-native' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {subDays} from 'date-fns' + +import {atoms as a, useTheme} from '#/alf' +import {Text} from '../Typography' +import {localDateString} from './util' + +const timeFormatter = new Intl.DateTimeFormat(undefined, { + hour: 'numeric', + minute: 'numeric', +}) +const weekdayFormatter = new Intl.DateTimeFormat(undefined, { + weekday: 'long', +}) +const longDateFormatter = new Intl.DateTimeFormat(undefined, { + weekday: 'short', + month: 'long', + day: 'numeric', +}) +const longDateFormatterWithYear = new Intl.DateTimeFormat(undefined, { + weekday: 'short', + month: 'long', + day: 'numeric', + year: 'numeric', +}) + +let DateDivider = ({date: dateStr}: {date: string}): React.ReactNode => { + const {_} = useLingui() + const t = useTheme() + + let date: string + const time = timeFormatter.format(new Date(dateStr)) + + const timestamp = new Date(dateStr) + + const today = new Date() + const yesterday = subDays(today, 1) + const oneWeekAgo = subDays(today, 7) + + if (localDateString(today) === localDateString(timestamp)) { + date = _(msg`Today`) + } else if (localDateString(yesterday) === localDateString(timestamp)) { + date = _(msg`Yesterday`) + } else { + if (timestamp < oneWeekAgo) { + if (timestamp.getFullYear() === today.getFullYear()) { + date = longDateFormatter.format(timestamp) + } else { + date = longDateFormatterWithYear.format(timestamp) + } + } else { + date = weekdayFormatter.format(timestamp) + } + } + + return ( + + + + + {date} + {' '} + at {time} + + + + ) +} +DateDivider = React.memo(DateDivider) +export {DateDivider} diff --git a/src/components/dms/MessageItem.tsx b/src/components/dms/MessageItem.tsx index c5c472cf08..52220e2cac 100644 --- a/src/components/dms/MessageItem.tsx +++ b/src/components/dms/MessageItem.tsx @@ -17,13 +17,15 @@ import {useLingui} from '@lingui/react' import {ConvoItem} from '#/state/messages/convo/types' import {useSession} from '#/state/session' -import {TimeElapsed} from 'view/com/util/TimeElapsed' +import {TimeElapsed} from '#/view/com/util/TimeElapsed' import {atoms as a, useTheme} from '#/alf' import {ActionsWrapper} from '#/components/dms/ActionsWrapper' import {InlineLinkText} from '#/components/Link' import {Text} from '#/components/Typography' import {isOnlyEmoji, RichText} from '../RichText' +import {DateDivider} from './DateDivider' import {MessageItemEmbed} from './MessageItemEmbed' +import {localDateString} from './util' let MessageItem = ({ item, @@ -33,14 +35,37 @@ let MessageItem = ({ const t = useTheme() const {currentAccount} = useSession() - const {message, nextMessage} = item + const {message, nextMessage, prevMessage} = item const isPending = item.type === 'pending-message' const isFromSelf = message.sender?.did === currentAccount?.did + const nextIsMessage = ChatBskyConvoDefs.isMessageView(nextMessage) + const isNextFromSelf = - ChatBskyConvoDefs.isMessageView(nextMessage) && - nextMessage.sender?.did === currentAccount?.did + nextIsMessage && nextMessage.sender?.did === currentAccount?.did + + const isNextFromSameSender = isNextFromSelf === isFromSelf + + const isNewDay = useMemo(() => { + if (!prevMessage) return true + + const thisDate = new Date(message.sentAt) + const prevDate = new Date(prevMessage.sentAt) + + return localDateString(thisDate) !== localDateString(prevDate) + }, [message, prevMessage]) + + const isLastMessageOfDay = useMemo(() => { + if (!nextMessage || !nextIsMessage) return true + + const thisDate = new Date(message.sentAt) + const prevDate = new Date(nextMessage.sentAt) + + return localDateString(thisDate) !== localDateString(prevDate) + }, [message.sentAt, nextIsMessage, nextMessage]) + + const needsTail = isLastMessageOfDay || !isNextFromSameSender const isLastInGroup = useMemo(() => { // if this message is pending, it means the next message is pending too @@ -48,24 +73,19 @@ let MessageItem = ({ return false } - // if the next message is from a different sender, then it's the last in the group - if (isFromSelf ? !isNextFromSelf : isNextFromSelf) { - return true - } - - // or, if there's a 3 minute gap between this message and the next + // or, if there's a 5 minute gap between this message and the next if (ChatBskyConvoDefs.isMessageView(nextMessage)) { const thisDate = new Date(message.sentAt) const nextDate = new Date(nextMessage.sentAt) const diff = nextDate.getTime() - thisDate.getTime() - // 3 minutes - return diff > 3 * 60 * 1000 + // 5 minutes + return diff > 5 * 60 * 1000 } return true - }, [message, nextMessage, isFromSelf, isNextFromSelf, isPending]) + }, [message, nextMessage, isPending]) const lastInGroupRef = useRef(isLastInGroup) if (lastInGroupRef.current !== isLastInGroup) { @@ -80,52 +100,59 @@ let MessageItem = ({ }, [message.text, message.facets]) return ( - - - {AppBskyEmbedRecord.isView(message.embed) && ( - - )} - {rt.text.length > 0 && ( - - - - )} - + <> + {isNewDay && } + + + {AppBskyEmbedRecord.isView(message.embed) && ( + + )} + {rt.text.length > 0 && ( + + + + )} + - {isLastInGroup && ( - - )} - + {isLastInGroup && ( + + )} + + ) } MessageItem = React.memo(MessageItem) @@ -165,31 +192,12 @@ let MessageItemMetadata = ({ const diff = now.getTime() - date.getTime() - // if under 1 minute - if (diff < 1000 * 60) { + // if under 30 seconds + if (diff < 1000 * 30) { return _(msg`Now`) } - // if in the last day - if (localDateString(now) === localDateString(date)) { - return time - } - - // if yesterday - const yesterday = new Date(now) - yesterday.setDate(yesterday.getDate() - 1) - - if (localDateString(yesterday) === localDateString(date)) { - return _(msg`Yesterday, ${time}`) - } - - return i18n.date(date, { - hour: 'numeric', - minute: 'numeric', - day: 'numeric', - month: 'numeric', - year: 'numeric', - }) + return time }, [_], ) @@ -242,15 +250,5 @@ let MessageItemMetadata = ({ ) } - MessageItemMetadata = React.memo(MessageItemMetadata) export {MessageItemMetadata} - -function localDateString(date: Date) { - // can't use toISOString because it should be in local time - const mm = date.getMonth() - const dd = date.getDate() - const yyyy = date.getFullYear() - // not padding with 0s because it's not necessary, it's just used for comparison - return `${yyyy}-${mm}-${dd}` -} diff --git a/src/components/dms/ReportDialog.tsx b/src/components/dms/ReportDialog.tsx index 5493a1c87f..2dcd778545 100644 --- a/src/components/dms/ReportDialog.tsx +++ b/src/components/dms/ReportDialog.tsx @@ -277,6 +277,7 @@ function PreviewMessage({message}: {message: ChatBskyConvoDefs.MessageView}) { message, key: '', nextMessage: null, + prevMessage: null, }} style={[a.text_left, a.mb_0]} /> diff --git a/src/components/dms/util.ts b/src/components/dms/util.ts index 5952b9acf4..003532d0c6 100644 --- a/src/components/dms/util.ts +++ b/src/components/dms/util.ts @@ -16,3 +16,12 @@ export function canBeMessaged(profile: AppBskyActorDefs.ProfileView) { return false } } + +export function localDateString(date: Date) { + // can't use toISOString because it should be in local time + const mm = date.getMonth() + const dd = date.getDate() + const yyyy = date.getFullYear() + // not padding with 0s because it's not necessary, it's just used for comparison + return `${yyyy}-${mm}-${dd}` +} diff --git a/src/state/messages/convo/agent.ts b/src/state/messages/convo/agent.ts index de2605b5ad..53d77046a2 100644 --- a/src/state/messages/convo/agent.ts +++ b/src/state/messages/convo/agent.ts @@ -972,6 +972,7 @@ export class Convo { key: m.id, message: m, nextMessage: null, + prevMessage: null, }) } else if (ChatBskyConvoDefs.isDeletedMessageView(m)) { items.unshift({ @@ -979,6 +980,7 @@ export class Convo { key: m.id, message: m, nextMessage: null, + prevMessage: null, }) } }) @@ -1001,6 +1003,7 @@ export class Convo { key: m.id, message: m, nextMessage: null, + prevMessage: null, }) } else if (ChatBskyConvoDefs.isDeletedMessageView(m)) { items.push({ @@ -1008,6 +1011,7 @@ export class Convo { key: m.id, message: m, nextMessage: null, + prevMessage: null, }) } }) @@ -1030,6 +1034,7 @@ export class Convo { sender: this.sender!, }, nextMessage: null, + prevMessage: null, failed: this.pendingMessageFailure !== null, retry: this.pendingMessageFailure === 'recoverable' @@ -1060,29 +1065,39 @@ export class Convo { }) .map((item, i, arr) => { let nextMessage = null + let prevMessage = null const isMessage = isConvoItemMessage(item) if (isMessage) { if ( - isMessage && - (ChatBskyConvoDefs.isMessageView(item.message) || - ChatBskyConvoDefs.isDeletedMessageView(item.message)) + ChatBskyConvoDefs.isMessageView(item.message) || + ChatBskyConvoDefs.isDeletedMessageView(item.message) ) { const next = arr[i + 1] if ( isConvoItemMessage(next) && - next && (ChatBskyConvoDefs.isMessageView(next.message) || ChatBskyConvoDefs.isDeletedMessageView(next.message)) ) { nextMessage = next.message } + + const prev = arr[i - 1] + + if ( + isConvoItemMessage(prev) && + (ChatBskyConvoDefs.isMessageView(prev.message) || + ChatBskyConvoDefs.isDeletedMessageView(prev.message)) + ) { + prevMessage = prev.message + } } return { ...item, nextMessage, + prevMessage, } } diff --git a/src/state/messages/convo/types.ts b/src/state/messages/convo/types.ts index 53e205e211..21772262ea 100644 --- a/src/state/messages/convo/types.ts +++ b/src/state/messages/convo/types.ts @@ -87,6 +87,10 @@ export type ConvoItem = | ChatBskyConvoDefs.MessageView | ChatBskyConvoDefs.DeletedMessageView | null + prevMessage: + | ChatBskyConvoDefs.MessageView + | ChatBskyConvoDefs.DeletedMessageView + | null } | { type: 'pending-message' @@ -96,6 +100,10 @@ export type ConvoItem = | ChatBskyConvoDefs.MessageView | ChatBskyConvoDefs.DeletedMessageView | null + prevMessage: + | ChatBskyConvoDefs.MessageView + | ChatBskyConvoDefs.DeletedMessageView + | null failed: boolean /** * Retry sending the message. If present, the message is in a failed state. @@ -110,6 +118,10 @@ export type ConvoItem = | ChatBskyConvoDefs.MessageView | ChatBskyConvoDefs.DeletedMessageView | null + prevMessage: + | ChatBskyConvoDefs.MessageView + | ChatBskyConvoDefs.DeletedMessageView + | null } | { type: 'error' From c00c5fa495460e54551ab7325a5f79d94dd192a7 Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Thu, 3 Oct 2024 10:45:01 -0500 Subject: [PATCH 18/30] Fix profile header buttons (#5558) * Fix profile header buttons * Adjust labeler buttons too * Fix load state jumps * Small tweak for web * Remove log --- src/components/Button.tsx | 10 ++--- src/components/dms/MessageProfileButton.tsx | 26 +++++-------- .../Profile/Header/ProfileHeaderLabeler.tsx | 14 +++---- .../Profile/Header/ProfileHeaderStandard.tsx | 9 +++-- src/screens/Profile/Header/index.tsx | 7 ++-- src/view/com/profile/ProfileMenu.tsx | 39 +++++++------------ 6 files changed, 42 insertions(+), 63 deletions(-) diff --git a/src/components/Button.tsx b/src/components/Button.tsx index 17179994a9..1c14b48c75 100644 --- a/src/components/Button.tsx +++ b/src/components/Button.tsx @@ -14,7 +14,7 @@ import { } from 'react-native' import {LinearGradient} from 'expo-linear-gradient' -import {atoms as a, flatten, select, tokens, useTheme, web} from '#/alf' +import {atoms as a, flatten, select, tokens, useTheme} from '#/alf' import {Props as SVGIconProps} from '#/components/icons/common' import {Text} from '#/components/Typography' @@ -352,7 +352,7 @@ export const Button = React.forwardRef( }) } else if (size === 'small') { baseStyles.push({ - paddingVertical: 8, + paddingVertical: 9, paddingHorizontal: 12, borderRadius: 6, gap: 6, @@ -374,7 +374,7 @@ export const Button = React.forwardRef( } } else if (size === 'small') { if (shape === 'round') { - baseStyles.push({height: 36, width: 36}) + baseStyles.push({height: 34, width: 34}) } else { baseStyles.push({height: 34, width: 34}) } @@ -627,9 +627,9 @@ export function useSharedButtonTextStyles() { } if (size === 'large') { - baseStyles.push(a.text_md, a.leading_tight, web({top: -0.4})) + baseStyles.push(a.text_md, a.leading_tight) } else if (size === 'small') { - baseStyles.push(a.text_sm, a.leading_tight, web({top: -0.4})) + baseStyles.push(a.text_sm, a.leading_tight) } else if (size === 'tiny') { baseStyles.push(a.text_xs, a.leading_tight) } diff --git a/src/components/dms/MessageProfileButton.tsx b/src/components/dms/MessageProfileButton.tsx index 7f440d621f..932982d05e 100644 --- a/src/components/dms/MessageProfileButton.tsx +++ b/src/components/dms/MessageProfileButton.tsx @@ -4,12 +4,13 @@ import {AppBskyActorDefs} from '@atproto/api' import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' +import {logEvent} from '#/lib/statsig/statsig' import {useMaybeConvoForUser} from '#/state/queries/messages/get-convo-for-members' -import {logEvent} from 'lib/statsig/statsig' import {atoms as a, useTheme} from '#/alf' -import {Message_Stroke2_Corner0_Rounded as Message} from '../icons/Message' -import {Link} from '../Link' -import {canBeMessaged} from './util' +import {ButtonIcon} from '#/components/Button' +import {canBeMessaged} from '#/components/dms/util' +import {Message_Stroke2_Corner0_Rounded as Message} from '#/components/icons/Message' +import {Link} from '#/components/Link' export function MessageProfileButton({ profile, @@ -40,15 +41,9 @@ export function MessageProfileButton({ a.align_center, t.atoms.bg_contrast_25, a.rounded_full, - {width: 36, height: 36}, + {width: 34, height: 34}, ]}> - + ) } else { @@ -66,12 +61,9 @@ export function MessageProfileButton({ shape="round" label={_(msg`Message ${profile.handle}`)} to={`/messages/${convo.id}`} - style={[a.justify_center, {width: 36, height: 36}]} + style={[a.justify_center]} onPress={onPress}> - + ) } else { diff --git a/src/screens/Profile/Header/ProfileHeaderLabeler.tsx b/src/screens/Profile/Header/ProfileHeaderLabeler.tsx index 7b44e58693..8c95413a8c 100644 --- a/src/screens/Profile/Header/ProfileHeaderLabeler.tsx +++ b/src/screens/Profile/Header/ProfileHeaderLabeler.tsx @@ -25,7 +25,7 @@ import {usePreferencesQuery} from '#/state/queries/preferences' import {useRequireAuth, useSession} from '#/state/session' import {ProfileMenu} from '#/view/com/profile/ProfileMenu' import * as Toast from '#/view/com/util/Toast' -import {atoms as a, tokens, useBreakpoints, useTheme} from '#/alf' +import {atoms as a, tokens, useTheme} from '#/alf' import {Button, ButtonText} from '#/components/Button' import {DialogOuterProps} from '#/components/Dialog' import { @@ -61,7 +61,6 @@ let ProfileHeaderLabeler = ({ const profile: Shadow = useProfileShadow(profileUnshadowed) const t = useTheme() - const {gtMobile} = useBreakpoints() const {_} = useLingui() const {currentAccount, hasSession} = useSession() const {openModal} = useModalControls() @@ -167,7 +166,7 @@ let ProfileHeaderLabeler = ({ style={[a.px_lg, a.pt_md, a.pb_sm]} pointerEvents={isIOS ? 'auto' : 'box-none'}> {isMe ? ( ) }} From b073c0483016c9cddd13a66ec5c7678b28aadc7b Mon Sep 17 00:00:00 2001 From: Takayuki KUSANO <65759+tkusano@users.noreply.github.com> Date: Fri, 4 Oct 2024 10:20:26 +0900 Subject: [PATCH 19/30] Update --- src/locale/locales/ja/messages.po | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/src/locale/locales/ja/messages.po b/src/locale/locales/ja/messages.po index 0948345dba..dd3ff6fa30 100644 --- a/src/locale/locales/ja/messages.po +++ b/src/locale/locales/ja/messages.po @@ -234,6 +234,10 @@ msgstr "<0>{0}はあなたのスターターパックに含まれていま msgid "<0>{0} members" msgstr "<0>{0}のメンバー" +#: src/components/dms/DateDivider.tsx:69 +msgid "<0>{date} at {time}" +msgstr "<0>{date} {time}" + #: src/view/com/modals/SelfLabel.tsx:135 msgid "<0>Not Applicable. This warning is only available for posts with media attached." msgstr "<0>適用できません。 この警告はメディアが添付された投稿にのみ利用可能です。" @@ -6684,6 +6688,10 @@ msgstr "Blueskyにビデオをアップロードするには、まずメール msgid "To whom would you like to send this report?" msgstr "この報告を誰に送りたいですか?" +#: src/components/dms/DateDivider.tsx:44 +msgid "Today" +msgstr "今日" + #: src/view/com/util/forms/DropdownButton.tsx:255 msgid "Toggle dropdown" msgstr "ドロップダウンを切り替え" @@ -7442,9 +7450,9 @@ msgstr "はい、非表示にします" msgid "Yes, reactivate my account" msgstr "はい、アカウントを再有効化します" -#: src/components/dms/MessageItem.tsx:183 -msgid "Yesterday, {time}" -msgstr "昨日、{time}" +#: src/components/dms/DateDivider.tsx:46 +msgid "Yesterday" +msgstr "昨日" #: src/components/StarterPack/StarterPackCard.tsx:76 #: src/screens/List/ListHiddenScreen.tsx:140 From e84c8603a42c4dcb982d19962e5a78a9d426b72e Mon Sep 17 00:00:00 2001 From: Takayuki KUSANO <65759+tkusano@users.noreply.github.com> Date: Fri, 4 Oct 2024 10:27:52 +0900 Subject: [PATCH 20/30] Revert "Update" This reverts commit b073c0483016c9cddd13a66ec5c7678b28aadc7b. --- src/locale/locales/ja/messages.po | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/src/locale/locales/ja/messages.po b/src/locale/locales/ja/messages.po index dd3ff6fa30..0948345dba 100644 --- a/src/locale/locales/ja/messages.po +++ b/src/locale/locales/ja/messages.po @@ -234,10 +234,6 @@ msgstr "<0>{0}はあなたのスターターパックに含まれていま msgid "<0>{0} members" msgstr "<0>{0}のメンバー" -#: src/components/dms/DateDivider.tsx:69 -msgid "<0>{date} at {time}" -msgstr "<0>{date} {time}" - #: src/view/com/modals/SelfLabel.tsx:135 msgid "<0>Not Applicable. This warning is only available for posts with media attached." msgstr "<0>適用できません。 この警告はメディアが添付された投稿にのみ利用可能です。" @@ -6688,10 +6684,6 @@ msgstr "Blueskyにビデオをアップロードするには、まずメール msgid "To whom would you like to send this report?" msgstr "この報告を誰に送りたいですか?" -#: src/components/dms/DateDivider.tsx:44 -msgid "Today" -msgstr "今日" - #: src/view/com/util/forms/DropdownButton.tsx:255 msgid "Toggle dropdown" msgstr "ドロップダウンを切り替え" @@ -7450,9 +7442,9 @@ msgstr "はい、非表示にします" msgid "Yes, reactivate my account" msgstr "はい、アカウントを再有効化します" -#: src/components/dms/DateDivider.tsx:46 -msgid "Yesterday" -msgstr "昨日" +#: src/components/dms/MessageItem.tsx:183 +msgid "Yesterday, {time}" +msgstr "昨日、{time}" #: src/components/StarterPack/StarterPackCard.tsx:76 #: src/screens/List/ListHiddenScreen.tsx:140 From e928cd16956a3adba6393a1d226de295426c2409 Mon Sep 17 00:00:00 2001 From: Takayuki KUSANO <65759+tkusano@users.noreply.github.com> Date: Fri, 4 Oct 2024 10:28:05 +0900 Subject: [PATCH 21/30] Revert "Fix profile header buttons (#5558)" This reverts commit c00c5fa495460e54551ab7325a5f79d94dd192a7. --- src/components/Button.tsx | 10 ++--- src/components/dms/MessageProfileButton.tsx | 26 ++++++++----- .../Profile/Header/ProfileHeaderLabeler.tsx | 14 +++---- .../Profile/Header/ProfileHeaderStandard.tsx | 9 ++--- src/screens/Profile/Header/index.tsx | 7 ++-- src/view/com/profile/ProfileMenu.tsx | 39 ++++++++++++------- 6 files changed, 63 insertions(+), 42 deletions(-) diff --git a/src/components/Button.tsx b/src/components/Button.tsx index 1c14b48c75..17179994a9 100644 --- a/src/components/Button.tsx +++ b/src/components/Button.tsx @@ -14,7 +14,7 @@ import { } from 'react-native' import {LinearGradient} from 'expo-linear-gradient' -import {atoms as a, flatten, select, tokens, useTheme} from '#/alf' +import {atoms as a, flatten, select, tokens, useTheme, web} from '#/alf' import {Props as SVGIconProps} from '#/components/icons/common' import {Text} from '#/components/Typography' @@ -352,7 +352,7 @@ export const Button = React.forwardRef( }) } else if (size === 'small') { baseStyles.push({ - paddingVertical: 9, + paddingVertical: 8, paddingHorizontal: 12, borderRadius: 6, gap: 6, @@ -374,7 +374,7 @@ export const Button = React.forwardRef( } } else if (size === 'small') { if (shape === 'round') { - baseStyles.push({height: 34, width: 34}) + baseStyles.push({height: 36, width: 36}) } else { baseStyles.push({height: 34, width: 34}) } @@ -627,9 +627,9 @@ export function useSharedButtonTextStyles() { } if (size === 'large') { - baseStyles.push(a.text_md, a.leading_tight) + baseStyles.push(a.text_md, a.leading_tight, web({top: -0.4})) } else if (size === 'small') { - baseStyles.push(a.text_sm, a.leading_tight) + baseStyles.push(a.text_sm, a.leading_tight, web({top: -0.4})) } else if (size === 'tiny') { baseStyles.push(a.text_xs, a.leading_tight) } diff --git a/src/components/dms/MessageProfileButton.tsx b/src/components/dms/MessageProfileButton.tsx index 932982d05e..7f440d621f 100644 --- a/src/components/dms/MessageProfileButton.tsx +++ b/src/components/dms/MessageProfileButton.tsx @@ -4,13 +4,12 @@ import {AppBskyActorDefs} from '@atproto/api' import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' -import {logEvent} from '#/lib/statsig/statsig' import {useMaybeConvoForUser} from '#/state/queries/messages/get-convo-for-members' +import {logEvent} from 'lib/statsig/statsig' import {atoms as a, useTheme} from '#/alf' -import {ButtonIcon} from '#/components/Button' -import {canBeMessaged} from '#/components/dms/util' -import {Message_Stroke2_Corner0_Rounded as Message} from '#/components/icons/Message' -import {Link} from '#/components/Link' +import {Message_Stroke2_Corner0_Rounded as Message} from '../icons/Message' +import {Link} from '../Link' +import {canBeMessaged} from './util' export function MessageProfileButton({ profile, @@ -41,9 +40,15 @@ export function MessageProfileButton({ a.align_center, t.atoms.bg_contrast_25, a.rounded_full, - {width: 34, height: 34}, + {width: 36, height: 36}, ]}> - + ) } else { @@ -61,9 +66,12 @@ export function MessageProfileButton({ shape="round" label={_(msg`Message ${profile.handle}`)} to={`/messages/${convo.id}`} - style={[a.justify_center]} + style={[a.justify_center, {width: 36, height: 36}]} onPress={onPress}> - + ) } else { diff --git a/src/screens/Profile/Header/ProfileHeaderLabeler.tsx b/src/screens/Profile/Header/ProfileHeaderLabeler.tsx index 8c95413a8c..7b44e58693 100644 --- a/src/screens/Profile/Header/ProfileHeaderLabeler.tsx +++ b/src/screens/Profile/Header/ProfileHeaderLabeler.tsx @@ -25,7 +25,7 @@ import {usePreferencesQuery} from '#/state/queries/preferences' import {useRequireAuth, useSession} from '#/state/session' import {ProfileMenu} from '#/view/com/profile/ProfileMenu' import * as Toast from '#/view/com/util/Toast' -import {atoms as a, tokens, useTheme} from '#/alf' +import {atoms as a, tokens, useBreakpoints, useTheme} from '#/alf' import {Button, ButtonText} from '#/components/Button' import {DialogOuterProps} from '#/components/Dialog' import { @@ -61,6 +61,7 @@ let ProfileHeaderLabeler = ({ const profile: Shadow = useProfileShadow(profileUnshadowed) const t = useTheme() + const {gtMobile} = useBreakpoints() const {_} = useLingui() const {currentAccount, hasSession} = useSession() const {openModal} = useModalControls() @@ -166,7 +167,7 @@ let ProfileHeaderLabeler = ({ style={[a.px_lg, a.pt_md, a.pb_sm]} pointerEvents={isIOS ? 'auto' : 'box-none'}> {isMe ? ( + style={[ + a.rounded_full, + a.justify_center, + a.align_center, + {width: 36, height: 36}, + alf.atoms.bg_contrast_25, + (state.hovered || state.pressed) && [ + alf.atoms.bg_contrast_50, + ], + ]}> + + ) }} From a36b7817db5fc11918fb3c71fd3c2e052d2c6e17 Mon Sep 17 00:00:00 2001 From: Takayuki KUSANO <65759+tkusano@users.noreply.github.com> Date: Fri, 4 Oct 2024 10:28:38 +0900 Subject: [PATCH 22/30] =?UTF-8?q?Revert=20"[=F0=9F=90=B4]=20Reduce=20amoun?= =?UTF-8?q?t=20that=20message=20sent=20date=20is=20shown=20(#4228)"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit af8e3422a6cb2131f5386fbdc94e9b158e5562b6. --- src/components/dms/DateDivider.tsx | 80 ------------- src/components/dms/MessageItem.tsx | 178 ++++++++++++++-------------- src/components/dms/ReportDialog.tsx | 1 - src/components/dms/util.ts | 9 -- src/state/messages/convo/agent.ts | 23 +--- src/state/messages/convo/types.ts | 12 -- 6 files changed, 94 insertions(+), 209 deletions(-) delete mode 100644 src/components/dms/DateDivider.tsx diff --git a/src/components/dms/DateDivider.tsx b/src/components/dms/DateDivider.tsx deleted file mode 100644 index a9c82e8ea2..0000000000 --- a/src/components/dms/DateDivider.tsx +++ /dev/null @@ -1,80 +0,0 @@ -import React from 'react' -import {View} from 'react-native' -import {msg, Trans} from '@lingui/macro' -import {useLingui} from '@lingui/react' -import {subDays} from 'date-fns' - -import {atoms as a, useTheme} from '#/alf' -import {Text} from '../Typography' -import {localDateString} from './util' - -const timeFormatter = new Intl.DateTimeFormat(undefined, { - hour: 'numeric', - minute: 'numeric', -}) -const weekdayFormatter = new Intl.DateTimeFormat(undefined, { - weekday: 'long', -}) -const longDateFormatter = new Intl.DateTimeFormat(undefined, { - weekday: 'short', - month: 'long', - day: 'numeric', -}) -const longDateFormatterWithYear = new Intl.DateTimeFormat(undefined, { - weekday: 'short', - month: 'long', - day: 'numeric', - year: 'numeric', -}) - -let DateDivider = ({date: dateStr}: {date: string}): React.ReactNode => { - const {_} = useLingui() - const t = useTheme() - - let date: string - const time = timeFormatter.format(new Date(dateStr)) - - const timestamp = new Date(dateStr) - - const today = new Date() - const yesterday = subDays(today, 1) - const oneWeekAgo = subDays(today, 7) - - if (localDateString(today) === localDateString(timestamp)) { - date = _(msg`Today`) - } else if (localDateString(yesterday) === localDateString(timestamp)) { - date = _(msg`Yesterday`) - } else { - if (timestamp < oneWeekAgo) { - if (timestamp.getFullYear() === today.getFullYear()) { - date = longDateFormatter.format(timestamp) - } else { - date = longDateFormatterWithYear.format(timestamp) - } - } else { - date = weekdayFormatter.format(timestamp) - } - } - - return ( - - - - - {date} - {' '} - at {time} - - - - ) -} -DateDivider = React.memo(DateDivider) -export {DateDivider} diff --git a/src/components/dms/MessageItem.tsx b/src/components/dms/MessageItem.tsx index 52220e2cac..c5c472cf08 100644 --- a/src/components/dms/MessageItem.tsx +++ b/src/components/dms/MessageItem.tsx @@ -17,15 +17,13 @@ import {useLingui} from '@lingui/react' import {ConvoItem} from '#/state/messages/convo/types' import {useSession} from '#/state/session' -import {TimeElapsed} from '#/view/com/util/TimeElapsed' +import {TimeElapsed} from 'view/com/util/TimeElapsed' import {atoms as a, useTheme} from '#/alf' import {ActionsWrapper} from '#/components/dms/ActionsWrapper' import {InlineLinkText} from '#/components/Link' import {Text} from '#/components/Typography' import {isOnlyEmoji, RichText} from '../RichText' -import {DateDivider} from './DateDivider' import {MessageItemEmbed} from './MessageItemEmbed' -import {localDateString} from './util' let MessageItem = ({ item, @@ -35,37 +33,14 @@ let MessageItem = ({ const t = useTheme() const {currentAccount} = useSession() - const {message, nextMessage, prevMessage} = item + const {message, nextMessage} = item const isPending = item.type === 'pending-message' const isFromSelf = message.sender?.did === currentAccount?.did - const nextIsMessage = ChatBskyConvoDefs.isMessageView(nextMessage) - const isNextFromSelf = - nextIsMessage && nextMessage.sender?.did === currentAccount?.did - - const isNextFromSameSender = isNextFromSelf === isFromSelf - - const isNewDay = useMemo(() => { - if (!prevMessage) return true - - const thisDate = new Date(message.sentAt) - const prevDate = new Date(prevMessage.sentAt) - - return localDateString(thisDate) !== localDateString(prevDate) - }, [message, prevMessage]) - - const isLastMessageOfDay = useMemo(() => { - if (!nextMessage || !nextIsMessage) return true - - const thisDate = new Date(message.sentAt) - const prevDate = new Date(nextMessage.sentAt) - - return localDateString(thisDate) !== localDateString(prevDate) - }, [message.sentAt, nextIsMessage, nextMessage]) - - const needsTail = isLastMessageOfDay || !isNextFromSameSender + ChatBskyConvoDefs.isMessageView(nextMessage) && + nextMessage.sender?.did === currentAccount?.did const isLastInGroup = useMemo(() => { // if this message is pending, it means the next message is pending too @@ -73,19 +48,24 @@ let MessageItem = ({ return false } - // or, if there's a 5 minute gap between this message and the next + // if the next message is from a different sender, then it's the last in the group + if (isFromSelf ? !isNextFromSelf : isNextFromSelf) { + return true + } + + // or, if there's a 3 minute gap between this message and the next if (ChatBskyConvoDefs.isMessageView(nextMessage)) { const thisDate = new Date(message.sentAt) const nextDate = new Date(nextMessage.sentAt) const diff = nextDate.getTime() - thisDate.getTime() - // 5 minutes - return diff > 5 * 60 * 1000 + // 3 minutes + return diff > 3 * 60 * 1000 } return true - }, [message, nextMessage, isPending]) + }, [message, nextMessage, isFromSelf, isNextFromSelf, isPending]) const lastInGroupRef = useRef(isLastInGroup) if (lastInGroupRef.current !== isLastInGroup) { @@ -100,59 +80,52 @@ let MessageItem = ({ }, [message.text, message.facets]) return ( - <> - {isNewDay && } - - - {AppBskyEmbedRecord.isView(message.embed) && ( - - )} - {rt.text.length > 0 && ( - - - - )} - - - {isLastInGroup && ( - + + + {AppBskyEmbedRecord.isView(message.embed) && ( + )} - - + {rt.text.length > 0 && ( + + + + )} + + + {isLastInGroup && ( + + )} + ) } MessageItem = React.memo(MessageItem) @@ -192,12 +165,31 @@ let MessageItemMetadata = ({ const diff = now.getTime() - date.getTime() - // if under 30 seconds - if (diff < 1000 * 30) { + // if under 1 minute + if (diff < 1000 * 60) { return _(msg`Now`) } - return time + // if in the last day + if (localDateString(now) === localDateString(date)) { + return time + } + + // if yesterday + const yesterday = new Date(now) + yesterday.setDate(yesterday.getDate() - 1) + + if (localDateString(yesterday) === localDateString(date)) { + return _(msg`Yesterday, ${time}`) + } + + return i18n.date(date, { + hour: 'numeric', + minute: 'numeric', + day: 'numeric', + month: 'numeric', + year: 'numeric', + }) }, [_], ) @@ -250,5 +242,15 @@ let MessageItemMetadata = ({ ) } + MessageItemMetadata = React.memo(MessageItemMetadata) export {MessageItemMetadata} + +function localDateString(date: Date) { + // can't use toISOString because it should be in local time + const mm = date.getMonth() + const dd = date.getDate() + const yyyy = date.getFullYear() + // not padding with 0s because it's not necessary, it's just used for comparison + return `${yyyy}-${mm}-${dd}` +} diff --git a/src/components/dms/ReportDialog.tsx b/src/components/dms/ReportDialog.tsx index 2dcd778545..5493a1c87f 100644 --- a/src/components/dms/ReportDialog.tsx +++ b/src/components/dms/ReportDialog.tsx @@ -277,7 +277,6 @@ function PreviewMessage({message}: {message: ChatBskyConvoDefs.MessageView}) { message, key: '', nextMessage: null, - prevMessage: null, }} style={[a.text_left, a.mb_0]} /> diff --git a/src/components/dms/util.ts b/src/components/dms/util.ts index 003532d0c6..5952b9acf4 100644 --- a/src/components/dms/util.ts +++ b/src/components/dms/util.ts @@ -16,12 +16,3 @@ export function canBeMessaged(profile: AppBskyActorDefs.ProfileView) { return false } } - -export function localDateString(date: Date) { - // can't use toISOString because it should be in local time - const mm = date.getMonth() - const dd = date.getDate() - const yyyy = date.getFullYear() - // not padding with 0s because it's not necessary, it's just used for comparison - return `${yyyy}-${mm}-${dd}` -} diff --git a/src/state/messages/convo/agent.ts b/src/state/messages/convo/agent.ts index 53d77046a2..de2605b5ad 100644 --- a/src/state/messages/convo/agent.ts +++ b/src/state/messages/convo/agent.ts @@ -972,7 +972,6 @@ export class Convo { key: m.id, message: m, nextMessage: null, - prevMessage: null, }) } else if (ChatBskyConvoDefs.isDeletedMessageView(m)) { items.unshift({ @@ -980,7 +979,6 @@ export class Convo { key: m.id, message: m, nextMessage: null, - prevMessage: null, }) } }) @@ -1003,7 +1001,6 @@ export class Convo { key: m.id, message: m, nextMessage: null, - prevMessage: null, }) } else if (ChatBskyConvoDefs.isDeletedMessageView(m)) { items.push({ @@ -1011,7 +1008,6 @@ export class Convo { key: m.id, message: m, nextMessage: null, - prevMessage: null, }) } }) @@ -1034,7 +1030,6 @@ export class Convo { sender: this.sender!, }, nextMessage: null, - prevMessage: null, failed: this.pendingMessageFailure !== null, retry: this.pendingMessageFailure === 'recoverable' @@ -1065,39 +1060,29 @@ export class Convo { }) .map((item, i, arr) => { let nextMessage = null - let prevMessage = null const isMessage = isConvoItemMessage(item) if (isMessage) { if ( - ChatBskyConvoDefs.isMessageView(item.message) || - ChatBskyConvoDefs.isDeletedMessageView(item.message) + isMessage && + (ChatBskyConvoDefs.isMessageView(item.message) || + ChatBskyConvoDefs.isDeletedMessageView(item.message)) ) { const next = arr[i + 1] if ( isConvoItemMessage(next) && + next && (ChatBskyConvoDefs.isMessageView(next.message) || ChatBskyConvoDefs.isDeletedMessageView(next.message)) ) { nextMessage = next.message } - - const prev = arr[i - 1] - - if ( - isConvoItemMessage(prev) && - (ChatBskyConvoDefs.isMessageView(prev.message) || - ChatBskyConvoDefs.isDeletedMessageView(prev.message)) - ) { - prevMessage = prev.message - } } return { ...item, nextMessage, - prevMessage, } } diff --git a/src/state/messages/convo/types.ts b/src/state/messages/convo/types.ts index 21772262ea..53e205e211 100644 --- a/src/state/messages/convo/types.ts +++ b/src/state/messages/convo/types.ts @@ -87,10 +87,6 @@ export type ConvoItem = | ChatBskyConvoDefs.MessageView | ChatBskyConvoDefs.DeletedMessageView | null - prevMessage: - | ChatBskyConvoDefs.MessageView - | ChatBskyConvoDefs.DeletedMessageView - | null } | { type: 'pending-message' @@ -100,10 +96,6 @@ export type ConvoItem = | ChatBskyConvoDefs.MessageView | ChatBskyConvoDefs.DeletedMessageView | null - prevMessage: - | ChatBskyConvoDefs.MessageView - | ChatBskyConvoDefs.DeletedMessageView - | null failed: boolean /** * Retry sending the message. If present, the message is in a failed state. @@ -118,10 +110,6 @@ export type ConvoItem = | ChatBskyConvoDefs.MessageView | ChatBskyConvoDefs.DeletedMessageView | null - prevMessage: - | ChatBskyConvoDefs.MessageView - | ChatBskyConvoDefs.DeletedMessageView - | null } | { type: 'error' From aff2332513cdee05edcf955dd603ad189009f5f1 Mon Sep 17 00:00:00 2001 From: Takayuki KUSANO <65759+tkusano@users.noreply.github.com> Date: Fri, 4 Oct 2024 10:29:48 +0900 Subject: [PATCH 23/30] =?UTF-8?q?Revert=20"=F0=9F=AA=B5=F0=9F=93=8C=20(#55?= =?UTF-8?q?94)"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 3ab5190aca767b9ed1900a84eab538f41000526c. --- src/lib/statsig/events.ts | 2 -- src/view/com/util/forms/PostDropdownBtn.tsx | 2 -- 2 files changed, 4 deletions(-) diff --git a/src/lib/statsig/events.ts b/src/lib/statsig/events.ts index 9a306ee4f4..c9bc8fefb2 100644 --- a/src/lib/statsig/events.ts +++ b/src/lib/statsig/events.ts @@ -145,8 +145,6 @@ export type LogEvents = { } 'post:mute': {} 'post:unmute': {} - 'post:pin': {} - 'post:unpin': {} 'profile:follow:sampled': { didBecomeMutual: boolean | undefined followeeClout: number | undefined diff --git a/src/view/com/util/forms/PostDropdownBtn.tsx b/src/view/com/util/forms/PostDropdownBtn.tsx index 33287564a7..fe6efc02fa 100644 --- a/src/view/com/util/forms/PostDropdownBtn.tsx +++ b/src/view/com/util/forms/PostDropdownBtn.tsx @@ -22,7 +22,6 @@ import {getCurrentRoute} from '#/lib/routes/helpers' import {makeProfileLink} from '#/lib/routes/links' import {CommonNavigatorParams, NavigationProp} from '#/lib/routes/types' import {shareUrl} from '#/lib/sharing' -import {logEvent} from '#/lib/statsig/statsig' import {richTextToString} from '#/lib/strings/rich-text-helpers' import {toShareUrl} from '#/lib/strings/url-helpers' import {useTheme} from '#/lib/ThemeContext' @@ -351,7 +350,6 @@ let PostDropdownBtn = ({ ]) const onPressPin = useCallback(() => { - logEvent(isPinned ? 'post:unpin' : 'post:pin', {}) pinPostMutate({ postUri, postCid, From 30b80116cf46bdabcf81c7567617710552e6b904 Mon Sep 17 00:00:00 2001 From: Takayuki KUSANO <65759+tkusano@users.noreply.github.com> Date: Fri, 4 Oct 2024 10:29:51 +0900 Subject: [PATCH 24/30] Revert "Rename some files and variables (#5587)" This reverts commit 475708ea30dd367c5d79f524bec907a356b6155a. --- src/lib/api/index.ts | 2 +- src/lib/media/video/compress.ts | 2 +- .../media => state/queries}/video/util.ts | 0 .../queries/video/video-upload.shared.ts} | 0 .../queries/video/video-upload.ts} | 4 +- .../queries/video/video-upload.web.ts} | 4 +- .../state => state/queries/video}/video.ts | 4 +- src/view/com/composer/Composer.tsx | 120 ++++++++++-------- src/view/com/composer/photos/Gallery.tsx | 2 +- .../composer/{state/composer.ts => state.ts} | 7 +- 10 files changed, 81 insertions(+), 64 deletions(-) rename src/{lib/media => state/queries}/video/util.ts (100%) rename src/{lib/media/video/upload.shared.ts => state/queries/video/video-upload.shared.ts} (100%) rename src/{lib/media/video/upload.ts => state/queries/video/video-upload.ts} (92%) rename src/{lib/media/video/upload.web.ts => state/queries/video/video-upload.web.ts} (93%) rename src/{view/com/composer/state => state/queries/video}/video.ts (98%) rename src/view/com/composer/{state/composer.ts => state.ts} (97%) diff --git a/src/lib/api/index.ts b/src/lib/api/index.ts index 8b79250042..51bf51ffff 100644 --- a/src/lib/api/index.ts +++ b/src/lib/api/index.ts @@ -24,7 +24,7 @@ import { threadgateAllowUISettingToAllowRecordValue, writeThreadgateRecord, } from '#/state/queries/threadgate' -import {ComposerState} from '#/view/com/composer/state/composer' +import {ComposerState} from '#/view/com/composer/state' import {LinkMeta} from '../link-meta/link-meta' import {uploadBlob} from './upload-blob' diff --git a/src/lib/media/video/compress.ts b/src/lib/media/video/compress.ts index c2d1470c63..dec9032a34 100644 --- a/src/lib/media/video/compress.ts +++ b/src/lib/media/video/compress.ts @@ -2,8 +2,8 @@ import {getVideoMetaData, Video} from 'react-native-compressor' import {ImagePickerAsset} from 'expo-image-picker' import {SUPPORTED_MIME_TYPES, SupportedMimeTypes} from '#/lib/constants' +import {extToMime} from '#/state/queries/video/util' import {CompressedVideo} from './types' -import {extToMime} from './util' const MIN_SIZE_FOR_COMPRESSION = 25 // 25mb diff --git a/src/lib/media/video/util.ts b/src/state/queries/video/util.ts similarity index 100% rename from src/lib/media/video/util.ts rename to src/state/queries/video/util.ts diff --git a/src/lib/media/video/upload.shared.ts b/src/state/queries/video/video-upload.shared.ts similarity index 100% rename from src/lib/media/video/upload.shared.ts rename to src/state/queries/video/video-upload.shared.ts diff --git a/src/lib/media/video/upload.ts b/src/state/queries/video/video-upload.ts similarity index 92% rename from src/lib/media/video/upload.ts rename to src/state/queries/video/video-upload.ts index 3330370b3e..46f24a58b1 100644 --- a/src/lib/media/video/upload.ts +++ b/src/state/queries/video/video-upload.ts @@ -7,8 +7,8 @@ import {nanoid} from 'nanoid/non-secure' import {AbortError} from '#/lib/async/cancelable' import {ServerError} from '#/lib/media/video/errors' import {CompressedVideo} from '#/lib/media/video/types' -import {createVideoEndpointUrl, mimeToExt} from './util' -import {getServiceAuthToken, getVideoUploadLimits} from './upload.shared' +import {createVideoEndpointUrl, mimeToExt} from '#/state/queries/video/util' +import {getServiceAuthToken, getVideoUploadLimits} from './video-upload.shared' export async function uploadVideo({ video, diff --git a/src/lib/media/video/upload.web.ts b/src/state/queries/video/video-upload.web.ts similarity index 93% rename from src/lib/media/video/upload.web.ts rename to src/state/queries/video/video-upload.web.ts index ec65f96c97..bbae641999 100644 --- a/src/lib/media/video/upload.web.ts +++ b/src/state/queries/video/video-upload.web.ts @@ -7,8 +7,8 @@ import {nanoid} from 'nanoid/non-secure' import {AbortError} from '#/lib/async/cancelable' import {ServerError} from '#/lib/media/video/errors' import {CompressedVideo} from '#/lib/media/video/types' -import {createVideoEndpointUrl, mimeToExt} from './util' -import {getServiceAuthToken, getVideoUploadLimits} from './upload.shared' +import {createVideoEndpointUrl, mimeToExt} from '#/state/queries/video/util' +import {getServiceAuthToken, getVideoUploadLimits} from './video-upload.shared' export async function uploadVideo({ video, diff --git a/src/view/com/composer/state/video.ts b/src/state/queries/video/video.ts similarity index 98% rename from src/view/com/composer/state/video.ts rename to src/state/queries/video/video.ts index 2695056579..dbbb6c2026 100644 --- a/src/view/com/composer/state/video.ts +++ b/src/state/queries/video/video.ts @@ -4,8 +4,6 @@ import {JobStatus} from '@atproto/api/dist/client/types/app/bsky/video/defs' import {I18n} from '@lingui/core' import {msg} from '@lingui/macro' -import {createVideoAgent} from '#/lib/media/video/util' -import {uploadVideo} from '#/lib/media/video/upload' import {AbortError} from '#/lib/async/cancelable' import {compressVideo} from '#/lib/media/video/compress' import { @@ -15,6 +13,8 @@ import { } from '#/lib/media/video/errors' import {CompressedVideo} from '#/lib/media/video/types' import {logger} from '#/logger' +import {createVideoAgent} from '#/state/queries/video/util' +import {uploadVideo} from '#/state/queries/video/video-upload' export type VideoAction = | { diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx index f4e290ca8d..59aae29516 100644 --- a/src/view/com/composer/Composer.tsx +++ b/src/view/com/composer/Composer.tsx @@ -82,6 +82,13 @@ import {useProfileQuery} from '#/state/queries/profile' import {Gif} from '#/state/queries/tenor' import {ThreadgateAllowUISetting} from '#/state/queries/threadgate' import {threadgateViewToAllowUISetting} from '#/state/queries/threadgate/util' +import {NO_VIDEO, NoVideoState} from '#/state/queries/video/video' +import { + processVideo, + VideoAction, + VideoState, + VideoState as VideoUploadState, +} from '#/state/queries/video/video' import {useAgent, useSession} from '#/state/session' import {useComposerControls} from '#/state/shell/composer' import {ComposerOpts} from '#/state/shell/composer' @@ -116,8 +123,7 @@ import {EmojiArc_Stroke2_Corner0_Rounded as EmojiSmile} from '#/components/icons import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times' import * as Prompt from '#/components/Prompt' import {Text as NewText} from '#/components/Typography' -import {composerReducer, createComposerState} from './state/composer' -import {NO_VIDEO, NoVideoState, processVideo, VideoState} from './state/video' +import {composerReducer, createComposerState} from './state' const MAX_IMAGES = 4 @@ -194,10 +200,16 @@ export const ComposePost = ({ createComposerState, ) - let videoState: VideoState | NoVideoState = NO_VIDEO + let videoUploadState: VideoState | NoVideoState = NO_VIDEO if (composerState.embed.media?.type === 'video') { - videoState = composerState.embed.media.video + videoUploadState = composerState.embed.media.video } + const videoDispatch = useCallback( + (videoAction: VideoAction) => { + dispatch({type: 'embed_update_video', videoAction}) + }, + [dispatch], + ) const selectVideo = React.useCallback( (asset: ImagePickerAsset) => { @@ -205,14 +217,14 @@ export const ComposePost = ({ dispatch({type: 'embed_add_video', asset, abortController}) processVideo( asset, - videoAction => dispatch({type: 'embed_update_video', videoAction}), + videoDispatch, agent, currentDid, abortController.signal, _, ) }, - [_, agent, currentDid], + [_, videoDispatch, agent, currentDid], ) // Whenever we receive an initial video uri, we should immediately run compression if necessary @@ -223,26 +235,23 @@ export const ComposePost = ({ }, [initVideoUri, selectVideo]) const clearVideo = React.useCallback(() => { - videoState.abortController.abort() + videoUploadState.abortController.abort() dispatch({type: 'embed_remove_video'}) - }, [videoState.abortController, dispatch]) + }, [videoUploadState.abortController, dispatch]) const updateVideoDimensions = useCallback( (width: number, height: number) => { - dispatch({ - type: 'embed_update_video', - videoAction: { - type: 'update_dimensions', - width, - height, - signal: videoState.abortController.signal, - }, + videoDispatch({ + type: 'update_dimensions', + width, + height, + signal: videoUploadState.abortController.signal, }) }, - [videoState.abortController], + [videoUploadState.abortController, videoDispatch], ) - const hasVideo = Boolean(videoState.asset || videoState.video) + const hasVideo = Boolean(videoUploadState.asset || videoUploadState.video) const [publishOnUpload, setPublishOnUpload] = useState(false) @@ -279,7 +288,7 @@ export const ComposePost = ({ graphemeLength > 0 || images.length !== 0 || extGif || - videoState.status !== 'idle' + videoUploadState.status !== 'idle' ) { closeAllDialogs() Keyboard.dismiss() @@ -294,7 +303,7 @@ export const ComposePost = ({ closeAllDialogs, discardPromptControl, onClose, - videoState.status, + videoUploadState.status, ]) useImperativeHandle(cancelRef, () => ({onPressCancel})) @@ -391,8 +400,8 @@ export const ComposePost = ({ if ( !finishedUploading && - videoState.asset && - videoState.status !== 'done' + videoUploadState.asset && + videoUploadState.status !== 'done' ) { setPublishOnUpload(true) return @@ -405,7 +414,7 @@ export const ComposePost = ({ images.length === 0 && !extLink && !quote && - videoState.status === 'idle' + videoUploadState.status === 'idle' ) { setError(_(msg`Did you want to say anything?`)) return @@ -433,14 +442,14 @@ export const ComposePost = ({ onStateChange: setProcessingState, langs: toPostLanguages(langPrefs.postLanguage), video: - videoState.status === 'done' + videoUploadState.status === 'done' ? { - blobRef: videoState.pendingPublish.blobRef, + blobRef: videoUploadState.pendingPublish.blobRef, altText: videoAltText, captions: captions, aspectRatio: { - width: videoState.asset.width, - height: videoState.asset.height, + width: videoUploadState.asset.width, + height: videoUploadState.asset.height, }, } : undefined, @@ -541,20 +550,20 @@ export const ComposePost = ({ setLangPrefs, threadgateAllowUISettings, videoAltText, - videoState.asset, - videoState.pendingPublish, - videoState.status, + videoUploadState.asset, + videoUploadState.pendingPublish, + videoUploadState.status, ], ) React.useEffect(() => { - if (videoState.pendingPublish && publishOnUpload) { - if (!videoState.pendingPublish.mutableProcessed) { - videoState.pendingPublish.mutableProcessed = true + if (videoUploadState.pendingPublish && publishOnUpload) { + if (!videoUploadState.pendingPublish.mutableProcessed) { + videoUploadState.pendingPublish.mutableProcessed = true onPressPublish(true) } } - }, [onPressPublish, publishOnUpload, videoState.pendingPublish]) + }, [onPressPublish, publishOnUpload, videoUploadState.pendingPublish]) const canPost = useMemo( () => graphemeLength <= MAX_GRAPHEME_LENGTH && !isAltTextRequiredAndMissing, @@ -567,10 +576,10 @@ export const ComposePost = ({ const canSelectImages = images.length < MAX_IMAGES && !extLink && - videoState.status === 'idle' && - !videoState.video + videoUploadState.status === 'idle' && + !videoUploadState.video const hasMedia = - images.length > 0 || Boolean(extLink) || Boolean(videoState.video) + images.length > 0 || Boolean(extLink) || Boolean(videoUploadState.video) const onEmojiButtonPress = useCallback(() => { openEmojiPicker?.(textInput.current?.getCursorPosition()) @@ -685,7 +694,9 @@ export const ComposePost = ({ size="small" style={[a.rounded_full, a.py_sm]} onPress={() => onPressPublish()} - disabled={videoState.status !== 'idle' && publishOnUpload}> + disabled={ + videoUploadState.status !== 'idle' && publishOnUpload + }> {replyTo ? ( Reply @@ -721,7 +732,7 @@ export const ComposePost = ({ )} setError('')} clearVideo={clearVideo} /> @@ -787,17 +798,17 @@ export const ComposePost = ({ style={[a.w_full, a.mt_lg]} entering={native(ZoomIn)} exiting={native(ZoomOut)}> - {videoState.asset && - (videoState.status === 'compressing' ? ( + {videoUploadState.asset && + (videoUploadState.status === 'compressing' ? ( - ) : videoState.video ? ( + ) : videoUploadState.video ? ( @@ -843,8 +854,9 @@ export const ComposePost = ({ t.atoms.border_contrast_medium, styles.bottomBar, ]}> - {videoState.status !== 'idle' && videoState.status !== 'done' ? ( - + {videoUploadState.status !== 'idle' && + videoUploadState.status !== 'done' ? ( + ) : ( void clearVideo: () => void }) { @@ -1122,7 +1134,7 @@ function ErrorBanner({ const {_} = useLingui() const videoError = - videoState.status === 'error' ? videoState.error : undefined + videoUploadState.status === 'error' ? videoUploadState.error : undefined const error = standardError || videoError const onClearError = () => { @@ -1164,7 +1176,7 @@ function ErrorBanner({ - {videoError && videoState.jobId && ( + {videoError && videoUploadState.jobId && ( - Job ID: {videoState.jobId} + Job ID: {videoUploadState.jobId} )} @@ -1199,7 +1211,7 @@ function ToolbarWrapper({ ) } -function VideoUploadToolbar({state}: {state: VideoState}) { +function VideoUploadToolbar({state}: {state: VideoUploadState}) { const t = useTheme() const {_} = useLingui() const progress = state.progress diff --git a/src/view/com/composer/photos/Gallery.tsx b/src/view/com/composer/photos/Gallery.tsx index 5ff7042bc1..5692f3d2c9 100644 --- a/src/view/com/composer/photos/Gallery.tsx +++ b/src/view/com/composer/photos/Gallery.tsx @@ -21,7 +21,7 @@ import {ComposerImage, cropImage} from '#/state/gallery' import {Text} from '#/view/com/util/text/Text' import {useTheme} from '#/alf' import * as Dialog from '#/components/Dialog' -import {ComposerAction} from '../state/composer' +import {ComposerAction} from '../state' import {EditImageDialog} from './EditImageDialog' import {ImageAltTextDialog} from './ImageAltTextDialog' diff --git a/src/view/com/composer/state/composer.ts b/src/view/com/composer/state.ts similarity index 97% rename from src/view/com/composer/state/composer.ts rename to src/view/com/composer/state.ts index a23a5d8c86..8e974ad7a5 100644 --- a/src/view/com/composer/state/composer.ts +++ b/src/view/com/composer/state.ts @@ -1,8 +1,13 @@ import {ImagePickerAsset} from 'expo-image-picker' import {ComposerImage, createInitialImages} from '#/state/gallery' +import { + createVideoState, + VideoAction, + videoReducer, + VideoState, +} from '#/state/queries/video/video' import {ComposerOpts} from '#/state/shell/composer' -import {createVideoState, VideoAction, videoReducer, VideoState} from './video' type PostRecord = { uri: string From 6e583392878efdd5693602599a9b187ad828c71b Mon Sep 17 00:00:00 2001 From: Takayuki KUSANO <65759+tkusano@users.noreply.github.com> Date: Fri, 4 Oct 2024 10:29:57 +0900 Subject: [PATCH 25/30] Revert "Manage video reducer from composer reducer (#5573)" This reverts commit 59589e34a375e15c3a802c3dd18170fde913c9cd. --- src/state/queries/video/video.ts | 67 +++++++++++++++----------- src/view/com/composer/Composer.tsx | 49 ++++++++----------- src/view/com/composer/state.ts | 75 +----------------------------- 3 files changed, 62 insertions(+), 129 deletions(-) diff --git a/src/state/queries/video/video.ts b/src/state/queries/video/video.ts index dbbb6c2026..fabee6ad1a 100644 --- a/src/state/queries/video/video.ts +++ b/src/state/queries/video/video.ts @@ -16,7 +16,13 @@ import {logger} from '#/logger' import {createVideoAgent} from '#/state/queries/video/util' import {uploadVideo} from '#/state/queries/video/video-upload' -export type VideoAction = +type Action = + | {type: 'to_idle'; nextController: AbortController} + | { + type: 'idle_to_compressing' + asset: ImagePickerAsset + signal: AbortSignal + } | { type: 'compressing_to_uploading' video: CompressedVideo @@ -46,20 +52,15 @@ export type VideoAction = signal: AbortSignal } -const noopController = new AbortController() -noopController.abort() - -export const NO_VIDEO = Object.freeze({ - status: 'idle', - progress: 0, - abortController: noopController, - asset: undefined, - video: undefined, - jobId: undefined, - pendingPublish: undefined, -}) - -export type NoVideoState = typeof NO_VIDEO +type IdleState = { + status: 'idle' + progress: 0 + abortController: AbortController + asset?: undefined + video?: undefined + jobId?: undefined + pendingPublish?: undefined +} type ErrorState = { status: 'error' @@ -113,7 +114,8 @@ type DoneState = { pendingPublish: {blobRef: BlobRef; mutableProcessed: boolean} } -export type VideoState = +export type State = + | IdleState | ErrorState | CompressingState | UploadingState @@ -121,21 +123,19 @@ export type VideoState = | DoneState export function createVideoState( - asset: ImagePickerAsset, - abortController: AbortController, -): CompressingState { + abortController: AbortController = new AbortController(), +): IdleState { return { - status: 'compressing', + status: 'idle', progress: 0, abortController, - asset, } } -export function videoReducer( - state: VideoState, - action: VideoAction, -): VideoState { +export function videoReducer(state: State, action: Action): State { + if (action.type === 'to_idle') { + return createVideoState(action.nextController) + } if (action.signal.aborted || action.signal !== state.abortController.signal) { // This action is stale and the process that spawned it is no longer relevant. return state @@ -157,6 +157,15 @@ export function videoReducer( progress: action.progress, } } + } else if (action.type === 'idle_to_compressing') { + if (state.status === 'idle') { + return { + status: 'compressing', + progress: 0, + abortController: state.abortController, + asset: action.asset, + } + } } else if (action.type === 'update_dimensions') { if (state.asset) { return { @@ -229,12 +238,18 @@ function trunc2dp(num: number) { export async function processVideo( asset: ImagePickerAsset, - dispatch: (action: VideoAction) => void, + dispatch: (action: Action) => void, agent: BskyAgent, did: string, signal: AbortSignal, _: I18n['_'], ) { + dispatch({ + type: 'idle_to_compressing', + asset, + signal, + }) + let video: CompressedVideo | undefined try { video = await compressVideo(asset, { diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx index 59aae29516..185a57fc35 100644 --- a/src/view/com/composer/Composer.tsx +++ b/src/view/com/composer/Composer.tsx @@ -82,12 +82,11 @@ import {useProfileQuery} from '#/state/queries/profile' import {Gif} from '#/state/queries/tenor' import {ThreadgateAllowUISetting} from '#/state/queries/threadgate' import {threadgateViewToAllowUISetting} from '#/state/queries/threadgate/util' -import {NO_VIDEO, NoVideoState} from '#/state/queries/video/video' import { + createVideoState, processVideo, - VideoAction, - VideoState, - VideoState as VideoUploadState, + State as VideoUploadState, + videoReducer, } from '#/state/queries/video/video' import {useAgent, useSession} from '#/state/session' import {useComposerControls} from '#/state/shell/composer' @@ -193,38 +192,24 @@ export const ComposePost = ({ const [videoAltText, setVideoAltText] = useState('') const [captions, setCaptions] = useState<{lang: string; file: File}[]>([]) - // TODO: Move more state here. - const [composerState, dispatch] = useReducer( - composerReducer, - {initImageUris}, - createComposerState, - ) - - let videoUploadState: VideoState | NoVideoState = NO_VIDEO - if (composerState.embed.media?.type === 'video') { - videoUploadState = composerState.embed.media.video - } - const videoDispatch = useCallback( - (videoAction: VideoAction) => { - dispatch({type: 'embed_update_video', videoAction}) - }, - [dispatch], + const [videoUploadState, videoDispatch] = useReducer( + videoReducer, + undefined, + createVideoState, ) const selectVideo = React.useCallback( (asset: ImagePickerAsset) => { - const abortController = new AbortController() - dispatch({type: 'embed_add_video', asset, abortController}) processVideo( asset, videoDispatch, agent, currentDid, - abortController.signal, + videoUploadState.abortController.signal, _, ) }, - [_, videoDispatch, agent, currentDid], + [_, videoUploadState.abortController, videoDispatch, agent, currentDid], ) // Whenever we receive an initial video uri, we should immediately run compression if necessary @@ -236,8 +221,8 @@ export const ComposePost = ({ const clearVideo = React.useCallback(() => { videoUploadState.abortController.abort() - dispatch({type: 'embed_remove_video'}) - }, [videoUploadState.abortController, dispatch]) + videoDispatch({type: 'to_idle', nextController: new AbortController()}) + }, [videoUploadState.abortController, videoDispatch]) const updateVideoDimensions = useCallback( (width: number, height: number) => { @@ -248,7 +233,7 @@ export const ComposePost = ({ signal: videoUploadState.abortController.signal, }) }, - [videoUploadState.abortController, videoDispatch], + [videoUploadState.abortController], ) const hasVideo = Boolean(videoUploadState.asset || videoUploadState.video) @@ -264,6 +249,12 @@ export const ComposePost = ({ ) const [postgate, setPostgate] = useState(createPostgateRecord({post: ''})) + // TODO: Move more state here. + const [composerState, dispatch] = useReducer( + composerReducer, + {initImageUris}, + createComposerState, + ) let images = NO_IMAGES if (composerState.embed.media?.type === 'images') { images = composerState.embed.media.images @@ -866,7 +857,7 @@ export const ComposePost = ({ /> 0} + disabled={!canSelectImages} setError={setError} /> @@ -1126,7 +1117,7 @@ function ErrorBanner({ clearVideo, }: { error: string - videoUploadState: VideoUploadState | NoVideoState + videoUploadState: VideoUploadState clearError: () => void clearVideo: () => void }) { diff --git a/src/view/com/composer/state.ts b/src/view/com/composer/state.ts index 8e974ad7a5..5588de1aa3 100644 --- a/src/view/com/composer/state.ts +++ b/src/view/com/composer/state.ts @@ -1,12 +1,4 @@ -import {ImagePickerAsset} from 'expo-image-picker' - import {ComposerImage, createInitialImages} from '#/state/gallery' -import { - createVideoState, - VideoAction, - videoReducer, - VideoState, -} from '#/state/queries/video/video' import {ComposerOpts} from '#/state/shell/composer' type PostRecord = { @@ -19,16 +11,11 @@ type ImagesMedia = { labels: string[] } -type VideoMedia = { - type: 'video' - video: VideoState -} - type ComposerEmbed = { // TODO: Other record types. record: PostRecord | undefined // TODO: Other media types. - media: ImagesMedia | VideoMedia | undefined + media: ImagesMedia | undefined } export type ComposerState = { @@ -40,13 +27,6 @@ export type ComposerAction = | {type: 'embed_add_images'; images: ComposerImage[]} | {type: 'embed_update_image'; image: ComposerImage} | {type: 'embed_remove_image'; image: ComposerImage} - | { - type: 'embed_add_video' - asset: ImagePickerAsset - abortController: AbortController - } - | {type: 'embed_remove_video'} - | {type: 'embed_update_video'; videoAction: VideoAction} const MAX_IMAGES = 4 @@ -56,9 +36,6 @@ export function composerReducer( ): ComposerState { switch (action.type) { case 'embed_add_images': { - if (action.images.length === 0) { - return state - } const prevMedia = state.embed.media let nextMedia = prevMedia if (!prevMedia) { @@ -127,55 +104,6 @@ export function composerReducer( } return state } - case 'embed_add_video': { - const prevMedia = state.embed.media - let nextMedia = prevMedia - if (!prevMedia) { - nextMedia = { - type: 'video', - video: createVideoState(action.asset, action.abortController), - } - } - return { - ...state, - embed: { - ...state.embed, - media: nextMedia, - }, - } - } - case 'embed_update_video': { - const videoAction = action.videoAction - const prevMedia = state.embed.media - let nextMedia = prevMedia - if (prevMedia?.type === 'video') { - nextMedia = { - ...prevMedia, - video: videoReducer(prevMedia.video, videoAction), - } - } - return { - ...state, - embed: { - ...state.embed, - media: nextMedia, - }, - } - } - case 'embed_remove_video': { - const prevMedia = state.embed.media - let nextMedia = prevMedia - if (prevMedia?.type === 'video') { - nextMedia = undefined - } - return { - ...state, - embed: { - ...state.embed, - media: nextMedia, - }, - } - } default: return state } @@ -194,7 +122,6 @@ export function createComposerState({ labels: [], } } - // TODO: initial video. return { embed: { record: undefined, From d139c7d3433292a07287f77161ab0eab03b29702 Mon Sep 17 00:00:00 2001 From: Takayuki KUSANO <65759+tkusano@users.noreply.github.com> Date: Fri, 4 Oct 2024 10:29:59 +0900 Subject: [PATCH 26/30] Revert "Refactor video uploads (#5570)" This reverts commit f74f9bc1d93e9e52e39e1082e55e4172c6d18301. --- src/state/queries/video/compress-video.ts | 39 ++ src/state/queries/video/util.ts | 11 +- .../queries/video/video-upload.shared.ts | 88 +-- src/state/queries/video/video-upload.ts | 111 ++- src/state/queries/video/video-upload.web.ts | 137 ++-- src/state/queries/video/video.ts | 646 ++++++++---------- src/view/com/composer/Composer.tsx | 124 ++-- .../com/composer/videos/SelectVideoBtn.tsx | 47 +- 8 files changed, 559 insertions(+), 644 deletions(-) create mode 100644 src/state/queries/video/compress-video.ts diff --git a/src/state/queries/video/compress-video.ts b/src/state/queries/video/compress-video.ts new file mode 100644 index 0000000000..cefbf94066 --- /dev/null +++ b/src/state/queries/video/compress-video.ts @@ -0,0 +1,39 @@ +import {ImagePickerAsset} from 'expo-image-picker' +import {useMutation} from '@tanstack/react-query' + +import {cancelable} from '#/lib/async/cancelable' +import {CompressedVideo} from '#/lib/media/video/types' +import {compressVideo} from 'lib/media/video/compress' + +export function useCompressVideoMutation({ + onProgress, + onSuccess, + onError, + signal, +}: { + onProgress: (progress: number) => void + onError: (e: any) => void + onSuccess: (video: CompressedVideo) => void + signal: AbortSignal +}) { + return useMutation({ + mutationKey: ['video', 'compress'], + mutationFn: cancelable( + (asset: ImagePickerAsset) => + compressVideo(asset, { + onProgress: num => onProgress(trunc2dp(num)), + signal, + }), + signal, + ), + onError, + onSuccess, + onMutate: () => { + onProgress(0) + }, + }) +} + +function trunc2dp(num: number) { + return Math.trunc(num * 100) / 100 +} diff --git a/src/state/queries/video/util.ts b/src/state/queries/video/util.ts index 87b422c2c9..2c1298ab63 100644 --- a/src/state/queries/video/util.ts +++ b/src/state/queries/video/util.ts @@ -1,3 +1,4 @@ +import {useMemo} from 'react' import {AtpAgent} from '@atproto/api' import {SupportedMimeTypes, VIDEO_SERVICE} from '#/lib/constants' @@ -16,10 +17,12 @@ export const createVideoEndpointUrl = ( return url.href } -export function createVideoAgent() { - return new AtpAgent({ - service: VIDEO_SERVICE, - }) +export function useVideoAgent() { + return useMemo(() => { + return new AtpAgent({ + service: VIDEO_SERVICE, + }) + }, []) } export function mimeToExt(mimeType: SupportedMimeTypes | (string & {})) { diff --git a/src/state/queries/video/video-upload.shared.ts b/src/state/queries/video/video-upload.shared.ts index 8c217eadcf..6b633bf213 100644 --- a/src/state/queries/video/video-upload.shared.ts +++ b/src/state/queries/video/video-upload.shared.ts @@ -1,61 +1,73 @@ -import {BskyAgent} from '@atproto/api' -import {I18n} from '@lingui/core' +import {useCallback} from 'react' import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' import {VIDEO_SERVICE_DID} from '#/lib/constants' import {UploadLimitError} from '#/lib/media/video/errors' import {getServiceAuthAudFromUrl} from '#/lib/strings/url-helpers' -import {createVideoAgent} from './util' +import {useAgent} from '#/state/session' +import {useVideoAgent} from './util' -export async function getServiceAuthToken({ - agent, +export function useServiceAuthToken({ aud, lxm, exp, }: { - agent: BskyAgent aud?: string lxm: string exp?: number }) { - const pdsAud = getServiceAuthAudFromUrl(agent.dispatchUrl) - if (!pdsAud) { - throw new Error('Agent does not have a PDS URL') - } - const {data: serviceAuth} = await agent.com.atproto.server.getServiceAuth({ - aud: aud ?? pdsAud, - lxm, - exp, - }) - return serviceAuth.token + const agent = useAgent() + + return useCallback(async () => { + const pdsAud = getServiceAuthAudFromUrl(agent.dispatchUrl) + + if (!pdsAud) { + throw new Error('Agent does not have a PDS URL') + } + + const {data: serviceAuth} = await agent.com.atproto.server.getServiceAuth({ + aud: aud ?? pdsAud, + lxm, + exp, + }) + + return serviceAuth.token + }, [agent, aud, lxm, exp]) } -export async function getVideoUploadLimits(agent: BskyAgent, _: I18n['_']) { - const token = await getServiceAuthToken({ - agent, +export function useVideoUploadLimits() { + const agent = useVideoAgent() + const getToken = useServiceAuthToken({ lxm: 'app.bsky.video.getUploadLimits', aud: VIDEO_SERVICE_DID, }) - const videoAgent = createVideoAgent() - const {data: limits} = await videoAgent.app.bsky.video - .getUploadLimits({}, {headers: {Authorization: `Bearer ${token}`}}) - .catch(err => { - if (err instanceof Error) { - throw new UploadLimitError(err.message) - } else { - throw err - } - }) + const {_} = useLingui() - if (!limits.canUpload) { - if (limits.message) { - throw new UploadLimitError(limits.message) - } else { - throw new UploadLimitError( - _( - msg`You have temporarily reached the limit for video uploads. Please try again later.`, - ), + return useCallback(async () => { + const {data: limits} = await agent.app.bsky.video + .getUploadLimits( + {}, + {headers: {Authorization: `Bearer ${await getToken()}`}}, ) + .catch(err => { + if (err instanceof Error) { + throw new UploadLimitError(err.message) + } else { + throw err + } + }) + + if (!limits.canUpload) { + if (limits.message) { + throw new UploadLimitError(limits.message) + } else { + throw new UploadLimitError( + _( + msg`You have temporarily reached the limit for video uploads. Please try again later.`, + ), + ) + } } - } + }, [agent, _, getToken]) } diff --git a/src/state/queries/video/video-upload.ts b/src/state/queries/video/video-upload.ts index 46f24a58b1..170b538901 100644 --- a/src/state/queries/video/video-upload.ts +++ b/src/state/queries/video/video-upload.ts @@ -1,79 +1,76 @@ import {createUploadTask, FileSystemUploadType} from 'expo-file-system' -import {AppBskyVideoDefs, BskyAgent} from '@atproto/api' -import {I18n} from '@lingui/core' +import {AppBskyVideoDefs} from '@atproto/api' import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useMutation} from '@tanstack/react-query' import {nanoid} from 'nanoid/non-secure' -import {AbortError} from '#/lib/async/cancelable' +import {cancelable} from '#/lib/async/cancelable' import {ServerError} from '#/lib/media/video/errors' import {CompressedVideo} from '#/lib/media/video/types' import {createVideoEndpointUrl, mimeToExt} from '#/state/queries/video/util' -import {getServiceAuthToken, getVideoUploadLimits} from './video-upload.shared' +import {useSession} from '#/state/session' +import {useServiceAuthToken, useVideoUploadLimits} from './video-upload.shared' -export async function uploadVideo({ - video, - agent, - did, +export const useUploadVideoMutation = ({ + onSuccess, + onError, setProgress, signal, - _, }: { - video: CompressedVideo - agent: BskyAgent - did: string + onSuccess: (response: AppBskyVideoDefs.JobStatus) => void + onError: (e: any) => void setProgress: (progress: number) => void signal: AbortSignal - _: I18n['_'] -}) { - if (signal.aborted) { - throw new AbortError() - } - await getVideoUploadLimits(agent, _) - - const uri = createVideoEndpointUrl('/xrpc/app.bsky.video.uploadVideo', { - did, - name: `${nanoid(12)}.${mimeToExt(video.mimeType)}`, - }) - - if (signal.aborted) { - throw new AbortError() - } - const token = await getServiceAuthToken({ - agent, +}) => { + const {currentAccount} = useSession() + const getToken = useServiceAuthToken({ lxm: 'com.atproto.repo.uploadBlob', exp: Date.now() / 1000 + 60 * 30, // 30 minutes }) - const uploadTask = createUploadTask( - uri, - video.uri, - { - headers: { - 'content-type': video.mimeType, - Authorization: `Bearer ${token}`, - }, - httpMethod: 'POST', - uploadType: FileSystemUploadType.BINARY_CONTENT, - }, - p => setProgress(p.totalBytesSent / p.totalBytesExpectedToSend), - ) + const checkLimits = useVideoUploadLimits() + const {_} = useLingui() - if (signal.aborted) { - throw new AbortError() - } - const res = await uploadTask.uploadAsync() + return useMutation({ + mutationKey: ['video', 'upload'], + mutationFn: cancelable(async (video: CompressedVideo) => { + await checkLimits() - if (!res?.body) { - throw new Error('No response') - } + const uri = createVideoEndpointUrl('/xrpc/app.bsky.video.uploadVideo', { + did: currentAccount!.did, + name: `${nanoid(12)}.${mimeToExt(video.mimeType)}`, + }) - const responseBody = JSON.parse(res.body) as AppBskyVideoDefs.JobStatus + const uploadTask = createUploadTask( + uri, + video.uri, + { + headers: { + 'content-type': video.mimeType, + Authorization: `Bearer ${await getToken()}`, + }, + httpMethod: 'POST', + uploadType: FileSystemUploadType.BINARY_CONTENT, + }, + p => setProgress(p.totalBytesSent / p.totalBytesExpectedToSend), + ) + const res = await uploadTask.uploadAsync() - if (!responseBody.jobId) { - throw new ServerError(responseBody.error || _(msg`Failed to upload video`)) - } + if (!res?.body) { + throw new Error('No response') + } - if (signal.aborted) { - throw new AbortError() - } - return responseBody + const responseBody = JSON.parse(res.body) as AppBskyVideoDefs.JobStatus + + if (!responseBody.jobId) { + throw new ServerError( + responseBody.error || _(msg`Failed to upload video`), + ) + } + + return responseBody + }, signal), + onError, + onSuccess, + }) } diff --git a/src/state/queries/video/video-upload.web.ts b/src/state/queries/video/video-upload.web.ts index bbae641999..c93e206030 100644 --- a/src/state/queries/video/video-upload.web.ts +++ b/src/state/queries/video/video-upload.web.ts @@ -1,95 +1,86 @@ import {AppBskyVideoDefs} from '@atproto/api' -import {BskyAgent} from '@atproto/api' -import {I18n} from '@lingui/core' import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useMutation} from '@tanstack/react-query' import {nanoid} from 'nanoid/non-secure' -import {AbortError} from '#/lib/async/cancelable' +import {cancelable} from '#/lib/async/cancelable' import {ServerError} from '#/lib/media/video/errors' import {CompressedVideo} from '#/lib/media/video/types' import {createVideoEndpointUrl, mimeToExt} from '#/state/queries/video/util' -import {getServiceAuthToken, getVideoUploadLimits} from './video-upload.shared' +import {useSession} from '#/state/session' +import {useServiceAuthToken, useVideoUploadLimits} from './video-upload.shared' -export async function uploadVideo({ - video, - agent, - did, +export const useUploadVideoMutation = ({ + onSuccess, + onError, setProgress, signal, - _, }: { - video: CompressedVideo - agent: BskyAgent - did: string + onSuccess: (response: AppBskyVideoDefs.JobStatus) => void + onError: (e: any) => void setProgress: (progress: number) => void signal: AbortSignal - _: I18n['_'] -}) { - if (signal.aborted) { - throw new AbortError() - } - await getVideoUploadLimits(agent, _) - - const uri = createVideoEndpointUrl('/xrpc/app.bsky.video.uploadVideo', { - did, - name: `${nanoid(12)}.${mimeToExt(video.mimeType)}`, - }) - - let bytes = video.bytes - if (!bytes) { - if (signal.aborted) { - throw new AbortError() - } - bytes = await fetch(video.uri).then(res => res.arrayBuffer()) - } - - if (signal.aborted) { - throw new AbortError() - } - const token = await getServiceAuthToken({ - agent, +}) => { + const {currentAccount} = useSession() + const getToken = useServiceAuthToken({ lxm: 'com.atproto.repo.uploadBlob', exp: Date.now() / 1000 + 60 * 30, // 30 minutes }) + const checkLimits = useVideoUploadLimits() + const {_} = useLingui() + + return useMutation({ + mutationKey: ['video', 'upload'], + mutationFn: cancelable(async (video: CompressedVideo) => { + await checkLimits() - if (signal.aborted) { - throw new AbortError() - } - const xhr = new XMLHttpRequest() - const res = await new Promise( - (resolve, reject) => { - xhr.upload.addEventListener('progress', e => { - const progress = e.loaded / e.total - setProgress(progress) + const uri = createVideoEndpointUrl('/xrpc/app.bsky.video.uploadVideo', { + did: currentAccount!.did, + name: `${nanoid(12)}.${mimeToExt(video.mimeType)}`, }) - xhr.onloadend = () => { - if (signal.aborted) { - reject(new AbortError()) - } else if (xhr.readyState === 4) { - const uploadRes = JSON.parse( - xhr.responseText, - ) as AppBskyVideoDefs.JobStatus - resolve(uploadRes) - } else { - reject(new ServerError(_(msg`Failed to upload video`))) - } - } - xhr.onerror = () => { - reject(new ServerError(_(msg`Failed to upload video`))) + + let bytes = video.bytes + if (!bytes) { + bytes = await fetch(video.uri).then(res => res.arrayBuffer()) } - xhr.open('POST', uri) - xhr.setRequestHeader('Content-Type', video.mimeType) - xhr.setRequestHeader('Authorization', `Bearer ${token}`) - xhr.send(bytes) - }, - ) - if (!res.jobId) { - throw new ServerError(res.error || _(msg`Failed to upload video`)) - } + const token = await getToken() + + const xhr = new XMLHttpRequest() + const res = await new Promise( + (resolve, reject) => { + xhr.upload.addEventListener('progress', e => { + const progress = e.loaded / e.total + setProgress(progress) + }) + xhr.onloadend = () => { + if (xhr.readyState === 4) { + const uploadRes = JSON.parse( + xhr.responseText, + ) as AppBskyVideoDefs.JobStatus + resolve(uploadRes) + } else { + reject(new ServerError(_(msg`Failed to upload video`))) + } + } + xhr.onerror = () => { + reject(new ServerError(_(msg`Failed to upload video`))) + } + xhr.open('POST', uri) + xhr.setRequestHeader('Content-Type', video.mimeType) + xhr.setRequestHeader('Authorization', `Bearer ${token}`) + xhr.send(bytes) + }, + ) - if (signal.aborted) { - throw new AbortError() - } - return res + if (!res.jobId) { + throw new ServerError(res.error || _(msg`Failed to upload video`)) + } + + return res + }, signal), + onError, + onSuccess, + }) } diff --git a/src/state/queries/video/video.ts b/src/state/queries/video/video.ts index fabee6ad1a..0d77935da9 100644 --- a/src/state/queries/video/video.ts +++ b/src/state/queries/video/video.ts @@ -1,11 +1,12 @@ +import React, {useCallback, useEffect} from 'react' import {ImagePickerAsset} from 'expo-image-picker' -import {AppBskyVideoDefs, BlobRef, BskyAgent} from '@atproto/api' -import {JobStatus} from '@atproto/api/dist/client/types/app/bsky/video/defs' -import {I18n} from '@lingui/core' +import {AppBskyVideoDefs, BlobRef} from '@atproto/api' import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {QueryClient, useQuery, useQueryClient} from '@tanstack/react-query' import {AbortError} from '#/lib/async/cancelable' -import {compressVideo} from '#/lib/media/video/compress' +import {SUPPORTED_MIME_TYPES, SupportedMimeTypes} from '#/lib/constants' import { ServerError, UploadLimitError, @@ -13,409 +14,338 @@ import { } from '#/lib/media/video/errors' import {CompressedVideo} from '#/lib/media/video/types' import {logger} from '#/logger' -import {createVideoAgent} from '#/state/queries/video/util' -import {uploadVideo} from '#/state/queries/video/video-upload' +import {isWeb} from '#/platform/detection' +import {useCompressVideoMutation} from '#/state/queries/video/compress-video' +import {useVideoAgent} from '#/state/queries/video/util' +import {useUploadVideoMutation} from '#/state/queries/video/video-upload' -type Action = - | {type: 'to_idle'; nextController: AbortController} - | { - type: 'idle_to_compressing' - asset: ImagePickerAsset - signal: AbortSignal - } - | { - type: 'compressing_to_uploading' - video: CompressedVideo - signal: AbortSignal - } - | { - type: 'uploading_to_processing' - jobId: string - signal: AbortSignal - } - | {type: 'to_error'; error: string; signal: AbortSignal} - | { - type: 'to_done' - blobRef: BlobRef - signal: AbortSignal - } - | {type: 'update_progress'; progress: number; signal: AbortSignal} - | { - type: 'update_dimensions' - width: number - height: number - signal: AbortSignal - } - | { - type: 'update_job_status' - jobStatus: AppBskyVideoDefs.JobStatus - signal: AbortSignal - } +type Status = 'idle' | 'compressing' | 'processing' | 'uploading' | 'done' -type IdleState = { - status: 'idle' - progress: 0 - abortController: AbortController - asset?: undefined - video?: undefined - jobId?: undefined - pendingPublish?: undefined -} - -type ErrorState = { - status: 'error' - progress: 100 - abortController: AbortController - asset: ImagePickerAsset | null - video: CompressedVideo | null - jobId: string | null - error: string - pendingPublish?: undefined -} - -type CompressingState = { - status: 'compressing' - progress: number - abortController: AbortController - asset: ImagePickerAsset - video?: undefined - jobId?: undefined - pendingPublish?: undefined -} - -type UploadingState = { - status: 'uploading' - progress: number - abortController: AbortController - asset: ImagePickerAsset - video: CompressedVideo - jobId?: undefined - pendingPublish?: undefined -} +type Action = + | {type: 'SetStatus'; status: Status} + | {type: 'SetProgress'; progress: number} + | {type: 'SetError'; error: string | undefined} + | {type: 'Reset'} + | {type: 'SetAsset'; asset: ImagePickerAsset} + | {type: 'SetDimensions'; width: number; height: number} + | {type: 'SetVideo'; video: CompressedVideo} + | {type: 'SetJobStatus'; jobStatus: AppBskyVideoDefs.JobStatus} + | {type: 'SetComplete'; blobRef: BlobRef} -type ProcessingState = { - status: 'processing' +export interface State { + status: Status progress: number + asset?: ImagePickerAsset + video: CompressedVideo | null + jobStatus?: AppBskyVideoDefs.JobStatus + blobRef?: BlobRef + error?: string abortController: AbortController - asset: ImagePickerAsset - video: CompressedVideo - jobId: string - jobStatus: AppBskyVideoDefs.JobStatus | null - pendingPublish?: undefined -} - -type DoneState = { - status: 'done' - progress: 100 - abortController: AbortController - asset: ImagePickerAsset - video: CompressedVideo - jobId?: undefined - pendingPublish: {blobRef: BlobRef; mutableProcessed: boolean} + pendingPublish?: {blobRef: BlobRef; mutableProcessed: boolean} } -export type State = - | IdleState - | ErrorState - | CompressingState - | UploadingState - | ProcessingState - | DoneState - -export function createVideoState( - abortController: AbortController = new AbortController(), -): IdleState { - return { - status: 'idle', - progress: 0, - abortController, - } -} +export type VideoUploadDispatch = (action: Action) => void -export function videoReducer(state: State, action: Action): State { - if (action.type === 'to_idle') { - return createVideoState(action.nextController) - } - if (action.signal.aborted || action.signal !== state.abortController.signal) { - // This action is stale and the process that spawned it is no longer relevant. - return state - } - if (action.type === 'to_error') { - return { - status: 'error', - progress: 100, - abortController: state.abortController, - error: action.error, - asset: state.asset ?? null, - video: state.video ?? null, - jobId: state.jobId ?? null, - } - } else if (action.type === 'update_progress') { - if (state.status === 'compressing' || state.status === 'uploading') { - return { - ...state, - progress: action.progress, - } - } - } else if (action.type === 'idle_to_compressing') { - if (state.status === 'idle') { - return { - status: 'compressing', +function reducer(queryClient: QueryClient) { + return (state: State, action: Action): State => { + let updatedState = state + if (action.type === 'SetStatus') { + updatedState = {...state, status: action.status} + } else if (action.type === 'SetProgress') { + updatedState = {...state, progress: action.progress} + } else if (action.type === 'SetError') { + updatedState = {...state, error: action.error} + } else if (action.type === 'Reset') { + state.abortController.abort() + queryClient.cancelQueries({ + queryKey: ['video'], + }) + updatedState = { + status: 'idle', progress: 0, - abortController: state.abortController, - asset: action.asset, + video: null, + blobRef: undefined, + abortController: new AbortController(), } - } - } else if (action.type === 'update_dimensions') { - if (state.asset) { - return { + } else if (action.type === 'SetAsset') { + updatedState = { ...state, - asset: {...state.asset, width: action.width, height: action.height}, - } - } - } else if (action.type === 'compressing_to_uploading') { - if (state.status === 'compressing') { - return { - status: 'uploading', - progress: 0, - abortController: state.abortController, - asset: state.asset, - video: action.video, - } - } - return state - } else if (action.type === 'uploading_to_processing') { - if (state.status === 'uploading') { - return { - status: 'processing', - progress: 0, - abortController: state.abortController, - asset: state.asset, - video: state.video, - jobId: action.jobId, - jobStatus: null, + asset: action.asset, + status: 'compressing', + error: undefined, } - } - } else if (action.type === 'update_job_status') { - if (state.status === 'processing') { - return { + } else if (action.type === 'SetDimensions') { + updatedState = { ...state, - jobStatus: action.jobStatus, - progress: - action.jobStatus.progress !== undefined - ? action.jobStatus.progress / 100 - : state.progress, + asset: state.asset + ? {...state.asset, width: action.width, height: action.height} + : undefined, } - } - } else if (action.type === 'to_done') { - if (state.status === 'processing') { - return { - status: 'done', - progress: 100, - abortController: state.abortController, - asset: state.asset, - video: state.video, + } else if (action.type === 'SetVideo') { + updatedState = {...state, video: action.video, status: 'uploading'} + } else if (action.type === 'SetJobStatus') { + updatedState = {...state, jobStatus: action.jobStatus} + } else if (action.type === 'SetComplete') { + updatedState = { + ...state, pendingPublish: { blobRef: action.blobRef, mutableProcessed: false, }, + status: 'done', } } + return updatedState } - console.error( - 'Unexpected video action (' + - action.type + - ') while in ' + - state.status + - ' state', - ) - return state -} - -function trunc2dp(num: number) { - return Math.trunc(num * 100) / 100 } -export async function processVideo( - asset: ImagePickerAsset, - dispatch: (action: Action) => void, - agent: BskyAgent, - did: string, - signal: AbortSignal, - _: I18n['_'], -) { - dispatch({ - type: 'idle_to_compressing', - asset, - signal, +export function useUploadVideo({ + setStatus, + initialVideoUri, +}: { + setStatus: (status: string) => void + onSuccess: () => void + initialVideoUri?: string +}) { + const {_} = useLingui() + const queryClient = useQueryClient() + const [state, dispatch] = React.useReducer(reducer(queryClient), { + status: 'idle', + progress: 0, + video: null, + abortController: new AbortController(), }) - let video: CompressedVideo | undefined - try { - video = await compressVideo(asset, { - onProgress: num => { - dispatch({type: 'update_progress', progress: trunc2dp(num), signal}) - }, - signal, - }) - } catch (e) { - const message = getCompressErrorMessage(e, _) - if (message !== null) { + const {setJobId} = useUploadStatusQuery({ + onStatusChange: (status: AppBskyVideoDefs.JobStatus) => { + // This might prove unuseful, most of the job status steps happen too quickly to even be displayed to the user + // Leaving it for now though dispatch({ - type: 'to_error', - error: message, - signal, + type: 'SetJobStatus', + jobStatus: status, }) - } - return - } - dispatch({ - type: 'compressing_to_uploading', - video, - signal, + setStatus(status.state.toString()) + }, + onSuccess: blobRef => { + dispatch({ + type: 'SetComplete', + blobRef, + }) + }, + onError: useCallback( + error => { + logger.error('Error processing video', {safeMessage: error}) + dispatch({ + type: 'SetError', + error: _(msg`Video failed to process`), + }) + }, + [_], + ), }) - let uploadResponse: AppBskyVideoDefs.JobStatus | undefined - try { - uploadResponse = await uploadVideo({ - video, - agent, - did, - signal, - _, - setProgress: p => { - dispatch({type: 'update_progress', progress: p, signal}) - }, - }) - } catch (e) { - const message = getUploadErrorMessage(e, _) - if (message !== null) { + const {mutate: onVideoCompressed} = useUploadVideoMutation({ + onSuccess: response => { dispatch({ - type: 'to_error', - error: message, - signal, + type: 'SetStatus', + status: 'processing', }) - } - return - } - - const jobId = uploadResponse.jobId - dispatch({ - type: 'uploading_to_processing', - jobId, - signal, + setJobId(response.jobId) + }, + onError: e => { + if (e instanceof AbortError) { + return + } else if (e instanceof ServerError || e instanceof UploadLimitError) { + let message + // https://github.com/bluesky-social/tango/blob/lumi/lumi/worker/permissions.go#L77 + switch (e.message) { + case 'User is not allowed to upload videos': + message = _(msg`You are not allowed to upload videos.`) + break + case 'Uploading is disabled at the moment': + message = _( + msg`Hold up! We’re gradually giving access to video, and you’re still waiting in line. Check back soon!`, + ) + break + case "Failed to get user's upload stats": + message = _( + msg`We were unable to determine if you are allowed to upload videos. Please try again.`, + ) + break + case 'User has exceeded daily upload bytes limit': + message = _( + msg`You've reached your daily limit for video uploads (too many bytes)`, + ) + break + case 'User has exceeded daily upload videos limit': + message = _( + msg`You've reached your daily limit for video uploads (too many videos)`, + ) + break + case 'Account is not old enough to upload videos': + message = _( + msg`Your account is not yet old enough to upload videos. Please try again later.`, + ) + break + default: + message = e.message + break + } + dispatch({ + type: 'SetError', + error: message, + }) + } else { + dispatch({ + type: 'SetError', + error: _(msg`An error occurred while uploading the video.`), + }) + } + logger.error('Error uploading video', {safeMessage: e}) + }, + setProgress: p => { + dispatch({type: 'SetProgress', progress: p}) + }, + signal: state.abortController.signal, }) - let pollFailures = 0 - while (true) { - if (signal.aborted) { - return // Exit async loop - } - - const videoAgent = createVideoAgent() - let status: JobStatus | undefined - let blob: BlobRef | undefined - try { - const response = await videoAgent.app.bsky.video.getJobStatus({jobId}) - status = response.data.jobStatus - pollFailures = 0 - - if (status.state === 'JOB_STATE_COMPLETED') { - blob = status.blob - if (!blob) { - throw new Error('Job completed, but did not return a blob') - } - } else if (status.state === 'JOB_STATE_FAILED') { - throw new Error(status.error ?? 'Job failed to process') + const {mutate: onSelectVideo} = useCompressVideoMutation({ + onProgress: p => { + dispatch({type: 'SetProgress', progress: p}) + }, + onSuccess: (video: CompressedVideo) => { + dispatch({ + type: 'SetVideo', + video, + }) + onVideoCompressed(video) + }, + onError: e => { + if (e instanceof AbortError) { + return + } else if (e instanceof VideoTooLargeError) { + dispatch({ + type: 'SetError', + error: _(msg`The selected video is larger than 50MB.`), + }) + } else { + dispatch({ + type: 'SetError', + error: _(msg`An error occurred while compressing the video.`), + }) + logger.error('Error compressing video', {safeMessage: e}) } - } catch (e) { - if (!status) { - pollFailures++ - if (pollFailures < 50) { - await new Promise(resolve => setTimeout(resolve, 5000)) - continue // Continue async loop + }, + signal: state.abortController.signal, + }) + + const selectVideo = React.useCallback( + (asset: ImagePickerAsset) => { + // compression step on native converts to mp4, so no need to check there + if (isWeb) { + const mimeType = getMimeType(asset) + if (!SUPPORTED_MIME_TYPES.includes(mimeType as SupportedMimeTypes)) { + throw new Error(_(msg`Unsupported video type: ${mimeType}`)) } } - logger.error('Error processing video', {safeMessage: e}) dispatch({ - type: 'to_error', - error: _(msg`Video failed to process`), - signal, + type: 'SetAsset', + asset, }) - return // Exit async loop - } + onSelectVideo(asset) + }, + [_, onSelectVideo], + ) - if (blob) { - dispatch({ - type: 'to_done', - blobRef: blob, - signal, - }) - } else { - dispatch({ - type: 'update_job_status', - jobStatus: status, - signal, - }) - } + const clearVideo = () => { + dispatch({type: 'Reset'}) + } - if ( - status.state !== 'JOB_STATE_COMPLETED' && - status.state !== 'JOB_STATE_FAILED' - ) { - await new Promise(resolve => setTimeout(resolve, 1500)) - continue // Continue async loop + const updateVideoDimensions = useCallback((width: number, height: number) => { + dispatch({ + type: 'SetDimensions', + width, + height, + }) + }, []) + + // Whenever we receive an initial video uri, we should immediately run compression if necessary + useEffect(() => { + if (initialVideoUri) { + selectVideo({uri: initialVideoUri} as ImagePickerAsset) } + }, [initialVideoUri, selectVideo]) - return // Exit async loop + return { + state, + dispatch, + selectVideo, + clearVideo, + updateVideoDimensions, } } -function getCompressErrorMessage(e: unknown, _: I18n['_']): string | null { - if (e instanceof AbortError) { - return null - } - if (e instanceof VideoTooLargeError) { - return _(msg`The selected video is larger than 50MB.`) +const useUploadStatusQuery = ({ + onStatusChange, + onSuccess, + onError, +}: { + onStatusChange: (status: AppBskyVideoDefs.JobStatus) => void + onSuccess: (blobRef: BlobRef) => void + onError: (error: Error) => void +}) => { + const videoAgent = useVideoAgent() + const [enabled, setEnabled] = React.useState(true) + const [jobId, setJobId] = React.useState() + + const {error} = useQuery({ + queryKey: ['video', 'upload status', jobId], + queryFn: async () => { + if (!jobId) return // this won't happen, can ignore + + const {data} = await videoAgent.app.bsky.video.getJobStatus({jobId}) + const status = data.jobStatus + if (status.state === 'JOB_STATE_COMPLETED') { + setEnabled(false) + if (!status.blob) + throw new Error('Job completed, but did not return a blob') + onSuccess(status.blob) + } else if (status.state === 'JOB_STATE_FAILED') { + throw new Error(status.error ?? 'Job failed to process') + } + onStatusChange(status) + return status + }, + enabled: Boolean(jobId && enabled), + refetchInterval: 1500, + }) + + useEffect(() => { + if (error) { + onError(error) + setEnabled(false) + } + }, [error, onError]) + + return { + setJobId: (_jobId: string) => { + setJobId(_jobId) + setEnabled(true) + }, } - logger.error('Error compressing video', {safeMessage: e}) - return _(msg`An error occurred while compressing the video.`) } -function getUploadErrorMessage(e: unknown, _: I18n['_']): string | null { - if (e instanceof AbortError) { - return null - } - logger.error('Error uploading video', {safeMessage: e}) - if (e instanceof ServerError || e instanceof UploadLimitError) { - // https://github.com/bluesky-social/tango/blob/lumi/lumi/worker/permissions.go#L77 - switch (e.message) { - case 'User is not allowed to upload videos': - return _(msg`You are not allowed to upload videos.`) - case 'Uploading is disabled at the moment': - return _( - msg`Hold up! We’re gradually giving access to video, and you’re still waiting in line. Check back soon!`, - ) - case "Failed to get user's upload stats": - return _( - msg`We were unable to determine if you are allowed to upload videos. Please try again.`, - ) - case 'User has exceeded daily upload bytes limit': - return _( - msg`You've reached your daily limit for video uploads (too many bytes)`, - ) - case 'User has exceeded daily upload videos limit': - return _( - msg`You've reached your daily limit for video uploads (too many videos)`, - ) - case 'Account is not old enough to upload videos': - return _( - msg`Your account is not yet old enough to upload videos. Please try again later.`, - ) - default: - return e.message +function getMimeType(asset: ImagePickerAsset) { + if (isWeb) { + const [mimeType] = asset.uri.slice('data:'.length).split(';base64,') + if (!mimeType) { + throw new Error('Could not determine mime type') } + return mimeType + } + if (!asset.mimeType) { + throw new Error('Could not determine mime type') } - return _(msg`An error occurred while uploading the video.`) + return asset.mimeType } diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx index 185a57fc35..f354f0f0dc 100644 --- a/src/view/com/composer/Composer.tsx +++ b/src/view/com/composer/Composer.tsx @@ -36,7 +36,6 @@ import Animated, { ZoomOut, } from 'react-native-reanimated' import {useSafeAreaInsets} from 'react-native-safe-area-context' -import {ImagePickerAsset} from 'expo-image-picker' import { AppBskyFeedDefs, AppBskyFeedGetPostThread, @@ -83,10 +82,9 @@ import {Gif} from '#/state/queries/tenor' import {ThreadgateAllowUISetting} from '#/state/queries/threadgate' import {threadgateViewToAllowUISetting} from '#/state/queries/threadgate/util' import { - createVideoState, - processVideo, State as VideoUploadState, - videoReducer, + useUploadVideo, + VideoUploadDispatch, } from '#/state/queries/video/video' import {useAgent, useSession} from '#/state/session' import {useComposerControls} from '#/state/shell/composer' @@ -149,8 +147,7 @@ export const ComposePost = ({ }) => { const {currentAccount} = useSession() const agent = useAgent() - const currentDid = currentAccount!.did - const {data: currentProfile} = useProfileQuery({did: currentDid}) + const {data: currentProfile} = useProfileQuery({did: currentAccount!.did}) const {isModalActive} = useModals() const {closeComposer} = useComposerControls() const pal = usePalette('default') @@ -192,50 +189,21 @@ export const ComposePost = ({ const [videoAltText, setVideoAltText] = useState('') const [captions, setCaptions] = useState<{lang: string; file: File}[]>([]) - const [videoUploadState, videoDispatch] = useReducer( - videoReducer, - undefined, - createVideoState, - ) - - const selectVideo = React.useCallback( - (asset: ImagePickerAsset) => { - processVideo( - asset, - videoDispatch, - agent, - currentDid, - videoUploadState.abortController.signal, - _, - ) - }, - [_, videoUploadState.abortController, videoDispatch, agent, currentDid], - ) - - // Whenever we receive an initial video uri, we should immediately run compression if necessary - useEffect(() => { - if (initVideoUri) { - selectVideo({uri: initVideoUri} as ImagePickerAsset) - } - }, [initVideoUri, selectVideo]) - - const clearVideo = React.useCallback(() => { - videoUploadState.abortController.abort() - videoDispatch({type: 'to_idle', nextController: new AbortController()}) - }, [videoUploadState.abortController, videoDispatch]) - - const updateVideoDimensions = useCallback( - (width: number, height: number) => { - videoDispatch({ - type: 'update_dimensions', - width, - height, - signal: videoUploadState.abortController.signal, - }) + const { + selectVideo, + clearVideo, + state: videoUploadState, + updateVideoDimensions, + dispatch: videoUploadDispatch, + } = useUploadVideo({ + setStatus: setProcessingState, + onSuccess: () => { + if (publishOnUpload) { + onPressPublish(true) + } }, - [videoUploadState.abortController], - ) - + initialVideoUri: initVideoUri, + }) const hasVideo = Boolean(videoUploadState.asset || videoUploadState.video) const [publishOnUpload, setPublishOnUpload] = useState(false) @@ -432,18 +400,19 @@ export const ComposePost = ({ postgate, onStateChange: setProcessingState, langs: toPostLanguages(langPrefs.postLanguage), - video: - videoUploadState.status === 'done' - ? { - blobRef: videoUploadState.pendingPublish.blobRef, - altText: videoAltText, - captions: captions, - aspectRatio: { - width: videoUploadState.asset.width, - height: videoUploadState.asset.height, - }, - } - : undefined, + video: videoUploadState.pendingPublish?.blobRef + ? { + blobRef: videoUploadState.pendingPublish.blobRef, + altText: videoAltText, + captions: captions, + aspectRatio: videoUploadState.asset + ? { + width: videoUploadState.asset?.width, + height: videoUploadState.asset?.height, + } + : undefined, + } + : undefined, }) ).uri try { @@ -725,7 +694,7 @@ export const ComposePost = ({ error={error} videoUploadState={videoUploadState} clearError={() => setError('')} - clearVideo={clearVideo} + videoUploadDispatch={videoUploadDispatch} /> void - clearVideo: () => void + videoUploadDispatch: VideoUploadDispatch }) { const t = useTheme() const {_} = useLingui() const videoError = - videoUploadState.status === 'error' ? videoUploadState.error : undefined + videoUploadState.status !== 'idle' ? videoUploadState.error : undefined const error = standardError || videoError const onClearError = () => { if (standardError) { clearError() } else { - clearVideo() + videoUploadDispatch({type: 'Reset'}) } } @@ -1167,7 +1136,7 @@ function ErrorBanner({ - {videoError && videoUploadState.jobId && ( + {videoError && videoUploadState.jobStatus?.jobId && ( - Job ID: {videoUploadState.jobId} + Job ID: {videoUploadState.jobStatus.jobId} )} @@ -1205,7 +1174,9 @@ function ToolbarWrapper({ function VideoUploadToolbar({state}: {state: VideoUploadState}) { const t = useTheme() const {_} = useLingui() - const progress = state.progress + const progress = state.jobStatus?.progress + ? state.jobStatus.progress / 100 + : state.progress const shouldRotate = state.status === 'processing' && (progress === 0 || progress === 1) let wheelProgress = shouldRotate ? 0.33 : progress @@ -1241,15 +1212,16 @@ function VideoUploadToolbar({state}: {state: VideoUploadState}) { case 'processing': text = _('Processing video...') break - case 'error': - text = _('Error') - wheelProgress = 100 - break case 'done': text = _('Video uploaded') break } + if (state.error) { + text = _('Error') + wheelProgress = 100 + } + return ( @@ -1257,11 +1229,7 @@ function VideoUploadToolbar({state}: {state: VideoUploadState}) { size={30} borderWidth={1} borderColor={t.atoms.border_contrast_low.borderColor} - color={ - state.status === 'error' - ? t.palette.negative_500 - : t.palette.primary_500 - } + color={state.error ? t.palette.negative_500 : t.palette.primary_500} progress={wheelProgress} /> diff --git a/src/view/com/composer/videos/SelectVideoBtn.tsx b/src/view/com/composer/videos/SelectVideoBtn.tsx index bbb3d95f2b..2f2b4c3e7a 100644 --- a/src/view/com/composer/videos/SelectVideoBtn.tsx +++ b/src/view/com/composer/videos/SelectVideoBtn.tsx @@ -9,14 +9,12 @@ import { import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' -import {SUPPORTED_MIME_TYPES, SupportedMimeTypes} from '#/lib/constants' -import {BSKY_SERVICE} from '#/lib/constants' import {useVideoLibraryPermission} from '#/lib/hooks/usePermissions' -import {getHostnameFromUrl} from '#/lib/strings/url-helpers' -import {isWeb} from '#/platform/detection' import {isNative} from '#/platform/detection' import {useModalControls} from '#/state/modals' import {useSession} from '#/state/session' +import {BSKY_SERVICE} from 'lib/constants' +import {getHostnameFromUrl} from 'lib/strings/url-helpers' import {atoms as a, useTheme} from '#/alf' import {Button} from '#/components/Button' import {VideoClip_Stroke2_Corner0_Rounded as VideoClipIcon} from '#/components/icons/VideoClip' @@ -60,25 +58,16 @@ export function SelectVideoBtn({onSelectVideo, disabled, setError}: Props) { UIImagePickerPreferredAssetRepresentationMode.Current, }) if (response.assets && response.assets.length > 0) { - const asset = response.assets[0] - try { - if (isWeb) { - // compression step on native converts to mp4, so no need to check there - const mimeType = getMimeType(asset) - if ( - !SUPPORTED_MIME_TYPES.includes(mimeType as SupportedMimeTypes) - ) { - throw Error(_(msg`Unsupported video type: ${mimeType}`)) - } - } else { - if (typeof asset.duration !== 'number') { - throw Error('Asset is not a video') - } - if (asset.duration > VIDEO_MAX_DURATION) { - throw Error(_(msg`Videos must be less than 60 seconds long`)) - } + if (isNative) { + if (typeof response.assets[0].duration !== 'number') + throw Error('Asset is not a video') + if (response.assets[0].duration > VIDEO_MAX_DURATION) { + setError(_(msg`Videos must be less than 60 seconds long`)) + return } - onSelectVideo(asset) + } + try { + onSelectVideo(response.assets[0]) } catch (err) { if (err instanceof Error) { setError(err.message) @@ -143,17 +132,3 @@ function VerifyEmailPrompt({control}: {control: Prompt.PromptControlProps}) { /> ) } - -function getMimeType(asset: ImagePickerAsset) { - if (isWeb) { - const [mimeType] = asset.uri.slice('data:'.length).split(';base64,') - if (!mimeType) { - throw new Error('Could not determine mime type') - } - return mimeType - } - if (!asset.mimeType) { - throw new Error('Could not determine mime type') - } - return asset.mimeType -} From 221e65050e540c6d0a260a78e62dbcca8645cba0 Mon Sep 17 00:00:00 2001 From: Takayuki KUSANO <65759+tkusano@users.noreply.github.com> Date: Fri, 4 Oct 2024 10:41:12 +0900 Subject: [PATCH 27/30] Update translation --- src/locale/locales/ja/messages.po | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/src/locale/locales/ja/messages.po b/src/locale/locales/ja/messages.po index 0948345dba..29e14e24ee 100644 --- a/src/locale/locales/ja/messages.po +++ b/src/locale/locales/ja/messages.po @@ -8,7 +8,7 @@ msgstr "" "Language: ja\n" "Project-Id-Version: \n" "Report-Msgid-Bugs-To: \n" -"PO-Revision-Date: 2024-10-03 10:49+0900\n" +"PO-Revision-Date: 2024-10-04 10:40+0900\n" "Last-Translator: tkusano\n" "Language-Team: Hima-Zinn, tkusano, dolciss, oboenikui, noritada, middlingphys, hibiki, reindex-ot, haoyayoi, vyv03354\n" "Plural-Forms: \n" @@ -234,6 +234,10 @@ msgstr "<0>{0}はあなたのスターターパックに含まれていま msgid "<0>{0} members" msgstr "<0>{0}のメンバー" +#: src/components/dms/DateDivider.tsx:69 +msgid "<0>{date} at {time}" +msgstr "<0>{date} {time}" + #: src/view/com/modals/SelfLabel.tsx:135 msgid "<0>Not Applicable. This warning is only available for posts with media attached." msgstr "<0>適用できません。 この警告はメディアが添付された投稿にのみ利用可能です。" @@ -609,6 +613,11 @@ msgstr "アニメーションGIF" msgid "Anti-Social Behavior" msgstr "反社会的な行動" +#: src/view/screens/Search/Search.tsx:347 +#: src/view/screens/Search/Search.tsx:348 +msgid "Any language" +msgstr "全言語" + #: src/view/com/composer/threadgate/ThreadgateBtn.tsx:54 msgid "Anybody can interact" msgstr "誰でも反応可能" @@ -6684,6 +6693,10 @@ msgstr "Blueskyにビデオをアップロードするには、まずメール msgid "To whom would you like to send this report?" msgstr "この報告を誰に送りたいですか?" +#: src/components/dms/DateDivider.tsx:44 +msgid "Today" +msgstr "今日" + #: src/view/com/util/forms/DropdownButton.tsx:255 msgid "Toggle dropdown" msgstr "ドロップダウンを切り替え" @@ -7442,9 +7455,9 @@ msgstr "はい、非表示にします" msgid "Yes, reactivate my account" msgstr "はい、アカウントを再有効化します" -#: src/components/dms/MessageItem.tsx:183 -msgid "Yesterday, {time}" -msgstr "昨日、{time}" +#: src/components/dms/DateDivider.tsx:46 +msgid "Yesterday" +msgstr "昨日" #: src/components/StarterPack/StarterPackCard.tsx:76 #: src/screens/List/ListHiddenScreen.tsx:140 From 7886101c42c2511264e6f6656e2df6fe9113190a Mon Sep 17 00:00:00 2001 From: Takayuki KUSANO <65759+tkusano@users.noreply.github.com> Date: Sun, 6 Oct 2024 09:43:36 +0900 Subject: [PATCH 28/30] Update translation --- src/locale/locales/ja/messages.po | 37 ++++++++++++++++++++++++------- 1 file changed, 29 insertions(+), 8 deletions(-) diff --git a/src/locale/locales/ja/messages.po b/src/locale/locales/ja/messages.po index 29e14e24ee..5b19e5f3d6 100644 --- a/src/locale/locales/ja/messages.po +++ b/src/locale/locales/ja/messages.po @@ -8,7 +8,7 @@ msgstr "" "Language: ja\n" "Project-Id-Version: \n" "Report-Msgid-Bugs-To: \n" -"PO-Revision-Date: 2024-10-04 10:40+0900\n" +"PO-Revision-Date: 2024-10-06 09:43+0900\n" "Last-Translator: tkusano\n" "Language-Team: Hima-Zinn, tkusano, dolciss, oboenikui, noritada, middlingphys, hibiki, reindex-ot, haoyayoi, vyv03354\n" "Plural-Forms: \n" @@ -513,6 +513,11 @@ msgstr "ALTテキスト" msgid "Alt text describes images for blind and low-vision users, and helps give context to everyone." msgstr "ALTテキストは、すべての人が文脈を理解できるようにするために、視覚障害者や低視力者向けに提供する画像の説明文です。" +#: src/view/com/composer/GifAltText.tsx:171 +#: src/view/com/composer/photos/ImageAltTextDialog.tsx:143 +msgid "Alt text will be truncated. Limit: {0} characters." +msgstr "ALTテキストは切り詰められます。上限:{0}文字。" + #: src/view/com/modals/VerifyEmail.tsx:132 #: src/view/screens/Settings/DisableEmail2FADialog.tsx:96 msgid "An email has been sent to {0}. It includes a confirmation code which you can enter below." @@ -915,9 +920,17 @@ msgstr "作成者:{0}" msgid "by <0/>" msgstr "作成者:<0/>" -#: src/screens/Signup/StepInfo/Policies.tsx:80 -msgid "By creating an account you agree to the {els}." -msgstr "アカウントを作成することで、{els}に同意したものとみなされます。" +#: src/screens/Signup/StepInfo/Policies.tsx:81 +msgid "By creating an account you agree to the <0>Privacy Policy." +msgstr "アカウントを作成することで、<0>プライバシーポリシーに同意したものとみなされます。" + +#: src/screens/Signup/StepInfo/Policies.tsx:48 +msgid "By creating an account you agree to the <0>Terms of Service and <1>Privacy Policy." +msgstr "アカウントを作成することで、<0>利用規約と<1>プライバシーポリシーに同意したものとみなされます。" + +#: src/screens/Signup/StepInfo/Policies.tsx:68 +msgid "By creating an account you agree to the <0>Terms of Service." +msgstr "アカウントを作成することで、<0>利用規約に同意したものとみなされます。" #: src/view/com/profile/ProfileSubpageHeader.tsx:162 msgid "by you" @@ -1812,6 +1825,10 @@ msgstr "新しいフィードを探す" msgid "Discover New Feeds" msgstr "新しいフィードを探す" +#: src/components/Dialog/index.tsx:267 +msgid "Dismiss" +msgstr "消す" + #: src/view/com/composer/Composer.tsx:1107 msgid "Dismiss error" msgstr "エラーを消す" @@ -1888,6 +1905,10 @@ msgstr "完了" msgid "Done{extraText}" msgstr "完了{extraText}" +#: src/components/Dialog/index.tsx:268 +msgid "Double tap to close the dialog" +msgstr "ダブルタップでダイアログを閉じる" + #: src/screens/StarterPack/StarterPackLandingScreen.tsx:326 msgid "Download Bluesky" msgstr "Blueskyをダウンロード" @@ -6035,9 +6056,9 @@ msgstr "返信を並び替える" msgid "Sort replies to the same post by:" msgstr "次の方法で同じ投稿への返信を並び替えます。" -#: src/components/moderation/LabelsOnMeDialog.tsx:163 -msgid "Source: <0>{sourceName}" -msgstr "ソース:<0>{sourceName}" +#: src/components/moderation/LabelsOnMeDialog.tsx:167 +msgid "Source:" +msgstr "ソース:" #: src/lib/moderation/useReportOptions.ts:72 #: src/lib/moderation/useReportOptions.ts:85 @@ -6547,7 +6568,7 @@ msgid "This is important in case you ever need to change your email or reset you msgstr "これは、メールアドレスの変更やパスワードのリセットが必要な場合に重要です。" #: src/components/moderation/ModerationDetailsDialog.tsx:144 -msgid "This label was applied by <0>{0}." +msgid "This label was applied by <0>{0}" msgstr "<0>{0}によって適用されたラベルです。" #: src/components/moderation/ModerationDetailsDialog.tsx:142 From d78d7f07cd60eb6035ef05512cfbdde0822baf48 Mon Sep 17 00:00:00 2001 From: Takayuki KUSANO <65759+tkusano@users.noreply.github.com> Date: Sun, 6 Oct 2024 09:51:20 +0900 Subject: [PATCH 29/30] Delete unused entries --- src/locale/locales/ja/messages.po | 39 +------------------------------ 1 file changed, 1 insertion(+), 38 deletions(-) diff --git a/src/locale/locales/ja/messages.po b/src/locale/locales/ja/messages.po index 5b19e5f3d6..6dbc8d4358 100644 --- a/src/locale/locales/ja/messages.po +++ b/src/locale/locales/ja/messages.po @@ -8,7 +8,7 @@ msgstr "" "Language: ja\n" "Project-Id-Version: \n" "Report-Msgid-Bugs-To: \n" -"PO-Revision-Date: 2024-10-06 09:43+0900\n" +"PO-Revision-Date: 2024-10-06 09:51+0900\n" "Last-Translator: tkusano\n" "Language-Team: Hima-Zinn, tkusano, dolciss, oboenikui, noritada, middlingphys, hibiki, reindex-ot, haoyayoi, vyv03354\n" "Plural-Forms: \n" @@ -1231,10 +1231,6 @@ msgstr "画像を閉じる" msgid "Close image viewer" msgstr "画像ビューアを閉じる" -#: src/components/dms/MessagesNUX.tsx:162 -msgid "Close modal" -msgstr "モーダルを閉じる" - #: src/view/shell/index.web.tsx:61 msgid "Close navigation footer" msgstr "ナビゲーションフッターを閉じる" @@ -1770,10 +1766,6 @@ msgstr "なにか言いたいことはあった?" msgid "Dim" msgstr "グレー" -#: src/components/dms/MessagesNUX.tsx:88 -msgid "Direct messages are here!" -msgstr "ダイレクトメッセージはこちら!" - #: src/view/screens/AccessibilitySettings.tsx:111 msgid "Disable autoplay for videos and GIFs" msgstr "ビデオやGIFを自動再生しない" @@ -2726,10 +2718,6 @@ msgstr "スターターパックを生成" msgid "Get help" msgstr "ヘルプを表示" -#: src/components/dms/MessagesNUX.tsx:168 -msgid "Get started" -msgstr "始める" - #: src/view/com/modals/VerifyEmail.tsx:197 #: src/view/com/modals/VerifyEmail.tsx:199 msgid "Get Started" @@ -3081,10 +3069,6 @@ msgstr "あなたのユーザーハンドルを入力" msgid "Interaction limited" msgstr "反応が制限されています" -#: src/components/dms/MessagesNUX.tsx:82 -msgid "Introducing Direct Messages" -msgstr "ダイレクトメッセージの紹介" - #: src/components/dialogs/nuxs/NeueTypography.tsx:48 msgid "Introducing new font settings" msgstr "新しいフォント設定の紹介" @@ -4760,10 +4744,6 @@ msgstr "プライバシー" msgid "Privacy Policy" msgstr "プライバシーポリシー" -#: src/components/dms/MessagesNUX.tsx:91 -msgid "Privately chat with other users." -msgstr "他のユーザーとプライベートにチャットします。" - #: src/screens/Login/ForgotPasswordForm.tsx:155 msgid "Processing..." msgstr "処理中…" @@ -5903,10 +5883,6 @@ msgstr "警告を表示" msgid "Show warning and filter from feeds" msgstr "警告の表示とフィードからのフィルタリング" -#: src/view/com/post-thread/PostThreadFollowBtn.tsx:128 -msgid "Shows posts from {0} in your feed" -msgstr "マイフィード内の{0}からの投稿を表示します" - #: src/components/dialogs/Signin.tsx:97 #: src/components/dialogs/Signin.tsx:99 #: src/screens/Login/index.tsx:100 @@ -6082,10 +6058,6 @@ msgstr "新しいチャットを開始" msgid "Start chat with {displayName}" msgstr "{displayName}とのチャットを開始" -#: src/components/dms/MessagesNUX.tsx:161 -msgid "Start chatting" -msgstr "チャットを開始" - #: src/Navigation.tsx:358 #: src/Navigation.tsx:363 #: src/screens/StarterPack/Wizard/index.tsx:182 @@ -7384,11 +7356,6 @@ msgstr "アルゴリズムによるフィードにはどの言語を使用しま msgid "Who can interact with this post?" msgstr "誰がこの投稿に反応できますか?" -#: src/components/dms/MessagesNUX.tsx:110 -#: src/components/dms/MessagesNUX.tsx:124 -msgid "Who can message you?" -msgstr "誰があなたへメッセージを送れるか?" - #: src/components/WhoCanReply.tsx:87 msgid "Who can reply" msgstr "返信できるユーザー" @@ -7514,10 +7481,6 @@ msgstr "また、あなたはフォローすべき新しいカスタムフィー msgid "You can also temporarily deactivate your account instead, and reactivate it at any time." msgstr "代わりにアカウントを一時的に無効化して、いつでも再有効化することもできます。" -#: src/components/dms/MessagesNUX.tsx:119 -msgid "You can change this at any time." -msgstr "これはいつでも変更できます。" - #: src/screens/Messages/Settings.tsx:111 msgid "You can continue ongoing conversations regardless of which setting you choose." msgstr "どの設定を選択しても進行中の会話は続けることができます。" From 58e553d6b1c24794509bf0a0d1d9db39c4b2b867 Mon Sep 17 00:00:00 2001 From: Takayuki KUSANO <65759+tkusano@users.noreply.github.com> Date: Mon, 7 Oct 2024 22:24:05 +0900 Subject: [PATCH 30/30] Restored the trailing dot by #5622 --- src/locale/locales/ja/messages.po | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/locale/locales/ja/messages.po b/src/locale/locales/ja/messages.po index 6dbc8d4358..d692974c87 100644 --- a/src/locale/locales/ja/messages.po +++ b/src/locale/locales/ja/messages.po @@ -8,7 +8,7 @@ msgstr "" "Language: ja\n" "Project-Id-Version: \n" "Report-Msgid-Bugs-To: \n" -"PO-Revision-Date: 2024-10-06 09:51+0900\n" +"PO-Revision-Date: 2024-10-07 22:22+0900\n" "Last-Translator: tkusano\n" "Language-Team: Hima-Zinn, tkusano, dolciss, oboenikui, noritada, middlingphys, hibiki, reindex-ot, haoyayoi, vyv03354\n" "Plural-Forms: \n" @@ -6540,7 +6540,7 @@ msgid "This is important in case you ever need to change your email or reset you msgstr "これは、メールアドレスの変更やパスワードのリセットが必要な場合に重要です。" #: src/components/moderation/ModerationDetailsDialog.tsx:144 -msgid "This label was applied by <0>{0}" +msgid "This label was applied by <0>{0}." msgstr "<0>{0}によって適用されたラベルです。" #: src/components/moderation/ModerationDetailsDialog.tsx:142