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