Merge remote-tracking branch 'origin/main' into app-2670-toolbox-video-compressor
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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}</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 ""
|
||||
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user