Merge remote-tracking branch 'origin/main' into app-2670-toolbox-video-compressor

This commit is contained in:
vineyardbovines
2026-08-05 10:15:48 -04:00
22 changed files with 405 additions and 78 deletions
-11
View File
@@ -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
+2
View File
@@ -19,9 +19,11 @@ 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',
OnboardingInterestsRequiredEnable = 'onboarding:interests:required:enable',
// values
TrendingDiscoverValues = 'trending_discover:values',
+1
View File
@@ -143,6 +143,7 @@ export type Events = {
selectedInterests: string[]
selectedInterestsLength: number
}
'onboarding:interests:disabledNextPressed': {}
'onboarding:suggestedAccounts:tabPressed': {
tab: string
}
@@ -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<string, unknown>
constructor({
detail,
type,
cause,
diagnostics,
}: {
detail: string
type: string
cause: Error
diagnostics: Record<string, unknown>
}) {
super(cause.message, {cause})
this.detail = detail
this.type = type
this.diagnostics = diagnostics
}
}
@@ -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)
@@ -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}) {
/>
</>
)}
<ErrorBoundary renderError={renderError} key={key}>
<ErrorBoundary
renderError={renderError}
getErrorMetadata={getErrorMetadata}
key={key}>
<OnlyNearScreen>
<VideoEmbedInnerWeb
embed={embed}
@@ -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<AppBskyEmbedGallery.Main>(
@@ -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
+1
View File
@@ -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.
+3 -1
View File
@@ -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,
})
}
+6 -1
View File
@@ -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<AbortUploadResponse>({
route: '/xrpc/app.bsky.video.abortUpload',
token,
signal,
body: {jobId},
})
}
+11 -2
View File
@@ -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
+29 -3
View File
@@ -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<AppBskyVideoDefs.JobStatus> {
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
+45 -8
View File
@@ -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<ReturnType<UploadPartFn>>) => {
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', {
@@ -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<UploadPartFn>,
+5 -2
View File
@@ -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)
}
}
}
+6
View File
@@ -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)
}
+32 -9
View File
@@ -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 "Wed love to hear about your experience testing beta features!"
msgid "We'll send an email to <0>{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 ""
+84 -23
View File
@@ -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<string[]>(
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 = (
<Button
disabled={saving || missingRequiredInterest}
testID="onboardingContinue"
variant="solid"
color="primary"
size="large"
label={
missingRequiredInterest
? l`Choose an interest`
: l`Continue to next step`
}
onPress={() => void saveInterests()}>
<ButtonText style={{pointerEvents: 'none'}}>
{missingRequiredInterest ? (
<Trans>Choose an interest</Trans>
) : (
<Trans>Continue</Trans>
)}
</ButtonText>
{saving && <ButtonIcon icon={Loader} />}
</Button>
)
return (
<View style={[a.align_start, a.gap_sm]} testID="onboardingInterests">
<OnboardingPosition />
@@ -59,14 +98,21 @@ export function StepInterests() {
<Trans>What are your interests?</Trans>
</OnboardingTitleText>
<OnboardingDescriptionText>
<Trans>We'll use this to help customize your experience.</Trans>
{interestRequired ? (
<Trans>
Choose at least one. We'll use this to customize your experience.
You can change these anytime.
</Trans>
) : (
<Trans>We'll use this to help customize your experience.</Trans>
)}
</OnboardingDescriptionText>
<View style={[a.w_full, a.pt_lg]}>
<Toggle.Group
values={selectedInterests}
onChange={setSelectedInterests}
label={_(msg`Select your interests from the options below`)}>
label={l`Select your interests from the options below`}>
<View style={[a.flex_row, a.gap_md, a.flex_wrap]}>
{interests.map(interest => (
<Toggle.Item
@@ -81,19 +127,34 @@ export function StepInterests() {
</View>
<OnboardingControls.Portal>
<Button
disabled={saving}
testID="onboardingContinue"
variant="solid"
color="primary"
size="large"
label={_(msg`Continue to next step`)}
onPress={saveInterests}>
<ButtonText>
<Trans>Continue</Trans>
</ButtonText>
{saving && <ButtonIcon icon={Loader} />}
</Button>
<View style={[a.relative]}>
{missingRequiredInterest ? (
<Tooltip.Outer
position="top"
visible={tooltipVisible}
onVisibleChange={setTooltipVisible}>
<Tooltip.Target>
<Pressable
accessibilityRole="button"
accessibilityLabel={l`Choose an interest`}
accessibilityHint={l`Choose at least one interest to continue`}
onPress={showMissingInterestTooltip}>
<View
pointerEvents="none"
accessibilityElementsHidden
importantForAccessibility="no-hide-descendants">
{continueButton}
</View>
</Pressable>
</Tooltip.Target>
<Tooltip.BubbleText label={l`Choose at least one interest.`}>
<Trans>Choose at least one interest.</Trans>
</Tooltip.BubbleText>
</Tooltip.Outer>
) : (
continueButton
)}
</View>
</OnboardingControls.Portal>
</View>
)
@@ -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 =
+35 -6
View File
@@ -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(
+26 -6
View File
@@ -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(() => {
+6 -1
View File
@@ -4,12 +4,14 @@ import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {logger} from '#/logger'
import {type Metadata} from '#/logger/types'
import {ErrorScreen} from './error/ErrorScreen'
import {CenteredView} from './Views'
interface Props {
children?: ReactNode
renderError?: (error: any) => ReactNode
getErrorMetadata?: (error: Error) => Metadata
style?: StyleProp<ViewStyle>
}
@@ -29,7 +31,10 @@ export class ErrorBoundary extends Component<Props, State> {
}
public componentDidCatch(error: Error, errorInfo: ErrorInfo) {
logger.error(error, {errorInfo})
logger.error(error, {
errorInfo,
...this.props.getErrorMetadata?.(error),
})
}
public render() {