From f1cfa37babe6e96334f16d748a1e8edfbc9b8446 Mon Sep 17 00:00:00 2001 From: Spence Pope Date: Tue, 4 Aug 2026 10:45:44 -0400 Subject: [PATCH 1/7] APP-2764: Remove unnecessary top padding on GIF posts (#11321) --- src/components/images/Gallery/maybeApplyGalleryOffsetStyles.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/components/images/Gallery/maybeApplyGalleryOffsetStyles.ts b/src/components/images/Gallery/maybeApplyGalleryOffsetStyles.ts index 6ac2c0086f..eea54b5ed9 100644 --- a/src/components/images/Gallery/maybeApplyGalleryOffsetStyles.ts +++ b/src/components/images/Gallery/maybeApplyGalleryOffsetStyles.ts @@ -88,6 +88,7 @@ export function maybeApplyGalleryOffsetStyles( if (!isPostGalleryEmbedEnabled) return // one image, not a gallery if (embed.media.images.length === 1) return + hasImages = true } if ( bsky.dangerousIsType( @@ -97,8 +98,8 @@ export function maybeApplyGalleryOffsetStyles( ) { // single (or empty) gallery - no offset needed if (embed.media.items.length <= 1) return + hasImages = true } - hasImages = true } if (!hasImages) return From ec49988fa791021c538fdcebd692c535a75e7135 Mon Sep 17 00:00:00 2001 From: smileyhead Date: Tue, 4 Aug 2026 19:47:31 +0200 Subject: [PATCH 2/7] =?UTF-8?q?Add=20unique=20context=20to=20=E2=80=98Peop?= =?UTF-8?q?le=20I=20follow=E2=80=99=20in=20FromDropdown.tsx=20(#11275)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Byte --- .../Search/components/AdvancedSearchDialog/FromDropdown.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/screens/Search/components/AdvancedSearchDialog/FromDropdown.tsx b/src/screens/Search/components/AdvancedSearchDialog/FromDropdown.tsx index abcaf59285..835cc41c6f 100644 --- a/src/screens/Search/components/AdvancedSearchDialog/FromDropdown.tsx +++ b/src/screens/Search/components/AdvancedSearchDialog/FromDropdown.tsx @@ -20,7 +20,10 @@ export function FromDropdown({ const options: {value: FromFilter; label: string}[] = [ {value: 'anyone', label: l`No author filter`}, - {value: 'following', label: l`People I follow`}, + { + value: 'following', + label: l({context: 'display posts made by', message: 'People I follow'}), + }, {value: 'me', label: l`Me`}, ] const currentLabel = From 5494f7deb59197610b00f78e88eb8cb65587f10b Mon Sep 17 00:00:00 2001 From: Alex Benzer Date: Tue, 4 Aug 2026 14:57:43 -0700 Subject: [PATCH 3/7] Test requiring at least one interest in onboarding (#11391) Co-authored-by: DS Boyce <260543580+ds-boyce@users.noreply.github.com> --- oxlint-suppressions.json | 11 -- src/analytics/features/types.ts | 1 + src/analytics/metrics/types.ts | 1 + .../Onboarding/StepInterests/index.tsx | 107 ++++++++++++++---- 4 files changed, 86 insertions(+), 34 deletions(-) diff --git a/oxlint-suppressions.json b/oxlint-suppressions.json index 9f8f3cc426..2bc0ab246f 100644 --- a/oxlint-suppressions.json +++ b/oxlint-suppressions.json @@ -933,17 +933,6 @@ "count": 1 } }, - "src/screens/Onboarding/StepInterests/index.tsx": { - "typescript/no-explicit-any": { - "count": 1 - }, - "typescript/no-misused-promises": { - "count": 1 - }, - "typescript/require-await": { - "count": 1 - } - }, "src/screens/Onboarding/StepProfile/index.tsx": { "typescript/no-floating-promises": { "count": 2 diff --git a/src/analytics/features/types.ts b/src/analytics/features/types.ts index b7c70fa9a7..353073216b 100644 --- a/src/analytics/features/types.ts +++ b/src/analytics/features/types.ts @@ -22,6 +22,7 @@ export enum Features { VideoMultipartUploadEnable = 'video:multipart_upload:enable', SearchStarterPacksV2Enable = 'search_starter_packs_v2:enable', FollowSortEnable = 'follow_sort:enable', + OnboardingInterestsRequiredEnable = 'onboarding:interests:required:enable', // values TrendingDiscoverValues = 'trending_discover:values', diff --git a/src/analytics/metrics/types.ts b/src/analytics/metrics/types.ts index 740865326a..592f9a563d 100644 --- a/src/analytics/metrics/types.ts +++ b/src/analytics/metrics/types.ts @@ -143,6 +143,7 @@ export type Events = { selectedInterests: string[] selectedInterestsLength: number } + 'onboarding:interests:disabledNextPressed': {} 'onboarding:suggestedAccounts:tabPressed': { tab: string } diff --git a/src/screens/Onboarding/StepInterests/index.tsx b/src/screens/Onboarding/StepInterests/index.tsx index 2b9afa0630..10611c92d5 100644 --- a/src/screens/Onboarding/StepInterests/index.tsx +++ b/src/screens/Onboarding/StepInterests/index.tsx @@ -1,8 +1,6 @@ import {useCallback, useState} from 'react' -import {View} from 'react-native' -import {msg} from '@lingui/core/macro' -import {useLingui} from '@lingui/react' -import {Trans} from '@lingui/react/macro' +import {Pressable, View} from 'react-native' +import {Trans, useLingui} from '@lingui/react/macro' import {interests, useInterestsDisplayNames} from '#/lib/interests' import {capitalize} from '#/lib/strings/capitalize' @@ -19,20 +17,36 @@ import {atoms as a} from '#/alf' import {Button, ButtonIcon, ButtonText} from '#/components/Button' import * as Toggle from '#/components/forms/Toggle' import {Loader} from '#/components/Loader' +import * as Tooltip from '#/components/Tooltip' import {useAnalytics} from '#/analytics' export function StepInterests() { - const {_} = useLingui() + const {t: l} = useLingui() const ax = useAnalytics() const interestsDisplayNames = useInterestsDisplayNames() const {state, dispatch} = useOnboardingInternalState() const [saving, setSaving] = useState(false) + const [tooltipVisible, setTooltipVisible] = useState(false) const [selectedInterests, setSelectedInterests] = useState( state.interestsStepResults.selectedInterests.map(i => i), ) + /* + * Behind this gate, users must choose at least one interest before they can + * continue. + */ + const interestRequired = ax.features.enabled( + ax.features.OnboardingInterestsRequiredEnable, + ) + const missingRequiredInterest = + interestRequired && selectedInterests.length === 0 - const saveInterests = useCallback(async () => { + const showMissingInterestTooltip = () => { + ax.metric('onboarding:interests:disabledNextPressed', {}) + setTooltipVisible(true) + } + + const saveInterests = useCallback(() => { setSaving(true) try { @@ -46,12 +60,37 @@ export function StepInterests() { selectedInterests, selectedInterestsLength: selectedInterests.length, }) - } catch (e: any) { - logger.info(`onboading: error saving interests`) + } catch (error) { + const e = error as Error + logger.info(`onboarding: error saving interests`) logger.error(e) } }, [ax, selectedInterests, setSaving, dispatch]) + const continueButton = ( + + ) + return ( @@ -59,14 +98,21 @@ export function StepInterests() { What are your interests? - We'll use this to help customize your experience. + {interestRequired ? ( + + Choose at least one. We'll use this to customize your experience. + You can change these anytime. + + ) : ( + We'll use this to help customize your experience. + )} + label={l`Select your interests from the options below`}> {interests.map(interest => ( - + + {missingRequiredInterest ? ( + + + + + {continueButton} + + + + + Choose at least one interest. + + + ) : ( + continueButton + )} + ) From 5db6b4e2fd310ede76c28d4ab8b1f5852a14aa07 Mon Sep 17 00:00:00 2001 From: pfrazee <1270099+pfrazee@users.noreply.github.com> Date: Wed, 5 Aug 2026 03:10:14 +0000 Subject: [PATCH 4/7] Nightly source-language update --- src/locale/locales/en/messages.po | 41 ++++++++++++++++++++++++------- 1 file changed, 32 insertions(+), 9 deletions(-) diff --git a/src/locale/locales/en/messages.po b/src/locale/locales/en/messages.po index c71c8c5256..f82413f9f7 100644 --- a/src/locale/locales/en/messages.po +++ b/src/locale/locales/en/messages.po @@ -2805,6 +2805,25 @@ msgstr "" msgid "Child Sexual Abuse Material (CSAM)" msgstr "" +#: src/screens/Onboarding/StepInterests/index.tsx:79 +#: src/screens/Onboarding/StepInterests/index.tsx:85 +#: src/screens/Onboarding/StepInterests/index.tsx:139 +msgid "Choose an interest" +msgstr "Choose an interest" + +#: src/screens/Onboarding/StepInterests/index.tsx:140 +msgid "Choose at least one interest to continue" +msgstr "Choose at least one interest to continue" + +#: src/screens/Onboarding/StepInterests/index.tsx:150 +#: src/screens/Onboarding/StepInterests/index.tsx:151 +msgid "Choose at least one interest." +msgstr "Choose at least one interest." + +#: src/screens/Onboarding/StepInterests/index.tsx:102 +msgid "Choose at least one. We'll use this to customize your experience. You can change these anytime." +msgstr "Choose at least one. We'll use this to customize your experience. You can change these anytime." + #: src/screens/Settings/components/ChangeHandleDialog.tsx:401 msgid "Choose domain verification method" msgstr "" @@ -3273,7 +3292,7 @@ msgstr "" #: src/components/PolicyUpdateOverlay/updates/202508/index.tsx:171 #: src/screens/Login/components/ConfirmHostingProviderDialog.tsx:149 #: src/screens/Login/components/ConfirmHostingProviderDialog.tsx:152 -#: src/screens/Onboarding/StepInterests/index.tsx:93 +#: src/screens/Onboarding/StepInterests/index.tsx:87 #: src/screens/Onboarding/StepProfile/index.tsx:301 #: src/screens/Onboarding/StepSuggestedAccounts/index.tsx:305 #: src/screens/Onboarding/StepSuggestedStarterpacks/index.tsx:117 @@ -3302,7 +3321,7 @@ msgstr "" msgid "Continue to group name" msgstr "Continue to group name" -#: src/screens/Onboarding/StepInterests/index.tsx:90 +#: src/screens/Onboarding/StepInterests/index.tsx:80 #: src/screens/Onboarding/StepProfile/index.tsx:298 #: src/screens/Onboarding/StepSuggestedAccounts/index.tsx:302 #: src/screens/Onboarding/StepSuggestedStarterpacks/index.tsx:114 @@ -5250,7 +5269,7 @@ msgstr "" msgid "File saved successfully!" msgstr "" -#: src/screens/Search/components/AdvancedSearchDialog/FromDropdown.tsx:31 +#: src/screens/Search/components/AdvancedSearchDialog/FromDropdown.tsx:34 msgid "Filter by author (currently: {currentLabel})" msgstr "Filter by author (currently: {currentLabel})" @@ -7440,7 +7459,7 @@ msgstr "Marked all requests as read" msgid "Maybe later" msgstr "" -#: src/screens/Search/components/AdvancedSearchDialog/FromDropdown.tsx:24 +#: src/screens/Search/components/AdvancedSearchDialog/FromDropdown.tsx:27 msgid "Me" msgstr "Me" @@ -8059,7 +8078,7 @@ msgid "No app passwords yet" msgstr "" #: src/screens/Search/components/AdvancedSearchDialog/FromDropdown.tsx:22 -#: src/screens/Search/components/AdvancedSearchDialog/FromDropdown.tsx:27 +#: src/screens/Search/components/AdvancedSearchDialog/FromDropdown.tsx:30 msgid "No author filter" msgstr "No author filter" @@ -8978,7 +8997,6 @@ msgstr "" msgid "People following @{0}" msgstr "" -#: src/screens/Search/components/AdvancedSearchDialog/FromDropdown.tsx:23 #: src/screens/Settings/NotificationSettings/components/PreferenceControls.tsx:180 #: src/screens/Settings/NotificationSettings/components/PreferenceControls.tsx:184 msgid "People I follow" @@ -8994,6 +9012,11 @@ msgctxt "allow messages from" msgid "People I follow" msgstr "People I follow" +#: src/screens/Search/components/AdvancedSearchDialog/FromDropdown.tsx:25 +msgctxt "display posts made by" +msgid "People I follow" +msgstr "People I follow" + #: src/screens/Messages/components/InviteLinkDialog.tsx:163 msgid "People I follow can join instantly" msgstr "People I follow can join instantly" @@ -10939,7 +10962,7 @@ msgstr "" msgid "Select your date of birth" msgstr "" -#: src/screens/Onboarding/StepInterests/index.tsx:69 +#: src/screens/Onboarding/StepInterests/index.tsx:115 #: src/screens/Settings/InterestsSettings.tsx:178 msgid "Select your interests from the options below" msgstr "" @@ -14107,7 +14130,7 @@ msgstr "We’d love to hear about your experience testing beta features!" msgid "We'll send an email to <0>{0} containing a link. Please click on it to complete the email verification process." msgstr "" -#: src/screens/Onboarding/StepInterests/index.tsx:62 +#: src/screens/Onboarding/StepInterests/index.tsx:107 msgid "We'll use this to help customize your experience." msgstr "" @@ -14221,7 +14244,7 @@ msgstr "" msgid "Welcome, friend!" msgstr "" -#: src/screens/Onboarding/StepInterests/index.tsx:59 +#: src/screens/Onboarding/StepInterests/index.tsx:98 msgid "What are your interests?" msgstr "" From 0ccc0297168b79a1bea5b2c7532b7aaea19f3ef9 Mon Sep 17 00:00:00 2001 From: Spence Pope Date: Wed, 5 Aug 2026 10:15:06 -0400 Subject: [PATCH 5/7] Allow feature-gated 10-minute video uploads (#11388) --- src/analytics/features/types.ts | 1 + src/lib/constants.ts | 1 + src/lib/media/picker.shared.ts | 4 +- src/view/com/composer/Composer.tsx | 41 ++++++++++++++++++--- src/view/com/composer/SelectMediaButton.tsx | 32 +++++++++++++--- 5 files changed, 66 insertions(+), 13 deletions(-) diff --git a/src/analytics/features/types.ts b/src/analytics/features/types.ts index 353073216b..62fe079b7b 100644 --- a/src/analytics/features/types.ts +++ b/src/analytics/features/types.ts @@ -19,6 +19,7 @@ export enum Features { PostThreadKnownLikersEnable = 'post_thread:known_likers:enable', PostThreadKnownLikersFetchEnable = 'post_thread:known_likers:fetch:enable', CustomLogoJapanEnable = 'custom_logo:japan:enable', + VideoAllow10MinuteEnable = 'video:allow-10-minute:enable', VideoMultipartUploadEnable = 'video:multipart_upload:enable', SearchStarterPacksV2Enable = 'search_starter_packs_v2:enable', FollowSortEnable = 'follow_sort:enable', diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 80d8639ff2..bc079c7af5 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -194,6 +194,7 @@ export const VIDEO_SERVICE = 'https://video.bsky.app' export const VIDEO_SERVICE_DID = 'did:web:video.bsky.app' export const VIDEO_MAX_DURATION_MS = 3 * 60 * 1000 // 3 minutes in milliseconds +export const VIDEO_10_MINUTE_MAX_DURATION_MS = 10 * 60 * 1000 /** * Maximum size of a video in megabytes, _not_ mebibytes. Backend uses * ISO megabytes. diff --git a/src/lib/media/picker.shared.ts b/src/lib/media/picker.shared.ts index d7d8dab440..63e2a1e06c 100644 --- a/src/lib/media/picker.shared.ts +++ b/src/lib/media/picker.shared.ts @@ -47,8 +47,10 @@ export async function openPicker(opts?: ImagePickerOptions) { export async function openUnifiedPicker({ selectionCountRemaining, + videoMaxDurationMs = VIDEO_MAX_DURATION_MS, }: { selectionCountRemaining: number + videoMaxDurationMs?: number }) { return await launchImageLibraryAsync({ exif: false, @@ -61,6 +63,6 @@ export async function openUnifiedPicker({ preferredAssetRepresentationMode: UIImagePickerPreferredAssetRepresentationMode.Automatic, videoExportPreset: VideoExportPreset.Passthrough, - videoMaxDuration: VIDEO_MAX_DURATION_MS / 1000, + videoMaxDuration: videoMaxDurationMs / 1000, }) } diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx index 026697fe25..b080441230 100644 --- a/src/view/com/composer/Composer.tsx +++ b/src/view/com/composer/Composer.tsx @@ -70,6 +70,7 @@ import { MAX_GRAPHEME_LENGTH, SUPPORTED_MIME_TYPES, type SupportedMimeTypes, + VIDEO_10_MINUTE_MAX_DURATION_MS, VIDEO_MAX_DURATION_MS, } from '#/lib/constants' import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' @@ -267,6 +268,12 @@ export const ComposePost = ({ const {currentAccount} = useSession() const t = useTheme() const ax = useAnalytics() + const allow10MinuteVideos = ax.features.enabled( + ax.features.VideoAllow10MinuteEnable, + ) + const videoMaxDurationMs = allow10MinuteVideos + ? VIDEO_10_MINUTE_MAX_DURATION_MS + : VIDEO_MAX_DURATION_MS const agent = useAgent() const queryClient = useQueryClient() const currentDid = currentAccount!.did @@ -434,7 +441,7 @@ export const ComposePost = ({ * Fail early on duration so we don't spend time compressing a video the * server would reject anyway. */ - if (asset.duration != null && asset.duration > VIDEO_MAX_DURATION_MS) { + if (asset.duration != null && asset.duration > videoMaxDurationMs) { composerDispatch({ type: 'update_post', postId: postId, @@ -442,7 +449,9 @@ export const ComposePost = ({ type: 'embed_update_video', videoAction: { type: 'to_error', - error: l`Videos must be less than 3 minutes long.`, + error: allow10MinuteVideos + ? l`Videos must be 10 minutes or less.` + : l`Videos must be less than 3 minutes long.`, signal: abortController.signal, }, }, @@ -469,7 +478,16 @@ export const ComposePost = ({ telemetry, ) }, - [l, i18n, agent, currentDid, composerDispatch, ax.metric], + [ + l, + i18n, + agent, + currentDid, + composerDispatch, + ax.metric, + videoMaxDurationMs, + allow10MinuteVideos, + ], ) const onInitVideo = useNonReactiveCallback(() => { @@ -566,7 +584,7 @@ export const ComposePost = ({ }, }) - if (asset.duration != null && asset.duration > VIDEO_MAX_DURATION_MS) { + if (asset.duration != null && asset.duration > videoMaxDurationMs) { composerDispatch({ type: 'update_post', postId, @@ -574,7 +592,9 @@ export const ComposePost = ({ type: 'embed_update_video', videoAction: { type: 'to_error', - error: l`Videos must be less than 3 minutes long.`, + error: allow10MinuteVideos + ? l`Videos must be 10 minutes or less.` + : l`Videos must be less than 3 minutes long.`, signal: abortController.signal, }, }, @@ -646,7 +666,16 @@ export const ComposePost = ({ }) } }, - [l, i18n, agent, currentDid, composerDispatch, ax.metric], + [ + l, + i18n, + agent, + currentDid, + composerDispatch, + ax.metric, + videoMaxDurationMs, + allow10MinuteVideos, + ], ) const handleSelectDraft = useCallback( diff --git a/src/view/com/composer/SelectMediaButton.tsx b/src/view/com/composer/SelectMediaButton.tsx index d3cb92db7c..34c0101875 100644 --- a/src/view/com/composer/SelectMediaButton.tsx +++ b/src/view/com/composer/SelectMediaButton.tsx @@ -6,6 +6,7 @@ import {msg, plural} from '@lingui/core/macro' import {useLingui} from '@lingui/react' import { + VIDEO_10_MINUTE_MAX_DURATION_MS, VIDEO_MAX_DURATION_MS, VIDEO_MAX_SIZE, VIDEO_MAX_SIZE_MB, @@ -22,6 +23,7 @@ import {Button} from '#/components/Button' import {useSheetWrapper} from '#/components/Dialog/sheet-wrapper' import {Image_Stroke2_Corner0_Rounded as ImageIcon} from '#/components/icons/Image' import * as toast from '#/components/Toast' +import {useAnalytics} from '#/analytics' import {IS_NATIVE, IS_WEB} from '#/env' import {isAnimatedGif} from './videos/isAnimatedGif' import {hasWebCodecs} from './videos/metadata' @@ -236,9 +238,11 @@ async function processImagePickerAssets( { selectionCountRemaining, allowedAssetTypes, + videoMaxDurationMs, }: { selectionCountRemaining: number allowedAssetTypes: AssetType | undefined + videoMaxDurationMs: number }, ) { /* @@ -362,7 +366,7 @@ async function processImagePickerAssets( supportedAssets[0].duration = supportedAssets[0].duration * 1000 } - if (supportedAssets[0].duration > VIDEO_MAX_DURATION_MS) { + if (supportedAssets[0].duration > videoMaxDurationMs) { errors.add(SelectedAssetError.VideoTooLong) supportedAssets = [] } @@ -393,6 +397,13 @@ export function SelectMediaButton({ autoOpen, }: SelectMediaButtonProps) { const {_} = useLingui() + const ax = useAnalytics() + const allow10MinuteVideos = ax.features.enabled( + ax.features.VideoAllow10MinuteEnable, + ) + const videoMaxDurationMs = allow10MinuteVideos + ? VIDEO_10_MINUTE_MAX_DURATION_MS + : VIDEO_MAX_DURATION_MS const {requestPhotoAccessIfNeeded} = usePhotoLibraryPermission() const {requestVideoAccessIfNeeded} = useVideoLibraryPermission() const sheetWrapper = useSheetWrapper() @@ -412,6 +423,7 @@ export function SelectMediaButton({ } = await processImagePickerAssets(rawAssets, { selectionCountRemaining, allowedAssetTypes, + videoMaxDurationMs, }) /* @@ -436,9 +448,9 @@ export function SelectMediaButton({ [SelectedAssetError.MaxVideos]: _( msg`You can only select one video at a time.`, ), - [SelectedAssetError.VideoTooLong]: _( - msg`Videos must be less than 3 minutes long.`, - ), + [SelectedAssetError.VideoTooLong]: allow10MinuteVideos + ? _(msg`Videos must be 10 minutes or less.`) + : _(msg`Videos must be less than 3 minutes long.`), [SelectedAssetError.MaxGIFs]: _( msg`You can only select one GIF at a time.`, ), @@ -458,7 +470,14 @@ export function SelectMediaButton({ errors, }) }, - [_, onSelectAssets, selectionCountRemaining, allowedAssetTypes], + [ + _, + onSelectAssets, + selectionCountRemaining, + allowedAssetTypes, + videoMaxDurationMs, + allow10MinuteVideos, + ], ) const onPressSelectMedia = useCallback(async () => { @@ -481,7 +500,7 @@ export function SelectMediaButton({ } const {assets, canceled} = await sheetWrapper( - openUnifiedPicker({selectionCountRemaining}), + openUnifiedPicker({selectionCountRemaining, videoMaxDurationMs}), ) if (canceled) return @@ -494,6 +513,7 @@ export function SelectMediaButton({ sheetWrapper, processSelectedAssets, selectionCountRemaining, + videoMaxDurationMs, ]) useEffect(() => { From 17a8fe87c209afa4c05d3446b98e1cfd07af84ae Mon Sep 17 00:00:00 2001 From: Spence Pope Date: Wed, 5 Aug 2026 10:15:16 -0400 Subject: [PATCH 6/7] APP-2793: harden multipart video uploads (#11366) --- src/lib/media/video/multipart/api.ts | 7 ++- src/lib/media/video/multipart/constants.ts | 13 ++++- src/lib/media/video/multipart/upload.ts | 32 +++++++++-- src/lib/media/video/multipart/uploadPart.ts | 53 ++++++++++++++++--- .../media/video/multipart/uploadParts.test.ts | 31 +++++++++++ src/lib/media/video/multipart/uploadParts.ts | 7 ++- src/lib/media/video/multipart/utils.ts | 6 +++ 7 files changed, 133 insertions(+), 16 deletions(-) diff --git a/src/lib/media/video/multipart/api.ts b/src/lib/media/video/multipart/api.ts index 811696b3e3..c6a268ffe1 100644 --- a/src/lib/media/video/multipart/api.ts +++ b/src/lib/media/video/multipart/api.ts @@ -111,10 +111,15 @@ export function getUploadStatus( }) } -export function abortUpload(jobId: string, token: string) { +export function abortUpload( + jobId: string, + token: string, + signal?: AbortSignal, +) { return request({ route: '/xrpc/app.bsky.video.abortUpload', token, + signal, body: {jobId}, }) } diff --git a/src/lib/media/video/multipart/constants.ts b/src/lib/media/video/multipart/constants.ts index 2aaa37b8c2..2592a9d7db 100644 --- a/src/lib/media/video/multipart/constants.ts +++ b/src/lib/media/video/multipart/constants.ts @@ -4,10 +4,19 @@ */ /** Max parts uploaded concurrently. */ -export const MULTIPART_CONCURRENCY = 3 +export const MULTIPART_CONCURRENCY = 4 /** Per-part upload attempts before the part (and the upload) fails. */ -export const MULTIPART_MAX_ATTEMPTS = 3 +export const MULTIPART_MAX_ATTEMPTS = 5 + +/** Maximum time to wait for an individual part request to settle. */ +export const MULTIPART_PART_TIMEOUT_MS = 120_000 + +/** Maximum time to wait for each best-effort abort request. */ +export const MULTIPART_ABORT_TIMEOUT_MS = 10_000 + +/** Attempts to release a failed multipart upload reservation. */ +export const MULTIPART_ABORT_ATTEMPTS = 3 /** Attempts to begin/continue server-side finalization before checking state. */ export const MULTIPART_FINISH_ATTEMPTS = 3 diff --git a/src/lib/media/video/multipart/upload.ts b/src/lib/media/video/multipart/upload.ts index af2d71d596..2b45e6d2c6 100644 --- a/src/lib/media/video/multipart/upload.ts +++ b/src/lib/media/video/multipart/upload.ts @@ -14,12 +14,16 @@ import { MultipartUploadError, startUpload, } from './api' -import {MULTIPART_FINISH_ATTEMPTS} from './constants' +import { + MULTIPART_ABORT_ATTEMPTS, + MULTIPART_ABORT_TIMEOUT_MS, + MULTIPART_FINISH_ATTEMPTS, +} from './constants' import {getMissingParts, planParts} from './planParts' import {createChunkReader} from './readChunk' import {createUploadPart} from './uploadPart' import {uploadParts} from './uploadParts' -import {delay, isRetryableMultipartError} from './utils' +import {delay, isRetryableMultipartError, retryDelayMs} from './utils' export class MultipartFallbackError extends Error {} @@ -221,7 +225,7 @@ async function abortThenFallbackOrResolve( token: string, cause: unknown, ): Promise { - const result = await abortUpload(jobId, token) + const result = await abortUploadWithRetry(jobId, token) if (result.state === 'aborted') { throw new MultipartFallbackError( cause instanceof Error ? cause.message : 'Multipart upload failed', @@ -238,6 +242,28 @@ async function abortThenFallbackOrResolve( ) } +async function abortUploadWithRetry(jobId: string, token: string) { + let lastError: unknown + for (let attempt = 1; attempt <= MULTIPART_ABORT_ATTEMPTS; attempt++) { + const controller = new AbortController() + const timer = setTimeout( + () => controller.abort(), + MULTIPART_ABORT_TIMEOUT_MS, + ) + try { + return await abortUpload(jobId, token, controller.signal) + } catch (err) { + lastError = err + if (attempt < MULTIPART_ABORT_ATTEMPTS) { + await delay(retryDelayMs(attempt), new AbortController().signal) + } + } finally { + clearTimeout(timer) + } + } + throw lastError +} + function createTokenProvider(agent: AtpAgent, signal: AbortSignal) { let token: string | undefined let expiresAt = 0 diff --git a/src/lib/media/video/multipart/uploadPart.ts b/src/lib/media/video/multipart/uploadPart.ts index e3c57fc2b2..d0a910682c 100644 --- a/src/lib/media/video/multipart/uploadPart.ts +++ b/src/lib/media/video/multipart/uploadPart.ts @@ -1,6 +1,7 @@ import {AbortError} from '#/lib/async/cancelable' import {createVideoEndpointUrl} from '#/lib/media/video/util' import {MultipartUploadError} from './api' +import {MULTIPART_PART_TIMEOUT_MS} from './constants' import {type UploadPartFn} from './types' export function createUploadPart( @@ -34,23 +35,40 @@ function sendPart( return } const xhr = new XMLHttpRequest() + xhr.timeout = MULTIPART_PART_TIMEOUT_MS const abort = () => xhr.abort() signal.addEventListener('abort', abort, {once: true}) - const cleanup = () => signal.removeEventListener('abort', abort) + let settled = false + const cleanup = () => { + signal.removeEventListener('abort', abort) + xhr.onreadystatechange = null + } + const rejectOnce = (err: Error) => { + if (settled) return + settled = true + cleanup() + reject(err) + } + const resolveOnce = (result: Awaited>) => { + if (settled) return + settled = true + cleanup() + resolve(result) + } xhr.upload.addEventListener('progress', event => { onProgress(event.loaded) }) xhr.onerror = () => { - cleanup() - reject(new TypeError('Network request failed')) + rejectOnce(new TypeError('Network request failed')) + } + xhr.ontimeout = () => { + rejectOnce(new TypeError('Multipart part upload timed out')) } xhr.onabort = () => { - cleanup() - reject(new AbortError()) + rejectOnce(new AbortError()) } xhr.onload = () => { - cleanup() let data: { partNumber?: number sizeBytes?: number @@ -63,7 +81,7 @@ function sendPart( data = {} } if (xhr.status < 200 || xhr.status >= 300) { - reject( + rejectOnce( new MultipartUploadError( data.message || data.error || @@ -74,12 +92,31 @@ function sendPart( ) } else { onProgress(part.size) - resolve({ + resolveOnce({ partNumber: data.partNumber ?? part.partNumber, sizeBytes: data.sizeBytes ?? part.size, }) } } + xhr.onreadystatechange = () => { + if ( + xhr.readyState === XMLHttpRequest.HEADERS_RECEIVED && + xhr.status >= 400 + ) { + // React Native does not dispatch `load` until the response body has + // completed. Reject from the headers so a stalled 5xx response body + // cannot prevent the retry loop (or eventual abortUpload) from running. + const status = xhr.status + rejectOnce( + new MultipartUploadError( + `Video service returned ${status}`, + undefined, + status, + ), + ) + xhr.abort() + } + } xhr.open( 'POST', createVideoEndpointUrl('/xrpc/app.bsky.video.uploadPart', { diff --git a/src/lib/media/video/multipart/uploadParts.test.ts b/src/lib/media/video/multipart/uploadParts.test.ts index 7965a28df9..b580f14de2 100644 --- a/src/lib/media/video/multipart/uploadParts.test.ts +++ b/src/lib/media/video/multipart/uploadParts.test.ts @@ -115,6 +115,37 @@ describe('uploadParts', () => { expect(attempts).toBe(2) }) + it('retries service-unavailable parts', async () => { + let attempts = 0 + const uploadPart: UploadPartFn = ({part}) => { + attempts++ + if (attempts === 1) { + return Promise.reject( + new MultipartUploadError( + 'failed to upload multipart part', + 'ServiceUnavailable', + 503, + ), + ) + } + return Promise.resolve({ + partNumber: part.partNumber, + sizeBytes: part.size, + }) + } + + await uploadParts({ + parts: parts.slice(0, 1), + reader: fakeReader(), + uploadPart, + totalBytes: 10, + setProgress: () => {}, + signal: new AbortController().signal, + }) + + expect(attempts).toBe(2) + }) + it('does not retry a non-retryable response', async () => { const uploadPart = jest.fn< ReturnType, diff --git a/src/lib/media/video/multipart/uploadParts.ts b/src/lib/media/video/multipart/uploadParts.ts index cda502fb54..041ecff1b9 100644 --- a/src/lib/media/video/multipart/uploadParts.ts +++ b/src/lib/media/video/multipart/uploadParts.ts @@ -7,7 +7,7 @@ import { type PartUploadResult, type UploadPartFn, } from './types' -import {delay, isRetryableMultipartError} from './utils' +import {delay, isRetryableMultipartError, retryDelayMs} from './utils' /** * Uploads every part with a concurrency cap and per-part retry, aggregating @@ -121,7 +121,10 @@ async function uploadPartWithRetry({ lastError = err if (!isRetryableMultipartError(err)) throw err if (attempt < maxAttempts) { - await delay(500 * 2 ** (attempt - 1), signal) + // XHR progress starts over on a retry, so remove bytes reported by the + // failed attempt from the aggregate while backing off. + onProgress(0) + await delay(retryDelayMs(attempt), signal) } } } diff --git a/src/lib/media/video/multipart/utils.ts b/src/lib/media/video/multipart/utils.ts index 4184ab5666..05c42858f9 100644 --- a/src/lib/media/video/multipart/utils.ts +++ b/src/lib/media/video/multipart/utils.ts @@ -25,3 +25,9 @@ export function delay(ms: number, signal: AbortSignal) { signal.addEventListener('abort', onAbort, {once: true}) }) } + +/** Exponential backoff with 50-100% jitter to avoid synchronized retries. */ +export function retryDelayMs(attempt: number) { + const ceiling = Math.min(500 * 2 ** (attempt - 1), 8_000) + return ceiling * (0.5 + Math.random() * 0.5) +} From 32ec5330b0ed8f6b1f473cc75358cc20ee0c6ce6 Mon Sep 17 00:00:00 2001 From: Spence Pope Date: Wed, 5 Aug 2026 10:15:26 -0400 Subject: [PATCH 7/7] Enrich fatal HLS errors in Sentry (#11359) --- .../VideoEmbedInnerWeb.shared.ts | 16 ++++++- .../VideoEmbedInner/VideoEmbedInnerWeb.tsx | 48 ++++++++++++++++++- .../Post/Embed/VideoEmbed/index.web.tsx | 15 +++++- src/view/com/util/ErrorBoundary.tsx | 7 ++- 4 files changed, 82 insertions(+), 4 deletions(-) diff --git a/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb.shared.ts b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb.shared.ts index d7c44b0d91..9184ccf37c 100644 --- a/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb.shared.ts +++ b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb.shared.ts @@ -27,8 +27,22 @@ export class VideoNotFoundError extends Error { */ export class HLSFatalError extends Error { detail: string - constructor(detail: string, cause: Error) { + type: string + diagnostics: Record + constructor({ + detail, + type, + cause, + diagnostics, + }: { + detail: string + type: string + cause: Error + diagnostics: Record + }) { super(cause.message, {cause}) this.detail = detail + this.type = type + this.diagnostics = diagnostics } } diff --git a/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb.tsx b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb.tsx index 6ece8f5a9b..0599240816 100644 --- a/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb.tsx +++ b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb.tsx @@ -308,7 +308,53 @@ function useHLS({ ) { setError(new VideoNotFoundError()) } else { - setError(new HLSFatalError(data.details, data.error)) + const video = videoRef.current + const mediaError = video?.error + setError( + new HLSFatalError({ + detail: data.details, + type: data.type, + cause: data.error, + diagnostics: { + hlsError: { + detail: data.details, + type: data.type, + sourceBufferName: data.sourceBufferName, + parent: data.parent, + reason: data.reason, + errorName: data.error.name, + errorCode: (data.error as DOMException).code, + }, + fragment: data.frag + ? { + sn: data.frag.sn, + level: data.frag.level, + type: data.frag.type, + start: data.frag.start, + duration: data.frag.duration, + cc: data.frag.cc, + } + : undefined, + media: video + ? { + errorCode: mediaError?.code, + errorMessage: mediaError?.message, + readyState: video.readyState, + networkState: video.networkState, + currentTime: video.currentTime, + paused: video.paused, + ended: video.ended, + seeking: video.seeking, + } + : undefined, + lifecycle: { + documentVisibility: document.visibilityState, + hlsIsCurrent: hlsRef.current === hls, + }, + playlist, + }, + }), + ) } } else { console.error(data.error) diff --git a/src/components/Post/Embed/VideoEmbed/index.web.tsx b/src/components/Post/Embed/VideoEmbed/index.web.tsx index ee44ba33e8..0eb622bad7 100644 --- a/src/components/Post/Embed/VideoEmbed/index.web.tsx +++ b/src/components/Post/Embed/VideoEmbed/index.web.tsx @@ -82,6 +82,16 @@ export function VideoEmbed({embed}: {embed: AppBskyEmbedVideo.View}) { ), [key, embed], ) + const getErrorMetadata = useCallback((error: Error) => { + if (!(error instanceof HLSFatalError)) return {} + return { + tags: { + hls_error_detail: error.detail, + hls_error_type: error.type, + }, + hls: error.diagnostics, + } + }, []) let aspectRatio: number | undefined const dims = embed.aspectRatio @@ -158,7 +168,10 @@ export function VideoEmbed({embed}: {embed: AppBskyEmbedVideo.View}) { /> )} - + ReactNode + getErrorMetadata?: (error: Error) => Metadata style?: StyleProp } @@ -29,7 +31,10 @@ export class ErrorBoundary extends Component { } public componentDidCatch(error: Error, errorInfo: ErrorInfo) { - logger.error(error, {errorInfo}) + logger.error(error, { + errorInfo, + ...this.props.getErrorMetadata?.(error), + }) } public render() {