From f72aae3bac0d9a65bc96ec6274d4988b5bab1750 Mon Sep 17 00:00:00 2001 From: pfrazee <1270099+pfrazee@users.noreply.github.com> Date: Thu, 25 Jun 2026 03:18:16 +0000 Subject: [PATCH 01/10] Nightly source-language update --- src/locale/locales/en/messages.po | 230 +++++++++++++++--------------- 1 file changed, 118 insertions(+), 112 deletions(-) diff --git a/src/locale/locales/en/messages.po b/src/locale/locales/en/messages.po index 78455288dd..2408fefc62 100644 --- a/src/locale/locales/en/messages.po +++ b/src/locale/locales/en/messages.po @@ -1956,10 +1956,10 @@ msgstr "" #: src/components/dms/dialogs/NewChatDialog.tsx:155 #: src/components/dms/MessageProfileButton.tsx:60 -#: src/screens/Messages/ChatList.tsx:155 -#: src/screens/Messages/ChatList.tsx:173 -#: src/screens/Messages/ChatList.tsx:274 -#: src/screens/Messages/ChatList.tsx:527 +#: src/screens/Messages/ChatList.tsx:166 +#: src/screens/Messages/ChatList.tsx:184 +#: src/screens/Messages/ChatList.tsx:285 +#: src/screens/Messages/ChatList.tsx:541 #: src/screens/Messages/Conversation.tsx:203 #: src/screens/Messages/ConversationSettings/MemberMenu.tsx:99 msgid "Before you can message another user, you must first verify your email." @@ -2065,7 +2065,7 @@ msgstr "" msgid "Block user and/or leave this conversation" msgstr "Block user and/or leave this conversation" -#: src/components/Post/Embed/index.tsx:216 +#: src/components/Post/Embed/index.tsx:212 msgid "Blocked" msgstr "" @@ -2335,7 +2335,7 @@ msgstr "Camera access needed" #: src/components/dialogs/lists/CreateOrEditListDialog.tsx:299 #: src/components/dialogs/nuxs/InviteFriendsAnnouncement.tsx:175 #: src/components/dialogs/nuxs/InviteFriendsAnnouncement.tsx:181 -#: src/components/Menu/index.tsx:367 +#: src/components/Menu/index.tsx:373 #: src/components/moderation/BlockDialog.tsx:202 #: src/components/PostControls/RepostButton.tsx:210 #: src/components/Prompt.tsx:152 @@ -2531,6 +2531,12 @@ msgstr "" msgid "Chat not found." msgstr "Chat not found." +#: src/screens/Messages/ChatList.tsx:567 +#: src/screens/Messages/ChatList.tsx:602 +#: src/screens/Messages/ChatList.tsx:640 +msgid "Chat options" +msgstr "Chat options" + #: src/components/moderation/BlockDialog.tsx:293 msgid "Chat owners cannot leave a group chat." msgstr "Chat owners cannot leave a group chat." @@ -2550,10 +2556,10 @@ msgstr "" #: src/components/dms/ConvoMenu.tsx:88 #: src/Navigation.tsx:506 -#: src/screens/Messages/ChatList.tsx:84 -#: src/screens/Messages/ChatList.tsx:88 -#: src/screens/Messages/ChatList.tsx:552 -#: src/screens/Messages/ChatList.tsx:583 +#: src/screens/Messages/ChatList.tsx:95 +#: src/screens/Messages/ChatList.tsx:99 +#: src/screens/Messages/ChatList.tsx:652 +#: src/screens/Messages/ChatList.tsx:662 #: src/screens/Messages/Settings.tsx:39 msgid "Chat settings" msgstr "" @@ -2580,9 +2586,9 @@ msgctxt "toast" msgid "Chat unmuted" msgstr "" -#: src/screens/Messages/ChatList.tsx:78 -#: src/screens/Messages/ChatList.tsx:539 -#: src/screens/Messages/ChatList.tsx:576 +#: src/screens/Messages/ChatList.tsx:89 +#: src/screens/Messages/ChatList.tsx:553 +#: src/screens/Messages/ChatList.tsx:593 msgid "Chats" msgstr "" @@ -2656,7 +2662,7 @@ msgstr "" msgid "Choose your password" msgstr "" -#: src/screens/Signup/index.tsx:192 +#: src/screens/Signup/index.tsx:193 msgid "Choose your username" msgstr "" @@ -2857,7 +2863,7 @@ msgstr "Close the incoming requests banner" #: src/components/intents/GroupChatJoinDialog.tsx:240 #: src/components/intents/GroupChatJoinDialog.tsx:276 #: src/components/intents/GroupChatJoinDialog.tsx:277 -#: src/components/Menu/index.tsx:361 +#: src/components/Menu/index.tsx:367 msgid "Close this dialog" msgstr "" @@ -2881,7 +2887,7 @@ msgstr "" msgid "Collapses list of users for a given notification" msgstr "" -#: src/features/inviteFriends/components/ThemePicker.tsx:66 +#: src/features/inviteFriends/components/ThemePicker.tsx:68 msgid "Color" msgstr "Color" @@ -2912,7 +2918,7 @@ msgstr "" msgid "Complete onboarding and start using your account" msgstr "" -#: src/screens/Signup/index.tsx:194 +#: src/screens/Signup/index.tsx:195 msgid "Complete the challenge" msgstr "" @@ -2989,8 +2995,8 @@ msgid "Contact our support team" msgstr "" #: src/components/ageAssurance/AgeAssuranceInitDialog.tsx:156 -#: src/screens/Signup/index.tsx:233 -#: src/screens/Signup/index.tsx:236 +#: src/screens/Signup/index.tsx:240 +#: src/screens/Signup/index.tsx:243 msgid "Contact support" msgstr "" @@ -3130,7 +3136,7 @@ msgid "Copied build version to clipboard" msgstr "" #: src/components/dms/ChatInvite/Root.tsx:99 -#: src/components/dms/MessageContextMenu.tsx:83 +#: src/components/dms/MessageContextMenu.tsx:97 #: src/components/PostControls/DiscoverDebug.tsx:36 #: src/components/PostControls/PostMenu/PostMenuItems.tsx:273 #: src/components/PostControls/ShareMenu/ShareMenuItems.tsx:77 @@ -3222,8 +3228,8 @@ msgstr "" msgid "Copy link to starter pack" msgstr "" -#: src/components/dms/MessageContextMenu.tsx:183 -#: src/components/dms/MessageContextMenu.tsx:187 +#: src/components/dms/MessageContextMenu.tsx:198 +#: src/components/dms/MessageContextMenu.tsx:202 msgid "Copy message text" msgstr "" @@ -3398,7 +3404,7 @@ msgstr "" #: src/components/WelcomeModal.tsx:158 #: src/components/WelcomeModal.tsx:166 #: src/screens/Messages/JoinRequest.tsx:310 -#: src/screens/Signup/index.tsx:135 +#: src/screens/Signup/index.tsx:136 #: src/view/com/auth/SplashScreen.tsx:107 #: src/view/com/auth/SplashScreen.web.tsx:116 #: src/view/shell/bottom-bar/BottomBar.tsx:345 @@ -3544,7 +3550,7 @@ msgstr "" msgid "Dawn" msgstr "Dawn" -#: src/features/inviteFriends/components/ThemePicker.tsx:29 +#: src/features/inviteFriends/components/ThemePicker.tsx:30 msgid "Day" msgstr "Day" @@ -3627,7 +3633,7 @@ msgstr "" msgid "Delete conversation" msgstr "" -#: src/components/dms/MessageContextMenu.tsx:197 +#: src/components/dms/MessageContextMenu.tsx:212 msgid "Delete for me" msgstr "" @@ -3640,7 +3646,7 @@ msgstr "" msgid "Delete message" msgstr "" -#: src/components/dms/MessageContextMenu.tsx:194 +#: src/components/dms/MessageContextMenu.tsx:209 msgid "Delete message for me" msgstr "" @@ -3671,7 +3677,7 @@ msgstr "" msgid "Delete this post?" msgstr "" -#: src/components/Post/Embed/index.tsx:209 +#: src/components/Post/Embed/index.tsx:205 msgid "Deleted" msgstr "" @@ -4040,7 +4046,7 @@ msgstr "" msgid "Duration:" msgstr "" -#: src/features/inviteFriends/components/ThemePicker.tsx:30 +#: src/features/inviteFriends/components/ThemePicker.tsx:31 msgid "Dusk" msgstr "Dusk" @@ -4171,12 +4177,12 @@ msgstr "" #: src/screens/Profile/Header/EditProfileDialog.tsx:265 #: src/screens/Profile/Header/EditProfileDialog.tsx:271 -#: src/screens/Profile/Header/ProfileHeaderLabeler.tsx:296 +#: src/screens/Profile/Header/ProfileHeaderLabeler.tsx:299 #: src/screens/Profile/Header/ProfileHeaderStandard.tsx:350 msgid "Edit profile" msgstr "" -#: src/screens/Profile/Header/ProfileHeaderLabeler.tsx:299 +#: src/screens/Profile/Header/ProfileHeaderLabeler.tsx:302 #: src/screens/Profile/Header/ProfileHeaderStandard.tsx:352 msgid "Edit Profile" msgstr "" @@ -4598,7 +4604,7 @@ msgid "Failed to accept join request" msgstr "Failed to accept join request" #: src/components/dms/ActionsWrapper.web.tsx:92 -#: src/components/dms/MessageContextMenu.tsx:126 +#: src/components/dms/MessageContextMenu.tsx:140 msgid "Failed to add emoji reaction" msgstr "" @@ -4693,10 +4699,6 @@ msgstr "" msgid "Failed to hide suggestion, please check your internet connection" msgstr "" -#: src/screens/Messages/JoinRequests.tsx:223 -msgid "Failed to ignore join request" -msgstr "Failed to ignore join request" - #: src/components/intents/GroupChatJoinDialog.tsx:147 msgid "Failed to join the group chat. Please try again." msgstr "Failed to join the group chat. Please try again." @@ -4712,7 +4714,7 @@ msgctxt "toast" msgid "Failed to leave group chat" msgstr "Failed to leave group chat" -#: src/screens/Messages/ChatList.tsx:404 +#: src/screens/Messages/ChatList.tsx:418 #: src/screens/Messages/Inbox.tsx:209 msgid "Failed to load conversations" msgstr "" @@ -4762,6 +4764,7 @@ msgstr "" msgid "Failed to lock group chat" msgstr "Failed to lock group chat" +#: src/screens/Messages/ChatList.tsx:634 #: src/view/shell/bottom-bar/BottomBar.tsx:441 msgid "Failed to mark all chats as read" msgstr "Failed to mark all chats as read" @@ -4784,6 +4787,10 @@ msgstr "" msgid "Failed to reconnect Germ DM. Error: {0}" msgstr "" +#: src/screens/Messages/JoinRequests.tsx:223 +msgid "Failed to reject join request" +msgstr "Failed to reject join request" + #: src/screens/Settings/FindContactsSettings.tsx:509 msgid "Failed to remove data due to a network error, please check your internet connection." msgstr "" @@ -4794,7 +4801,7 @@ msgid "Failed to remove data. {0}" msgstr "" #: src/components/dms/ActionsWrapper.web.tsx:77 -#: src/components/dms/MessageContextMenu.tsx:111 +#: src/components/dms/MessageContextMenu.tsx:125 #: src/components/dms/ReactionsDialog.tsx:179 msgid "Failed to remove emoji reaction" msgstr "" @@ -5779,7 +5786,7 @@ msgstr "" msgid "Have we got your location wrong? <0>Tap here to update your location with GPS." msgstr "Have we got your location wrong? <0>Tap here to update your location with GPS." -#: src/screens/Signup/index.tsx:231 +#: src/screens/Signup/index.tsx:238 msgid "Having trouble?" msgstr "" @@ -6224,7 +6231,7 @@ msgstr "In-app, push, everyone" msgid "In-app, push, people you follow" msgstr "In-app, push, people you follow" -#: src/screens/Messages/ChatList.tsx:421 +#: src/screens/Messages/ChatList.tsx:435 msgid "Inbox empty" msgstr "Inbox empty" @@ -6403,8 +6410,8 @@ msgid "Invites, but personal" msgstr "" #: src/ageAssurance/components/NoAccessScreen.tsx:394 -msgid "Is your location not accurate? <0>Tap here to update your location with GPS. " -msgstr "Is your location not accurate? <0>Tap here to update your location with GPS. " +msgid "Is your location not accurate? <0>Tap here to update your location with GPS." +msgstr "Is your location not accurate? <0>Tap here to update your location with GPS." #: src/components/contacts/components/InviteInfo.tsx:47 msgid "It looks like some of your contacts have not tried to find you here yet. You can personally invite them by customizing a draft message we will provide." @@ -6474,11 +6481,6 @@ msgstr "" msgid "Keep me posted" msgstr "" -#: src/components/moderation/BlockDialog.tsx:365 -#: src/components/moderation/BlockDialog.tsx:372 -msgid "Kick member" -msgstr "Kick member" - #: src/components/ageAssurance/AgeAssuranceInitDialog.tsx:200 msgid "KWS website" msgstr "" @@ -6949,9 +6951,7 @@ msgstr "" msgid "Load new posts" msgstr "" -#: src/screens/Messages/components/MessageComposer.tsx:245 -#: src/screens/Messages/components/MessageInput.tsx:258 -#: src/screens/Messages/components/MessageInput.web.tsx:228 +#: src/screens/Messages/components/MessageComposer.tsx:254 msgctxt "placeholder" msgid "Loading chat…" msgstr "Loading chat…" @@ -7065,6 +7065,8 @@ msgstr "" msgid "Mark all as read" msgstr "" +#: src/screens/Messages/ChatList.tsx:644 +#: src/screens/Messages/ChatList.tsx:648 #: src/view/shell/bottom-bar/BottomBar.tsx:459 #: src/view/shell/bottom-bar/BottomBar.tsx:463 msgid "Mark all chats as read" @@ -7084,6 +7086,7 @@ msgstr "" msgid "Marked all as read" msgstr "" +#: src/screens/Messages/ChatList.tsx:631 #: src/view/shell/bottom-bar/BottomBar.tsx:438 msgid "Marked all chats as read" msgstr "Marked all chats as read" @@ -7139,19 +7142,17 @@ msgstr "" msgid "Mentions" msgstr "" -#: src/components/Menu/index.tsx:113 +#: src/components/Menu/index.tsx:119 msgid "Menu" msgstr "" -#: src/screens/Messages/components/MessageInput.tsx:202 +#: src/screens/Messages/ConversationSettings/MemberMenu.tsx:203 +msgctxt "action" msgid "Message" msgstr "Message" -#: src/screens/Messages/components/MessageComposer.tsx:246 -#: src/screens/Messages/components/MessageInput.tsx:259 -#: src/screens/Messages/components/MessageInput.web.tsx:229 -#: src/screens/Messages/ConversationSettings/MemberMenu.tsx:203 -msgctxt "action" +#: src/screens/Messages/components/MessageComposer.tsx:255 +msgctxt "description" msgid "Message" msgstr "Message" @@ -7179,7 +7180,7 @@ msgstr "Message failed to send." #. placeholder {0}: sender?.handle ?? 'unknown' #. placeholder {1}: message.text -#: src/components/dms/MessageContextMenu.tsx:152 +#: src/components/dms/MessageContextMenu.tsx:166 msgid "Message from @{0}: {1}" msgstr "" @@ -7188,21 +7189,15 @@ msgstr "" msgid "Message from server: {0}" msgstr "" -#: src/screens/Messages/components/MessageComposer.tsx:242 -#: src/screens/Messages/components/MessageInput.tsx:200 +#: src/screens/Messages/components/MessageComposer.tsx:251 msgid "Message input field" msgstr "" -#: src/screens/Messages/components/MessageInput.tsx:96 -#: src/screens/Messages/components/MessageInput.web.tsx:65 -msgid "Message is too long" -msgstr "" - -#: src/screens/Messages/components/MessageComposer.tsx:107 +#: src/screens/Messages/components/MessageComposer.tsx:116 msgid "Message is too long ({graphemeCount}/{MAX_DM_GRAPHEME_LENGTH})" msgstr "Message is too long ({graphemeCount}/{MAX_DM_GRAPHEME_LENGTH})" -#: src/components/dms/MessageContextMenu.tsx:151 +#: src/components/dms/MessageContextMenu.tsx:165 msgid "Message options" msgstr "" @@ -7510,7 +7505,6 @@ msgstr "" msgid "Nevermind, create a handle for me" msgstr "" -#: src/components/dialogs/nuxs/GroupChatsAnnouncement.tsx:171 #: src/screens/Search/modules/ExploreTrendingTopics.tsx:184 #: src/view/com/profile/ProfileMenu.tsx:398 msgid "New" @@ -7522,6 +7516,11 @@ msgctxt "action" msgid "New" msgstr "" +#: src/components/dialogs/nuxs/GroupChatsAnnouncement.tsx:171 +msgctxt "nux-description" +msgid "New" +msgstr "New" + #: src/view/com/notifications/NotificationFeedItem.tsx:569 msgid "New {postsCount, plural, one {post} other {posts}} from {firstAuthorLink}" msgstr "" @@ -7532,11 +7531,11 @@ msgstr "" #: src/components/dms/dialogs/NewChatDialog.tsx:169 #: src/components/dms/dialogs/NewChatDialog.tsx:184 -#: src/screens/Messages/ChatList.tsx:218 -#: src/screens/Messages/ChatList.tsx:219 -#: src/screens/Messages/ChatList.tsx:439 -#: src/screens/Messages/ChatList.tsx:440 -#: src/screens/Messages/ChatList.tsx:561 +#: src/screens/Messages/ChatList.tsx:229 +#: src/screens/Messages/ChatList.tsx:230 +#: src/screens/Messages/ChatList.tsx:453 +#: src/screens/Messages/ChatList.tsx:454 +#: src/screens/Messages/ChatList.tsx:578 msgid "New chat" msgstr "" @@ -7676,7 +7675,7 @@ msgstr "" msgid "Next image" msgstr "" -#: src/features/inviteFriends/components/ThemePicker.tsx:31 +#: src/features/inviteFriends/components/ThemePicker.tsx:33 msgid "Night" msgstr "Night" @@ -8087,8 +8086,8 @@ msgid "Only admins can accept join requests." msgstr "Only admins can accept join requests." #: src/screens/Messages/JoinRequests.tsx:233 -msgid "Only admins can ignore join requests." -msgstr "Only admins can ignore join requests." +msgid "Only admins can reject join requests." +msgstr "Only admins can reject join requests." #: src/components/intents/GroupChatJoinDialog.tsx:155 msgid "Only followers can join this group chat." @@ -8165,8 +8164,7 @@ msgstr "" msgid "Open drawer menu" msgstr "" -#: src/screens/Messages/components/MessageComposer.tsx:204 -#: src/screens/Messages/components/MessageInput.web.tsx:174 +#: src/screens/Messages/components/MessageComposer.tsx:213 #: src/view/com/composer/Composer.tsx:2158 msgid "Open emoji picker" msgstr "" @@ -8275,7 +8273,7 @@ msgstr "" msgid "Opens a dialog to choose who can interact with this post" msgstr "" -#: src/features/inviteFriends/components/ThemePicker.tsx:38 +#: src/features/inviteFriends/components/ThemePicker.tsx:40 msgid "Opens a list of color themes for the QR card" msgstr "Opens a list of color themes for the QR card" @@ -8605,7 +8603,7 @@ msgstr "" msgid "Photography" msgstr "" -#: src/features/inviteFriends/components/ThemePicker.tsx:37 +#: src/features/inviteFriends/components/ThemePicker.tsx:39 msgid "Pick a color theme" msgstr "Pick a color theme" @@ -9334,7 +9332,7 @@ msgstr "" msgid "Reject join request" msgstr "Reject join request" -#: src/screens/Messages/ChatList.tsx:408 +#: src/screens/Messages/ChatList.tsx:422 #: src/screens/Messages/Inbox.tsx:213 msgid "Reload conversations" msgstr "" @@ -9461,6 +9459,11 @@ msgstr "" msgid "Remove live status" msgstr "" +#: src/components/moderation/BlockDialog.tsx:365 +#: src/components/moderation/BlockDialog.tsx:372 +msgid "Remove member" +msgstr "Remove member" + #: src/components/dialogs/MutedWords.tsx:613 msgid "Remove mute word from your list" msgstr "" @@ -9502,11 +9505,11 @@ msgstr "" msgid "Remove your verification for this account?" msgstr "" -#: src/components/Post/Embed/index.tsx:244 +#: src/components/Post/Embed/index.tsx:240 msgid "Removed by author" msgstr "" -#: src/components/Post/Embed/index.tsx:242 +#: src/components/Post/Embed/index.tsx:238 msgid "Removed by you" msgstr "" @@ -9604,8 +9607,8 @@ msgstr "" msgid "Replies to this post are disabled." msgstr "" -#: src/components/dms/MessageContextMenu.tsx:167 -#: src/components/dms/MessageContextMenu.tsx:170 +#: src/components/dms/MessageContextMenu.tsx:182 +#: src/components/dms/MessageContextMenu.tsx:185 msgid "Reply" msgstr "Reply" @@ -9647,7 +9650,7 @@ msgstr "" msgid "Reply was successfully hidden" msgstr "" -#: src/components/dms/MessageContextMenu.tsx:205 +#: src/components/dms/MessageContextMenu.tsx:220 #: src/features/liveNow/components/LiveStatusDialog.tsx:267 #: src/screens/Messages/ConversationSettings/index.tsx:583 msgid "Report" @@ -9680,7 +9683,7 @@ msgstr "" msgid "Report list" msgstr "" -#: src/components/dms/MessageContextMenu.tsx:202 +#: src/components/dms/MessageContextMenu.tsx:217 msgid "Report message" msgstr "" @@ -9808,8 +9811,8 @@ msgid "Request code" msgstr "" #: src/screens/Messages/JoinRequests.tsx:215 -msgid "Request ignored." -msgstr "Request ignored." +msgid "Request rejected." +msgstr "Request rejected." #: src/components/dms/ChatInvite/Root.tsx:117 #: src/components/intents/GroupChatJoinDialog.tsx:295 @@ -9929,7 +9932,7 @@ msgstr "" #: src/components/StarterPack/ProfileStarterPacks.tsx:377 #: src/screens/Login/LoginForm.tsx:329 #: src/screens/Login/LoginForm.tsx:335 -#: src/screens/Messages/ChatList.tsx:413 +#: src/screens/Messages/ChatList.tsx:427 #: src/screens/Messages/components/MessageListError.tsx:24 #: src/screens/Messages/Inbox.tsx:218 #: src/screens/Messages/JoinRequests.tsx:361 @@ -10084,8 +10087,8 @@ msgstr "" msgid "Say hello!" msgstr "" -#: src/screens/Messages/ChatList.tsx:209 -#: src/screens/Messages/ChatList.tsx:430 +#: src/screens/Messages/ChatList.tsx:220 +#: src/screens/Messages/ChatList.tsx:444 msgid "Say hi to someone" msgstr "Say hi to someone" @@ -10486,8 +10489,7 @@ msgstr "Send error report" msgid "Send feedback" msgstr "" -#: src/screens/Messages/components/MessageComposer.tsx:316 -#: src/screens/Messages/components/MessageInput.web.tsx:251 +#: src/screens/Messages/components/MessageComposer.tsx:324 msgid "Send message" msgstr "" @@ -10521,7 +10523,7 @@ msgid "Send via direct message" msgstr "" #. placeholder {0}: i18n.date(new Date(message.sentAt), { timeStyle: 'short', }) -#: src/components/dms/MessageContextMenu.tsx:161 +#: src/components/dms/MessageContextMenu.tsx:175 msgid "Sent at {0}" msgstr "Sent at {0}" @@ -11218,7 +11220,7 @@ msgstr "" #. placeholder {1}: state.serviceDescription && !state.serviceDescription.phoneVerificationRequired ? '2' : '3' #. placeholder {1}: state.totalSteps #: src/screens/Onboarding/Layout.tsx:226 -#: src/screens/Signup/index.tsx:180 +#: src/screens/Signup/index.tsx:181 msgid "Step {0} of {1}" msgstr "" @@ -11290,11 +11292,11 @@ msgstr "" msgid "Subscribe to account activity" msgstr "" -#: src/screens/Profile/Header/ProfileHeaderLabeler.tsx:347 +#: src/screens/Profile/Header/ProfileHeaderLabeler.tsx:350 msgid "Subscribe to Labeler" msgstr "" -#: src/screens/Profile/Header/ProfileHeaderLabeler.tsx:313 +#: src/screens/Profile/Header/ProfileHeaderLabeler.tsx:316 msgid "Subscribe to this labeler" msgstr "" @@ -11340,6 +11342,10 @@ msgstr "" msgid "Suggestive" msgstr "" +#: src/features/inviteFriends/components/ThemePicker.tsx:29 +msgid "Sunlight" +msgstr "Sunlight" + #: src/screens/Settings/AppIconSettings/useAppIconSets.ts:72 msgctxt "Name of app icon variant" msgid "Sunrise" @@ -12299,8 +12305,8 @@ msgstr "" msgid "Topic" msgstr "" -#: src/components/dms/MessageContextMenu.tsx:176 -#: src/components/dms/MessageContextMenu.tsx:179 +#: src/components/dms/MessageContextMenu.tsx:191 +#: src/components/dms/MessageContextMenu.tsx:194 #: src/components/Post/Translated/index.tsx:150 #: src/components/Post/Translated/index.tsx:157 #: src/components/PostControls/PostMenu/PostMenuItems.tsx:557 @@ -12388,14 +12394,14 @@ msgstr "Try it" msgid "TV" msgstr "" +#: src/features/inviteFriends/components/ThemePicker.tsx:32 +msgid "Twilight" +msgstr "Twilight" + #: src/screens/Settings/PrivacyAndSecuritySettings.tsx:65 msgid "Two-factor authentication (2FA)" msgstr "" -#: src/screens/Messages/components/MessageInput.tsx:201 -msgid "Type your message here" -msgstr "" - #: src/screens/Settings/components/ChangeHandleDialog.tsx:445 msgid "Type:" msgstr "" @@ -12418,7 +12424,7 @@ msgstr "" #: src/screens/Login/index.tsx:96 #: src/screens/Login/LoginForm.tsx:167 #: src/screens/Login/SetNewPasswordForm.tsx:83 -#: src/screens/Signup/index.tsx:87 +#: src/screens/Signup/index.tsx:88 msgid "Unable to contact your service. Please check your Internet connection." msgstr "" @@ -12677,7 +12683,7 @@ msgstr "" msgid "Unsnooze email reminder" msgstr "" -#: src/screens/Profile/Header/ProfileHeaderLabeler.tsx:345 +#: src/screens/Profile/Header/ProfileHeaderLabeler.tsx:348 msgid "Unsubscribe" msgstr "" @@ -12686,7 +12692,7 @@ msgstr "" msgid "Unsubscribe from list" msgstr "" -#: src/screens/Profile/Header/ProfileHeaderLabeler.tsx:312 +#: src/screens/Profile/Header/ProfileHeaderLabeler.tsx:315 msgid "Unsubscribe from this labeler" msgstr "" @@ -13455,7 +13461,7 @@ msgstr "We’re having network issues, try again" msgid "We’re introducing a new layer of verification on Bluesky — an easy-to-see checkmark." msgstr "" -#: src/screens/Signup/index.tsx:136 +#: src/screens/Signup/index.tsx:137 msgid "We’re so excited to have you join us!" msgstr "We’re so excited to have you join us!" @@ -13559,7 +13565,7 @@ msgid "Who can verify?" msgstr "" #: src/screens/Home/NoFeedsPinned.tsx:80 -#: src/screens/Messages/ChatList.tsx:393 +#: src/screens/Messages/ChatList.tsx:407 #: src/screens/Messages/Inbox.tsx:198 msgid "Whoops!" msgstr "" @@ -13844,7 +13850,7 @@ msgid "You can update this later from your settings." msgstr "" #: src/components/dms/ActionsWrapper.web.tsx:81 -#: src/components/dms/MessageContextMenu.tsx:115 +#: src/components/dms/MessageContextMenu.tsx:129 msgid "You cannot add more than {EMOJI_REACTION_LIMIT, plural, one {# emoji reaction} other {# emoji reactions}}" msgstr "You cannot add more than {EMOJI_REACTION_LIMIT, plural, one {# emoji reaction} other {# emoji reactions}}" @@ -14112,11 +14118,11 @@ msgstr "You won’t be able to rejoin unless you’re invited." msgid "You: {message}" msgstr "You: {message}" -#: src/screens/Signup/index.tsx:152 +#: src/screens/Signup/index.tsx:153 msgid "You'll follow the suggested users and feeds once you finish creating your account!" msgstr "" -#: src/screens/Signup/index.tsx:157 +#: src/screens/Signup/index.tsx:158 msgid "You'll follow the suggested users once you finish creating your account!" msgstr "" @@ -14204,7 +14210,7 @@ msgstr "" msgid "You've run out of videos to watch. Maybe it's a good time to take a break?" msgstr "" -#: src/screens/Signup/index.tsx:190 +#: src/screens/Signup/index.tsx:191 msgid "Your account" msgstr "" From 1ff27bfd3b22e8fb8e51cb92ca6acb0cccb9b5f3 Mon Sep 17 00:00:00 2001 From: DS Boyce <260543580+ds-boyce@users.noreply.github.com> Date: Thu, 25 Jun 2026 11:43:33 -0700 Subject: [PATCH 02/10] Upgrade pnpm to 11.9.0 (#10952) --- Dockerfile.embedr | 2 +- package.json | 2 +- pnpm-lock.yaml | 76 +++++++++++++++++++++++------------------------ 3 files changed, 40 insertions(+), 40 deletions(-) diff --git a/Dockerfile.embedr b/Dockerfile.embedr index bf559ec583..203149115f 100644 --- a/Dockerfile.embedr +++ b/Dockerfile.embedr @@ -33,7 +33,7 @@ RUN mkdir --parents $NVM_DIR && \ RUN \. "$NVM_DIR/nvm.sh" && \ nvm install $NODE_VERSION && \ nvm use $NODE_VERSION && \ - npm install --global pnpm@11.7.0 && \ + npm install --global pnpm@11.9.0 && \ pnpm install --frozen-lockfile && \ cd bskyembed && pnpm install --frozen-lockfile && cd .. && \ pnpm intl:build && \ diff --git a/package.json b/package.json index ccd213d743..3981e9016f 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "devEngines": { "packageManager": { "name": "pnpm", - "version": "11.7.0", + "version": "11.9.0", "onFail": "warn" }, "runtime": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index eee11bf2c3..7be643fae5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,52 +7,52 @@ importers: configDependencies: {} packageManagerDependencies: '@pnpm/exe': - specifier: 11.7.0 - version: 11.7.0 + specifier: 11.9.0 + version: 11.9.0 pnpm: - specifier: 11.7.0 - version: 11.7.0 + specifier: 11.9.0 + version: 11.9.0 packages: - '@pnpm/exe@11.7.0': - resolution: {integrity: sha512-3CujpSSp2PIDE0pwu7mWSdjhdDqaZa7OppVooECWWaNEoA/z66s9FZts1MhDO+2yq1XER4gBHh84DVbFN/r1rA==} + '@pnpm/exe@11.9.0': + resolution: {integrity: sha512-pPPOpR79qW3nsNhlyDIdfstli4Bi78mk8r22ySxpFRwMbO8KXSjGrVzGmJBsVX39NnJTh7/WADj527nZhG9H9g==} hasBin: true - '@pnpm/linux-arm64@11.7.0': - resolution: {integrity: sha512-ANTX2SlMO+d2y/4bYQhHCwHPX7gSSADJ5+pMUIiDFzIsybnFFaJdZboaFfq9NOxCbETcnDxqZ95Rz3+NHx1JIw==} + '@pnpm/linux-arm64@11.9.0': + resolution: {integrity: sha512-XYmY2qadHauBA3QaHi2R7fI6kt5Flje0WHz9MVrbH0kVH/XLpfOLnwPeE1+EX6K/nDa2CBvzp35VjYCNGFJa9A==} cpu: [arm64] os: [linux] - '@pnpm/linux-x64@11.7.0': - resolution: {integrity: sha512-fr75tqixXoS8cnA81HQIomjOGEPnsOsd3xCDL5pMNY5raOXbKurtgRV+RjATvjxlJxSLIVFKegABlxAiB7q72A==} + '@pnpm/linux-x64@11.9.0': + resolution: {integrity: sha512-fl7W5imnSmmgXIqMQFZ/rPaVvk9OkKF8/anqHZE3XEDfWcn3BlWGndyOEas/JN7u2BXWYjs63DJZ3rnG6WOhLA==} cpu: [x64] os: [linux] - '@pnpm/linuxstatic-arm64@11.7.0': - resolution: {integrity: sha512-Q++pgzvXkGeqnVRl26/uqmpMGdttQus0rGyL3XIfYGLCi8ZfajYUaCKdZID2MH7+CNOuugWDdFDup3r7BR7Rfg==} + '@pnpm/linuxstatic-arm64@11.9.0': + resolution: {integrity: sha512-fif8xbnzVEAIlvaU4yIgWKXeXYb4Kj6WMEl/KvM2x1Rp3AKAjBW/53SGzxO4cZP9doAqUzOIpMRGFVbHGeMDRw==} cpu: [arm64] os: [linux] libc: [musl] - '@pnpm/linuxstatic-x64@11.7.0': - resolution: {integrity: sha512-z1+exW6ocU/rmOvJnmU3FUBJYaryCUqFoaXN6KZW5BqTj7BPJb7HJcAyXRlirNZMJlEiUY5rXbfStGPQJhGsVA==} + '@pnpm/linuxstatic-x64@11.9.0': + resolution: {integrity: sha512-9dKu3QdShqOpnWrjW9owARpIJeP0ul8UgIIbBUv8VDGEYibt+g49zQsNaD7SdMD3WeRSjExPQ1zIhplzr5cwvQ==} cpu: [x64] os: [linux] libc: [musl] - '@pnpm/macos-arm64@11.7.0': - resolution: {integrity: sha512-gD34/k3JT5oab4BYaqrUor3e4VdwXvkfLNlEI+lvDtX1MHT+2Nauc9p9NsQnpn1zE8blQEflzbF8wAUQ6Dmvkw==} + '@pnpm/macos-arm64@11.9.0': + resolution: {integrity: sha512-MWzBTgeI5p3odjdVltYvFXaSWAjF2Xk5YaxiP/u2RmW8N6PHsLyIyj37Ds992CrXgFM8fO1RpvsEirozhsD6KA==} cpu: [arm64] os: [darwin] - '@pnpm/win-arm64@11.7.0': - resolution: {integrity: sha512-hRTcDmm2j7KoRwbqNo0rUAu9A1kVJN98Eob7H09U0uPJbEMax85JGOmERkT3Lf6HjVJuFNBvfaJ3OTI3HmlVGg==} + '@pnpm/win-arm64@11.9.0': + resolution: {integrity: sha512-u/QxEcbKJZxC1t3zUYCZiHzu7TaZ/iXc6EGZoQjkeVT0LXmEVR6ypcK3ByjhIqbxQ3HGX75gnpIpR+VjRjubfw==} cpu: [arm64] os: [win32] - '@pnpm/win-x64@11.7.0': - resolution: {integrity: sha512-r/1NuKY7Z+6ZXVIzVrUEMj6TTEBdR63geV4Rlm8HKEhgQTdxyIsJoqV3FJGqoyzbRaScObqAwRfMaK9dskPddQ==} + '@pnpm/win-x64@11.9.0': + resolution: {integrity: sha512-HqJVHmZG5UKfLi38AjMl2azVmq87TlWRhwSW+f4q9LLaZeAkFyIZ7LY/pN6mh4VlH9yWj90C+tsb1yKiy7OKnw==} cpu: [x64] os: [win32] @@ -116,45 +116,45 @@ packages: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} - pnpm@11.7.0: - resolution: {integrity: sha512-GcyFLBIMcSV2DyRD7mvgyltA+fUFmN4aCaHxd1A+AQ5Xwjx3ZG4B52HeWb+HT7IqM5jDOrlpH8E+uUa28PTWIA==} + pnpm@11.9.0: + resolution: {integrity: sha512-vWgtXQP+Ul73yf1ngMaITR51asTJyf4AxTh4KCQxDc+Q493E9Tg18G3669UIXkGFXgvLs7YN4qxburieUDbwOw==} engines: {node: '>=22.13'} hasBin: true snapshots: - '@pnpm/exe@11.7.0': + '@pnpm/exe@11.9.0': dependencies: '@reflink/reflink': 0.1.19 detect-libc: 2.1.2 optionalDependencies: - '@pnpm/linux-arm64': 11.7.0 - '@pnpm/linux-x64': 11.7.0 - '@pnpm/linuxstatic-arm64': 11.7.0 - '@pnpm/linuxstatic-x64': 11.7.0 - '@pnpm/macos-arm64': 11.7.0 - '@pnpm/win-arm64': 11.7.0 - '@pnpm/win-x64': 11.7.0 + '@pnpm/linux-arm64': 11.9.0 + '@pnpm/linux-x64': 11.9.0 + '@pnpm/linuxstatic-arm64': 11.9.0 + '@pnpm/linuxstatic-x64': 11.9.0 + '@pnpm/macos-arm64': 11.9.0 + '@pnpm/win-arm64': 11.9.0 + '@pnpm/win-x64': 11.9.0 - '@pnpm/linux-arm64@11.7.0': + '@pnpm/linux-arm64@11.9.0': optional: true - '@pnpm/linux-x64@11.7.0': + '@pnpm/linux-x64@11.9.0': optional: true - '@pnpm/linuxstatic-arm64@11.7.0': + '@pnpm/linuxstatic-arm64@11.9.0': optional: true - '@pnpm/linuxstatic-x64@11.7.0': + '@pnpm/linuxstatic-x64@11.9.0': optional: true - '@pnpm/macos-arm64@11.7.0': + '@pnpm/macos-arm64@11.9.0': optional: true - '@pnpm/win-arm64@11.7.0': + '@pnpm/win-arm64@11.9.0': optional: true - '@pnpm/win-x64@11.7.0': + '@pnpm/win-x64@11.9.0': optional: true '@reflink/reflink-darwin-arm64@0.1.19': @@ -194,7 +194,7 @@ snapshots: detect-libc@2.1.2: {} - pnpm@11.7.0: {} + pnpm@11.9.0: {} --- lockfileVersion: '9.0' From 3b2e6097619bbd94c3617fa750d62366eccba9b3 Mon Sep 17 00:00:00 2001 From: DS Boyce <260543580+ds-boyce@users.noreply.github.com> Date: Thu, 25 Jun 2026 12:01:33 -0700 Subject: [PATCH 03/10] Add new chat notification preferences (#10972) --- eslint-suppressions.json | 16 -- package.json | 2 +- pnpm-lock.yaml | 71 ++++---- .../dialogs/NotificationSettingsDialog.tsx | 22 ++- src/lib/hooks/useNotificationHandler.ts | 78 ++++---- src/screens/Messages/Settings.tsx | 111 +++++++++++- .../components/ChatNotificationDialogs.tsx | 41 +++++ .../components/PreferenceControls.tsx | 65 +++---- .../components/SettingPreview.tsx | 35 ++++ .../Settings/NotificationSettings/index.tsx | 95 +++++----- src/state/queries/notifications/settings.ts | 166 +++++++++++++++--- 11 files changed, 492 insertions(+), 210 deletions(-) create mode 100644 src/screens/Settings/NotificationSettings/components/ChatNotificationDialogs.tsx create mode 100644 src/screens/Settings/NotificationSettings/components/SettingPreview.tsx diff --git a/eslint-suppressions.json b/eslint-suppressions.json index c3bbd462ca..06ac176613 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -946,14 +946,6 @@ "count": 1 } }, - "src/lib/hooks/useNotificationHandler.ts": { - "@typescript-eslint/no-floating-promises": { - "count": 16 - }, - "@typescript-eslint/require-await": { - "count": 1 - } - }, "src/lib/hooks/useOTAUpdates.ts": { "@typescript-eslint/no-explicit-any": { "count": 2 @@ -2183,14 +2175,6 @@ "count": 2 } }, - "src/view/com/modals/Modal.tsx": { - "@typescript-eslint/no-misused-promises": { - "count": 1 - }, - "@typescript-eslint/require-await": { - "count": 1 - } - }, "src/view/com/notifications/NotificationFeed.tsx": { "@typescript-eslint/no-explicit-any": { "count": 2 diff --git a/package.json b/package.json index 3981e9016f..bb0c86960a 100644 --- a/package.json +++ b/package.json @@ -94,7 +94,7 @@ "update-actions": "pnpm dlx actions-up --min-age 7" }, "dependencies": { - "@atproto/api": "0.20.15", + "@atproto/api": "0.20.20", "@atproto/syntax": "0.6.1", "@bitdrift/react-native": "^0.6.8", "@braintree/sanitize-url": "^6.0.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7be643fae5..d1ff05dc50 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -241,8 +241,8 @@ importers: .: dependencies: '@atproto/api': - specifier: 0.20.15 - version: 0.20.15 + specifier: 0.20.20 + version: 0.20.20 '@atproto/syntax': specifier: 0.6.1 version: 0.6.1 @@ -876,32 +876,36 @@ packages: graphql: optional: true - '@atproto/api@0.20.15': - resolution: {integrity: sha512-b9TuVNY9iWIaRXAeKegNCqRsK9tSpB68DE/j/ytVTxEMK+/m43B0DycJND9tnhRiNIY+i7MhwLqNHPak9YaDJg==} + '@atproto/api@0.20.20': + resolution: {integrity: sha512-+yqxLCvMu36xRxwc8/x0mhptZBC+/cbfPwdzeOtlBUeVi4ZB/5RWg2L4lWWdhBSiQBZw0AgaPg/2zsx4lDFgjg==} engines: {node: '>=22'} - '@atproto/common-web@0.5.0': - resolution: {integrity: sha512-ReWnkuZdDU/74/I47gaI26uxQjHmpq4edp41NnZZQ5vIIKGb7Ei6pZHzDTUD9JURo109SKrPx9RMP2IQm0fOKA==} + '@atproto/common-web@0.5.1': + resolution: {integrity: sha512-nru02gLyzjMQn4ZFgms9ry3kIAKwMaCAvjeFhB9mU6h1ABiSho7aRDbGvnU50CC88TROXuZlgRiEkBjnuiHEtA==} engines: {node: '>=22'} - '@atproto/lex-data@0.1.1': - resolution: {integrity: sha512-/xza8nU/YhtzhETnHL3QKKofaJ28/0NCzhT7LaYoUkm8EgypWp5ykEtmW52yLhQM2JF6fVa25g1soQmNTGqtSg==} + '@atproto/lex-data@0.1.2': + resolution: {integrity: sha512-NZ4iZvNaqTM6pz+9VRDyEjtozrrf2EDHySNi3Xa8PCzS8gRMCvsnnsvM7r264v4eSqNIDhBB8fr9hfWCHMspXg==} engines: {node: '>=22'} - '@atproto/lex-json@0.1.0': - resolution: {integrity: sha512-oWUrRMwFyWpmi/5k1Se3xBTbP06XdxBS5iFuUz9LmqItaPXwrWRD87a9ldPvINQ/A2/mn7J6/qug8sDVlhD+vQ==} + '@atproto/lex-json@0.1.1': + resolution: {integrity: sha512-FC/NsKHm8TDzWikvf/268T3mMAh6+f31yfCApWC3SvrhWc9c06cSYPp7qzpXcdV0OdB+I5dqHScuTB5OC7HrXg==} engines: {node: '>=22'} - '@atproto/lexicon@0.7.1': - resolution: {integrity: sha512-voNfNED5KUxn3vpo7N5DMRblBDfWf7kSfdKhJFC1RrLCxg38YbBzzURNVQJ32bp13Oot8kYfyXBWxTgtKLvw8w==} + '@atproto/lexicon@0.7.2': + resolution: {integrity: sha512-LVZcTr5+9Qh0okZnNmfRiIDPfbL/6rRxf4goiQxPxwDzaeUDhn4M209i1drmiitbCO1qRLJA2hHF54yNA75Cig==} engines: {node: '>=22'} '@atproto/syntax@0.6.1': resolution: {integrity: sha512-kA4dQDoMPpWCH8N0Q4KoSq024u5MkVfDVa8DdhyLjGA72z/khbOf1jXKPv7NIL2oEc9aj7geKELdvqyf4ogopA==} engines: {node: '>=22'} - '@atproto/xrpc@0.8.0': - resolution: {integrity: sha512-NJy02bIKrWlE2NQkRV1kT0Cj0ixbuxlF/MejBdo4cPWAa9v3oZexvAcjjb0zaOYeABkaU14iyIhvn2G4e/oLpw==} + '@atproto/syntax@0.6.2': + resolution: {integrity: sha512-h3njTNFl/jv5kTbDqfamQGOJfGbulqLDuAiLbasKwVdBhFyQanpRLa6vE2WqTwv1XEFWLYNMH0KCVsYwT2+aww==} + engines: {node: '>=22'} + + '@atproto/xrpc@0.8.1': + resolution: {integrity: sha512-sKopRG3an6LN6NfHnRNIEX1fBx3CRtxbNFWQxyse2f86wMcTuXM+/+olN4lVXgFQgv4AWvKTxRXWyuIF2G4YxQ==} engines: {node: '>=22'} '@babel/code-frame@7.10.4': @@ -9472,40 +9476,40 @@ snapshots: '@0no-co/graphql.web@1.2.0': {} - '@atproto/api@0.20.15': + '@atproto/api@0.20.20': dependencies: - '@atproto/common-web': 0.5.0 - '@atproto/lexicon': 0.7.1 - '@atproto/syntax': 0.6.1 - '@atproto/xrpc': 0.8.0 + '@atproto/common-web': 0.5.1 + '@atproto/lexicon': 0.7.2 + '@atproto/syntax': 0.6.2 + '@atproto/xrpc': 0.8.1 await-lock: 3.0.0 multiformats: 13.4.2 tlds: 1.261.0 zod: 3.25.76 - '@atproto/common-web@0.5.0': + '@atproto/common-web@0.5.1': dependencies: - '@atproto/lex-data': 0.1.1 - '@atproto/lex-json': 0.1.0 - '@atproto/syntax': 0.6.1 + '@atproto/lex-data': 0.1.2 + '@atproto/lex-json': 0.1.1 + '@atproto/syntax': 0.6.2 zod: 3.25.76 - '@atproto/lex-data@0.1.1': + '@atproto/lex-data@0.1.2': dependencies: multiformats: 13.4.2 tslib: 2.8.1 uint8arrays: 5.1.1 unicode-segmenter: 0.14.5 - '@atproto/lex-json@0.1.0': + '@atproto/lex-json@0.1.1': dependencies: - '@atproto/lex-data': 0.1.1 + '@atproto/lex-data': 0.1.2 tslib: 2.8.1 - '@atproto/lexicon@0.7.1': + '@atproto/lexicon@0.7.2': dependencies: - '@atproto/common-web': 0.5.0 - '@atproto/syntax': 0.6.1 + '@atproto/common-web': 0.5.1 + '@atproto/syntax': 0.6.2 multiformats: 13.4.2 zod: 3.25.76 @@ -9514,9 +9518,14 @@ snapshots: iso-datestring-validator: 2.2.2 tslib: 2.8.1 - '@atproto/xrpc@0.8.0': + '@atproto/syntax@0.6.2': dependencies: - '@atproto/lexicon': 0.7.1 + iso-datestring-validator: 2.2.2 + tslib: 2.8.1 + + '@atproto/xrpc@0.8.1': + dependencies: + '@atproto/lexicon': 0.7.2 zod: 3.25.76 '@babel/code-frame@7.10.4': diff --git a/src/components/dialogs/NotificationSettingsDialog.tsx b/src/components/dialogs/NotificationSettingsDialog.tsx index c62f6a7d98..bed3106c08 100644 --- a/src/components/dialogs/NotificationSettingsDialog.tsx +++ b/src/components/dialogs/NotificationSettingsDialog.tsx @@ -1,8 +1,12 @@ import {View} from 'react-native' -import {type AppBskyNotificationDefs} from '@atproto/api' import {Trans, useLingui} from '@lingui/react/macro' -import {useNotificationSettingsQuery} from '#/state/queries/notifications/settings' +import { + isChatPreferenceName, + type NotificationSettingsPreferenceName, + useChatNotificationSettingsQuery, + useNotificationSettingsQuery, +} from '#/state/queries/notifications/settings' import * as SettingsList from '#/screens/Settings/components/SettingsList' import {PreferenceControls} from '#/screens/Settings/NotificationSettings/components/PreferenceControls' import {atoms as a, useTheme, web} from '#/alf' @@ -15,8 +19,8 @@ import {IS_NATIVE} from '#/env' type NotificationSettingsDialogProps = { control: Dialog.DialogControlProps - name: Exclude - syncOthers?: Exclude[] + name: NotificationSettingsPreferenceName + syncOthers?: NotificationSettingsPreferenceName[] icon: React.ComponentType titleText: React.ReactNode subtitleText: React.ReactNode @@ -55,7 +59,11 @@ function NotificationSettingsDialogInner({ }: Omit) { const t = useTheme() const {t: l} = useLingui() - const {data: preferences, isError} = useNotificationSettingsQuery() + const isChat = isChatPreferenceName(name) + const appQuery = useNotificationSettingsQuery({enabled: !isChat}) + const chatQuery = useChatNotificationSettingsQuery({enabled: isChat}) + const isError = isChat ? chatQuery.isError : appQuery.isError + const preference = isChat ? chatQuery.data?.[name] : appQuery.data?.[name] return ( <> @@ -81,7 +89,7 @@ function NotificationSettingsDialogInner({ )} @@ -89,7 +97,7 @@ function NotificationSettingsDialogInner({ {IS_NATIVE && ( From b1b0ad09ccfb27eb168e64858409f9c9dff27933 Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Thu, 25 Jun 2026 16:52:09 -0500 Subject: [PATCH 05/10] Video alt text (#10990) Co-authored-by: Zaven477 <77801431+Zaven477@users.noreply.github.com> --- src/components/AltBadgeWithDialog.tsx | 93 ++++++++++++++ .../Post/Embed/ExternalEmbed/Gif.tsx | 14 +-- .../VideoEmbed/GifPresentationControls.tsx | 113 ++++++------------ .../VideoEmbedInner/VideoEmbedInnerNative.tsx | 32 +++-- .../VideoEmbedInner/VideoEmbedInnerWeb.tsx | 9 +- 5 files changed, 159 insertions(+), 102 deletions(-) create mode 100644 src/components/AltBadgeWithDialog.tsx diff --git a/src/components/AltBadgeWithDialog.tsx b/src/components/AltBadgeWithDialog.tsx new file mode 100644 index 0000000000..07cf9544f0 --- /dev/null +++ b/src/components/AltBadgeWithDialog.tsx @@ -0,0 +1,93 @@ +import {Pressable} from 'react-native' +import {Trans, useLingui} from '@lingui/react/macro' + +import {HITSLOP_20} from '#/lib/constants' +import {useLargeAltBadgeEnabled} from '#/state/preferences/large-alt-badge' +import {atoms as a, useTheme} from '#/alf' +import * as Prompt from '#/components/Prompt' +import {Text} from '#/components/Typography' + +const positionStyles = { + 'top-left': { + top: a.p_xs.padding, + left: a.p_xs.padding, + }, + 'top-right': { + top: a.p_xs.padding, + right: a.p_xs.padding, + }, + 'bottom-left': { + bottom: a.p_xs.padding, + left: a.p_xs.padding, + }, + 'bottom-right': { + bottom: a.p_xs.padding, + right: a.p_xs.padding, + }, +} + +export function AltBadgeWithDialog({ + text, + position, +}: { + text: string + position?: 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right' +}) { + const t = useTheme() + const {t: l} = useLingui() + const large = useLargeAltBadgeEnabled() + const control = Prompt.usePromptControl() + + const pos = position ? [a.absolute, positionStyles[position]] : {} + + return ( + <> + [ + a.justify_center, + a.rounded_sm, + a.p_xs, + a.z_10, + t.atoms.bg_contrast_25, + large && { + padding: 6, + }, + { + opacity: 0.8, + }, + pos, + s.hovered || s.pressed + ? [ + { + opacity: 1, + }, + ] + : [], + ]}> + + ALT + + + + + + + Alt Text + + {text} + + + + + + + ) +} diff --git a/src/components/Post/Embed/ExternalEmbed/Gif.tsx b/src/components/Post/Embed/ExternalEmbed/Gif.tsx index 4cf95e0fab..24ea286ada 100644 --- a/src/components/Post/Embed/ExternalEmbed/Gif.tsx +++ b/src/components/Post/Embed/ExternalEmbed/Gif.tsx @@ -66,19 +66,7 @@ export function GifEmbed({ a.overflow_hidden, {backgroundColor: t.palette.black}, ]}> - + @@ -64,74 +60,41 @@ export function GifPresentationControls({ ]} /> )} - - - GIF - + + + + GIF + + + {altText && } - {altText && } ) } - -function AltBadge({text}: {text: string}) { - const control = Prompt.usePromptControl() - const {_} = useLingui() - - return ( - <> - - - ALT - - - - - - Alt Text - - {text} - - - control.close()} - cta={_(msg`Close`)} - color="secondary" - /> - - - - ) -} - -const styles = StyleSheet.create({ - gifBadgeContainer: { - backgroundColor: 'rgba(0, 0, 0, 0.75)', - borderRadius: 6, - paddingHorizontal: 4, - paddingVertical: 3, - position: 'absolute', - left: 6, - bottom: 6, - zIndex: 2, - }, - altBadgeContainer: { - backgroundColor: 'rgba(0, 0, 0, 0.75)', - borderRadius: 6, - paddingHorizontal: 4, - paddingVertical: 3, - position: 'absolute', - right: 6, - bottom: 6, - zIndex: 2, - }, -}) diff --git a/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerNative.tsx b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerNative.tsx index 05e8b84581..f38677ea1d 100644 --- a/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerNative.tsx +++ b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerNative.tsx @@ -8,6 +8,7 @@ import {useLingui} from '@lingui/react' import {HITSLOP_30} from '#/lib/constants' import {useAutoplayDisabled} from '#/state/preferences' import {atoms as a, useTheme} from '#/alf' +import {AltBadgeWithDialog} from '#/components/AltBadgeWithDialog' import {useIsWithinMessage} from '#/components/dms/MessageContext' import {Mute_Stroke2_Corner0_Rounded as MuteIcon} from '#/components/icons/Mute' import {Pause_Filled_Corner0_Rounded as PauseIcon} from '#/components/icons/Pause' @@ -98,19 +99,24 @@ export function VideoEmbedInnerNative({ altText={embed.alt} /> ) : ( - { - videoRef.current?.enterFullscreen(true) - }} - toggleMuted={() => { - videoRef.current?.toggleMuted() - }} - togglePlayback={() => { - videoRef.current?.togglePlayback() - }} - isPlaying={isPlaying} - timeRemaining={timeRemaining} - /> + <> + { + videoRef.current?.enterFullscreen(true) + }} + toggleMuted={() => { + videoRef.current?.toggleMuted() + }} + togglePlayback={() => { + videoRef.current?.togglePlayback() + }} + isPlaying={isPlaying} + timeRemaining={timeRemaining} + /> + {embed.alt && ( + + )} + )} diff --git a/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb.tsx b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb.tsx index d56c0b234d..b97317dfbf 100644 --- a/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb.tsx +++ b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb.tsx @@ -7,6 +7,8 @@ import type * as HlsTypes from 'hls.js' import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' import {atoms as a} from '#/alf' +import {AltBadgeWithDialog} from '#/components/AltBadgeWithDialog' +import {useFullscreen} from '#/components/hooks/useFullscreen' import * as BandwidthEstimate from './bandwidth-estimate' import {Controls} from './web-controls/VideoControls' @@ -30,6 +32,8 @@ export function VideoEmbedInnerWeb({ const [hlsLoading, setHlsLoading] = useState(false) const figId = useId() const {_} = useLingui() + const [isFullscreen] = useFullscreen(containerRef) + const isGif = embed.presentation === 'gif' // send error up to error boundary const [error, setError] = useState(null) @@ -77,6 +81,9 @@ export function VideoEmbedInnerWeb({ )} + {!isFullscreen && !isGif && embed.alt && ( + + )} From c33a35851e911edcecb30118b3c901351214b5b5 Mon Sep 17 00:00:00 2001 From: Alex Benzer Date: Thu, 25 Jun 2026 14:53:49 -0700 Subject: [PATCH 06/10] Show shorter text for account label badge (#10983) --- src/components/moderation/LabelsOnMe.tsx | 28 ++++++++++-------------- 1 file changed, 11 insertions(+), 17 deletions(-) diff --git a/src/components/moderation/LabelsOnMe.tsx b/src/components/moderation/LabelsOnMe.tsx index ee8625b8a4..126e496887 100644 --- a/src/components/moderation/LabelsOnMe.tsx +++ b/src/components/moderation/LabelsOnMe.tsx @@ -2,7 +2,7 @@ import {type StyleProp, View, type ViewStyle} from 'react-native' import {type AppBskyFeedDefs, type ComAtprotoLabelDefs} from '@atproto/api' import {msg} from '@lingui/core/macro' import {useLingui} from '@lingui/react' -import {Plural, Trans} from '@lingui/react/macro' +import {Plural} from '@lingui/react/macro' import {useSession} from '#/state/session' import {atoms as a} from '#/alf' @@ -60,23 +60,17 @@ export function LabelsOnMe({ {type === 'account' ? ( - - {' '} - been placed on this account - + ) : ( - - {' '} - been placed on this content - + )} From 29a0d16b6953ce758840d807935254a218eb6521 Mon Sep 17 00:00:00 2001 From: Lynx Date: Thu, 25 Jun 2026 16:54:31 -0500 Subject: [PATCH 07/10] Add icon to AppLanguageDropdown (#10977) --- src/components/AppLanguageDropdown.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/components/AppLanguageDropdown.tsx b/src/components/AppLanguageDropdown.tsx index 4d66ba6db5..1114310f44 100644 --- a/src/components/AppLanguageDropdown.tsx +++ b/src/components/AppLanguageDropdown.tsx @@ -7,9 +7,10 @@ import {sanitizeAppLanguageSetting} from '#/locale/helpers' import {APP_LANGUAGES} from '#/locale/languages' import {useLanguagePrefs, useLanguagePrefsApi} from '#/state/preferences' import {resetPostsFeedQueries} from '#/state/queries/post-feed' -import {atoms as a, platform, useTheme} from '#/alf' +import {atoms as a, platform, useTheme, web} from '#/alf' import * as Select from '#/components/Select' -import {Button} from './Button' +import {Button, ButtonIcon} from './Button' +import {Earth_Stroke2_Corner2_Rounded as EarthIcon} from './icons/Globe' export function AppLanguageDropdown() { const t = useTheme() @@ -57,6 +58,7 @@ export function AppLanguageDropdown() { native: [a.gap_xs], }), ]}> + ( - + {label} From eae15aca3ea591201d2b37b90da32d549b3cd8ff Mon Sep 17 00:00:00 2001 From: DS Boyce <260543580+ds-boyce@users.noreply.github.com> Date: Thu, 25 Jun 2026 14:57:03 -0700 Subject: [PATCH 08/10] Increase unread cap to 99+ (#10985) --- src/screens/Messages/Inbox.tsx | 13 +++---------- .../Messages/components/InboxRequests.tsx | 14 ++++++-------- .../queries/messages/get-unread-counts.ts | 13 ++++++------- .../queries/messages/list-conversations.tsx | 10 ++++------ src/view/shell/bottom-bar/BottomBar.tsx | 18 ++++++++++++------ 5 files changed, 31 insertions(+), 37 deletions(-) diff --git a/src/screens/Messages/Inbox.tsx b/src/screens/Messages/Inbox.tsx index 2fb58253a5..f6b4216b2c 100644 --- a/src/screens/Messages/Inbox.tsx +++ b/src/screens/Messages/Inbox.tsx @@ -23,6 +23,7 @@ import {cleanError} from '#/lib/strings/errors' import {logger} from '#/logger' import {MESSAGE_SCREEN_POLL_INTERVAL} from '#/state/messages/convo/const' import {useMessagesEventBus} from '#/state/messages/events' +import {useUnreadCountsQuery} from '#/state/queries/messages/get-unread-counts' import {useListConvoRequests} from '#/state/queries/messages/list-conversation-requests' import {useUpdateAllRead} from '#/state/queries/messages/update-all-read' import {EmptyState} from '#/view/com/util/EmptyState' @@ -84,16 +85,8 @@ export function MessagesInboxScreenInner({}: Props) { return items }, [data]) - const hasUnreadConvos = useMemo(() => { - return conversations.some( - item => - item.type === 'incoming' && - item.view.members.every( - member => member.handle !== 'missing.invalid', - ) && - item.view.unreadCount > 0, - ) - }, [conversations]) + const {data: unreadCounts} = useUnreadCountsQuery() + const hasUnreadConvos = (unreadCounts?.unreadRequestConvos ?? 0) > 0 return ( diff --git a/src/screens/Messages/components/InboxRequests.tsx b/src/screens/Messages/components/InboxRequests.tsx index ba5f2bf385..bcea519100 100644 --- a/src/screens/Messages/components/InboxRequests.tsx +++ b/src/screens/Messages/components/InboxRequests.tsx @@ -1,14 +1,12 @@ import {plural} from '@lingui/core/macro' import {useLingui} from '@lingui/react/macro' +import {UNREAD_REQUEST_CAP} from '#/state/queries/messages/get-unread-counts' import {atoms as a} from '#/alf' import {ButtonIcon, ButtonText} from '#/components/Button' import {Inbox_Stroke2_Corner2_Rounded as InboxIcon} from '#/components/icons/Inbox' import {Link} from '#/components/Link' -// The server caps unreadRequestConvos at 11, where 11 means "any more than 10". -const REQUEST_COUNT_CAP = 11 - export function InboxRequests({ count, variant, @@ -21,7 +19,7 @@ export function InboxRequests({ const {t: l} = useLingui() const unread = count > 0 - const overflow = count >= REQUEST_COUNT_CAP + const overflow = count >= UNREAD_REQUEST_CAP const label = !unread ? l({ @@ -30,8 +28,8 @@ export function InboxRequests({ }) : overflow ? l({ - message: `10+ requests`, - comment: 'Displayed when the number of requests is greater than 10', + message: `${UNREAD_REQUEST_CAP - 1}+ requests`, + comment: 'Displayed when the number of requests exceeds the cap', }) : plural(count, { one: '# request', @@ -55,9 +53,9 @@ export function InboxRequests({ {overflow ? l({ - message: `10+`, + message: `${UNREAD_REQUEST_CAP - 1}+`, comment: - 'Displayed when the number of requests is greater than 10', + 'Displayed when the number of requests exceeds the cap – for example, 99+ requests', }) : count} diff --git a/src/state/queries/messages/get-unread-counts.ts b/src/state/queries/messages/get-unread-counts.ts index 57b565fa17..8c276d67f7 100644 --- a/src/state/queries/messages/get-unread-counts.ts +++ b/src/state/queries/messages/get-unread-counts.ts @@ -10,13 +10,12 @@ export const RQKEY = (includeGroupChats: boolean) => [RQKEY_ROOT, includeGroupChats] as const export const RQKEY_PARTIAL = [RQKEY_ROOT] as const -// the server sentinel-caps the badge counts: unreadAcceptedConvos maxes at 31 -// (meaning "more than 30") and unreadRequestConvos at 11 (meaning "more than -// 10"). at the cap the value is no longer an exact count, so consumers must not -// treat it as one - both the optimistic decrement and the badge display ceiling -// key off these. -export const UNREAD_ACCEPTED_CAP = 31 -export const UNREAD_REQUEST_CAP = 11 +// the server sentinel-caps the badge counts: unreadAcceptedConvos and +// unreadRequestConvos max out at 100 (meaning "more than 99"). at the cap the +// value is no longer an exact count, so consumers must not treat it as one - +// both the optimistic decrement and the badge display ceiling key off these. +export const UNREAD_ACCEPTED_CAP = 100 +export const UNREAD_REQUEST_CAP = 100 export function useUnreadCountsQuery() { const agent = useAgent() diff --git a/src/state/queries/messages/list-conversations.tsx b/src/state/queries/messages/list-conversations.tsx index 3a3183bad1..1f73c720bd 100644 --- a/src/state/queries/messages/list-conversations.tsx +++ b/src/state/queries/messages/list-conversations.tsx @@ -856,18 +856,16 @@ export function useUnreadMessageCount(): { const request = data?.unreadRequestConvos ?? 0 if (accepted > 0) { - const total = accepted + Math.min(request, 1) return { - count: total, + count: accepted, // accepted is sentinel-capped at UNREAD_ACCEPTED_CAP (meaning "more than // cap - 1"). show the "+" overflow label only when accepted is actually - // capped - the +1 request nudge must not trip it at exactly cap - 1 - // accepted convos. otherwise clamp the number to cap - 1 so the nudge - // never surfaces the sentinel value (31) itself + // capped, otherwise clamp the number to cap - 1 so we never surface the + // sentinel value (100) itself numUnread: accepted >= UNREAD_ACCEPTED_CAP ? `${UNREAD_ACCEPTED_CAP - 1}+` - : String(Math.min(total, UNREAD_ACCEPTED_CAP - 1)), + : String(Math.min(accepted, UNREAD_ACCEPTED_CAP - 1)), // only needed when numUnread is undefined hasNew: false, } diff --git a/src/view/shell/bottom-bar/BottomBar.tsx b/src/view/shell/bottom-bar/BottomBar.tsx index 8650e99b89..717921a092 100644 --- a/src/view/shell/bottom-bar/BottomBar.tsx +++ b/src/view/shell/bottom-bar/BottomBar.tsx @@ -242,12 +242,18 @@ export function BottomBar({navigation}: BottomTabBarProps) { accessibilityLabel={l`Chat`} accessibilityHint={ !aa.flags.chatDisabled && numUnreadMessages.count > 0 - ? l({ - message: plural(numUnreadMessages.numUnread ?? 0, { - one: '# unread item', - other: '# unread items', - }), - }) + ? numUnreadMessages.numUnread?.includes('+') + ? l({ + message: `${numUnreadMessages.numUnread} unread items`, + comment: + 'Accessibility hint for the bottom bar chat icon when the number of unread messages exceeds the cap, with the + symbol already included – for example, 99+ unread items', + }) + : l({ + message: plural(numUnreadMessages.numUnread ?? 0, { + one: '# unread item', + other: '# unread items', + }), + }) : '' } /> From 6b342f61b433e20201799cf680b9f771f1c2d78b Mon Sep 17 00:00:00 2001 From: DS Boyce <260543580+ds-boyce@users.noreply.github.com> Date: Thu, 25 Jun 2026 14:59:50 -0700 Subject: [PATCH 09/10] Hide autocomplete suggestions when input loses focus (#10989) --- src/view/shell/desktop/Search.tsx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/view/shell/desktop/Search.tsx b/src/view/shell/desktop/Search.tsx index 189a9fafcf..b36ba52ce1 100644 --- a/src/view/shell/desktop/Search.tsx +++ b/src/view/shell/desktop/Search.tsx @@ -27,6 +27,10 @@ export function DesktopSearch() { if (query.length) setActive(true) } + const onBlur = () => { + setActive(false) + } + const onChangeText = (text: string) => { setQuery(text) if (!active) { @@ -64,6 +68,7 @@ export function DesktopSearch() { hotkey value={query} onFocus={onFocus} + onBlur={onBlur} onChangeText={onChangeText} onClearText={onClearText} onSubmitEditing={onSubmit} From fcf17a482fe5fa945553c2a96c6b46d36da98ee5 Mon Sep 17 00:00:00 2001 From: Spence Pope Date: Thu, 25 Jun 2026 18:14:05 -0400 Subject: [PATCH 10/10] Add video upload telemetry events (#10991) --- src/analytics/metrics/types.ts | 96 +++++++++ src/lib/media/video/compress.ts | 41 +++- src/lib/media/video/compress.web.ts | 26 ++- src/lib/media/video/telemetry.ts | 271 ++++++++++++++++++++++++ src/lib/media/video/types.ts | 13 ++ src/view/com/composer/Composer.tsx | 30 ++- src/view/com/composer/state/composer.ts | 8 +- src/view/com/composer/state/video.ts | 31 +++ 8 files changed, 496 insertions(+), 20 deletions(-) create mode 100644 src/lib/media/video/telemetry.ts diff --git a/src/analytics/metrics/types.ts b/src/analytics/metrics/types.ts index a9d7f7bb17..e34feae3a5 100644 --- a/src/analytics/metrics/types.ts +++ b/src/analytics/metrics/types.ts @@ -5,6 +5,7 @@ import {type Platform} from 'react-native' import {type NotificationReason} from '#/lib/hooks/useNotificationHandler' +import {type VideoCompressSkipReason} from '#/lib/media/video/types' import {type NotificationType} from '#/state/queries/notifications/types' import {type FeedDescriptor} from '#/state/queries/post-feed' import {type LiveEventFeedMetricContext} from '#/features/liveEvents/types' @@ -1331,4 +1332,99 @@ export type Events = { 'invite:followersPromo:press': {} // user dismissed the empty-followers promo banner 'invite:followersPromo:dismiss': {} + + // === Video upload funnel (Frontend Spec section D) === + // Every event carries uploadId (client-generated UUID, ties one upload + // session end-to-end) + engine (compression engine id, e.g. + // native:react-native-compressor@1.13.0). jobId is added once the server + // returns it. Sizes / codecs / dimensions / timings only - never content. + 'video:upload:picked': { + uploadId: string + engine: string + sourceMimeType?: string + sourceBytes?: number + sourceDurationMs?: number + sourceWidth?: number + sourceHeight?: number + } + 'video:upload:compressStarted': { + uploadId: string + engine: string + sourceBytes?: number + } + 'video:upload:compressCompleted': { + uploadId: string + engine: string + bytesIn?: number + bytesOut: number + outputMimeType: string + elapsedMs: number + } + 'video:upload:compressSkipped': { + uploadId: string + engine: string + skipReason: VideoCompressSkipReason + bytes: number + mimeType: string + elapsedMs: number + } + 'video:upload:compressFailed': { + uploadId: string + engine: string + errorClass: string + elapsedMs: number + } + 'video:upload:uploadStarted': { + uploadId: string + engine: string + bytes: number + } + 'video:upload:uploadCompleted': { + uploadId: string + engine: string + jobId: string + bytes: number + elapsedMs: number + throughputBytesPerSec: number + } + 'video:upload:uploadFailed': { + uploadId: string + engine: string + bytes: number + errorClass: string + elapsedMs: number + } + 'video:upload:processingStarted': { + uploadId: string + engine: string + jobId: string + } + 'video:upload:processingCompleted': { + uploadId: string + engine: string + jobId: string + elapsedMs: number + } + 'video:upload:processingFailed': { + uploadId: string + engine: string + jobId: string + errorClass: string + elapsedMs: number + } + 'video:upload:published': { + uploadId: string + engine: string + jobId: string + // wall-clock from picked to published + totalElapsedMs: number + } + // The event that measures the actual problem: users giving up mid-wait. + 'video:upload:abandoned': { + uploadId: string + engine: string + phase: 'compress' | 'upload' | 'processing' + jobId?: string + elapsedInPhaseMs: number + } } diff --git a/src/lib/media/video/compress.ts b/src/lib/media/video/compress.ts index 1d00bfcea1..dfb2e17b4b 100644 --- a/src/lib/media/video/compress.ts +++ b/src/lib/media/video/compress.ts @@ -5,7 +5,7 @@ import {SUPPORTED_MIME_TYPES, type SupportedMimeTypes} from '#/lib/constants' import {type CompressedVideo} from './types' import {extToMime} from './util' -const MIN_SIZE_FOR_COMPRESSION = 25 // 25mb +const MIN_SIZE_FOR_COMPRESSION_BYTES = 25 * 1024 * 1024 // 25mb export async function compressVideo( file: ImagePickerAsset, @@ -16,20 +16,36 @@ export async function compressVideo( ): Promise { const {onProgress, signal} = opts || {} - const isAcceptableFormat = SUPPORTED_MIME_TYPES.includes( - file.mimeType as SupportedMimeTypes, - ) - if (file.mimeType === 'image/gif') { // let's hope they're small enough that they don't need compression! // this compression library doesn't support gifs // worst case - server rejects them. I think that's fine -sfn - return {uri: file.uri, size: file.fileSize ?? -1, mimeType: 'image/gif'} + return { + uri: file.uri, + size: file.fileSize ?? -1, + mimeType: 'image/gif', + passthroughReason: 'gif', + } } - const minimumFileSizeForCompress = isAcceptableFormat - ? MIN_SIZE_FOR_COMPRESSION - : 0 + // Pre-check the threshold ourselves so we can label the skip in telemetry. + // rnc would do the same skip internally via minimumFileSizeForCompress, but + // that path is invisible to us. + const isAcceptableFormat = SUPPORTED_MIME_TYPES.includes( + file.mimeType as SupportedMimeTypes, + ) + if ( + isAcceptableFormat && + file.fileSize != null && + file.fileSize < MIN_SIZE_FOR_COMPRESSION_BYTES + ) { + return { + uri: file.uri, + size: file.fileSize, + mimeType: file.mimeType ?? 'video/mp4', + passthroughReason: 'below-byte-threshold', + } + } const compressed = await Video.compress( file.uri, @@ -37,8 +53,13 @@ export async function compressVideo( compressionMethod: 'manual', bitrate: 3_000_000, // 3mbps maxSize: 1920, + // Force a transcode for unacceptable-format files regardless of size. + // rnc's default minimumFileSizeForCompress would otherwise pass small + // unacceptable-format files through unchanged and the server would + // reject them. Acceptable formats are already short-circuited above so + // they never reach this call. // WARNING: this ONE SPECIFIC ARG is in MB -sfn - minimumFileSizeForCompress, + minimumFileSizeForCompress: 0, getCancellationId: id => { if (signal) { signal.addEventListener('abort', () => { diff --git a/src/lib/media/video/compress.web.ts b/src/lib/media/video/compress.web.ts index 96e0fc43f0..62e8469351 100644 --- a/src/lib/media/video/compress.web.ts +++ b/src/lib/media/video/compress.web.ts @@ -47,6 +47,7 @@ export async function compressVideo( const blob = await response.blob() const isGif = blob.type === 'image/gif' + const hasCodecs = hasWebCodecs() logger.debug('compress: fetched blob', { size: blob.size, @@ -57,22 +58,32 @@ export async function compressVideo( // Try MediaBunny compression if WebCodecs is available and file is large enough // Skip GIFs - MediaBunny doesn't support them - if (hasWebCodecs() && blob.size >= COMPRESSION_MIN_SIZE_BYTES && !isGif) { + let fallbackReason: NonNullable | null = + null + if (isGif) { + fallbackReason = 'gif' + } else if (!hasCodecs) { + fallbackReason = 'no-webcodecs' + } else if (blob.size < COMPRESSION_MIN_SIZE_BYTES) { + fallbackReason = 'below-byte-threshold' + } else { try { return await doCompression(blob, asset.uri, {onProgress, signal}) } catch (e) { logger.warn('compress: MediaBunny compression failed, using original', { safeMessage: e, }) + fallbackReason = 'compress-error-fallback' } - } else { - logger.debug('compress: skipping compression', { - hasWebCodecs: hasWebCodecs(), - blobSize: blob.size, - minSize: COMPRESSION_MIN_SIZE_BYTES, - }) } + logger.debug('compress: skipping compression', { + hasWebCodecs: hasCodecs, + blobSize: blob.size, + minSize: COMPRESSION_MIN_SIZE_BYTES, + reason: fallbackReason, + }) + // No compression path - just return the blob as-is if (blob.size > VIDEO_MAX_SIZE) { throw new VideoTooLargeError() @@ -83,6 +94,7 @@ export async function compressVideo( size: blob.size, bytes: await blob.arrayBuffer(), mimeType: blob.type || 'video/mp4', + passthroughReason: fallbackReason, } } diff --git a/src/lib/media/video/telemetry.ts b/src/lib/media/video/telemetry.ts new file mode 100644 index 0000000000..7ef5a874ef --- /dev/null +++ b/src/lib/media/video/telemetry.ts @@ -0,0 +1,271 @@ +import {Platform} from 'react-native' +import {type ImagePickerAsset} from 'expo-image-picker' +import {nanoid} from 'nanoid/non-secure' + +import {type VideoCompressSkipReason} from '#/lib/media/video/types' +import {Sentry} from '#/logger/sentry/lib' +import {type Metrics} from '#/analytics/metrics' + +type MetricFn = (event: E, payload: Metrics[E]) => void + +// Identifies the active compression engine. Bumped when the engine swaps. +// Versions are intentionally hardcoded so a dependency bump shows up in +// analytics as a label change. +const COMPRESS_ENGINE = + Platform.OS === 'web' + ? 'web:mediabunny@1.25.3' + : 'native:react-native-compressor@1.13.0' + +type Phase = 'compress' | 'upload' | 'processing' + +function errorClass(e: unknown): string { + if (e instanceof Error) return e.name || 'Error' + return 'Unknown' +} + +export type VideoTelemetry = { + readonly uploadId: string + readonly engine: string + picked: () => void + compressStarted: () => void + compressSkipped: (video: { + size: number + mimeType: string + skipReason: VideoCompressSkipReason + }) => void + compressCompleted: (video: {size: number; mimeType: string}) => void + compressFailed: (e: unknown) => void + uploadStarted: (bytes: number) => void + uploadCompleted: (jobId: string) => void + uploadFailed: (e: unknown) => void + processingStarted: (jobId: string) => void + processingCompleted: () => void + processingFailed: (e: unknown) => void + published: () => void +} + +export function createVideoTelemetry({ + asset, + signal, + metric, +}: { + asset: ImagePickerAsset + signal: AbortSignal + metric: MetricFn +}): VideoTelemetry { + const uploadId = nanoid() + const engine = COMPRESS_ENGINE + const startedAt = Date.now() + + let phase: Phase | undefined + let phaseStartedAt = startedAt + let jobId: string | undefined + let uploadBytes: number | undefined + let txnEnded = false + let abortBound = true + + // Parent span: full selection->ready arc. Inactive so phase spans can be + // attached as children regardless of the current async context. + const txn = Sentry.startInactiveSpan({ + name: 'video.upload', + op: 'video.upload', + attributes: { + uploadId, + engine, + 'video.source.mime': asset.mimeType ?? 'unknown', + 'video.source.bytes': asset.fileSize ?? 0, + 'video.source.durationMs': asset.duration ?? 0, + 'video.source.width': asset.width ?? 0, + 'video.source.height': asset.height ?? 0, + }, + }) + + let phaseSpan: ReturnType | undefined + + function endPhaseSpan() { + if (!phaseSpan) return + phaseSpan.end() + phaseSpan = undefined + } + + function enterPhase(next: Phase, spanName: string) { + endPhaseSpan() + phase = next + phaseStartedAt = Date.now() + phaseSpan = Sentry.withActiveSpan(txn, () => + Sentry.startInactiveSpan({ + name: spanName, + op: spanName, + attributes: {uploadId, engine}, + }), + ) + } + + function endTxn(outcome: 'ok' | 'error' | 'cancelled') { + if (txnEnded) return + txnEnded = true + endPhaseSpan() + txn.setAttribute('outcome', outcome) + txn.end() + } + + function detachAbort() { + if (!abortBound) return + abortBound = false + signal.removeEventListener('abort', onAbort) + } + + function onAbort() { + if (phase) { + metric('video:upload:abandoned', { + uploadId, + engine, + phase, + jobId, + elapsedInPhaseMs: Date.now() - phaseStartedAt, + }) + } + endTxn('cancelled') + abortBound = false + } + signal.addEventListener('abort', onAbort, {once: true}) + + return { + uploadId, + engine, + + picked() { + metric('video:upload:picked', { + uploadId, + engine, + sourceMimeType: asset.mimeType, + sourceBytes: asset.fileSize, + sourceDurationMs: asset.duration ?? undefined, + sourceWidth: asset.width, + sourceHeight: asset.height, + }) + }, + + compressStarted() { + enterPhase('compress', 'video.compress') + metric('video:upload:compressStarted', { + uploadId, + engine, + sourceBytes: asset.fileSize, + }) + }, + + compressSkipped({size, mimeType, skipReason}) { + metric('video:upload:compressSkipped', { + uploadId, + engine, + skipReason, + bytes: size, + mimeType, + elapsedMs: Date.now() - phaseStartedAt, + }) + endPhaseSpan() + phase = undefined + }, + + compressCompleted({size, mimeType}) { + metric('video:upload:compressCompleted', { + uploadId, + engine, + bytesIn: asset.fileSize, + bytesOut: size, + outputMimeType: mimeType, + elapsedMs: Date.now() - phaseStartedAt, + }) + endPhaseSpan() + phase = undefined + }, + + compressFailed(e) { + metric('video:upload:compressFailed', { + uploadId, + engine, + errorClass: errorClass(e), + elapsedMs: Date.now() - phaseStartedAt, + }) + endTxn('error') + detachAbort() + }, + + uploadStarted(bytes) { + uploadBytes = bytes + enterPhase('upload', 'video.upload.transfer') + metric('video:upload:uploadStarted', {uploadId, engine, bytes}) + }, + + uploadCompleted(id) { + jobId = id + const elapsedMs = Date.now() - phaseStartedAt + const bytes = uploadBytes ?? 0 + metric('video:upload:uploadCompleted', { + uploadId, + engine, + jobId: id, + bytes, + elapsedMs, + throughputBytesPerSec: + elapsedMs > 0 ? Math.round((bytes * 1000) / elapsedMs) : 0, + }) + endPhaseSpan() + phase = undefined + }, + + uploadFailed(e) { + metric('video:upload:uploadFailed', { + uploadId, + engine, + bytes: uploadBytes ?? 0, + errorClass: errorClass(e), + elapsedMs: Date.now() - phaseStartedAt, + }) + endTxn('error') + detachAbort() + }, + + processingStarted(id) { + jobId = id + enterPhase('processing', 'video.processing') + metric('video:upload:processingStarted', {uploadId, engine, jobId: id}) + }, + + processingCompleted() { + metric('video:upload:processingCompleted', { + uploadId, + engine, + jobId: jobId ?? '', + elapsedMs: Date.now() - phaseStartedAt, + }) + // Upload pipeline is done; publish is a separate user action that + // fires its own event. Releases the parent span so its duration + // measures upload work, not idle composer time. + endTxn('ok') + detachAbort() + }, + + processingFailed(e) { + metric('video:upload:processingFailed', { + uploadId, + engine, + jobId: jobId ?? '', + errorClass: errorClass(e), + elapsedMs: Date.now() - phaseStartedAt, + }) + endTxn('error') + detachAbort() + }, + + published() { + metric('video:upload:published', { + uploadId, + engine, + jobId: jobId ?? '', + totalElapsedMs: Date.now() - startedAt, + }) + }, + } +} diff --git a/src/lib/media/video/types.ts b/src/lib/media/video/types.ts index ae873d7565..d22832794a 100644 --- a/src/lib/media/video/types.ts +++ b/src/lib/media/video/types.ts @@ -1,7 +1,20 @@ +// Why the compress engine returned the input unchanged. Used both as the +// reason on `CompressedVideo.passthroughReason` and as the `skipReason` field +// on the `video:upload:compressSkipped` analytics event, so the two stay in +// sync. +export type VideoCompressSkipReason = + | 'gif' + | 'below-byte-threshold' + | 'no-webcodecs' + | 'compress-error-fallback' + export type CompressedVideo = { uri: string mimeType: string size: number // web only, can fall back to uri if missing bytes?: ArrayBuffer + // Set when the engine returned the input unchanged. Undefined means the + // bytes were actually re-encoded. + passthroughReason?: VideoCompressSkipReason } diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx index 78a78a22a8..92b21ac52c 100644 --- a/src/view/com/composer/Composer.tsx +++ b/src/view/com/composer/Composer.tsx @@ -73,6 +73,7 @@ import { } from '#/lib/constants' import {useIsKeyboardVisible} from '#/lib/hooks/useIsKeyboardVisible' import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' +import {createVideoTelemetry} from '#/lib/media/video/telemetry' import {mimeToExt} from '#/lib/media/video/util' import {useCallOnce} from '#/lib/once' import {type NavigationProp} from '#/lib/routes/types' @@ -387,6 +388,12 @@ export const ComposePost = ({ const selectVideo = useCallback( (postId: string, asset: ImagePickerAsset) => { const abortController = new AbortController() + const telemetry = createVideoTelemetry({ + asset, + signal: abortController.signal, + metric: ax.metric, + }) + telemetry.picked() composerDispatch({ type: 'update_post', postId: postId, @@ -394,6 +401,7 @@ export const ComposePost = ({ type: 'embed_add_video', asset, abortController, + telemetry, }, }) void processVideo( @@ -412,9 +420,10 @@ export const ComposePost = ({ currentDid, abortController.signal, i18n, + telemetry, ) }, - [i18n, agent, currentDid, composerDispatch], + [i18n, agent, currentDid, composerDispatch, ax.metric], ) const onInitVideo = useNonReactiveCallback(() => { @@ -494,6 +503,12 @@ export const ComposePost = ({ // Start video processing using existing flow const abortController = new AbortController() + const telemetry = createVideoTelemetry({ + asset, + signal: abortController.signal, + metric: ax.metric, + }) + telemetry.picked() composerDispatch({ type: 'update_post', postId, @@ -501,6 +516,7 @@ export const ComposePost = ({ type: 'embed_add_video', asset, abortController, + telemetry, }, }) @@ -559,6 +575,7 @@ export const ComposePost = ({ currentDid, abortController.signal, i18n, + telemetry, ) } catch (e) { logger.error('Failed to restore video from draft', { @@ -567,7 +584,7 @@ export const ComposePost = ({ }) } }, - [i18n, agent, currentDid, composerDispatch], + [i18n, agent, currentDid, composerDispatch, ax.metric], ) const handleSelectDraft = useCallback( @@ -979,6 +996,15 @@ export const ComposePost = ({ }) ).uris[0] + // Fire published event for every video that made it into the post. + // The status guard upstream ensures each video.telemetry is present and + // processing has completed by this point. + for (const post of filteredThread.posts) { + if (post.embed.media?.type === 'video') { + post.embed.media.video.telemetry?.published() + } + } + /* * Wait for app view to have received the post(s). If this fails, it's * ok, because the post _was_ actually published above. diff --git a/src/view/com/composer/state/composer.ts b/src/view/com/composer/state/composer.ts index 35e1706007..fc8b341270 100644 --- a/src/view/com/composer/state/composer.ts +++ b/src/view/com/composer/state/composer.ts @@ -8,6 +8,7 @@ import { } from '@atproto/api' import {nanoid} from 'nanoid/non-secure' +import {type VideoTelemetry} from '#/lib/media/video/telemetry' import {type SelfLabel} from '#/lib/moderation' import {insertMentionAt} from '#/lib/strings/mention-manip' import {shortenLinks} from '#/lib/strings/rich-text-manip' @@ -88,6 +89,7 @@ export type PostAction = type: 'embed_add_video' asset: ImagePickerAsset abortController: AbortController + telemetry: VideoTelemetry } | {type: 'embed_remove_video'} | {type: 'embed_update_video'; videoAction: VideoAction} @@ -458,7 +460,11 @@ function postReducer(state: PostDraft, action: PostAction): PostDraft { if (!prevMedia) { nextMedia = { type: 'video', - video: createVideoState(action.asset, action.abortController), + video: createVideoState( + action.asset, + action.abortController, + action.telemetry, + ), } } return { diff --git a/src/view/com/composer/state/video.ts b/src/view/com/composer/state/video.ts index 54bfcd67af..6737d1f628 100644 --- a/src/view/com/composer/state/video.ts +++ b/src/view/com/composer/state/video.ts @@ -11,6 +11,7 @@ import { UploadLimitError, VideoTooLargeError, } from '#/lib/media/video/errors' +import {type VideoTelemetry} from '#/lib/media/video/telemetry' import {type CompressedVideo} from '#/lib/media/video/types' import {uploadVideo} from '#/lib/media/video/upload' import {createVideoAgent} from '#/lib/media/video/util' @@ -64,6 +65,7 @@ export const NO_VIDEO = Object.freeze({ video: undefined, jobId: undefined, pendingPublish: undefined, + telemetry: undefined, altText: '', captions: [], }) @@ -79,6 +81,7 @@ type ErrorState = { jobId: string | null error: string pendingPublish?: undefined + telemetry: VideoTelemetry altText: string captions: CaptionsTrack[] } @@ -91,6 +94,7 @@ type CompressingState = { video?: undefined jobId?: undefined pendingPublish?: undefined + telemetry: VideoTelemetry altText: string captions: CaptionsTrack[] } @@ -103,6 +107,7 @@ type UploadingState = { video: CompressedVideo jobId?: undefined pendingPublish?: undefined + telemetry: VideoTelemetry altText: string captions: CaptionsTrack[] } @@ -116,6 +121,7 @@ type ProcessingState = { jobId: string jobStatus: AppBskyVideoDefs.JobStatus | null pendingPublish?: undefined + telemetry: VideoTelemetry altText: string captions: CaptionsTrack[] } @@ -128,6 +134,7 @@ type DoneState = { video: CompressedVideo jobId?: undefined pendingPublish: {blobRef: BlobRef} + telemetry: VideoTelemetry altText: string captions: CaptionsTrack[] } @@ -142,12 +149,14 @@ export type VideoState = export function createVideoState( asset: ImagePickerAsset, abortController: AbortController, + telemetry: VideoTelemetry, ): CompressingState { return { status: 'compressing', progress: 0, abortController, asset, + telemetry, altText: '', captions: [], } @@ -170,6 +179,7 @@ export function videoReducer( asset: state.asset ?? null, video: state.video ?? null, jobId: state.jobId ?? null, + telemetry: state.telemetry, altText: state.altText, captions: state.captions, } @@ -198,6 +208,7 @@ export function videoReducer( abortController: state.abortController, asset: state.asset, video: action.video, + telemetry: state.telemetry, altText: state.altText, captions: state.captions, } @@ -213,6 +224,7 @@ export function videoReducer( video: state.video, jobId: action.jobId, jobStatus: null, + telemetry: state.telemetry, altText: state.altText, captions: state.captions, } @@ -239,6 +251,7 @@ export function videoReducer( pendingPublish: { blobRef: action.blobRef, }, + telemetry: state.telemetry, altText: state.altText, captions: state.captions, } @@ -265,9 +278,11 @@ export async function processVideo( did: string, signal: AbortSignal, i18n: I18n, + telemetry: VideoTelemetry, ) { let video: CompressedVideo | undefined try { + telemetry.compressStarted() video = await compressVideo(asset, { onProgress: num => { dispatch({type: 'update_progress', progress: trunc2dp(num), signal}) @@ -277,6 +292,7 @@ export async function processVideo( } catch (e) { const message = getCompressErrorMessage(e, i18n) if (message !== null) { + telemetry.compressFailed(e) dispatch({ type: 'to_error', error: message, @@ -285,6 +301,15 @@ export async function processVideo( } return } + if (video.passthroughReason) { + telemetry.compressSkipped({ + size: video.size, + mimeType: video.mimeType, + skipReason: video.passthroughReason, + }) + } else { + telemetry.compressCompleted({size: video.size, mimeType: video.mimeType}) + } dispatch({ type: 'compressing_to_uploading', video, @@ -293,6 +318,7 @@ export async function processVideo( let uploadResponse: AppBskyVideoDefs.JobStatus | undefined try { + telemetry.uploadStarted(video.size) uploadResponse = await uploadVideo({ video, agent, @@ -306,6 +332,7 @@ export async function processVideo( } catch (e) { const message = getUploadErrorMessage(e, i18n) if (message !== null) { + telemetry.uploadFailed(e) dispatch({ type: 'to_error', error: message, @@ -316,6 +343,8 @@ export async function processVideo( } const jobId = uploadResponse.jobId + telemetry.uploadCompleted(jobId) + telemetry.processingStarted(jobId) dispatch({ type: 'uploading_to_processing', jobId, @@ -354,6 +383,7 @@ export async function processVideo( } logger.error('Error processing video', {safeMessage: e}) + telemetry.processingFailed(e) dispatch({ type: 'to_error', error: i18n._(msg`Video failed to process`), @@ -363,6 +393,7 @@ export async function processVideo( } if (blob) { + telemetry.processingCompleted() dispatch({ type: 'to_done', blobRef: blob,