From 0ccc0297168b79a1bea5b2c7532b7aaea19f3ef9 Mon Sep 17 00:00:00 2001 From: Spence Pope Date: Wed, 5 Aug 2026 10:15:06 -0400 Subject: [PATCH] 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(() => {