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 "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": { "src/screens/Onboarding/StepProfile/index.tsx": {
"typescript/no-floating-promises": { "typescript/no-floating-promises": {
"count": 2 "count": 2
+2
View File
@@ -19,9 +19,11 @@ export enum Features {
PostThreadKnownLikersEnable = 'post_thread:known_likers:enable', PostThreadKnownLikersEnable = 'post_thread:known_likers:enable',
PostThreadKnownLikersFetchEnable = 'post_thread:known_likers:fetch:enable', PostThreadKnownLikersFetchEnable = 'post_thread:known_likers:fetch:enable',
CustomLogoJapanEnable = 'custom_logo:japan:enable', CustomLogoJapanEnable = 'custom_logo:japan:enable',
VideoAllow10MinuteEnable = 'video:allow-10-minute:enable',
VideoMultipartUploadEnable = 'video:multipart_upload:enable', VideoMultipartUploadEnable = 'video:multipart_upload:enable',
SearchStarterPacksV2Enable = 'search_starter_packs_v2:enable', SearchStarterPacksV2Enable = 'search_starter_packs_v2:enable',
FollowSortEnable = 'follow_sort:enable', FollowSortEnable = 'follow_sort:enable',
OnboardingInterestsRequiredEnable = 'onboarding:interests:required:enable',
// values // values
TrendingDiscoverValues = 'trending_discover:values', TrendingDiscoverValues = 'trending_discover:values',
+1
View File
@@ -143,6 +143,7 @@ export type Events = {
selectedInterests: string[] selectedInterests: string[]
selectedInterestsLength: number selectedInterestsLength: number
} }
'onboarding:interests:disabledNextPressed': {}
'onboarding:suggestedAccounts:tabPressed': { 'onboarding:suggestedAccounts:tabPressed': {
tab: string tab: string
} }
@@ -27,8 +27,22 @@ export class VideoNotFoundError extends Error {
*/ */
export class HLSFatalError extends Error { export class HLSFatalError extends Error {
detail: string 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}) super(cause.message, {cause})
this.detail = detail this.detail = detail
this.type = type
this.diagnostics = diagnostics
} }
} }
@@ -308,7 +308,53 @@ function useHLS({
) { ) {
setError(new VideoNotFoundError()) setError(new VideoNotFoundError())
} else { } 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 { } else {
console.error(data.error) console.error(data.error)
@@ -82,6 +82,16 @@ export function VideoEmbed({embed}: {embed: AppBskyEmbedVideo.View}) {
), ),
[key, embed], [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 let aspectRatio: number | undefined
const dims = embed.aspectRatio 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> <OnlyNearScreen>
<VideoEmbedInnerWeb <VideoEmbedInnerWeb
embed={embed} embed={embed}
@@ -88,6 +88,7 @@ export function maybeApplyGalleryOffsetStyles(
if (!isPostGalleryEmbedEnabled) return if (!isPostGalleryEmbedEnabled) return
// one image, not a gallery // one image, not a gallery
if (embed.media.images.length === 1) return if (embed.media.images.length === 1) return
hasImages = true
} }
if ( if (
bsky.dangerousIsType<AppBskyEmbedGallery.Main>( bsky.dangerousIsType<AppBskyEmbedGallery.Main>(
@@ -97,8 +98,8 @@ export function maybeApplyGalleryOffsetStyles(
) { ) {
// single (or empty) gallery - no offset needed // single (or empty) gallery - no offset needed
if (embed.media.items.length <= 1) return if (embed.media.items.length <= 1) return
hasImages = true
} }
hasImages = true
} }
if (!hasImages) return 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_SERVICE_DID = 'did:web:video.bsky.app'
export const VIDEO_MAX_DURATION_MS = 3 * 60 * 1000 // 3 minutes in milliseconds 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 * Maximum size of a video in megabytes, _not_ mebibytes. Backend uses
* ISO megabytes. * ISO megabytes.
+3 -1
View File
@@ -47,8 +47,10 @@ export async function openPicker(opts?: ImagePickerOptions) {
export async function openUnifiedPicker({ export async function openUnifiedPicker({
selectionCountRemaining, selectionCountRemaining,
videoMaxDurationMs = VIDEO_MAX_DURATION_MS,
}: { }: {
selectionCountRemaining: number selectionCountRemaining: number
videoMaxDurationMs?: number
}) { }) {
return await launchImageLibraryAsync({ return await launchImageLibraryAsync({
exif: false, exif: false,
@@ -61,6 +63,6 @@ export async function openUnifiedPicker({
preferredAssetRepresentationMode: preferredAssetRepresentationMode:
UIImagePickerPreferredAssetRepresentationMode.Automatic, UIImagePickerPreferredAssetRepresentationMode.Automatic,
videoExportPreset: VideoExportPreset.Passthrough, 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>({ return request<AbortUploadResponse>({
route: '/xrpc/app.bsky.video.abortUpload', route: '/xrpc/app.bsky.video.abortUpload',
token, token,
signal,
body: {jobId}, body: {jobId},
}) })
} }
+11 -2
View File
@@ -4,10 +4,19 @@
*/ */
/** Max parts uploaded concurrently. */ /** 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. */ /** 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. */ /** Attempts to begin/continue server-side finalization before checking state. */
export const MULTIPART_FINISH_ATTEMPTS = 3 export const MULTIPART_FINISH_ATTEMPTS = 3
+29 -3
View File
@@ -14,12 +14,16 @@ import {
MultipartUploadError, MultipartUploadError,
startUpload, startUpload,
} from './api' } 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 {getMissingParts, planParts} from './planParts'
import {createChunkReader} from './readChunk' import {createChunkReader} from './readChunk'
import {createUploadPart} from './uploadPart' import {createUploadPart} from './uploadPart'
import {uploadParts} from './uploadParts' import {uploadParts} from './uploadParts'
import {delay, isRetryableMultipartError} from './utils' import {delay, isRetryableMultipartError, retryDelayMs} from './utils'
export class MultipartFallbackError extends Error {} export class MultipartFallbackError extends Error {}
@@ -221,7 +225,7 @@ async function abortThenFallbackOrResolve(
token: string, token: string,
cause: unknown, cause: unknown,
): Promise<AppBskyVideoDefs.JobStatus> { ): Promise<AppBskyVideoDefs.JobStatus> {
const result = await abortUpload(jobId, token) const result = await abortUploadWithRetry(jobId, token)
if (result.state === 'aborted') { if (result.state === 'aborted') {
throw new MultipartFallbackError( throw new MultipartFallbackError(
cause instanceof Error ? cause.message : 'Multipart upload failed', 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) { function createTokenProvider(agent: AtpAgent, signal: AbortSignal) {
let token: string | undefined let token: string | undefined
let expiresAt = 0 let expiresAt = 0
+45 -8
View File
@@ -1,6 +1,7 @@
import {AbortError} from '#/lib/async/cancelable' import {AbortError} from '#/lib/async/cancelable'
import {createVideoEndpointUrl} from '#/lib/media/video/util' import {createVideoEndpointUrl} from '#/lib/media/video/util'
import {MultipartUploadError} from './api' import {MultipartUploadError} from './api'
import {MULTIPART_PART_TIMEOUT_MS} from './constants'
import {type UploadPartFn} from './types' import {type UploadPartFn} from './types'
export function createUploadPart( export function createUploadPart(
@@ -34,23 +35,40 @@ function sendPart(
return return
} }
const xhr = new XMLHttpRequest() const xhr = new XMLHttpRequest()
xhr.timeout = MULTIPART_PART_TIMEOUT_MS
const abort = () => xhr.abort() const abort = () => xhr.abort()
signal.addEventListener('abort', abort, {once: true}) 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 => { xhr.upload.addEventListener('progress', event => {
onProgress(event.loaded) onProgress(event.loaded)
}) })
xhr.onerror = () => { xhr.onerror = () => {
cleanup() rejectOnce(new TypeError('Network request failed'))
reject(new TypeError('Network request failed')) }
xhr.ontimeout = () => {
rejectOnce(new TypeError('Multipart part upload timed out'))
} }
xhr.onabort = () => { xhr.onabort = () => {
cleanup() rejectOnce(new AbortError())
reject(new AbortError())
} }
xhr.onload = () => { xhr.onload = () => {
cleanup()
let data: { let data: {
partNumber?: number partNumber?: number
sizeBytes?: number sizeBytes?: number
@@ -63,7 +81,7 @@ function sendPart(
data = {} data = {}
} }
if (xhr.status < 200 || xhr.status >= 300) { if (xhr.status < 200 || xhr.status >= 300) {
reject( rejectOnce(
new MultipartUploadError( new MultipartUploadError(
data.message || data.message ||
data.error || data.error ||
@@ -74,12 +92,31 @@ function sendPart(
) )
} else { } else {
onProgress(part.size) onProgress(part.size)
resolve({ resolveOnce({
partNumber: data.partNumber ?? part.partNumber, partNumber: data.partNumber ?? part.partNumber,
sizeBytes: data.sizeBytes ?? part.size, 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( xhr.open(
'POST', 'POST',
createVideoEndpointUrl('/xrpc/app.bsky.video.uploadPart', { createVideoEndpointUrl('/xrpc/app.bsky.video.uploadPart', {
@@ -115,6 +115,37 @@ describe('uploadParts', () => {
expect(attempts).toBe(2) 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 () => { it('does not retry a non-retryable response', async () => {
const uploadPart = jest.fn< const uploadPart = jest.fn<
ReturnType<UploadPartFn>, ReturnType<UploadPartFn>,
+5 -2
View File
@@ -7,7 +7,7 @@ import {
type PartUploadResult, type PartUploadResult,
type UploadPartFn, type UploadPartFn,
} from './types' } 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 * Uploads every part with a concurrency cap and per-part retry, aggregating
@@ -121,7 +121,10 @@ async function uploadPartWithRetry({
lastError = err lastError = err
if (!isRetryableMultipartError(err)) throw err if (!isRetryableMultipartError(err)) throw err
if (attempt < maxAttempts) { 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}) 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)" msgid "Child Sexual Abuse Material (CSAM)"
msgstr "" 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 #: src/screens/Settings/components/ChangeHandleDialog.tsx:401
msgid "Choose domain verification method" msgid "Choose domain verification method"
msgstr "" msgstr ""
@@ -3273,7 +3292,7 @@ msgstr ""
#: src/components/PolicyUpdateOverlay/updates/202508/index.tsx:171 #: src/components/PolicyUpdateOverlay/updates/202508/index.tsx:171
#: src/screens/Login/components/ConfirmHostingProviderDialog.tsx:149 #: src/screens/Login/components/ConfirmHostingProviderDialog.tsx:149
#: src/screens/Login/components/ConfirmHostingProviderDialog.tsx:152 #: 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/StepProfile/index.tsx:301
#: src/screens/Onboarding/StepSuggestedAccounts/index.tsx:305 #: src/screens/Onboarding/StepSuggestedAccounts/index.tsx:305
#: src/screens/Onboarding/StepSuggestedStarterpacks/index.tsx:117 #: src/screens/Onboarding/StepSuggestedStarterpacks/index.tsx:117
@@ -3302,7 +3321,7 @@ msgstr ""
msgid "Continue to group name" msgid "Continue to group name"
msgstr "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/StepProfile/index.tsx:298
#: src/screens/Onboarding/StepSuggestedAccounts/index.tsx:302 #: src/screens/Onboarding/StepSuggestedAccounts/index.tsx:302
#: src/screens/Onboarding/StepSuggestedStarterpacks/index.tsx:114 #: src/screens/Onboarding/StepSuggestedStarterpacks/index.tsx:114
@@ -5250,7 +5269,7 @@ msgstr ""
msgid "File saved successfully!" msgid "File saved successfully!"
msgstr "" msgstr ""
#: src/screens/Search/components/AdvancedSearchDialog/FromDropdown.tsx:31 #: src/screens/Search/components/AdvancedSearchDialog/FromDropdown.tsx:34
msgid "Filter by author (currently: {currentLabel})" msgid "Filter by author (currently: {currentLabel})"
msgstr "Filter by author (currently: {currentLabel})" msgstr "Filter by author (currently: {currentLabel})"
@@ -7440,7 +7459,7 @@ msgstr "Marked all requests as read"
msgid "Maybe later" msgid "Maybe later"
msgstr "" msgstr ""
#: src/screens/Search/components/AdvancedSearchDialog/FromDropdown.tsx:24 #: src/screens/Search/components/AdvancedSearchDialog/FromDropdown.tsx:27
msgid "Me" msgid "Me"
msgstr "Me" msgstr "Me"
@@ -8059,7 +8078,7 @@ msgid "No app passwords yet"
msgstr "" msgstr ""
#: src/screens/Search/components/AdvancedSearchDialog/FromDropdown.tsx:22 #: 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" msgid "No author filter"
msgstr "No author filter" msgstr "No author filter"
@@ -8978,7 +8997,6 @@ msgstr ""
msgid "People following @{0}" msgid "People following @{0}"
msgstr "" msgstr ""
#: src/screens/Search/components/AdvancedSearchDialog/FromDropdown.tsx:23
#: src/screens/Settings/NotificationSettings/components/PreferenceControls.tsx:180 #: src/screens/Settings/NotificationSettings/components/PreferenceControls.tsx:180
#: src/screens/Settings/NotificationSettings/components/PreferenceControls.tsx:184 #: src/screens/Settings/NotificationSettings/components/PreferenceControls.tsx:184
msgid "People I follow" msgid "People I follow"
@@ -8994,6 +9012,11 @@ msgctxt "allow messages from"
msgid "People I follow" msgid "People I follow"
msgstr "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 #: src/screens/Messages/components/InviteLinkDialog.tsx:163
msgid "People I follow can join instantly" msgid "People I follow can join instantly"
msgstr "People I follow can join instantly" msgstr "People I follow can join instantly"
@@ -10939,7 +10962,7 @@ msgstr ""
msgid "Select your date of birth" msgid "Select your date of birth"
msgstr "" msgstr ""
#: src/screens/Onboarding/StepInterests/index.tsx:69 #: src/screens/Onboarding/StepInterests/index.tsx:115
#: src/screens/Settings/InterestsSettings.tsx:178 #: src/screens/Settings/InterestsSettings.tsx:178
msgid "Select your interests from the options below" msgid "Select your interests from the options below"
msgstr "" 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." msgid "We'll send an email to <0>{0}</0> containing a link. Please click on it to complete the email verification process."
msgstr "" 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." msgid "We'll use this to help customize your experience."
msgstr "" msgstr ""
@@ -14221,7 +14244,7 @@ msgstr ""
msgid "Welcome, friend!" msgid "Welcome, friend!"
msgstr "" msgstr ""
#: src/screens/Onboarding/StepInterests/index.tsx:59 #: src/screens/Onboarding/StepInterests/index.tsx:98
msgid "What are your interests?" msgid "What are your interests?"
msgstr "" msgstr ""
+84 -23
View File
@@ -1,8 +1,6 @@
import {useCallback, useState} from 'react' import {useCallback, useState} from 'react'
import {View} from 'react-native' import {Pressable, View} from 'react-native'
import {msg} from '@lingui/core/macro' import {Trans, useLingui} from '@lingui/react/macro'
import {useLingui} from '@lingui/react'
import {Trans} from '@lingui/react/macro'
import {interests, useInterestsDisplayNames} from '#/lib/interests' import {interests, useInterestsDisplayNames} from '#/lib/interests'
import {capitalize} from '#/lib/strings/capitalize' import {capitalize} from '#/lib/strings/capitalize'
@@ -19,20 +17,36 @@ import {atoms as a} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button' import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import * as Toggle from '#/components/forms/Toggle' import * as Toggle from '#/components/forms/Toggle'
import {Loader} from '#/components/Loader' import {Loader} from '#/components/Loader'
import * as Tooltip from '#/components/Tooltip'
import {useAnalytics} from '#/analytics' import {useAnalytics} from '#/analytics'
export function StepInterests() { export function StepInterests() {
const {_} = useLingui() const {t: l} = useLingui()
const ax = useAnalytics() const ax = useAnalytics()
const interestsDisplayNames = useInterestsDisplayNames() const interestsDisplayNames = useInterestsDisplayNames()
const {state, dispatch} = useOnboardingInternalState() const {state, dispatch} = useOnboardingInternalState()
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [tooltipVisible, setTooltipVisible] = useState(false)
const [selectedInterests, setSelectedInterests] = useState<string[]>( const [selectedInterests, setSelectedInterests] = useState<string[]>(
state.interestsStepResults.selectedInterests.map(i => i), 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) setSaving(true)
try { try {
@@ -46,12 +60,37 @@ export function StepInterests() {
selectedInterests, selectedInterests,
selectedInterestsLength: selectedInterests.length, selectedInterestsLength: selectedInterests.length,
}) })
} catch (e: any) { } catch (error) {
logger.info(`onboading: error saving interests`) const e = error as Error
logger.info(`onboarding: error saving interests`)
logger.error(e) logger.error(e)
} }
}, [ax, selectedInterests, setSaving, dispatch]) }, [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 ( return (
<View style={[a.align_start, a.gap_sm]} testID="onboardingInterests"> <View style={[a.align_start, a.gap_sm]} testID="onboardingInterests">
<OnboardingPosition /> <OnboardingPosition />
@@ -59,14 +98,21 @@ export function StepInterests() {
<Trans>What are your interests?</Trans> <Trans>What are your interests?</Trans>
</OnboardingTitleText> </OnboardingTitleText>
<OnboardingDescriptionText> <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> </OnboardingDescriptionText>
<View style={[a.w_full, a.pt_lg]}> <View style={[a.w_full, a.pt_lg]}>
<Toggle.Group <Toggle.Group
values={selectedInterests} values={selectedInterests}
onChange={setSelectedInterests} 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]}> <View style={[a.flex_row, a.gap_md, a.flex_wrap]}>
{interests.map(interest => ( {interests.map(interest => (
<Toggle.Item <Toggle.Item
@@ -81,19 +127,34 @@ export function StepInterests() {
</View> </View>
<OnboardingControls.Portal> <OnboardingControls.Portal>
<Button <View style={[a.relative]}>
disabled={saving} {missingRequiredInterest ? (
testID="onboardingContinue" <Tooltip.Outer
variant="solid" position="top"
color="primary" visible={tooltipVisible}
size="large" onVisibleChange={setTooltipVisible}>
label={_(msg`Continue to next step`)} <Tooltip.Target>
onPress={saveInterests}> <Pressable
<ButtonText> accessibilityRole="button"
<Trans>Continue</Trans> accessibilityLabel={l`Choose an interest`}
</ButtonText> accessibilityHint={l`Choose at least one interest to continue`}
{saving && <ButtonIcon icon={Loader} />} onPress={showMissingInterestTooltip}>
</Button> <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> </OnboardingControls.Portal>
</View> </View>
) )
@@ -20,7 +20,10 @@ export function FromDropdown({
const options: {value: FromFilter; label: string}[] = [ const options: {value: FromFilter; label: string}[] = [
{value: 'anyone', label: l`No author filter`}, {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`}, {value: 'me', label: l`Me`},
] ]
const currentLabel = const currentLabel =
+35 -6
View File
@@ -70,6 +70,7 @@ import {
MAX_GRAPHEME_LENGTH, MAX_GRAPHEME_LENGTH,
SUPPORTED_MIME_TYPES, SUPPORTED_MIME_TYPES,
type SupportedMimeTypes, type SupportedMimeTypes,
VIDEO_10_MINUTE_MAX_DURATION_MS,
VIDEO_MAX_DURATION_MS, VIDEO_MAX_DURATION_MS,
} from '#/lib/constants' } from '#/lib/constants'
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
@@ -267,6 +268,12 @@ export const ComposePost = ({
const {currentAccount} = useSession() const {currentAccount} = useSession()
const t = useTheme() const t = useTheme()
const ax = useAnalytics() 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 agent = useAgent()
const queryClient = useQueryClient() const queryClient = useQueryClient()
const currentDid = currentAccount!.did 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 * Fail early on duration so we don't spend time compressing a video the
* server would reject anyway. * server would reject anyway.
*/ */
if (asset.duration != null && asset.duration > VIDEO_MAX_DURATION_MS) { if (asset.duration != null && asset.duration > videoMaxDurationMs) {
composerDispatch({ composerDispatch({
type: 'update_post', type: 'update_post',
postId: postId, postId: postId,
@@ -442,7 +449,9 @@ export const ComposePost = ({
type: 'embed_update_video', type: 'embed_update_video',
videoAction: { videoAction: {
type: 'to_error', 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, signal: abortController.signal,
}, },
}, },
@@ -469,7 +478,16 @@ export const ComposePost = ({
telemetry, telemetry,
) )
}, },
[l, i18n, agent, currentDid, composerDispatch, ax.metric], [
l,
i18n,
agent,
currentDid,
composerDispatch,
ax.metric,
videoMaxDurationMs,
allow10MinuteVideos,
],
) )
const onInitVideo = useNonReactiveCallback(() => { 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({ composerDispatch({
type: 'update_post', type: 'update_post',
postId, postId,
@@ -574,7 +592,9 @@ export const ComposePost = ({
type: 'embed_update_video', type: 'embed_update_video',
videoAction: { videoAction: {
type: 'to_error', 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, 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( const handleSelectDraft = useCallback(
+26 -6
View File
@@ -6,6 +6,7 @@ import {msg, plural} from '@lingui/core/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import { import {
VIDEO_10_MINUTE_MAX_DURATION_MS,
VIDEO_MAX_DURATION_MS, VIDEO_MAX_DURATION_MS,
VIDEO_MAX_SIZE, VIDEO_MAX_SIZE,
VIDEO_MAX_SIZE_MB, VIDEO_MAX_SIZE_MB,
@@ -22,6 +23,7 @@ import {Button} from '#/components/Button'
import {useSheetWrapper} from '#/components/Dialog/sheet-wrapper' import {useSheetWrapper} from '#/components/Dialog/sheet-wrapper'
import {Image_Stroke2_Corner0_Rounded as ImageIcon} from '#/components/icons/Image' import {Image_Stroke2_Corner0_Rounded as ImageIcon} from '#/components/icons/Image'
import * as toast from '#/components/Toast' import * as toast from '#/components/Toast'
import {useAnalytics} from '#/analytics'
import {IS_NATIVE, IS_WEB} from '#/env' import {IS_NATIVE, IS_WEB} from '#/env'
import {isAnimatedGif} from './videos/isAnimatedGif' import {isAnimatedGif} from './videos/isAnimatedGif'
import {hasWebCodecs} from './videos/metadata' import {hasWebCodecs} from './videos/metadata'
@@ -236,9 +238,11 @@ async function processImagePickerAssets(
{ {
selectionCountRemaining, selectionCountRemaining,
allowedAssetTypes, allowedAssetTypes,
videoMaxDurationMs,
}: { }: {
selectionCountRemaining: number selectionCountRemaining: number
allowedAssetTypes: AssetType | undefined allowedAssetTypes: AssetType | undefined
videoMaxDurationMs: number
}, },
) { ) {
/* /*
@@ -362,7 +366,7 @@ async function processImagePickerAssets(
supportedAssets[0].duration = supportedAssets[0].duration * 1000 supportedAssets[0].duration = supportedAssets[0].duration * 1000
} }
if (supportedAssets[0].duration > VIDEO_MAX_DURATION_MS) { if (supportedAssets[0].duration > videoMaxDurationMs) {
errors.add(SelectedAssetError.VideoTooLong) errors.add(SelectedAssetError.VideoTooLong)
supportedAssets = [] supportedAssets = []
} }
@@ -393,6 +397,13 @@ export function SelectMediaButton({
autoOpen, autoOpen,
}: SelectMediaButtonProps) { }: SelectMediaButtonProps) {
const {_} = useLingui() 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 {requestPhotoAccessIfNeeded} = usePhotoLibraryPermission()
const {requestVideoAccessIfNeeded} = useVideoLibraryPermission() const {requestVideoAccessIfNeeded} = useVideoLibraryPermission()
const sheetWrapper = useSheetWrapper() const sheetWrapper = useSheetWrapper()
@@ -412,6 +423,7 @@ export function SelectMediaButton({
} = await processImagePickerAssets(rawAssets, { } = await processImagePickerAssets(rawAssets, {
selectionCountRemaining, selectionCountRemaining,
allowedAssetTypes, allowedAssetTypes,
videoMaxDurationMs,
}) })
/* /*
@@ -436,9 +448,9 @@ export function SelectMediaButton({
[SelectedAssetError.MaxVideos]: _( [SelectedAssetError.MaxVideos]: _(
msg`You can only select one video at a time.`, msg`You can only select one video at a time.`,
), ),
[SelectedAssetError.VideoTooLong]: _( [SelectedAssetError.VideoTooLong]: allow10MinuteVideos
msg`Videos must be less than 3 minutes long.`, ? _(msg`Videos must be 10 minutes or less.`)
), : _(msg`Videos must be less than 3 minutes long.`),
[SelectedAssetError.MaxGIFs]: _( [SelectedAssetError.MaxGIFs]: _(
msg`You can only select one GIF at a time.`, msg`You can only select one GIF at a time.`,
), ),
@@ -458,7 +470,14 @@ export function SelectMediaButton({
errors, errors,
}) })
}, },
[_, onSelectAssets, selectionCountRemaining, allowedAssetTypes], [
_,
onSelectAssets,
selectionCountRemaining,
allowedAssetTypes,
videoMaxDurationMs,
allow10MinuteVideos,
],
) )
const onPressSelectMedia = useCallback(async () => { const onPressSelectMedia = useCallback(async () => {
@@ -481,7 +500,7 @@ export function SelectMediaButton({
} }
const {assets, canceled} = await sheetWrapper( const {assets, canceled} = await sheetWrapper(
openUnifiedPicker({selectionCountRemaining}), openUnifiedPicker({selectionCountRemaining, videoMaxDurationMs}),
) )
if (canceled) return if (canceled) return
@@ -494,6 +513,7 @@ export function SelectMediaButton({
sheetWrapper, sheetWrapper,
processSelectedAssets, processSelectedAssets,
selectionCountRemaining, selectionCountRemaining,
videoMaxDurationMs,
]) ])
useEffect(() => { useEffect(() => {
+6 -1
View File
@@ -4,12 +4,14 @@ import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {logger} from '#/logger' import {logger} from '#/logger'
import {type Metadata} from '#/logger/types'
import {ErrorScreen} from './error/ErrorScreen' import {ErrorScreen} from './error/ErrorScreen'
import {CenteredView} from './Views' import {CenteredView} from './Views'
interface Props { interface Props {
children?: ReactNode children?: ReactNode
renderError?: (error: any) => ReactNode renderError?: (error: any) => ReactNode
getErrorMetadata?: (error: Error) => Metadata
style?: StyleProp<ViewStyle> style?: StyleProp<ViewStyle>
} }
@@ -29,7 +31,10 @@ export class ErrorBoundary extends Component<Props, State> {
} }
public componentDidCatch(error: Error, errorInfo: ErrorInfo) { public componentDidCatch(error: Error, errorInfo: ErrorInfo) {
logger.error(error, {errorInfo}) logger.error(error, {
errorInfo,
...this.props.getErrorMetadata?.(error),
})
} }
public render() { public render() {