From c6a40088dd8d2ffb474f7479a150c996917f17e3 Mon Sep 17 00:00:00 2001 From: Foysal Ahamed Date: Wed, 29 Jul 2026 17:38:27 +0200 Subject: [PATCH 01/34] Add TIDA form URL for adult content report (#11150) Co-authored-by: Eric Bailey --- .../moderation/ReportDialog/const.ts | 1 + .../moderation/ReportDialog/index.tsx | 166 ++++++++++++++++-- .../moderation/ReportDialog/state.test.ts | 110 ++++++++++++ .../moderation/ReportDialog/state.ts | 66 ++++++- 4 files changed, 322 insertions(+), 21 deletions(-) create mode 100644 src/components/moderation/ReportDialog/state.test.ts diff --git a/src/components/moderation/ReportDialog/const.ts b/src/components/moderation/ReportDialog/const.ts index b516ec0006..8f5f16db9f 100644 --- a/src/components/moderation/ReportDialog/const.ts +++ b/src/components/moderation/ReportDialog/const.ts @@ -7,6 +7,7 @@ import {type ParsedReportSubject} from '#/components/moderation/ReportDialog/typ export const DMCA_LINK = 'https://bsky.social/about/support/copyright' export const SUPPORT_PAGE = 'https://bsky.social/about/support' +export const NCII_FORM = 'https://forms.bsky.app/f/ncii' export const NEW_TO_OLD_REASON_MAPPING: Record = {} diff --git a/src/components/moderation/ReportDialog/index.tsx b/src/components/moderation/ReportDialog/index.tsx index d1d3aa5584..dfb3e781a9 100644 --- a/src/components/moderation/ReportDialog/index.tsx +++ b/src/components/moderation/ReportDialog/index.tsx @@ -40,11 +40,18 @@ import {useSubmitReportMutation} from './action' import { BSKY_LABELER_ONLY_REPORT_REASONS, BSKY_LABELER_ONLY_SUBJECT_TYPES, + NCII_FORM, NEW_TO_OLD_REASONS_MAP, SUPPORT_PAGE, } from './const' import {useCopyForSubject} from './copy' -import {initialState, reducer} from './state' +import { + getNciiQualificationOutcome, + initialState, + type NciiQualification as NciiQualificationState, + reducer, + type ReportAction, +} from './state' import {type ReportDialogProps, type ReportSubject} from './types' import {parseReportSubject} from './utils/parseReportSubject' import { @@ -381,23 +388,28 @@ function Inner(props: ReportDialogProps) { activeIndex1={state.activeStepIndex1} /> {state.selectedOption ? ( - - - + <> + + + + + - - + {state.ncii && ( + + )} + ) : state.selectedCategory ? ( {getCategory(state.selectedCategory.key).options.map(o => ( @@ -780,6 +792,126 @@ function OptionCard({ ) } +/** + * Qualifying question shown when the NCII reason is selected. The depicted + * person (or their authorized representative) is directed to the external + * NCII report form; everyone else continues with the normal in-app + * submission. + */ +function NciiQualification({ + ncii, + dispatch, +}: { + ncii: NciiQualificationState + dispatch: React.Dispatch +}) { + const t = useTheme() + const {t: l} = useLingui() + const outcome = getNciiQualificationOutcome(ncii) + return ( + + { + dispatch({ + type: 'answerNciiQuestion', + question: 'isDepicted', + answer, + }) + }} + /> + {outcome === 'externalForm' && ( + + {({hovered, pressed}) => ( + + + + Please submit your report through the Report non-consensual + intimate imagery (NCII) form. + + + + + )} + + )} + + ) +} + +function YesNoQuestion({ + question, + value, + onAnswer, + testID, +}: { + question: string + value?: boolean + onAnswer: (answer: boolean) => void + testID?: string +}) { + const {t: l} = useLingui() + return ( + + {question} + + + + + + + + + + ) +} + function OptionCardSkeleton() { const t = useTheme() return ( diff --git a/src/components/moderation/ReportDialog/state.test.ts b/src/components/moderation/ReportDialog/state.test.ts new file mode 100644 index 0000000000..6c08fe3d5c --- /dev/null +++ b/src/components/moderation/ReportDialog/state.test.ts @@ -0,0 +1,110 @@ +import { + type AppBskyLabelerDefs, + ToolsOzoneReportDefs as OzoneReportDefs, +} from '@atproto/api' + +import { + getNciiQualificationOutcome, + initialState, + reducer, + type ReportState, +} from './state' + +const nciiOption = { + title: 'Non-consensual intimate imagery', + reason: OzoneReportDefs.REASONSEXUALNCII, +} + +const otherOption = { + title: 'Unlabeled adult content', + reason: OzoneReportDefs.REASONSEXUALUNLABELED, +} + +function selectNciiOption(state: ReportState = initialState) { + return reducer(state, {type: 'selectOption', option: nciiOption}) +} + +describe('getNciiQualificationOutcome', () => { + it('returns undefined when not an NCII report', () => { + expect(getNciiQualificationOutcome(undefined)).toBeUndefined() + }) + + it('is pending until the question is answered', () => { + expect(getNciiQualificationOutcome({})).toBe('pending') + }) + + it('directs the depicted person to the external form', () => { + expect(getNciiQualificationOutcome({isDepicted: true})).toBe('externalForm') + }) + + it('directs everyone else to in-app submission', () => { + expect(getNciiQualificationOutcome({isDepicted: false})).toBe('inApp') + }) +}) + +describe('reducer NCII qualification', () => { + it('holds at step 2 when the NCII reason is selected', () => { + const state = selectNciiOption() + expect(state.activeStepIndex1).toBe(2) + expect(state.ncii).toEqual({}) + }) + + it('does not gate non-NCII reasons', () => { + const state = reducer(initialState, { + type: 'selectOption', + option: otherOption, + }) + expect(state.activeStepIndex1).toBe(3) + expect(state.ncii).toBeUndefined() + }) + + it('holds at step 2 for the depicted person (external form)', () => { + let state = selectNciiOption() + state = reducer(state, { + type: 'answerNciiQuestion', + question: 'isDepicted', + answer: true, + }) + expect(getNciiQualificationOutcome(state.ncii)).toBe('externalForm') + expect(state.activeStepIndex1).toBe(2) + }) + + it('advances to step 3 when not the depicted person', () => { + let state = selectNciiOption() + state = reducer(state, { + type: 'answerNciiQuestion', + question: 'isDepicted', + answer: false, + }) + expect(state.activeStepIndex1).toBe(3) + }) + + it('does not advance past a pending question when a labeler is auto-selected', () => { + let state = selectNciiOption() + state = reducer(state, { + type: 'selectLabeler', + labeler: {} as AppBskyLabelerDefs.LabelerViewDetailed, + }) + expect(state.activeStepIndex1).toBe(2) + }) + + it('skips to step 4 when the answer resolves after labeler auto-selection', () => { + let state = selectNciiOption() + state = reducer(state, { + type: 'selectLabeler', + labeler: {} as AppBskyLabelerDefs.LabelerViewDetailed, + }) + state = reducer(state, { + type: 'answerNciiQuestion', + question: 'isDepicted', + answer: false, + }) + expect(state.activeStepIndex1).toBe(4) + }) + + it('clears NCII state when the reason or category is cleared', () => { + const state = selectNciiOption() + expect(reducer(state, {type: 'clearOption'}).ncii).toBeUndefined() + expect(reducer(state, {type: 'clearCategory'}).ncii).toBeUndefined() + }) +}) diff --git a/src/components/moderation/ReportDialog/state.ts b/src/components/moderation/ReportDialog/state.ts index 7b15e174b2..d86b5e28b9 100644 --- a/src/components/moderation/ReportDialog/state.ts +++ b/src/components/moderation/ReportDialog/state.ts @@ -1,4 +1,7 @@ -import {type AppBskyLabelerDefs} from '@atproto/api' +import { + type AppBskyLabelerDefs, + ToolsOzoneReportDefs as OzoneReportDefs, +} from '@atproto/api' import {OTHER_REPORT_REASONS} from '#/components/moderation/ReportDialog/const' import { @@ -6,6 +9,10 @@ import { type ReportOption, } from '#/components/moderation/ReportDialog/utils/useReportOptions' +export type NciiQualification = { + isDepicted?: boolean +} + export type ReportState = { selectedCategory?: ReportCategoryConfig selectedOption?: ReportOption @@ -14,6 +21,26 @@ export type ReportState = { detailsOpen: boolean activeStepIndex1: number error?: string + /** + * Present while the selected reason is NCII. Tracks the answer to the + * qualifying question that determines whether the report should go through + * the external NCII report form instead of in-app submission. + */ + ncii?: NciiQualification +} + +/** + * Resolves the NCII qualifying question into an outcome. The depicted person + * (or their authorized representative) is directed to the external NCII + * report form; everyone else proceeds with the normal in-app submission. + */ +export function getNciiQualificationOutcome( + ncii?: NciiQualification, +): 'pending' | 'externalForm' | 'inApp' | undefined { + if (!ncii) return undefined + if (ncii.isDepicted === true) return 'externalForm' + if (ncii.isDepicted === false) return 'inApp' + return 'pending' } export type ReportAction = @@ -32,6 +59,11 @@ export type ReportAction = | { type: 'clearOption' } + | { + type: 'answerNciiQuestion' + question: keyof NciiQualification + answer: boolean + } | { type: 'selectLabeler' labeler: AppBskyLabelerDefs.LabelerViewDetailed @@ -81,14 +113,19 @@ export function reducer(state: ReportState, action: ReportAction): ReportState { selectedLabeler: undefined, activeStepIndex1: 1, detailsOpen: false, + ncii: undefined, } - case 'selectOption': + case 'selectOption': { + const isNcii = action.option.reason === OzoneReportDefs.REASONSEXUALNCII return { ...state, selectedOption: action.option, - activeStepIndex1: 3, + // NCII reports require answering qualifying questions before moving on + activeStepIndex1: isNcii ? 2 : 3, detailsOpen: OTHER_REPORT_REASONS.has(action.option.reason), + ncii: isNcii ? {} : undefined, } + } case 'clearOption': return { ...state, @@ -96,12 +133,33 @@ export function reducer(state: ReportState, action: ReportAction): ReportState { selectedLabeler: undefined, activeStepIndex1: 2, detailsOpen: false, + ncii: undefined, } + case 'answerNciiQuestion': { + const ncii = {...state.ncii, [action.question]: action.answer} + return { + ...state, + ncii, + activeStepIndex1: + getNciiQualificationOutcome(ncii) === 'inApp' + ? state.selectedLabeler + ? 4 + : 3 + : 2, + } + } case 'selectLabeler': return { ...state, selectedLabeler: action.labeler, - activeStepIndex1: 4, + /* + * Labelers may be auto-selected (e.g. chat reports only go to + * Bluesky), so don't advance past pending NCII qualifying questions. + */ + activeStepIndex1: + getNciiQualificationOutcome(state.ncii) === 'inApp' || !state.ncii + ? 4 + : 2, detailsOpen: state.selectedOption ? OTHER_REPORT_REASONS.has(state.selectedOption?.reason) : false, From a35c4ac4bafea19e9b1c9330043f593d7867967b Mon Sep 17 00:00:00 2001 From: Spence Pope Date: Wed, 29 Jul 2026 12:54:03 -0400 Subject: [PATCH 02/34] APP-2670: add multipart video upload transport (#11222) --- src/analytics/features/types.ts | 1 + src/analytics/metrics/types.ts | 7 +- .../video/multipart/aggregateProgress.test.ts | 37 +++ .../video/multipart/aggregateProgress.ts | 19 ++ src/lib/media/video/multipart/api.ts | 134 ++++++++ src/lib/media/video/multipart/constants.ts | 13 + .../media/video/multipart/planParts.test.ts | 47 +++ src/lib/media/video/multipart/planParts.ts | 30 ++ src/lib/media/video/multipart/readChunk.ts | 21 ++ .../media/video/multipart/readChunk.web.ts | 27 ++ src/lib/media/video/multipart/types.ts | 72 +++++ src/lib/media/video/multipart/upload.ts | 292 ++++++++++++++++++ src/lib/media/video/multipart/uploadPart.ts | 94 ++++++ .../media/video/multipart/uploadParts.test.ts | 206 ++++++++++++ src/lib/media/video/multipart/uploadParts.ts | 129 ++++++++ src/lib/media/video/multipart/utils.ts | 27 ++ src/lib/media/video/telemetry.ts | 10 + src/lib/media/video/types.ts | 2 + src/lib/media/video/upload.ts | 27 +- src/lib/media/video/upload.web.ts | 27 +- src/lib/strings/errors.ts | 8 +- src/view/com/composer/state/video.ts | 16 +- 22 files changed, 1240 insertions(+), 6 deletions(-) create mode 100644 src/lib/media/video/multipart/aggregateProgress.test.ts create mode 100644 src/lib/media/video/multipart/aggregateProgress.ts create mode 100644 src/lib/media/video/multipart/api.ts create mode 100644 src/lib/media/video/multipart/constants.ts create mode 100644 src/lib/media/video/multipart/planParts.test.ts create mode 100644 src/lib/media/video/multipart/planParts.ts create mode 100644 src/lib/media/video/multipart/readChunk.ts create mode 100644 src/lib/media/video/multipart/readChunk.web.ts create mode 100644 src/lib/media/video/multipart/types.ts create mode 100644 src/lib/media/video/multipart/upload.ts create mode 100644 src/lib/media/video/multipart/uploadPart.ts create mode 100644 src/lib/media/video/multipart/uploadParts.test.ts create mode 100644 src/lib/media/video/multipart/uploadParts.ts create mode 100644 src/lib/media/video/multipart/utils.ts diff --git a/src/analytics/features/types.ts b/src/analytics/features/types.ts index 16cbe3a105..1d6cf131c1 100644 --- a/src/analytics/features/types.ts +++ b/src/analytics/features/types.ts @@ -19,6 +19,7 @@ export enum Features { PostThreadKnownLikersEnable = 'post_thread:known_likers:enable', PostThreadKnownLikersFetchEnable = 'post_thread:known_likers:fetch:enable', CustomLogoJapanEnable = 'custom_logo:japan:enable', + VideoMultipartUploadEnable = 'video:multipart_upload:enable', SearchStarterPacksV2Enable = 'search_starter_packs_v2:enable', FollowSortEnable = 'follow_sort:enable', diff --git a/src/analytics/metrics/types.ts b/src/analytics/metrics/types.ts index bd268603a0..12e0440846 100644 --- a/src/analytics/metrics/types.ts +++ b/src/analytics/metrics/types.ts @@ -5,7 +5,10 @@ import {type Platform} from 'react-native' import {type NotificationReason} from '#/lib/hooks/useNotificationHandler' -import {type VideoCompressSkipReason} from '#/lib/media/video/types' +import { + type VideoCompressSkipReason, + type VideoUploadTransport, +} from '#/lib/media/video/types' import {type NotificationType} from '#/state/queries/notifications/types' import {type FeedDescriptor} from '#/state/queries/post-feed' import {type LiveEventFeedMetricContext} from '#/features/liveEvents/types' @@ -1451,6 +1454,7 @@ export type Events = { bytes: number elapsedMs: number throughputBytesPerSec: number + transport: VideoUploadTransport } 'video:upload:uploadFailed': { uploadId: string @@ -1458,6 +1462,7 @@ export type Events = { bytes: number errorClass: string elapsedMs: number + transport: VideoUploadTransport } 'video:upload:processingStarted': { uploadId: string diff --git a/src/lib/media/video/multipart/aggregateProgress.test.ts b/src/lib/media/video/multipart/aggregateProgress.test.ts new file mode 100644 index 0000000000..a92273cbdb --- /dev/null +++ b/src/lib/media/video/multipart/aggregateProgress.test.ts @@ -0,0 +1,37 @@ +import {createProgressAggregator} from './aggregateProgress' + +describe('createProgressAggregator', () => { + it('sums bytes across parts against the total', () => { + const progress: number[] = [] + const report = createProgressAggregator(100, p => progress.push(p)) + + report(1, 50) + report(2, 25) + expect(progress).toEqual([0.5, 0.75]) + }) + + it('overwrites a part running count rather than adding it', () => { + const progress: number[] = [] + const report = createProgressAggregator(100, p => progress.push(p)) + + report(1, 20) + report(1, 40) + expect(progress).toEqual([0.2, 0.4]) + }) + + it('clamps to 1', () => { + const progress: number[] = [] + const report = createProgressAggregator(100, p => progress.push(p)) + + report(1, 150) + expect(progress).toEqual([1]) + }) + + it('reports 0 when the total is 0', () => { + const progress: number[] = [] + const report = createProgressAggregator(0, p => progress.push(p)) + + report(1, 10) + expect(progress).toEqual([0]) + }) +}) diff --git a/src/lib/media/video/multipart/aggregateProgress.ts b/src/lib/media/video/multipart/aggregateProgress.ts new file mode 100644 index 0000000000..c9e0b1914c --- /dev/null +++ b/src/lib/media/video/multipart/aggregateProgress.ts @@ -0,0 +1,19 @@ +/** + * Tracks bytes sent per part and reports overall progress (0..1) to the + * existing single-value progress callback. Parts upload concurrently, so each + * part reports its own running byte count and this sums them against the total. + */ +export function createProgressAggregator( + totalBytes: number, + setProgress: (progress: number) => void, +) { + const sentByPart = new Map() + return function reportPartProgress(partNumber: number, bytesSent: number) { + sentByPart.set(partNumber, bytesSent) + let sum = 0 + for (const value of sentByPart.values()) { + sum += value + } + setProgress(totalBytes > 0 ? Math.min(sum / totalBytes, 1) : 0) + } +} diff --git a/src/lib/media/video/multipart/api.ts b/src/lib/media/video/multipart/api.ts new file mode 100644 index 0000000000..811696b3e3 --- /dev/null +++ b/src/lib/media/video/multipart/api.ts @@ -0,0 +1,134 @@ +import {AbortError} from '#/lib/async/cancelable' +import {createVideoEndpointUrl} from '#/lib/media/video/util' +import { + type AbortUploadResponse, + type FinishUploadResponse, + type StartUploadResponse, + type UploadStatusResponse, +} from './types' + +export class MultipartUploadError extends Error { + constructor( + message: string, + public error?: string, + public status?: number, + ) { + super(message) + this.name = 'MultipartUploadError' + } +} + +async function request({ + route, + token, + signal, + method = 'POST', + body, + params, +}: { + route: string + token: string + signal?: AbortSignal + method?: 'GET' | 'POST' + body?: object + params?: Record +}): Promise { + if (signal?.aborted) throw new AbortError() + let res: Response + try { + res = await fetch(createVideoEndpointUrl(route, params), { + method, + headers: { + Authorization: `Bearer ${token}`, + ...(body ? {'Content-Type': 'application/json'} : {}), + }, + body: body ? JSON.stringify(body) : undefined, + signal, + }) + } catch (err) { + if (signal?.aborted) throw new AbortError() + throw err + } + const text = await res.text() + let data: unknown + try { + data = text ? JSON.parse(text) : undefined + } catch {} + if (!res.ok) { + const xrpc = data as {error?: string; message?: string} | undefined + throw new MultipartUploadError( + xrpc?.message || xrpc?.error || `Video service returned ${res.status}`, + xrpc?.error, + res.status, + ) + } + return data as T +} + +export function startUpload({ + token, + video, + name, + signal, +}: { + token: string + video: {size: number; mimeType: string} + name: string + signal: AbortSignal +}) { + return request({ + route: '/xrpc/app.bsky.video.startUpload', + token, + signal, + body: {sizeBytes: video.size, mimeType: video.mimeType, name}, + }) +} + +export function finishUpload( + jobId: string, + token: string, + signal: AbortSignal, +) { + return request({ + route: '/xrpc/app.bsky.video.finishUpload', + token, + signal, + body: {jobId}, + }) +} + +export function getUploadStatus( + jobId: string, + token: string, + signal?: AbortSignal, +) { + return request({ + route: '/xrpc/app.bsky.video.getUploadStatus', + token, + signal, + method: 'GET', + params: {jobId}, + }) +} + +export function abortUpload(jobId: string, token: string) { + return request({ + route: '/xrpc/app.bsky.video.abortUpload', + token, + body: {jobId}, + }) +} + +export function completedStatus(status: UploadStatusResponse) { + if ( + status.state !== 'completed' || + !status.completedJobId || + !status.jobStatus + ) { + return undefined + } + return { + completedJobId: status.completedJobId, + jobStatus: status.jobStatus, + } +} diff --git a/src/lib/media/video/multipart/constants.ts b/src/lib/media/video/multipart/constants.ts new file mode 100644 index 0000000000..2aaa37b8c2 --- /dev/null +++ b/src/lib/media/video/multipart/constants.ts @@ -0,0 +1,13 @@ +/* + * Multipart upload knobs. Tunable; part size must stay above the storage + * backend's minimum (R2/S3 require >= 5 MiB per part, except the last). + */ + +/** Max parts uploaded concurrently. */ +export const MULTIPART_CONCURRENCY = 3 + +/** Per-part upload attempts before the part (and the upload) fails. */ +export const MULTIPART_MAX_ATTEMPTS = 3 + +/** Attempts to begin/continue server-side finalization before checking state. */ +export const MULTIPART_FINISH_ATTEMPTS = 3 diff --git a/src/lib/media/video/multipart/planParts.test.ts b/src/lib/media/video/multipart/planParts.test.ts new file mode 100644 index 0000000000..fb7365fc74 --- /dev/null +++ b/src/lib/media/video/multipart/planParts.test.ts @@ -0,0 +1,47 @@ +import {getMissingParts, planParts} from './planParts' + +describe('planParts', () => { + it('splits an evenly divisible size into full parts', () => { + expect(planParts(20, 10)).toEqual([ + {partNumber: 1, offset: 0, size: 10}, + {partNumber: 2, offset: 10, size: 10}, + ]) + }) + + it('puts the remainder in the last part', () => { + expect(planParts(25, 10)).toEqual([ + {partNumber: 1, offset: 0, size: 10}, + {partNumber: 2, offset: 10, size: 10}, + {partNumber: 3, offset: 20, size: 5}, + ]) + }) + + it('returns a single part when the file is smaller than a part', () => { + expect(planParts(5, 10)).toEqual([{partNumber: 1, offset: 0, size: 5}]) + }) + + it('returns no parts for a non-positive size', () => { + expect(planParts(0, 10)).toEqual([]) + }) + + it('covers the whole file with no gaps or overlaps', () => { + const parts = planParts(1000, 128) + expect(parts[0].offset).toBe(0) + for (let i = 1; i < parts.length; i++) { + expect(parts[i].offset).toBe(parts[i - 1].offset + parts[i - 1].size) + } + const last = parts[parts.length - 1] + expect(last.offset + last.size).toBe(1000) + }) + + it('throws for a non-positive part size', () => { + expect(() => planParts(100, 0)).toThrow() + }) + + it('selects only parts the server has not received', () => { + const parts = planParts(25, 10) + expect(getMissingParts(parts, [1, 3])).toEqual([ + {partNumber: 2, offset: 10, size: 10}, + ]) + }) +}) diff --git a/src/lib/media/video/multipart/planParts.ts b/src/lib/media/video/multipart/planParts.ts new file mode 100644 index 0000000000..6dfaf29f1b --- /dev/null +++ b/src/lib/media/video/multipart/planParts.ts @@ -0,0 +1,30 @@ +import {type PartPlan} from './types' + +/** + * Splits a file of `totalSize` bytes into parts of at most `partSize` bytes. + * The last part carries the remainder. Part numbers are 1-indexed. Returns an + * empty list for a non-positive size. + */ +export function planParts(totalSize: number, partSize: number): PartPlan[] { + if (partSize <= 0) { + throw new Error('partSize must be positive') + } + const parts: PartPlan[] = [] + let offset = 0 + let partNumber = 1 + while (offset < totalSize) { + const size = Math.min(partSize, totalSize - offset) + parts.push({partNumber, offset, size}) + offset += size + partNumber += 1 + } + return parts +} + +export function getMissingParts( + parts: PartPlan[], + receivedPartNumbers: number[], +): PartPlan[] { + const received = new Set(receivedPartNumbers) + return parts.filter(part => !received.has(part.partNumber)) +} diff --git a/src/lib/media/video/multipart/readChunk.ts b/src/lib/media/video/multipart/readChunk.ts new file mode 100644 index 0000000000..9a8aaefe32 --- /dev/null +++ b/src/lib/media/video/multipart/readChunk.ts @@ -0,0 +1,21 @@ +import {File} from 'expo-file-system' + +import {type CompressedVideo} from '#/lib/media/video/types' +import {type ChunkReader} from './types' + +/** + * Native chunk reader. Opens one file handle and seeks per read, so the video + * bytes are never all held in JS memory. Call `close` when the upload finishes. + */ +export function createChunkReader(video: CompressedVideo): ChunkReader { + const handle = new File(video.uri).open() + return { + read(offset, size) { + handle.offset = offset + return Promise.resolve(handle.readBytes(size)) + }, + close() { + handle.close() + }, + } +} diff --git a/src/lib/media/video/multipart/readChunk.web.ts b/src/lib/media/video/multipart/readChunk.web.ts new file mode 100644 index 0000000000..8f3767f26c --- /dev/null +++ b/src/lib/media/video/multipart/readChunk.web.ts @@ -0,0 +1,27 @@ +import {type CompressedVideo} from '#/lib/media/video/types' +import {type ChunkReader} from './types' + +/** + * Web chunk reader. Web compression already produces the full buffer, so this + * slices it in memory. Falls back to fetching the uri once if `bytes` is + * missing. `close` is a no-op. + */ +export function createChunkReader(video: CompressedVideo): ChunkReader { + let bytesPromise: Promise | null = null + const getBytes = () => { + if (video.bytes) { + return Promise.resolve(video.bytes) + } + if (!bytesPromise) { + bytesPromise = fetch(video.uri).then(res => res.arrayBuffer()) + } + return bytesPromise + } + return { + async read(offset, size) { + const buffer = await getBytes() + return new Uint8Array(buffer, offset, size) + }, + close() {}, + } +} diff --git a/src/lib/media/video/multipart/types.ts b/src/lib/media/video/multipart/types.ts new file mode 100644 index 0000000000..16817778c3 --- /dev/null +++ b/src/lib/media/video/multipart/types.ts @@ -0,0 +1,72 @@ +/** + * One part of a multipart upload. `partNumber` is 1-indexed to match the S3 + * convention the backend uses. + */ +export type PartPlan = { + partNumber: number + offset: number + size: number +} + +/** Receipt returned by the video service after recording a part. */ +export type PartUploadResult = { + partNumber: number + sizeBytes: number +} + +/** + * Reads a byte range off the compressed video. Native opens a file handle and + * seeks; web slices an in-memory buffer. `close` releases the native handle and + * is a no-op on web. + */ +export type ChunkReader = { + read: (offset: number, size: number) => Promise + close: () => void +} + +/** + * Uploads one part through the video service's first-party proxy. + */ +export type UploadPartFn = (args: { + part: PartPlan + chunk: Uint8Array + onProgress: (bytesSent: number) => void + signal: AbortSignal +}) => Promise + +export type StartUploadResponse = { + jobId: string + partSizeBytes: number + partCount: number + expiresAt: string +} + +export type UploadState = + | 'created' + | 'finishing' + | 'completed' + | 'failed' + | 'aborted' + | 'expired' + +export type UploadStatusResponse = { + jobId: string + partSizeBytes: number + partCount: number + receivedParts: number[] + expiresAt: string + state: UploadState + completedJobId?: string + jobStatus?: import('@atproto/api').AppBskyVideoDefs.JobStatus + failureReason?: string +} + +export type FinishUploadResponse = { + completedJobId: string + jobStatus: import('@atproto/api').AppBskyVideoDefs.JobStatus +} + +export type AbortUploadResponse = Pick< + UploadStatusResponse, + 'completedJobId' | 'failureReason' +> & {state: 'aborted' | 'completed' | 'failed' | 'expired'} diff --git a/src/lib/media/video/multipart/upload.ts b/src/lib/media/video/multipart/upload.ts new file mode 100644 index 0000000000..af2d71d596 --- /dev/null +++ b/src/lib/media/video/multipart/upload.ts @@ -0,0 +1,292 @@ +import {type AppBskyVideoDefs, type AtpAgent} from '@atproto/api' +import {nanoid} from 'nanoid/non-secure' + +import {AbortError} from '#/lib/async/cancelable' +import {type CompressedVideo} from '#/lib/media/video/types' +import {shouldRetryError} from '#/lib/strings/errors' +import {getServiceAuthToken} from '../upload.shared' +import {mimeToExt} from '../util' +import { + abortUpload, + completedStatus, + finishUpload, + getUploadStatus, + MultipartUploadError, + startUpload, +} from './api' +import {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' + +export class MultipartFallbackError extends Error {} + +export async function uploadVideoMultipart({ + video, + agent, + setProgress, + signal, + onStarted, +}: { + video: CompressedVideo + agent: AtpAgent + setProgress: (progress: number) => void + signal: AbortSignal + onStarted?: () => void +}): Promise { + throwIfAborted(signal) + const tokenProvider = createTokenProvider(agent, signal) + const token = await tokenProvider.get() + const name = `${nanoid(12)}.${mimeToExt(video.mimeType)}` + let session + try { + session = await startUpload({token, video, name, signal}) + } catch (err) { + if (signal.aborted) throw new AbortError() + // A server without multipart support, or one with the kill switch active, + // leaves no reservation behind. The legacy path remains authoritative. + throw new MultipartFallbackError( + err instanceof Error ? err.message : 'Multipart upload unavailable', + ) + } + onStarted?.() + + const {jobId} = session + const abortOnCancel = () => { + void tokenProvider + .get() + .then(currentToken => abortUpload(jobId, currentToken)) + .catch(() => {}) + } + signal.addEventListener('abort', abortOnCancel, {once: true}) + let reader: ReturnType | undefined + // Kept outside the upload try block for finish-time missing-part recovery. + let parts: ReturnType = [] + try { + try { + reader = createChunkReader(video) + parts = planParts(video.size, session.partSizeBytes) + if (parts.length !== session.partCount) { + throw new Error('Video service returned an invalid multipart plan') + } + await uploadParts({ + parts, + reader, + uploadPart: createUploadPart(jobId, tokenProvider.get), + totalBytes: video.size, + setProgress, + signal, + }) + } catch (err) { + if (signal.aborted) throw new AbortError() + return await abortThenFallbackOrResolve( + jobId, + await tokenProvider.get(), + err, + ) + } + + // Preserve TypeScript's narrowing inside the recovery callback. + const activeReader = reader + if (!activeReader) throw new Error('Video chunk reader is unavailable') + return await finishAndRecover({ + jobId, + getToken: tokenProvider.get, + signal, + resendMissingParts: async receivedPartNumbers => { + const missing = getMissingParts(parts, receivedPartNumbers) + if (missing.length === 0) return false + const missingBytes = missing.reduce((sum, part) => sum + part.size, 0) + const completedBytes = video.size - missingBytes + await uploadParts({ + parts: missing, + reader: activeReader, + uploadPart: createUploadPart(jobId, tokenProvider.get), + totalBytes: missingBytes, + setProgress: progress => + setProgress( + (completedBytes + progress * missingBytes) / video.size, + ), + signal, + }) + return true + }, + }) + } finally { + reader?.close() + signal.removeEventListener('abort', abortOnCancel) + } +} + +async function finishAndRecover({ + jobId, + getToken, + signal, + resendMissingParts, +}: { + jobId: string + getToken: (forceRefresh?: boolean) => Promise + signal: AbortSignal + resendMissingParts: (receivedPartNumbers: number[]) => Promise +}): Promise { + let createdFailures = 0 + let forceTokenRefresh = true + while (true) { + throwIfAborted(signal) + // Finish stores this credential for the later PDS blob upload. Refresh it + // once after part transfer, then reuse it while polling/recovering. + const token = await getToken(forceTokenRefresh) + forceTokenRefresh = false + try { + const result = await finishUpload(jobId, token, signal) + return result.jobStatus + } catch (finishError) { + throwIfAborted(signal) + const status = await getUploadStatusWithRetry(jobId, token, signal) + const completed = completedStatus(status) + if (completed) return completed.jobStatus + + switch (status.state) { + case 'created': + try { + const resentParts = await resendMissingParts(status.receivedParts) + if (resentParts) { + createdFailures = 0 + continue + } + } catch (err) { + throwIfAborted(signal) + return await abortThenFallbackOrResolve(jobId, token, err) + } + createdFailures++ + if (createdFailures < MULTIPART_FINISH_ATTEMPTS) { + await delay(500 * 2 ** (createdFailures - 1), signal) + continue + } + return await abortThenFallbackOrResolve(jobId, token, finishError) + case 'finishing': + // The service may have assembled the upload even though the finish + // request failed. Poll and retry instead of starting a second upload. + await delay(1000, signal) + continue + case 'failed': + throw new MultipartUploadError( + status.failureReason || 'Multipart upload failed', + 'UploadFailed', + ) + case 'aborted': + case 'expired': + throw new MultipartUploadError( + `Multipart upload ${status.state}`, + status.state === 'aborted' ? 'UploadAborted' : 'UploadExpired', + ) + case 'completed': + throw new MultipartUploadError( + 'Multipart upload completed without a job status', + 'InvalidUploadStatus', + ) + default: + throw new MultipartUploadError( + 'Multipart upload returned an unknown status', + 'InvalidUploadStatus', + ) + } + } + } +} + +async function getUploadStatusWithRetry( + jobId: string, + token: string, + signal: AbortSignal, +) { + let lastError: unknown + for (let attempt = 1; attempt <= 3; attempt++) { + try { + return await getUploadStatus(jobId, token, signal) + } catch (err) { + throwIfAborted(signal) + if (!isRetryableMultipartError(err)) throw err + lastError = err + if (attempt < 3) await delay(500 * 2 ** (attempt - 1), signal) + } + } + throw lastError +} + +async function abortThenFallbackOrResolve( + jobId: string, + token: string, + cause: unknown, +): Promise { + const result = await abortUpload(jobId, token) + if (result.state === 'aborted') { + throw new MultipartFallbackError( + cause instanceof Error ? cause.message : 'Multipart upload failed', + ) + } + if (result.state === 'completed' && result.completedJobId) { + const status = await getUploadStatus(jobId, token) + const completed = completedStatus(status) + if (completed) return completed.jobStatus + } + throw new MultipartUploadError( + result.failureReason || `Multipart upload ${result.state}`, + result.state === 'failed' ? 'UploadFailed' : undefined, + ) +} + +function createTokenProvider(agent: AtpAgent, signal: AbortSignal) { + let token: string | undefined + let expiresAt = 0 + let refresh: Promise | undefined + + async function get(forceRefresh = false) { + if (!forceRefresh && token && Date.now() < expiresAt - 60_000) return token + if (!refresh) { + const exp = Math.floor(Date.now() / 1000) + 60 * 30 + refresh = getServiceAuthTokenWithRetry(agent, exp, signal) + .then(nextToken => { + token = nextToken + expiresAt = exp * 1000 + return nextToken + }) + .finally(() => { + refresh = undefined + }) + } + return refresh + } + + return {get} +} + +async function getServiceAuthTokenWithRetry( + agent: AtpAgent, + exp: number, + signal: AbortSignal, +) { + let lastError: unknown + for (let attempt = 1; attempt <= 3; attempt++) { + throwIfAborted(signal) + try { + return await getServiceAuthToken({ + agent, + lxm: 'com.atproto.repo.uploadBlob', + exp, + }) + } catch (err) { + throwIfAborted(signal) + if (!(err instanceof TypeError) && !shouldRetryError(err)) throw err + lastError = err + if (attempt < 3) await delay(500 * 2 ** (attempt - 1), signal) + } + } + throw lastError +} + +function throwIfAborted(signal: AbortSignal) { + if (signal.aborted) throw new AbortError() +} diff --git a/src/lib/media/video/multipart/uploadPart.ts b/src/lib/media/video/multipart/uploadPart.ts new file mode 100644 index 0000000000..e3c57fc2b2 --- /dev/null +++ b/src/lib/media/video/multipart/uploadPart.ts @@ -0,0 +1,94 @@ +import {AbortError} from '#/lib/async/cancelable' +import {createVideoEndpointUrl} from '#/lib/media/video/util' +import {MultipartUploadError} from './api' +import {type UploadPartFn} from './types' + +export function createUploadPart( + jobId: string, + getToken: (forceRefresh?: boolean) => Promise, +): UploadPartFn { + return async args => { + try { + return await sendPart(jobId, await getToken(), args) + } catch (err) { + if ( + err instanceof MultipartUploadError && + (err.status === 401 || err.error === 'AuthRequired') + ) { + args.onProgress(0) + return await sendPart(jobId, await getToken(true), args) + } + throw err + } + } +} + +function sendPart( + jobId: string, + token: string, + {part, chunk, onProgress, signal}: Parameters[0], +) { + return new Promise>>((resolve, reject) => { + if (signal.aborted) { + reject(new AbortError()) + return + } + const xhr = new XMLHttpRequest() + const abort = () => xhr.abort() + signal.addEventListener('abort', abort, {once: true}) + const cleanup = () => signal.removeEventListener('abort', abort) + + xhr.upload.addEventListener('progress', event => { + onProgress(event.loaded) + }) + xhr.onerror = () => { + cleanup() + reject(new TypeError('Network request failed')) + } + xhr.onabort = () => { + cleanup() + reject(new AbortError()) + } + xhr.onload = () => { + cleanup() + let data: { + partNumber?: number + sizeBytes?: number + error?: string + message?: string + } + try { + data = JSON.parse(xhr.responseText) + } catch { + data = {} + } + if (xhr.status < 200 || xhr.status >= 300) { + reject( + new MultipartUploadError( + data.message || + data.error || + `Video service returned ${xhr.status}`, + data.error, + xhr.status, + ), + ) + } else { + onProgress(part.size) + resolve({ + partNumber: data.partNumber ?? part.partNumber, + sizeBytes: data.sizeBytes ?? part.size, + }) + } + } + xhr.open( + 'POST', + createVideoEndpointUrl('/xrpc/app.bsky.video.uploadPart', { + jobId, + partNumber: String(part.partNumber), + }), + ) + xhr.setRequestHeader('Content-Type', 'application/octet-stream') + xhr.setRequestHeader('Authorization', `Bearer ${token}`) + xhr.send(chunk as XMLHttpRequestBodyInit) + }) +} diff --git a/src/lib/media/video/multipart/uploadParts.test.ts b/src/lib/media/video/multipart/uploadParts.test.ts new file mode 100644 index 0000000000..7965a28df9 --- /dev/null +++ b/src/lib/media/video/multipart/uploadParts.test.ts @@ -0,0 +1,206 @@ +import {AbortError} from '#/lib/async/cancelable' +import {MultipartUploadError} from './api' +import {type ChunkReader, type UploadPartFn} from './types' +import {uploadParts} from './uploadParts' + +function fakeReader(): ChunkReader { + return { + read: (_offset, size) => Promise.resolve(new Uint8Array(size)), + close: () => {}, + } +} + +const parts = [ + {partNumber: 1, offset: 0, size: 10}, + {partNumber: 2, offset: 10, size: 10}, + {partNumber: 3, offset: 20, size: 5}, +] + +describe('uploadParts', () => { + it('uploads every part and returns results ordered by part number', async () => { + const uploadPart: UploadPartFn = ({part}) => + Promise.resolve({ + partNumber: part.partNumber, + sizeBytes: part.size, + }) + + const results = await uploadParts({ + parts, + reader: fakeReader(), + uploadPart, + totalBytes: 25, + setProgress: () => {}, + signal: new AbortController().signal, + }) + + expect(results.map(r => r.partNumber)).toEqual([1, 2, 3]) + expect(results.map(r => r.sizeBytes)).toEqual([10, 10, 5]) + }) + + it('respects the concurrency cap', async () => { + let active = 0 + let maxActive = 0 + const uploadPart: UploadPartFn = async ({part}) => { + active++ + maxActive = Math.max(maxActive, active) + await new Promise(r => setTimeout(r, 5)) + active-- + return {partNumber: part.partNumber, sizeBytes: part.size} + } + + await uploadParts({ + parts, + reader: fakeReader(), + uploadPart, + totalBytes: 25, + setProgress: () => {}, + signal: new AbortController().signal, + concurrency: 2, + }) + + expect(maxActive).toBeLessThanOrEqual(2) + }) + + it('retries a failing part and succeeds', async () => { + const attemptsByPart = new Map() + const uploadPart: UploadPartFn = ({part}) => { + const n = (attemptsByPart.get(part.partNumber) ?? 0) + 1 + attemptsByPart.set(part.partNumber, n) + if (part.partNumber === 2 && n === 1) { + return Promise.reject(new TypeError('transient network error')) + } + return Promise.resolve({ + partNumber: part.partNumber, + sizeBytes: part.size, + }) + } + + const results = await uploadParts({ + parts, + reader: fakeReader(), + uploadPart, + totalBytes: 25, + setProgress: () => {}, + signal: new AbortController().signal, + }) + + expect(attemptsByPart.get(2)).toBe(2) + expect(results).toHaveLength(3) + }) + + it('retries rate-limited parts', async () => { + let attempts = 0 + const uploadPart: UploadPartFn = ({part}) => { + attempts++ + if (attempts === 1) { + return Promise.reject( + new MultipartUploadError('rate limited', 'RateLimitExceeded', 429), + ) + } + 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, + Parameters + >(() => + Promise.reject( + new MultipartUploadError('bad request', 'InvalidRequest', 400), + ), + ) + + await expect( + uploadParts({ + parts: parts.slice(0, 1), + reader: fakeReader(), + uploadPart, + totalBytes: 10, + setProgress: () => {}, + signal: new AbortController().signal, + }), + ).rejects.toThrow('bad request') + expect(uploadPart).toHaveBeenCalledTimes(1) + }) + + it('throws after exhausting attempts', async () => { + const uploadPart: UploadPartFn = () => + Promise.reject(new TypeError('always fails')) + + await expect( + uploadParts({ + parts, + reader: fakeReader(), + uploadPart, + totalBytes: 25, + setProgress: () => {}, + signal: new AbortController().signal, + maxAttempts: 2, + }), + ).rejects.toThrow('always fails') + }) + + it('preserves the originating error when sibling workers abort', async () => { + const uploadPart: UploadPartFn = ({part, signal}) => { + if (part.partNumber === 1) { + return new Promise((_resolve, reject) => { + signal.addEventListener('abort', () => reject(new AbortError()), { + once: true, + }) + }) + } + return Promise.reject(new Error('part upload failed')) + } + + await expect( + uploadParts({ + parts: parts.slice(0, 2), + reader: fakeReader(), + uploadPart, + totalBytes: 20, + setProgress: () => {}, + signal: new AbortController().signal, + concurrency: 2, + maxAttempts: 1, + }), + ).rejects.toThrow('part upload failed') + }) + + it('reports progress that reaches 1 when all parts complete', async () => { + const progress: number[] = [] + const uploadPart: UploadPartFn = ({part, chunk, onProgress}) => { + onProgress(chunk.byteLength) + return Promise.resolve({ + partNumber: part.partNumber, + sizeBytes: part.size, + }) + } + + await uploadParts({ + parts, + reader: fakeReader(), + uploadPart, + totalBytes: 25, + setProgress: p => progress.push(p), + signal: new AbortController().signal, + concurrency: 1, + }) + + expect(progress[progress.length - 1]).toBe(1) + }) +}) diff --git a/src/lib/media/video/multipart/uploadParts.ts b/src/lib/media/video/multipart/uploadParts.ts new file mode 100644 index 0000000000..cda502fb54 --- /dev/null +++ b/src/lib/media/video/multipart/uploadParts.ts @@ -0,0 +1,129 @@ +import {AbortError} from '#/lib/async/cancelable' +import {createProgressAggregator} from './aggregateProgress' +import {MULTIPART_CONCURRENCY, MULTIPART_MAX_ATTEMPTS} from './constants' +import { + type ChunkReader, + type PartPlan, + type PartUploadResult, + type UploadPartFn, +} from './types' +import {delay, isRetryableMultipartError} from './utils' + +/** + * Uploads every part with a concurrency cap and per-part retry, aggregating + * byte progress into `setProgress`. Reads each chunk lazily just before its + * upload so only `concurrency` chunks are in memory at once. Resolves with the + * part results ordered by part number. + */ +export async function uploadParts({ + parts, + reader, + uploadPart, + totalBytes, + setProgress, + signal, + concurrency = MULTIPART_CONCURRENCY, + maxAttempts = MULTIPART_MAX_ATTEMPTS, +}: { + parts: PartPlan[] + reader: ChunkReader + uploadPart: UploadPartFn + totalBytes: number + setProgress: (progress: number) => void + signal: AbortSignal + concurrency?: number + maxAttempts?: number +}): Promise { + const reportPartProgress = createProgressAggregator(totalBytes, setProgress) + const results: PartUploadResult[] = new Array(parts.length) + const workerController = new AbortController() + const abortWorkers = () => workerController.abort() + signal.addEventListener('abort', abortWorkers, {once: true}) + const workerSignal = workerController.signal + + let nextIndex = 0 + async function worker() { + while (true) { + if (workerSignal.aborted) { + throw new AbortError() + } + const index = nextIndex++ + if (index >= parts.length) { + return + } + const part = parts[index] + const chunk = await reader.read(part.offset, part.size) + results[index] = await uploadPartWithRetry({ + part, + chunk, + uploadPart, + maxAttempts, + signal: workerSignal, + onProgress: bytesSent => reportPartProgress(part.partNumber, bytesSent), + }) + } + } + + const workers = Array.from( + {length: Math.min(concurrency, parts.length)}, + () => worker(), + ) + const settled = await Promise.allSettled( + workers.map(async workerPromise => { + try { + await workerPromise + } catch (err) { + workerController.abort() + throw err + } + }), + ) + signal.removeEventListener('abort', abortWorkers) + const failures = settled.filter( + (result): result is PromiseRejectedResult => result.status === 'rejected', + ) + if (signal.aborted) throw new AbortError() + // A sibling worker aborted after the first failure can settle earlier in + // array order. Preserve the originating error for fallback and telemetry. + const failure = + failures.find(result => !(result.reason instanceof AbortError)) ?? + failures[0] + if (failure) throw failure.reason + return results +} + +async function uploadPartWithRetry({ + part, + chunk, + uploadPart, + maxAttempts, + signal, + onProgress, +}: { + part: PartPlan + chunk: Uint8Array + uploadPart: UploadPartFn + maxAttempts: number + signal: AbortSignal + onProgress: (bytesSent: number) => void +}): Promise { + let lastError: unknown + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + if (signal.aborted) { + throw new AbortError() + } + try { + return await uploadPart({part, chunk, onProgress, signal}) + } catch (err) { + if (signal.aborted) { + throw new AbortError() + } + lastError = err + if (!isRetryableMultipartError(err)) throw err + if (attempt < maxAttempts) { + await delay(500 * 2 ** (attempt - 1), signal) + } + } + } + throw lastError +} diff --git a/src/lib/media/video/multipart/utils.ts b/src/lib/media/video/multipart/utils.ts new file mode 100644 index 0000000000..4184ab5666 --- /dev/null +++ b/src/lib/media/video/multipart/utils.ts @@ -0,0 +1,27 @@ +import {AbortError} from '#/lib/async/cancelable' +import {isRetryableHttpStatus} from '#/lib/strings/errors' +import {MultipartUploadError} from './api' + +export function isRetryableMultipartError(err: unknown) { + return ( + err instanceof TypeError || + (err instanceof MultipartUploadError && + (err.error === 'ServiceOverloaded' || + err.status === undefined || + isRetryableHttpStatus(err.status))) + ) +} + +export function delay(ms: number, signal: AbortSignal) { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + signal.removeEventListener('abort', onAbort) + resolve() + }, ms) + function onAbort() { + clearTimeout(timer) + reject(new AbortError()) + } + signal.addEventListener('abort', onAbort, {once: true}) + }) +} diff --git a/src/lib/media/video/telemetry.ts b/src/lib/media/video/telemetry.ts index 67ff77c58e..9181ec002b 100644 --- a/src/lib/media/video/telemetry.ts +++ b/src/lib/media/video/telemetry.ts @@ -5,6 +5,7 @@ import {nanoid} from 'nanoid/non-secure' import { type ProbedMetadata, type VideoCompressSkipReason, + type VideoUploadTransport, } from '#/lib/media/video/types' import {Sentry} from '#/logger/sentry/lib' import {type Metrics} from '#/analytics/metrics' @@ -45,6 +46,7 @@ export type VideoTelemetry = { compressCompleted: (video: {size: number; mimeType: string}) => void compressFailed: (e: unknown) => void uploadStarted: (bytes: number) => void + uploadTransport: (transport: VideoUploadTransport) => void uploadCompleted: (jobId: string) => void uploadFailed: (e: unknown) => void processingStarted: (jobId: string) => void @@ -70,6 +72,7 @@ export function createVideoTelemetry({ let phaseStartedAt = startedAt let jobId: string | undefined let uploadBytes: number | undefined + let uploadTransport: VideoUploadTransport = 'legacy' let txnEnded = false let abortBound = true @@ -226,6 +229,11 @@ export function createVideoTelemetry({ metric('video:upload:uploadStarted', {uploadId, engine, bytes}) }, + uploadTransport(transport) { + uploadTransport = transport + phaseSpan?.setAttribute('video.upload.transport', transport) + }, + uploadCompleted(id) { jobId = id const elapsedMs = Date.now() - phaseStartedAt @@ -238,6 +246,7 @@ export function createVideoTelemetry({ elapsedMs, throughputBytesPerSec: elapsedMs > 0 ? Math.round((bytes * 1000) / elapsedMs) : 0, + transport: uploadTransport, }) endPhaseSpan() phase = undefined @@ -250,6 +259,7 @@ export function createVideoTelemetry({ bytes: uploadBytes ?? 0, errorClass: errorClass(e), elapsedMs: Date.now() - phaseStartedAt, + transport: uploadTransport, }) endTxn('error') detachAbort() diff --git a/src/lib/media/video/types.ts b/src/lib/media/video/types.ts index 6825f03ba3..1d2062ce00 100644 --- a/src/lib/media/video/types.ts +++ b/src/lib/media/video/types.ts @@ -8,6 +8,8 @@ export type VideoCompressSkipReason = | 'no-webcodecs' | 'compress-error-fallback' +export type VideoUploadTransport = 'multipart' | 'legacy' | 'legacy-fallback' + export type CompressedVideo = { uri: string mimeType: string diff --git a/src/lib/media/video/upload.ts b/src/lib/media/video/upload.ts index 721ee7f94f..b91ad7a153 100644 --- a/src/lib/media/video/upload.ts +++ b/src/lib/media/video/upload.ts @@ -6,7 +6,12 @@ import {nanoid} from 'nanoid/non-secure' import {AbortError} from '#/lib/async/cancelable' import {ServerError} from '#/lib/media/video/errors' -import {type CompressedVideo} from '#/lib/media/video/types' +import { + type CompressedVideo, + type VideoUploadTransport, +} from '#/lib/media/video/types' +import {Features, features} from '#/analytics/features' +import {MultipartFallbackError, uploadVideoMultipart} from './multipart/upload' import {getServiceAuthToken, getVideoUploadLimits} from './upload.shared' import {createVideoEndpointUrl, mimeToExt} from './util' @@ -17,6 +22,7 @@ export async function uploadVideo({ setProgress, signal, i18n, + onTransport, }: { video: CompressedVideo agent: AtpAgent @@ -24,12 +30,31 @@ export async function uploadVideo({ setProgress: (progress: number) => void signal: AbortSignal i18n: I18n + onTransport?: (transport: VideoUploadTransport) => void }) { if (signal.aborted) { throw new AbortError() } await getVideoUploadLimits(agent, i18n) + if (features.isOn(Features.VideoMultipartUploadEnable)) { + try { + return await uploadVideoMultipart({ + video, + agent, + setProgress, + signal, + onStarted: () => onTransport?.('multipart'), + }) + } catch (err) { + if (!(err instanceof MultipartFallbackError)) throw err + onTransport?.('legacy-fallback') + setProgress(0) + } + } else { + onTransport?.('legacy') + } + const uri = createVideoEndpointUrl('/xrpc/app.bsky.video.uploadVideo', { did, name: `${nanoid(12)}.${mimeToExt(video.mimeType)}`, diff --git a/src/lib/media/video/upload.web.ts b/src/lib/media/video/upload.web.ts index e88a04707c..cfefbd797d 100644 --- a/src/lib/media/video/upload.web.ts +++ b/src/lib/media/video/upload.web.ts @@ -5,7 +5,12 @@ import {nanoid} from 'nanoid/non-secure' import {AbortError} from '#/lib/async/cancelable' import {ServerError} from '#/lib/media/video/errors' -import {type CompressedVideo} from '#/lib/media/video/types' +import { + type CompressedVideo, + type VideoUploadTransport, +} from '#/lib/media/video/types' +import {Features, features} from '#/analytics/features' +import {MultipartFallbackError, uploadVideoMultipart} from './multipart/upload' import {getServiceAuthToken, getVideoUploadLimits} from './upload.shared' import {createVideoEndpointUrl, mimeToExt} from './util' @@ -16,6 +21,7 @@ export async function uploadVideo({ setProgress, signal, i18n, + onTransport, }: { video: CompressedVideo agent: AtpAgent @@ -23,12 +29,31 @@ export async function uploadVideo({ setProgress: (progress: number) => void signal: AbortSignal i18n: I18n + onTransport?: (transport: VideoUploadTransport) => void }) { if (signal.aborted) { throw new AbortError() } await getVideoUploadLimits(agent, i18n) + if (features.isOn(Features.VideoMultipartUploadEnable)) { + try { + return await uploadVideoMultipart({ + video, + agent, + setProgress, + signal, + onStarted: () => onTransport?.('multipart'), + }) + } catch (err) { + if (!(err instanceof MultipartFallbackError)) throw err + onTransport?.('legacy-fallback') + setProgress(0) + } + } else { + onTransport?.('legacy') + } + const uri = createVideoEndpointUrl('/xrpc/app.bsky.video.uploadVideo', { did, name: `${nanoid(12)}.${mimeToExt(video.mimeType)}`, diff --git a/src/lib/strings/errors.ts b/src/lib/strings/errors.ts index 994c91515b..11e7c11362 100644 --- a/src/lib/strings/errors.ts +++ b/src/lib/strings/errors.ts @@ -88,6 +88,10 @@ export function isCancelledError(e: unknown) { // TODO Replace this with error.shouldRetry() when available. -dsb const RETRYABLE_ERRORS = [408, 425, 429, 500, 502, 503, 504, 522, 524] -export function shouldRetryError(e: unknown) { - return e instanceof XRPCError && RETRYABLE_ERRORS.includes(e.status) +export function isRetryableHttpStatus(status: number) { + return RETRYABLE_ERRORS.includes(status) +} + +export function shouldRetryError(e: unknown) { + return e instanceof XRPCError && isRetryableHttpStatus(e.status) } diff --git a/src/view/com/composer/state/video.ts b/src/view/com/composer/state/video.ts index 492eac1c5d..ed4384ccda 100644 --- a/src/view/com/composer/state/video.ts +++ b/src/view/com/composer/state/video.ts @@ -326,6 +326,7 @@ export async function processVideo( did, signal, i18n, + onTransport: telemetry.uploadTransport, setProgress: p => { dispatch({type: 'update_progress', progress: p, signal}) }, @@ -387,7 +388,7 @@ export async function processVideo( telemetry.processingFailed(e) dispatch({ type: 'to_error', - error: i18n._(msg`Video failed to process`), + error: getProcessingErrorMessage(status?.error, i18n), signal, }) return // Exit async loop @@ -420,6 +421,19 @@ export async function processVideo( } } +function getProcessingErrorMessage(error: string | undefined, i18n: I18n) { + switch (error) { + case 'video_too_long': + return i18n._(msg`The selected video is too long.`) + case 'bad_aspect_ratio': + return i18n._(msg`The selected video has an unsupported aspect ratio.`) + case 'unsupported_codec': + return i18n._(msg`The selected video uses an unsupported format.`) + default: + return i18n._(msg`Video failed to process`) + } +} + function getCompressErrorMessage(e: unknown, i18n: I18n): string | null { if (e instanceof AbortError) { return null From 196e81eb0ccf721c86bd0c6e74da186db838125f Mon Sep 17 00:00:00 2001 From: DS Boyce <260543580+ds-boyce@users.noreply.github.com> Date: Wed, 29 Jul 2026 10:24:50 -0700 Subject: [PATCH 03/34] Get trending section indices from GrowthBook (#11319) --- src/analytics/features/types.ts | 3 +++ src/analytics/index.tsx | 5 ++++- src/view/com/posts/PostFeed.tsx | 20 +++++++++++++------- 3 files changed, 20 insertions(+), 8 deletions(-) diff --git a/src/analytics/features/types.ts b/src/analytics/features/types.ts index 1d6cf131c1..22cf07ad85 100644 --- a/src/analytics/features/types.ts +++ b/src/analytics/features/types.ts @@ -23,5 +23,8 @@ export enum Features { SearchStarterPacksV2Enable = 'search_starter_packs_v2:enable', FollowSortEnable = 'follow_sort:enable', + // values + TrendingDiscoverValues = 'trending_discover:values', + AATest = 'aa-test', } diff --git a/src/analytics/index.tsx b/src/analytics/index.tsx index 20f919f501..51438c2d7b 100644 --- a/src/analytics/index.tsx +++ b/src/analytics/index.tsx @@ -6,7 +6,7 @@ import { useSyncExternalStore, } from 'react' import {Platform} from 'react-native' -import {type Result} from '@growthbook/growthbook-react' +import {type Result, type WidenPrimitives} from '@growthbook/growthbook-react' import {Logger} from '#/logger' import { @@ -67,6 +67,7 @@ export type AnalyticsContextType = { ) => void features: typeof Features & { enabled(feature: Features): boolean + getValue(feature: Features, defaultValue: T): WidenPrimitives } } export type AnalyticsBaseContextType = Omit @@ -83,6 +84,7 @@ function createLogger( warn: logger.warn.bind(logger), error: logger.error.bind(logger), useChild: (context: Exclude) => { + // oxlint-disable-next-line react-hooks/exhaustive-deps return useMemo(() => createLogger(context, metadata), [context, metadata]) }, Context: Logger.Context, @@ -314,6 +316,7 @@ export function AnalyticsFeaturesContext({ ...parentContext, features: { enabled: feats.isOn.bind(feats), + getValue: feats.getFeatureValue.bind(feats), ...Features, }, } diff --git a/src/view/com/posts/PostFeed.tsx b/src/view/com/posts/PostFeed.tsx index 0100856307..4f341922bb 100644 --- a/src/view/com/posts/PostFeed.tsx +++ b/src/view/com/posts/PostFeed.tsx @@ -211,10 +211,6 @@ export type PostFeedRef = { // const REFRESH_AFTER = STALE.HOURS.ONE const CHECK_LATEST_AFTER = STALE.SECONDS.THIRTY -const TRENDING_TOPICS_INDEX = 5 -const TRENDING_VIDEO_INDEX = 30 -const SUGGESTED_FOR_YOU_INDEX = 15 - let PostFeed = ({ feed, description, @@ -280,6 +276,15 @@ let PostFeed = ({ const {rightNavVisible} = useLayoutBreakpoints() const areVideoFeedsEnabled = IS_NATIVE + const trendingIndices = ax.features.getValue( + ax.features.TrendingDiscoverValues, + { + topics: 5, + accounts: 15, + videos: 30, + }, + ) + const [hasPressedShowLessUris, setHasPressedShowLessUris] = useState( () => new Set(), ) @@ -574,19 +579,19 @@ let PostFeed = ({ key: 'composerPrompt-' + sliceIndex, }) } - } else if (sliceIndex === TRENDING_TOPICS_INDEX) { + } else if (sliceIndex === trendingIndices.topics) { arr.push({ type: 'interstitialFeedTrendingTopics', key: 'interstitialFeedTrendingTopics-' + sliceIndex, }) - } else if (sliceIndex === TRENDING_VIDEO_INDEX) { + } else if (sliceIndex === trendingIndices.videos) { if (areVideoFeedsEnabled && !trendingVideoDisabled) { arr.push({ type: 'interstitialTrendingVideos', key: 'interstitial-' + sliceIndex + '-' + lastFetchedAt, }) } - } else if (sliceIndex === SUGGESTED_FOR_YOU_INDEX) { + } else if (sliceIndex === trendingIndices.accounts) { arr.push({ type: 'interstitialFollows', key: 'interstitial-' + sliceIndex + '-' + lastFetchedAt, @@ -739,6 +744,7 @@ let PostFeed = ({ ageAssuranceBannerState, isCurrentFeedAtStartupSelected, blockedOrMutedAuthors, + trendingIndices, ]) // events From 45bdba5dfaeabdfe17182fab17115d6a93a88e93 Mon Sep 17 00:00:00 2001 From: DS Boyce <260543580+ds-boyce@users.noreply.github.com> Date: Wed, 29 Jul 2026 10:58:08 -0700 Subject: [PATCH 04/34] Let trending topic names wrap to two lines (#11323) --- src/components/interstitials/FeedTrendingTopics.tsx | 2 +- src/screens/Search/modules/ExploreTrendingTopics.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/interstitials/FeedTrendingTopics.tsx b/src/components/interstitials/FeedTrendingTopics.tsx index ac68de94ea..fdc4693d6a 100644 --- a/src/components/interstitials/FeedTrendingTopics.tsx +++ b/src/components/interstitials/FeedTrendingTopics.tsx @@ -218,7 +218,7 @@ function TrendRow({ - + {trend.displayName} diff --git a/src/screens/Search/modules/ExploreTrendingTopics.tsx b/src/screens/Search/modules/ExploreTrendingTopics.tsx index fab8fff816..e1caf61ffe 100644 --- a/src/screens/Search/modules/ExploreTrendingTopics.tsx +++ b/src/screens/Search/modules/ExploreTrendingTopics.tsx @@ -156,7 +156,7 @@ export function TrendRow({ + numberOfLines={2}> {trend.displayName} {description ? ( From e375ceb5b8749fe6149bee8d17feac751c7e0e77 Mon Sep 17 00:00:00 2001 From: DS Boyce <260543580+ds-boyce@users.noreply.github.com> Date: Wed, 29 Jul 2026 10:59:01 -0700 Subject: [PATCH 05/34] Update empty state for starter pack search (#11325) --- oxlint-suppressions.json | 5 ----- src/screens/StarterPack/Wizard/StepProfiles.tsx | 10 ++++++++-- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/oxlint-suppressions.json b/oxlint-suppressions.json index 996b647227..8b133a0912 100644 --- a/oxlint-suppressions.json +++ b/oxlint-suppressions.json @@ -1216,11 +1216,6 @@ "count": 1 } }, - "src/screens/StarterPack/Wizard/StepProfiles.tsx": { - "typescript/no-misused-promises": { - "count": 1 - } - }, "src/screens/StarterPack/Wizard/index.tsx": { "typescript/no-floating-promises": { "count": 2 diff --git a/src/screens/StarterPack/Wizard/StepProfiles.tsx b/src/screens/StarterPack/Wizard/StepProfiles.tsx index 4aef058b27..9f3b7076fd 100644 --- a/src/screens/StarterPack/Wizard/StepProfiles.tsx +++ b/src/screens/StarterPack/Wizard/StepProfiles.tsx @@ -87,7 +87,9 @@ export function StepProfiles({ sideBorders={false} style={[a.flex_1]} onEndReached={ - !query && !screenReaderEnabled ? () => fetchNextPage() : undefined + !query && !screenReaderEnabled + ? () => void fetchNextPage() + : undefined } onEndReachedThreshold={IS_NATIVE ? 2 : 0.25} keyboardDismissMode="on-drag" @@ -104,7 +106,11 @@ export function StepProfiles({ a.mt_lg, a.leading_snug, ]}> - Nobody was found. Try searching for someone else. + {query ? ( + + Nobody was found. Try searching for someone else. + + ) : null} )} From 8e3b252b86de024027bbbb979730645f660aee63 Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Wed, 29 Jul 2026 22:11:16 +0300 Subject: [PATCH 06/34] Use debugOptimized variant for Android dev (#11302) --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 54feea61ef..50fe793a1a 100644 --- a/package.json +++ b/package.json @@ -40,7 +40,7 @@ "prepare": "is-ci || husky", "postinstall": "pnpm intl:compile-if-needed", "prebuild": "EXPO_NO_GIT_STATUS=1 expo prebuild --clean", - "android": "expo run:android", + "android": "expo run:android --variant debugOptimized", "android:prod": "expo run:android --variant release", "android:profile": "BSKY_PROFILE=1 expo run:android --variant release", "ios": "expo run:ios", From b4bad1187a54ca9dc2d759a0b6d616109af02785 Mon Sep 17 00:00:00 2001 From: DS Boyce <260543580+ds-boyce@users.noreply.github.com> Date: Wed, 29 Jul 2026 12:11:28 -0700 Subject: [PATCH 07/34] Indicate if someone followed you via a starter pack (#11320) --- oxlint-suppressions.json | 25 -- package.json | 2 +- pnpm-lock.yaml | 10 +- .../notifications/__tests__/util.test.ts | 62 ++++ src/state/queries/notifications/util.ts | 3 + .../com/notifications/NotificationFeed.tsx | 79 ++-- .../notifications/NotificationFeedItem.tsx | 339 ++++++++++-------- 7 files changed, 304 insertions(+), 216 deletions(-) create mode 100644 src/state/queries/notifications/__tests__/util.test.ts diff --git a/oxlint-suppressions.json b/oxlint-suppressions.json index 8b133a0912..a4af3a3233 100644 --- a/oxlint-suppressions.json +++ b/oxlint-suppressions.json @@ -1638,31 +1638,6 @@ "count": 2 } }, - "src/view/com/notifications/NotificationFeed.tsx": { - "typescript/no-explicit-any": { - "count": 2 - }, - "typescript/no-floating-promises": { - "count": 1 - }, - "typescript/no-misused-promises": { - "count": 2 - }, - "typescript/no-unsafe-member-access": { - "count": 6 - } - }, - "src/view/com/notifications/NotificationFeedItem.tsx": { - "typescript/no-explicit-any": { - "count": 3 - }, - "typescript/no-misused-promises": { - "count": 3 - }, - "typescript/no-unsafe-member-access": { - "count": 2 - } - }, "src/view/com/pager/Pager.tsx": { "typescript/no-explicit-any": { "count": 4 diff --git a/package.json b/package.json index 50fe793a1a..c6dbf8949b 100644 --- a/package.json +++ b/package.json @@ -96,7 +96,7 @@ "prettier": "prettier --check ." }, "dependencies": { - "@atproto/api": "0.20.33", + "@atproto/api": "0.20.34", "@atproto/common-web": "0.5.6", "@atproto/syntax": "0.7.2", "@bitdrift/react-native": "^0.6.8", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 86c3430c04..f49ea4a226 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -242,8 +242,8 @@ importers: .: dependencies: '@atproto/api': - specifier: 0.20.33 - version: 0.20.33 + specifier: 0.20.34 + version: 0.20.34 '@atproto/common-web': specifier: 0.5.6 version: 0.5.6 @@ -871,8 +871,8 @@ packages: graphql: optional: true - '@atproto/api@0.20.33': - resolution: {integrity: sha512-3YpnBVMieQFWetLvqibn2yG6vhNZ7ozSi/EUujuGcvYXt9g/NrvmHufaUtrXCasp2mHDI7t7UG5sLEBg6YNEJw==} + '@atproto/api@0.20.34': + resolution: {integrity: sha512-nKfCLkH2Al58YupLGtoVIucZ5XjtN9sU5oOTI70lp8J4nxl52C/Tk7AktcWe+Nx7st3I0fUgf+C5cHePgvwT0w==} engines: {node: '>=22'} '@atproto/common-web@0.5.6': @@ -9389,7 +9389,7 @@ snapshots: '@0no-co/graphql.web@1.2.0': {} - '@atproto/api@0.20.33': + '@atproto/api@0.20.34': dependencies: '@atproto/common-web': 0.5.6 '@atproto/lexicon': 0.7.7 diff --git a/src/state/queries/notifications/__tests__/util.test.ts b/src/state/queries/notifications/__tests__/util.test.ts new file mode 100644 index 0000000000..d7bcb5e5d0 --- /dev/null +++ b/src/state/queries/notifications/__tests__/util.test.ts @@ -0,0 +1,62 @@ +import {type AppBskyNotificationListNotifications} from '@atproto/api' +import {describe, expect, it, jest} from '@jest/globals' + +import {groupNotifications} from '../util' + +jest.mock('#/state/queries/profile', () => ({precacheProfile: jest.fn()})) + +type Notification = AppBskyNotificationListNotifications.Notification + +function makeFollowNotification( + did: string, + starterPackUri?: string, +): Notification { + return { + uri: `at://${did}/app.bsky.graph.follow/follow`, + cid: `cid-${did}`, + author: { + did, + handle: `${did}.test`, + displayName: did, + avatar: undefined, + associated: undefined, + viewer: {}, + labels: [], + createdAt: '2026-07-28T12:00:00.000Z', + }, + reason: 'follow', + record: {}, + starterPack: starterPackUri + ? ({uri: starterPackUri} as Notification['starterPack']) + : undefined, + isRead: false, + indexedAt: '2026-07-28T12:00:00.000Z', + } +} + +describe('groupNotifications', () => { + it('groups follows by starter pack', () => { + const packA = 'at://did:plc:alice/app.bsky.graph.starterpack/a' + const packB = 'at://did:plc:bob/app.bsky.graph.starterpack/b' + + const grouped = groupNotifications([ + makeFollowNotification('did:plc:a', packA), + makeFollowNotification('did:plc:b', packB), + makeFollowNotification('did:plc:c', packA), + makeFollowNotification('did:plc:d'), + makeFollowNotification('did:plc:e', packB), + makeFollowNotification('did:plc:f'), + ]) + + expect( + grouped.map(item => [ + item.notification.author.did, + ...(item.additional ?? []).map(notification => notification.author.did), + ]), + ).toEqual([ + ['did:plc:a', 'did:plc:c'], + ['did:plc:b', 'did:plc:e'], + ['did:plc:d', 'did:plc:f'], + ]) + }) +}) diff --git a/src/state/queries/notifications/util.ts b/src/state/queries/notifications/util.ts index ded66fb62e..e5d1d81885 100644 --- a/src/state/queries/notifications/util.ts +++ b/src/state/queries/notifications/util.ts @@ -163,6 +163,9 @@ export function groupNotifications( Math.abs(ts2 - ts) < MS_2DAY && notif.reason === groupedNotif.notification.reason && notif.reasonSubject === groupedNotif.notification.reasonSubject && + (notif.reason !== 'follow' || + notif.starterPack?.uri === + groupedNotif.notification.starterPack?.uri) && (notif.author.did !== groupedNotif.notification.author.did || notif.reason === 'subscribed-post') ) { diff --git a/src/view/com/notifications/NotificationFeed.tsx b/src/view/com/notifications/NotificationFeed.tsx index a4c4c6a973..82b5008078 100644 --- a/src/view/com/notifications/NotificationFeed.tsx +++ b/src/view/com/notifications/NotificationFeed.tsx @@ -1,31 +1,37 @@ import {useCallback, useEffect, useMemo, useState} from 'react' -import { - ActivityIndicator, - type ListRenderItemInfo, - StyleSheet, - View, -} from 'react-native' -import {msg} from '@lingui/core/macro' -import {useLingui} from '@lingui/react' +import {ActivityIndicator, type ListRenderItemInfo, View} from 'react-native' +import {useLingui} from '@lingui/react/macro' import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender' import {usePostViewTracking} from '#/lib/hooks/usePostViewTracking' import {cleanError} from '#/lib/strings/errors' -import {s} from '#/lib/styles' import {logger} from '#/logger' import {useModerationOpts} from '#/state/preferences/moderation-opts' -import {useNotificationFeedQuery} from '#/state/queries/notifications/feed' +import { + type FeedNotification, + useNotificationFeedQuery, +} from '#/state/queries/notifications/feed' import {EmptyState} from '#/view/com/util/EmptyState' import {ErrorMessage} from '#/view/com/util/error/ErrorMessage' import {List, type ListProps, type ListRef} from '#/view/com/util/List' import {NotificationFeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder' import {LoadMoreRetryBtn} from '#/view/com/util/LoadMoreRetryBtn' +import {atoms as a, platform} from '#/alf' import {Bell_Stroke2_Corner0_Rounded as BellIcon} from '#/components/icons/Bell' import {NotificationFeedItem} from './NotificationFeedItem' -const EMPTY_FEED_ITEM = {_reactKey: '__empty__'} -const LOAD_MORE_ERROR_ITEM = {_reactKey: '__load_more_error__'} -const LOADING_ITEM = {_reactKey: '__loading__'} +const EMPTY_FEED_ITEM = {type: 'empty', _reactKey: '__empty__'} as const +const LOAD_MORE_ERROR_ITEM = { + type: 'load-more-error', + _reactKey: '__load_more_error__', +} as const +const LOADING_ITEM = {type: 'loading', _reactKey: '__loading__'} as const + +type NotificationFeedListItem = + | FeedNotification + | typeof EMPTY_FEED_ITEM + | typeof LOAD_MORE_ERROR_ITEM + | typeof LOADING_ITEM export function NotificationFeed({ filter, @@ -46,7 +52,7 @@ export function NotificationFeed({ }) { const initialNumToRender = useInitialNumToRender() const [isPTRing, setIsPTRing] = useState(false) - const {_} = useLingui() + const {t: l} = useLingui() const moderationOpts = useModerationOpts() const trackPostView = usePostViewTracking('Notifications') const { @@ -71,7 +77,7 @@ export function NotificationFeed({ !isFetching && !data?.pages.find(page => page.items.length > 0) const items = useMemo(() => { - let arr: any[] = [] + let arr: NotificationFeedListItem[] = [] if (isFetched) { if (isEmpty) { arr = arr.concat([EMPTY_FEED_ITEM]) @@ -113,29 +119,27 @@ export function NotificationFeed({ }, [isFetching, hasNextPage, isError, fetchNextPage]) const onPressRetryLoadMore = useCallback(() => { - fetchNextPage() + void fetchNextPage() }, [fetchNextPage]) const renderItem = useCallback( - ({item, index}: ListRenderItemInfo) => { - if (item === EMPTY_FEED_ITEM) { + ({item, index}: ListRenderItemInfo) => { + if (item.type === 'empty') { return ( ) - } else if (item === LOAD_MORE_ERROR_ITEM) { + } else if (item.type === 'load-more-error') { return ( ) - } else if (item === LOADING_ITEM) { + } else if (item.type === 'loading') { return } return ( @@ -147,13 +151,13 @@ export function NotificationFeed({ /> ) }, - [moderationOpts, _, onPressRetryLoadMore, filter], + [moderationOpts, l, onPressRetryLoadMore, filter], ) const FeedFooter = useCallback( () => isFetchingNextPage ? ( - + ) : ( @@ -169,7 +173,11 @@ export function NotificationFeed({ }, [enabled]) return ( - + {error && ( item._reactKey} + keyExtractor={(item: NotificationFeedListItem) => item._reactKey} renderItem={renderItem} ListHeaderComponent={ListHeaderComponent} ListFooterComponent={FeedFooter} refreshing={isPTRing} - onRefresh={onRefresh} - onEndReached={onEndReached} + onRefresh={() => void onRefresh()} + onEndReached={() => void onEndReached()} onEndReachedThreshold={2} onScrolledDownChange={onScrolledDownChange} - onItemSeen={item => { + onItemSeen={(item: NotificationFeedListItem) => { if ( (item.type === 'reply' || item.type === 'mention' || @@ -199,7 +207,7 @@ export function NotificationFeed({ trackPostView(item.subject) } }} - contentContainerStyle={s.contentContainer} + contentContainerStyle={{paddingBottom: 200}} desktopFixedHeight initialNumToRender={initialNumToRender} windowSize={11} @@ -209,8 +217,3 @@ export function NotificationFeed({ ) } - -const styles = StyleSheet.create({ - feedFooter: {paddingTop: 20}, - emptyState: {paddingVertical: 40}, -}) diff --git a/src/view/com/notifications/NotificationFeedItem.tsx b/src/view/com/notifications/NotificationFeedItem.tsx index a89431938d..49abf2f3ff 100644 --- a/src/view/com/notifications/NotificationFeedItem.tsx +++ b/src/view/com/notifications/NotificationFeedItem.tsx @@ -12,16 +12,17 @@ import { type AppBskyActorDefs, type AppBskyFeedDefs, AppBskyFeedPost, + type AppBskyGraphDefs, AppBskyGraphFollow, + AppBskyGraphStarterpack, + AtUri, moderateProfile, type ModerationDecision, type ModerationOpts, } from '@atproto/api' -import {AtUri} from '@atproto/api' import {TID} from '@atproto/common-web' -import {msg, plural} from '@lingui/core/macro' -import {useLingui} from '@lingui/react' -import {Plural, Trans} from '@lingui/react/macro' +import {plural} from '@lingui/core/macro' +import {Plural, Trans, useLingui} from '@lingui/react/macro' import {useNavigation} from '@react-navigation/native' import {useQueryClient} from '@tanstack/react-query' @@ -32,7 +33,6 @@ import {type NavigationProp} from '#/lib/routes/types' import {forceLTR} from '#/lib/strings/bidi' import {sanitizeDisplayName} from '#/lib/strings/display-names' import {niceDate} from '#/lib/strings/time' -import {s} from '#/lib/styles' import {logger} from '#/logger' import {useProfileShadow} from '#/state/cache/profile-shadow' import {type FeedNotification} from '#/state/queries/notifications/feed' @@ -44,7 +44,7 @@ import {Post} from '#/view/com/post/Post' import {formatCount} from '#/view/com/util/numeric/format' import {TimeElapsed} from '#/view/com/util/TimeElapsed' import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar' -import {atoms as a, platform, useTheme, web} from '#/alf' +import {atoms as a, native, platform, useTheme, web} from '#/alf' import {Button, ButtonIcon, ButtonText} from '#/components/Button' import {BellRinging_Filled_Corner0_Rounded as BellRingingIcon} from '#/components/icons/BellRinging' import {Check_Stroke2_Corner0_Rounded as CheckIcon} from '#/components/icons/Check' @@ -64,7 +64,10 @@ import * as MediaPreview from '#/components/MediaPreview' import {ProfileBadges} from '#/components/ProfileBadges' import * as ProfileCard from '#/components/ProfileCard' import {ProfileHoverCard} from '#/components/ProfileHoverCard' -import {Notification as StarterPackCard} from '#/components/StarterPack/StarterPackCard' +import { + Notification as StarterPackCard, + useStarterPackLink, +} from '#/components/StarterPack/StarterPackCard' import {SubtleHover} from '#/components/SubtleHover' import * as Toast from '#/components/Toast' import {Text} from '#/components/Typography' @@ -93,9 +96,9 @@ let NotificationFeedItem = ({ }): React.ReactNode => { const queryClient = useQueryClient() const t = useTheme() - const {_, i18n} = useLingui() + const {t: l, i18n} = useLingui() const ax = useAnalytics() - const [isAuthorsExpanded, setIsAuthorsExpanded] = useState(false) + const [isAuthorsExpanded, setIsAuthorsExpanded] = useState(false) const [isHoveringAuthorsList, setIsHoveringAuthorsList] = useState(false) const itemHref = useMemo(() => { switch (item.type) { @@ -253,7 +256,7 @@ let NotificationFeedItem = ({ to={firstAuthor.href} disableMismatchWarning emoji - label={_(msg`Go to ${firstAuthorName}'s profile`)}> + label={l`Go to ${firstAuthorName}'s profile`}> {forceLTR(firstAuthorName)} 0 + const starterPack = item.notification.starterPack + const allFollowedViaSameStarterPack = + item.type === 'follow' && + starterPack !== undefined && + (item.additional ?? []).every( + notification => notification.starterPack?.uri === starterPack.uri, + ) + const starterPackName = + allFollowedViaSameStarterPack && starterPack + ? getStarterPackName(starterPack) + : undefined const formattedAuthorsCount = hasMultipleAuthors ? formatCount(i18n, additionalAuthorsCount) : '' let a11yLabel = '' - let notificationContent: React.ReactElement + let notificationContent: React.ReactElement let icon = ( {firstAuthorLink} and{' '} @@ -317,13 +329,11 @@ let NotificationFeedItem = ({ ) } else if (item.type === 'repost') { a11yLabel = hasMultipleAuthors - ? _( - msg`${firstAuthorName} and ${plural(additionalAuthorsCount, { - one: `${formattedAuthorsCount} other`, - other: `${formattedAuthorsCount} others`, - })} reposted your post`, - ) - : _(msg`${firstAuthorName} reposted your post`) + ? l`${firstAuthorName} and ${plural(additionalAuthorsCount, { + one: `${formattedAuthorsCount} other`, + other: `${formattedAuthorsCount} others`, + })} reposted your post` + : l`${firstAuthorName} reposted your post` notificationContent = hasMultipleAuthors ? ( {firstAuthorLink} and{' '} @@ -346,17 +356,24 @@ let NotificationFeedItem = ({ * Follow-backs are ungrouped, grouped follow-backs not supported atm, * see `src/state/queries/notifications/util.ts` */ - a11yLabel = _(msg`${firstAuthorName} followed you back`) + a11yLabel = starterPackName + ? l`${firstAuthorName} followed you back via starter pack ${starterPackName}` + : l`${firstAuthorName} followed you back` notificationContent = {firstAuthorLink} followed you back } else { - a11yLabel = hasMultipleAuthors - ? _( - msg`${firstAuthorName} and ${plural(additionalAuthorsCount, { + a11yLabel = starterPackName + ? hasMultipleAuthors + ? l`${firstAuthorName} and ${plural(additionalAuthorsCount, { one: `${formattedAuthorsCount} other`, other: `${formattedAuthorsCount} others`, - })} followed you`, - ) - : _(msg`${firstAuthorName} followed you`) + })} followed you via starter pack ${starterPackName}` + : l`${firstAuthorName} followed you via starter pack ${starterPackName}` + : hasMultipleAuthors + ? l`${firstAuthorName} and ${plural(additionalAuthorsCount, { + one: `${formattedAuthorsCount} other`, + other: `${formattedAuthorsCount} others`, + })} followed you` + : l`${firstAuthorName} followed you` notificationContent = hasMultipleAuthors ? ( {firstAuthorLink} and{' '} @@ -375,7 +392,7 @@ let NotificationFeedItem = ({ } icon = } else if (item.type === 'contact-match') { - a11yLabel = _(msg`Your contact ${firstAuthorName} is on Bluesky`) + a11yLabel = l`Your contact ${firstAuthorName} is on Bluesky` notificationContent = ( Your contact {firstAuthorLink} is on Bluesky ) @@ -384,13 +401,11 @@ let NotificationFeedItem = ({ ) } else if (item.type === 'feedgen-like') { a11yLabel = hasMultipleAuthors - ? _( - msg`${firstAuthorName} and ${plural(additionalAuthorsCount, { - one: `${formattedAuthorsCount} other`, - other: `${formattedAuthorsCount} others`, - })} liked your custom feed`, - ) - : _(msg`${firstAuthorName} liked your custom feed`) + ? l`${firstAuthorName} and ${plural(additionalAuthorsCount, { + one: `${formattedAuthorsCount} other`, + other: `${formattedAuthorsCount} others`, + })} liked your custom feed` + : l`${firstAuthorName} liked your custom feed` notificationContent = hasMultipleAuthors ? ( {firstAuthorLink} and{' '} @@ -408,13 +423,11 @@ let NotificationFeedItem = ({ ) } else if (item.type === 'starterpack-joined') { a11yLabel = hasMultipleAuthors - ? _( - msg`${firstAuthorName} and ${plural(additionalAuthorsCount, { - one: `${formattedAuthorsCount} other`, - other: `${formattedAuthorsCount} others`, - })} signed up with your starter pack`, - ) - : _(msg`${firstAuthorName} signed up with your starter pack`) + ? l`${firstAuthorName} and ${plural(additionalAuthorsCount, { + one: `${formattedAuthorsCount} other`, + other: `${formattedAuthorsCount} others`, + })} signed up with your starter pack` + : l`${firstAuthorName} signed up with your starter pack` notificationContent = hasMultipleAuthors ? ( {firstAuthorLink} and{' '} @@ -437,13 +450,11 @@ let NotificationFeedItem = ({ ) } else if (item.type === 'verified') { a11yLabel = hasMultipleAuthors - ? _( - msg`${firstAuthorName} and ${plural(additionalAuthorsCount, { - one: `${formattedAuthorsCount} other`, - other: `${formattedAuthorsCount} others`, - })} verified you`, - ) - : _(msg`${firstAuthorName} verified you`) + ? l`${firstAuthorName} and ${plural(additionalAuthorsCount, { + one: `${formattedAuthorsCount} other`, + other: `${formattedAuthorsCount} others`, + })} verified you` + : l`${firstAuthorName} verified you` notificationContent = hasMultipleAuthors ? ( {firstAuthorLink} and{' '} @@ -462,13 +473,11 @@ let NotificationFeedItem = ({ icon = } else if (item.type === 'unverified') { a11yLabel = hasMultipleAuthors - ? _( - msg`${firstAuthorName} and ${plural(additionalAuthorsCount, { - one: `${formattedAuthorsCount} other`, - other: `${formattedAuthorsCount} others`, - })} removed their verifications from your account`, - ) - : _(msg`${firstAuthorName} removed their verification from your account`) + ? l`${firstAuthorName} and ${plural(additionalAuthorsCount, { + one: `${formattedAuthorsCount} other`, + other: `${formattedAuthorsCount} others`, + })} removed their verifications from your account` + : l`${firstAuthorName} removed their verification from your account` notificationContent = hasMultipleAuthors ? ( {firstAuthorLink} and{' '} @@ -489,13 +498,11 @@ let NotificationFeedItem = ({ icon = } else if (item.type === 'like-via-repost') { a11yLabel = hasMultipleAuthors - ? _( - msg`${firstAuthorName} and ${plural(additionalAuthorsCount, { - one: `${formattedAuthorsCount} other`, - other: `${formattedAuthorsCount} others`, - })} liked your repost`, - ) - : _(msg`${firstAuthorName} liked your repost`) + ? l`${firstAuthorName} and ${plural(additionalAuthorsCount, { + one: `${formattedAuthorsCount} other`, + other: `${formattedAuthorsCount} others`, + })} liked your repost` + : l`${firstAuthorName} liked your repost` notificationContent = hasMultipleAuthors ? ( {firstAuthorLink} and{' '} @@ -513,13 +520,11 @@ let NotificationFeedItem = ({ ) } else if (item.type === 'repost-via-repost') { a11yLabel = hasMultipleAuthors - ? _( - msg`${firstAuthorName} and ${plural(additionalAuthorsCount, { - one: `${formattedAuthorsCount} other`, - other: `${formattedAuthorsCount} others`, - })} reposted your repost`, - ) - : _(msg`${firstAuthorName} reposted your repost`) + ? l`${firstAuthorName} and ${plural(additionalAuthorsCount, { + one: `${formattedAuthorsCount} other`, + other: `${formattedAuthorsCount} others`, + })} reposted your repost` + : l`${firstAuthorName} reposted your repost` notificationContent = hasMultipleAuthors ? ( {firstAuthorLink} and{' '} @@ -539,21 +544,17 @@ let NotificationFeedItem = ({ } else if (item.type === 'subscribed-post') { const postsCount = 1 + (item.additional?.length || 0) a11yLabel = hasMultipleAuthors - ? _( - msg`New posts from ${firstAuthorName} and ${plural( - additionalAuthorsCount, - { - one: `${formattedAuthorsCount} other`, - other: `${formattedAuthorsCount} others`, - }, - )}`, - ) - : _( - msg`New ${plural(postsCount, { - one: 'post', - other: 'posts', - })} from ${firstAuthorName}`, - ) + ? l`New posts from ${firstAuthorName} and ${plural( + additionalAuthorsCount, + { + one: `${formattedAuthorsCount} other`, + other: `${formattedAuthorsCount} others`, + }, + )}` + : l`New ${plural(postsCount, { + one: 'post', + other: 'posts', + })} from ${firstAuthorName}` notificationContent = hasMultipleAuthors ? ( New posts from {firstAuthorLink} and{' '} @@ -608,18 +609,16 @@ let NotificationFeedItem = ({ { name: 'toggleAuthorsExpanded', label: isAuthorsExpanded - ? _(msg`Collapse list of users`) - : _(msg`Expand list of users`), + ? l`Collapse list of users` + : l`Expand list of users`, }, ] : [ { name: 'viewProfile', - label: _( - msg`View ${ - authors[0].profile.displayName || authors[0].profile.handle - }'s profile`, - ), + label: l`View ${ + authors[0].profile.displayName || authors[0].profile.handle + }'s profile`, }, ] } @@ -686,6 +685,9 @@ let NotificationFeedItem = ({ + {allFollowedViaSameStarterPack && starterPack ? ( + + ) : null} {(item.type === 'follow' && !hasMultipleAuthors && !isFollowBack) || (item.type === 'contact-match' && !item.notification.author.viewer?.following) ? ( @@ -737,6 +739,53 @@ let NotificationFeedItem = ({ NotificationFeedItem = memo(NotificationFeedItem) export {NotificationFeedItem} +function FollowedViaStarterPack({ + starterPack, +}: { + starterPack: AppBskyGraphDefs.StarterPackViewBasic +}) { + const t = useTheme() + const link = useStarterPackLink({view: starterPack}) + + const starterPackName = getStarterPackName(starterPack) + + if (!starterPackName) { + return null + } + + return ( + + + via starter pack{' '} + + + {starterPackName} + + + + ) +} + +function getStarterPackName( + starterPack: AppBskyGraphDefs.StarterPackViewBasic, +) { + return bsky.dangerousIsType( + starterPack.record, + AppBskyGraphStarterpack.isRecord, + ) + ? starterPack.record.name + : undefined +} + function ExpandListPressable({ hasMultipleAuthors, children, @@ -767,7 +816,7 @@ function ExpandListPressable({ } function FollowBackButton({profile}: {profile: AppBskyActorDefs.ProfileView}) { - const {_} = useLingui() + const {t: l} = useLingui() const {currentAccount, hasSession} = useSession() const profileShadow = useProfileShadow(profile) const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue( @@ -787,15 +836,14 @@ function FollowBackButton({profile}: {profile: AppBskyActorDefs.ProfileView}) { try { await queueFollow() Toast.show( - _( - msg`Following ${sanitizeDisplayName( - profile.displayName || profile.handle, - )}`, - ), + l`Following ${sanitizeDisplayName( + profile.displayName || profile.handle, + )}`, ) - } catch (err: any) { + } catch (error) { + const err = error as Error if (err?.name !== 'AbortError') { - Toast.show(_(msg`An issue occurred, please try again.`), { + Toast.show(l`An issue occurred, please try again.`, { type: 'error', }) } @@ -809,15 +857,14 @@ function FollowBackButton({profile}: {profile: AppBskyActorDefs.ProfileView}) { try { await queueUnfollow() Toast.show( - _( - msg`No longer following ${sanitizeDisplayName( - profile.displayName || profile.handle, - )}`, - ), + l`No longer following ${sanitizeDisplayName( + profile.displayName || profile.handle, + )}`, ) - } catch (err: any) { + } catch (error) { + const err = error as Error if (err?.name !== 'AbortError') { - Toast.show(_(msg`An issue occurred, please try again.`), { + Toast.show(l`An issue occurred, please try again.`, { type: 'error', }) } @@ -838,12 +885,10 @@ function FollowBackButton({profile}: {profile: AppBskyActorDefs.ProfileView}) { const isFollowing = profileShadow.viewer.following const isFollowedBy = profileShadow.viewer.followedBy - const followingLabel = _( - msg({ - message: 'Following', - comment: 'User is following this account, click to unfollow', - }), - ) + const followingLabel = l({ + message: 'Following', + comment: 'User is following this account, click to unfollow', + }) return ( @@ -853,7 +898,7 @@ function FollowBackButton({profile}: {profile: AppBskyActorDefs.ProfileView}) { color="secondary" size="small" style={[a.self_start]} - onPress={onPressUnfollow}> + onPress={(e: GestureResponderEvent) => void onPressUnfollow(e)}> Following @@ -861,11 +906,11 @@ function FollowBackButton({profile}: {profile: AppBskyActorDefs.ProfileView}) { ) : ( + ) +} + +function FeatureRow({ + name, + featureKey, + isRefreshing, +}: { + name: string + featureKey: string + isRefreshing: boolean +}) { + const t = useTheme() + const value = features.evalFeature(featureKey).value + + const onPress = () => { + void Clipboard.setStringAsync(featureKey) + Toast.show('Copied feature flag key to clipboard', {type: 'success'}) + } + + return ( + + + {name} + + {featureKey} + + + {isRefreshing ? : } + + ) +} + +function CurrentProfile() { + const t = useTheme() + const {currentAccount} = useSession() + const moderationOpts = useModerationOpts() + const {data: profile} = useProfileQuery({did: currentAccount?.did}) + + if (!currentAccount) { + return ( + + No active account + + ) + } + + const onPressDid = () => { + void Clipboard.setStringAsync(currentAccount.did) + Toast.show('Copied did to clipboard', {type: 'success'}) + } + + return profile && moderationOpts ? ( + + + + + + + ) : null +} + +function FeatureValue({value}: {value: unknown}) { + const t = useTheme() + + let label: string + let color: string + if (value === true) { + label = 'true' + color = t.palette.positive_500 + } else if (value === false) { + label = 'false' + color = t.palette.negative_500 + } else if (value === null || value === undefined) { + label = 'null' + color = t.palette.contrast_500 + } else { + label = JSON.stringify(value) + color = t.palette.contrast_700 + } + + return {label} +} From 973d0b06f269b7b269317cdb2f60bef7d738d9d6 Mon Sep 17 00:00:00 2001 From: pfrazee <1270099+pfrazee@users.noreply.github.com> Date: Thu, 30 Jul 2026 03:10:31 +0000 Subject: [PATCH 12/34] Nightly source-language update --- src/locale/locales/en/messages.po | 499 +++++++++++++++++------------- 1 file changed, 282 insertions(+), 217 deletions(-) diff --git a/src/locale/locales/en/messages.po b/src/locale/locales/en/messages.po index fd9f45c3a5..a33bebf50e 100644 --- a/src/locale/locales/en/messages.po +++ b/src/locale/locales/en/messages.po @@ -483,155 +483,167 @@ msgstr "" msgid "{filterCount, plural, one {+# filter} other {+# filters}}" msgstr "{filterCount, plural, one {+# filter} other {+# filters}}" -#: src/view/com/notifications/NotificationFeedItem.tsx:361 +#: src/view/com/notifications/NotificationFeedItem.tsx:378 msgid "{firstAuthorLink} and <0>{additionalAuthorsCount, plural, one {{formattedAuthorsCount} other} other {{formattedAuthorsCount} others}} followed you" msgstr "" -#: src/view/com/notifications/NotificationFeedItem.tsx:395 +#: src/view/com/notifications/NotificationFeedItem.tsx:410 msgid "{firstAuthorLink} and <0>{additionalAuthorsCount, plural, one {{formattedAuthorsCount} other} other {{formattedAuthorsCount} others}} liked your custom feed" msgstr "" -#: src/view/com/notifications/NotificationFeedItem.tsx:304 +#: src/view/com/notifications/NotificationFeedItem.tsx:316 msgid "{firstAuthorLink} and <0>{additionalAuthorsCount, plural, one {{formattedAuthorsCount} other} other {{formattedAuthorsCount} others}} liked your post" msgstr "" -#: src/view/com/notifications/NotificationFeedItem.tsx:500 +#: src/view/com/notifications/NotificationFeedItem.tsx:507 msgid "{firstAuthorLink} and <0>{additionalAuthorsCount, plural, one {{formattedAuthorsCount} other} other {{formattedAuthorsCount} others}} liked your repost" msgstr "" -#: src/view/com/notifications/NotificationFeedItem.tsx:473 +#: src/view/com/notifications/NotificationFeedItem.tsx:482 msgid "{firstAuthorLink} and <0>{additionalAuthorsCount, plural, one {{formattedAuthorsCount} other} other {{formattedAuthorsCount} others}} removed their verifications from your account" msgstr "" -#: src/view/com/notifications/NotificationFeedItem.tsx:328 +#: src/view/com/notifications/NotificationFeedItem.tsx:338 msgid "{firstAuthorLink} and <0>{additionalAuthorsCount, plural, one {{formattedAuthorsCount} other} other {{formattedAuthorsCount} others}} reposted your post" msgstr "" -#: src/view/com/notifications/NotificationFeedItem.tsx:524 +#: src/view/com/notifications/NotificationFeedItem.tsx:529 msgid "{firstAuthorLink} and <0>{additionalAuthorsCount, plural, one {{formattedAuthorsCount} other} other {{formattedAuthorsCount} others}} reposted your repost" msgstr "" -#: src/view/com/notifications/NotificationFeedItem.tsx:419 +#: src/view/com/notifications/NotificationFeedItem.tsx:432 msgid "{firstAuthorLink} and <0>{additionalAuthorsCount, plural, one {{formattedAuthorsCount} other} other {{formattedAuthorsCount} others}} signed up with your starter pack" msgstr "" -#: src/view/com/notifications/NotificationFeedItem.tsx:448 +#: src/view/com/notifications/NotificationFeedItem.tsx:459 msgid "{firstAuthorLink} and <0>{additionalAuthorsCount, plural, one {{formattedAuthorsCount} other} other {{formattedAuthorsCount} others}} verified you" msgstr "" -#: src/view/com/notifications/NotificationFeedItem.tsx:373 +#: src/view/com/notifications/NotificationFeedItem.tsx:390 msgid "{firstAuthorLink} followed you" msgstr "" -#: src/view/com/notifications/NotificationFeedItem.tsx:350 +#: src/view/com/notifications/NotificationFeedItem.tsx:362 msgid "{firstAuthorLink} followed you back" msgstr "" -#: src/view/com/notifications/NotificationFeedItem.tsx:407 +#: src/view/com/notifications/NotificationFeedItem.tsx:422 msgid "{firstAuthorLink} liked your custom feed" msgstr "" -#: src/view/com/notifications/NotificationFeedItem.tsx:316 +#: src/view/com/notifications/NotificationFeedItem.tsx:328 msgid "{firstAuthorLink} liked your post" msgstr "" -#: src/view/com/notifications/NotificationFeedItem.tsx:512 +#: src/view/com/notifications/NotificationFeedItem.tsx:519 msgid "{firstAuthorLink} liked your repost" msgstr "" -#: src/view/com/notifications/NotificationFeedItem.tsx:485 +#: src/view/com/notifications/NotificationFeedItem.tsx:494 msgid "{firstAuthorLink} removed their verification from your account" msgstr "" -#: src/view/com/notifications/NotificationFeedItem.tsx:340 +#: src/view/com/notifications/NotificationFeedItem.tsx:350 msgid "{firstAuthorLink} reposted your post" msgstr "" -#: src/view/com/notifications/NotificationFeedItem.tsx:536 +#: src/view/com/notifications/NotificationFeedItem.tsx:541 msgid "{firstAuthorLink} reposted your repost" msgstr "" -#: src/view/com/notifications/NotificationFeedItem.tsx:431 +#: src/view/com/notifications/NotificationFeedItem.tsx:444 msgid "{firstAuthorLink} signed up with your starter pack" msgstr "" -#: src/view/com/notifications/NotificationFeedItem.tsx:460 +#: src/view/com/notifications/NotificationFeedItem.tsx:471 msgid "{firstAuthorLink} verified you" msgstr "" -#: src/view/com/notifications/NotificationFeedItem.tsx:354 +#: src/view/com/notifications/NotificationFeedItem.tsx:372 msgid "{firstAuthorName} and {additionalAuthorsCount, plural, one {{formattedAuthorsCount} other} other {{formattedAuthorsCount} others}} followed you" msgstr "" -#: src/view/com/notifications/NotificationFeedItem.tsx:388 +#: src/view/com/notifications/NotificationFeedItem.tsx:366 +msgid "{firstAuthorName} and {additionalAuthorsCount, plural, one {{formattedAuthorsCount} other} other {{formattedAuthorsCount} others}} followed you via starter pack {starterPackName}" +msgstr "{firstAuthorName} and {additionalAuthorsCount, plural, one {{formattedAuthorsCount} other} other {{formattedAuthorsCount} others}} followed you via starter pack {starterPackName}" + +#: src/view/com/notifications/NotificationFeedItem.tsx:404 msgid "{firstAuthorName} and {additionalAuthorsCount, plural, one {{formattedAuthorsCount} other} other {{formattedAuthorsCount} others}} liked your custom feed" msgstr "" -#: src/view/com/notifications/NotificationFeedItem.tsx:297 +#: src/view/com/notifications/NotificationFeedItem.tsx:310 msgid "{firstAuthorName} and {additionalAuthorsCount, plural, one {{formattedAuthorsCount} other} other {{formattedAuthorsCount} others}} liked your post" msgstr "" -#: src/view/com/notifications/NotificationFeedItem.tsx:493 +#: src/view/com/notifications/NotificationFeedItem.tsx:501 msgid "{firstAuthorName} and {additionalAuthorsCount, plural, one {{formattedAuthorsCount} other} other {{formattedAuthorsCount} others}} liked your repost" msgstr "" -#: src/view/com/notifications/NotificationFeedItem.tsx:466 +#: src/view/com/notifications/NotificationFeedItem.tsx:476 msgid "{firstAuthorName} and {additionalAuthorsCount, plural, one {{formattedAuthorsCount} other} other {{formattedAuthorsCount} others}} removed their verifications from your account" msgstr "" -#: src/view/com/notifications/NotificationFeedItem.tsx:321 +#: src/view/com/notifications/NotificationFeedItem.tsx:332 msgid "{firstAuthorName} and {additionalAuthorsCount, plural, one {{formattedAuthorsCount} other} other {{formattedAuthorsCount} others}} reposted your post" msgstr "" -#: src/view/com/notifications/NotificationFeedItem.tsx:517 +#: src/view/com/notifications/NotificationFeedItem.tsx:523 msgid "{firstAuthorName} and {additionalAuthorsCount, plural, one {{formattedAuthorsCount} other} other {{formattedAuthorsCount} others}} reposted your repost" msgstr "" -#: src/view/com/notifications/NotificationFeedItem.tsx:412 +#: src/view/com/notifications/NotificationFeedItem.tsx:426 msgid "{firstAuthorName} and {additionalAuthorsCount, plural, one {{formattedAuthorsCount} other} other {{formattedAuthorsCount} others}} signed up with your starter pack" msgstr "" -#: src/view/com/notifications/NotificationFeedItem.tsx:441 +#: src/view/com/notifications/NotificationFeedItem.tsx:453 msgid "{firstAuthorName} and {additionalAuthorsCount, plural, one {{formattedAuthorsCount} other} other {{formattedAuthorsCount} others}} verified you" msgstr "" -#: src/view/com/notifications/NotificationFeedItem.tsx:359 +#: src/view/com/notifications/NotificationFeedItem.tsx:376 msgid "{firstAuthorName} followed you" msgstr "" -#: src/view/com/notifications/NotificationFeedItem.tsx:349 +#: src/view/com/notifications/NotificationFeedItem.tsx:361 msgid "{firstAuthorName} followed you back" msgstr "" -#: src/view/com/notifications/NotificationFeedItem.tsx:393 +#: src/view/com/notifications/NotificationFeedItem.tsx:360 +msgid "{firstAuthorName} followed you back via starter pack {starterPackName}" +msgstr "{firstAuthorName} followed you back via starter pack {starterPackName}" + +#: src/view/com/notifications/NotificationFeedItem.tsx:370 +msgid "{firstAuthorName} followed you via starter pack {starterPackName}" +msgstr "{firstAuthorName} followed you via starter pack {starterPackName}" + +#: src/view/com/notifications/NotificationFeedItem.tsx:408 msgid "{firstAuthorName} liked your custom feed" msgstr "" -#: src/view/com/notifications/NotificationFeedItem.tsx:302 +#: src/view/com/notifications/NotificationFeedItem.tsx:314 msgid "{firstAuthorName} liked your post" msgstr "" -#: src/view/com/notifications/NotificationFeedItem.tsx:498 +#: src/view/com/notifications/NotificationFeedItem.tsx:505 msgid "{firstAuthorName} liked your repost" msgstr "" -#: src/view/com/notifications/NotificationFeedItem.tsx:471 +#: src/view/com/notifications/NotificationFeedItem.tsx:480 msgid "{firstAuthorName} removed their verification from your account" msgstr "" -#: src/view/com/notifications/NotificationFeedItem.tsx:326 +#: src/view/com/notifications/NotificationFeedItem.tsx:336 msgid "{firstAuthorName} reposted your post" msgstr "" -#: src/view/com/notifications/NotificationFeedItem.tsx:522 +#: src/view/com/notifications/NotificationFeedItem.tsx:527 msgid "{firstAuthorName} reposted your repost" msgstr "" -#: src/view/com/notifications/NotificationFeedItem.tsx:417 +#: src/view/com/notifications/NotificationFeedItem.tsx:430 msgid "{firstAuthorName} signed up with your starter pack" msgstr "" -#: src/view/com/notifications/NotificationFeedItem.tsx:446 +#: src/view/com/notifications/NotificationFeedItem.tsx:457 msgid "{firstAuthorName} verified you" msgstr "" @@ -641,8 +653,8 @@ msgstr "" #. '{postCount} {posts}', e.g., '1.2K posts' #. '{postCount} {posts}', e.g., '1.2K posts' -#: src/components/interstitials/FeedTrendingTopics.tsx:231 -#: src/screens/Search/modules/ExploreTrendingTopics.tsx:177 +#: src/components/interstitials/FeedTrendingTopics.tsx:245 +#: src/screens/Search/modules/ExploreTrendingTopics.tsx:192 msgid "{formattedPostCount} {postCount, plural, one {post} other {posts}}" msgstr "{formattedPostCount} {postCount, plural, one {post} other {posts}}" @@ -743,8 +755,8 @@ msgstr "" #. The trending topic rank, i.e. "1. March Madness", "2. The Bachelor" #. The trending topic rank, i.e. "1. March Madness", "2. The Bachelor" -#: src/components/interstitials/FeedTrendingTopics.tsx:216 -#: src/screens/Search/modules/ExploreTrendingTopics.tsx:152 +#: src/components/interstitials/FeedTrendingTopics.tsx:230 +#: src/screens/Search/modules/ExploreTrendingTopics.tsx:167 msgid "{rank}." msgstr "" @@ -984,8 +996,8 @@ msgstr "A selected recipient is not followed by the sender." #: src/Navigation.tsx:483 #: src/screens/Settings/AboutSettings.tsx:85 -#: src/screens/Settings/Settings.tsx:264 -#: src/screens/Settings/Settings.tsx:267 +#: src/screens/Settings/Settings.tsx:265 +#: src/screens/Settings/Settings.tsx:268 msgid "About" msgstr "" @@ -1026,8 +1038,8 @@ msgid "Access requested! The group owner will review your request." msgstr "Access requested! The group owner will review your request." #: src/screens/Settings/AccessibilitySettings.tsx:45 -#: src/screens/Settings/Settings.tsx:234 -#: src/screens/Settings/Settings.tsx:237 +#: src/screens/Settings/Settings.tsx:235 +#: src/screens/Settings/Settings.tsx:238 msgid "Accessibility" msgstr "" @@ -1037,8 +1049,8 @@ msgstr "" #: src/Navigation.tsx:406 #: src/screens/Settings/AccountSettings.tsx:54 -#: src/screens/Settings/Settings.tsx:174 -#: src/screens/Settings/Settings.tsx:177 +#: src/screens/Settings/Settings.tsx:175 +#: src/screens/Settings/Settings.tsx:178 msgid "Account" msgstr "" @@ -1078,11 +1090,11 @@ msgstr "" msgid "Account Muted by List" msgstr "" -#: src/screens/Settings/Settings.tsx:646 +#: src/screens/Settings/Settings.tsx:656 msgid "Account options" msgstr "" -#: src/screens/Settings/Settings.tsx:681 +#: src/screens/Settings/Settings.tsx:691 msgid "Account removed from quick access" msgstr "" @@ -1171,8 +1183,8 @@ msgstr "" msgid "Add alt text (optional)" msgstr "" -#: src/screens/Settings/Settings.tsx:584 -#: src/screens/Settings/Settings.tsx:587 +#: src/screens/Settings/Settings.tsx:594 +#: src/screens/Settings/Settings.tsx:597 #: src/view/shell/desktop/LeftNav.tsx:276 #: src/view/shell/desktop/LeftNav.tsx:279 msgid "Add another account" @@ -1231,8 +1243,8 @@ msgstr "" msgid "Add members" msgstr "Add members" -#: src/components/moderation/ReportDialog/index.tsx:528 -#: src/components/moderation/ReportDialog/index.tsx:532 +#: src/components/moderation/ReportDialog/index.tsx:540 +#: src/components/moderation/ReportDialog/index.tsx:544 msgid "Add more details (optional)" msgstr "" @@ -1338,7 +1350,7 @@ msgstr "" msgid "Additional details (limit 1000 characters)" msgstr "" -#: src/components/moderation/ReportDialog/index.tsx:546 +#: src/components/moderation/ReportDialog/index.tsx:558 msgid "Additional details (limit 300 characters)" msgstr "" @@ -1564,7 +1576,7 @@ msgstr "" msgid "An error occurred" msgstr "" -#: src/view/com/composer/state/video.ts:433 +#: src/view/com/composer/state/video.ts:447 msgid "An error occurred while compressing the video." msgstr "" @@ -1610,11 +1622,11 @@ msgstr "" msgid "An error occurred while trying to follow all" msgstr "" -#: src/view/com/composer/state/video.ts:485 +#: src/view/com/composer/state/video.ts:499 msgid "An error occurred while uploading the video. {message}" msgstr "" -#: src/view/com/composer/state/video.ts:477 +#: src/view/com/composer/state/video.ts:491 msgid "An error occurred while uploading the video. Please check your internet connection and try again." msgstr "" @@ -1665,8 +1677,8 @@ msgstr "An issue occurred starting the group chat, please try again." #: src/components/hooks/useFollowMethods.ts:52 #: src/components/ProfileCard.tsx:510 #: src/components/ProfileCard.tsx:532 -#: src/view/com/notifications/NotificationFeedItem.tsx:798 -#: src/view/com/notifications/NotificationFeedItem.tsx:820 +#: src/view/com/notifications/NotificationFeedItem.tsx:846 +#: src/view/com/notifications/NotificationFeedItem.tsx:867 msgid "An issue occurred, please try again." msgstr "" @@ -1835,8 +1847,8 @@ msgstr "Appeal this label" #: src/Navigation.tsx:398 #: src/screens/Settings/AppearanceSettings.tsx:71 -#: src/screens/Settings/Settings.tsx:226 -#: src/screens/Settings/Settings.tsx:229 +#: src/screens/Settings/Settings.tsx:227 +#: src/screens/Settings/Settings.tsx:230 msgid "Appearance" msgstr "" @@ -1845,8 +1857,8 @@ msgstr "" msgid "Apply default recommended feeds" msgstr "" -#: src/screens/Settings/Settings.tsx:515 -#: src/screens/Settings/Settings.tsx:517 +#: src/screens/Settings/Settings.tsx:525 +#: src/screens/Settings/Settings.tsx:527 msgid "Apply Pull Request" msgstr "" @@ -1930,6 +1942,10 @@ msgstr "" msgid "Are you sure?" msgstr "" +#: src/components/moderation/ReportDialog/index.tsx:815 +msgid "Are you the person depicted, or an authorized representative acting on behalf of the person depicted?" +msgstr "Are you the person depicted, or an authorized representative acting on behalf of the person depicted?" + #: src/view/com/composer/select-language/SuggestedLanguage.tsx:349 msgid "Are you writing in <0>{suggestedLanguageName}?" msgstr "" @@ -2113,8 +2129,8 @@ msgstr "" #: src/Navigation.tsx:414 #: src/screens/Settings/BetaFeaturesSettings.tsx:108 -#: src/screens/Settings/Settings.tsx:248 -#: src/screens/Settings/Settings.tsx:251 +#: src/screens/Settings/Settings.tsx:249 +#: src/screens/Settings/Settings.tsx:252 msgid "Beta features" msgstr "Beta features" @@ -2383,25 +2399,25 @@ msgstr "" msgid "Browse other feeds" msgstr "" -#: src/components/TrendingTopics.tsx:78 +#: src/components/TrendingTopics.tsx:87 msgid "Browse posts about {displayName}" msgstr "" -#: src/components/TrendingTopics.tsx:86 +#: src/components/TrendingTopics.tsx:95 msgid "Browse posts tagged with {displayName}" msgstr "" -#: src/components/TrendingTopics.tsx:95 +#: src/components/TrendingTopics.tsx:104 msgid "Browse starter pack {displayName}" msgstr "" #. placeholder {0}: trend.displayName -#: src/components/interstitials/FeedTrendingTopics.tsx:188 -#: src/screens/Search/modules/ExploreTrendingTopics.tsx:133 +#: src/components/interstitials/FeedTrendingTopics.tsx:202 +#: src/screens/Search/modules/ExploreTrendingTopics.tsx:149 msgid "Browse topic {0}" msgstr "" -#: src/components/TrendingTopics.tsx:132 +#: src/components/TrendingTopics.tsx:141 msgid "Browse topic {displayName}" msgstr "" @@ -2418,7 +2434,7 @@ msgstr "" #. placeholder {0}: sanitizeHandle(handle, '@') #. placeholder {0}: sanitizeHandle(item.feed.creator.handle, '@') #: src/components/LabelingServiceCard/index.tsx:62 -#: src/components/moderation/ReportDialog/index.tsx:847 +#: src/components/moderation/ReportDialog/index.tsx:979 #: src/screens/Messages/JoinRequest.tsx:177 #: src/screens/Search/components/StarterPackCard.tsx:112 #: src/screens/Search/Explore.tsx:960 @@ -2521,7 +2537,7 @@ msgstr "Camera access needed" #: src/screens/Settings/components/ChangeHandleDialog.tsx:87 #: src/screens/Settings/components/ChangePasswordDialog.tsx:248 #: src/screens/Settings/components/ChangePasswordDialog.tsx:254 -#: src/screens/Settings/Settings.tsx:309 +#: src/screens/Settings/Settings.tsx:310 #: src/screens/Takendown.tsx:102 #: src/screens/Takendown.tsx:105 #: src/view/com/composer/Composer.tsx:1820 @@ -2610,7 +2626,7 @@ msgstr "" msgid "Change hosting provider" msgstr "Change hosting provider" -#: src/components/moderation/ReportDialog/index.tsx:445 +#: src/components/moderation/ReportDialog/index.tsx:457 msgid "Change moderation service" msgstr "" @@ -2623,11 +2639,11 @@ msgstr "" msgid "Change password dialog" msgstr "" -#: src/components/moderation/ReportDialog/index.tsx:312 +#: src/components/moderation/ReportDialog/index.tsx:319 msgid "Change report category" msgstr "" -#: src/components/moderation/ReportDialog/index.tsx:390 +#: src/components/moderation/ReportDialog/index.tsx:398 msgid "Change report reason" msgstr "" @@ -2849,11 +2865,11 @@ msgstr "" msgid "Clear" msgstr "Clear" -#: src/screens/Settings/Settings.tsx:507 +#: src/screens/Settings/Settings.tsx:517 msgid "Clear all storage data" msgstr "" -#: src/screens/Settings/Settings.tsx:509 +#: src/screens/Settings/Settings.tsx:519 msgid "Clear all storage data (restart after this)" msgstr "" @@ -3062,11 +3078,11 @@ msgstr "" msgid "Closes post composer and discards post draft" msgstr "" -#: src/view/com/notifications/NotificationFeedItem.tsx:611 +#: src/view/com/notifications/NotificationFeedItem.tsx:612 msgid "Collapse list of users" msgstr "" -#: src/view/com/notifications/NotificationFeedItem.tsx:949 +#: src/view/com/notifications/NotificationFeedItem.tsx:995 msgid "Collapses list of users for a given notification" msgstr "" @@ -3208,8 +3224,8 @@ msgstr "" msgid "Content & Media" msgstr "" -#: src/screens/Settings/Settings.tsx:206 -#: src/screens/Settings/Settings.tsx:209 +#: src/screens/Settings/Settings.tsx:207 +#: src/screens/Settings/Settings.tsx:210 msgid "Content and media" msgstr "" @@ -3664,8 +3680,8 @@ msgstr "Create or modify an invite link for this group chat" #. Accessibility label for button to create a moderation report for the selected option #. placeholder {0}: option.title -#: src/components/moderation/ReportDialog/index.tsx:709 -#: src/components/moderation/ReportDialog/index.tsx:754 +#: src/components/moderation/ReportDialog/index.tsx:721 +#: src/components/moderation/ReportDialog/index.tsx:766 msgid "Create report for {0}" msgstr "" @@ -3758,7 +3774,7 @@ msgstr "Day" msgid "Deactivate account" msgstr "" -#: src/screens/Settings/Settings.tsx:472 +#: src/screens/Settings/Settings.tsx:482 msgid "Debug Moderation" msgstr "" @@ -3811,7 +3827,8 @@ msgstr "" msgid "Delete chat" msgstr "" -#: src/screens/Settings/Settings.tsx:479 +#: src/screens/Settings/Settings.tsx:487 +#: src/screens/Settings/Settings.tsx:489 msgid "Delete chat declaration record" msgstr "" @@ -3927,8 +3944,8 @@ msgctxt "toast" msgid "Developer mode enabled" msgstr "" -#: src/screens/Settings/Settings.tsx:291 -#: src/screens/Settings/Settings.tsx:294 +#: src/screens/Settings/Settings.tsx:292 +#: src/screens/Settings/Settings.tsx:295 msgid "Developer options" msgstr "" @@ -4613,7 +4630,7 @@ msgstr "" msgid "Enters full screen" msgstr "" -#: src/screens/Search/modules/ExploreTrendingTopics.tsx:231 +#: src/screens/Search/modules/ExploreTrendingTopics.tsx:246 msgid "Entertainment" msgstr "" @@ -4710,7 +4727,7 @@ msgstr "" msgid "Expand alt text" msgstr "" -#: src/view/com/notifications/NotificationFeedItem.tsx:612 +#: src/view/com/notifications/NotificationFeedItem.tsx:613 msgid "Expand list of users" msgstr "" @@ -5132,10 +5149,10 @@ msgstr "" msgid "Failed to update settings" msgstr "" -#: src/lib/media/video/upload.ts:73 -#: src/lib/media/video/upload.web.ts:73 -#: src/lib/media/video/upload.web.ts:77 -#: src/lib/media/video/upload.web.ts:87 +#: src/lib/media/video/upload.ts:98 +#: src/lib/media/video/upload.web.ts:98 +#: src/lib/media/video/upload.web.ts:102 +#: src/lib/media/video/upload.web.ts:112 msgid "Failed to upload video" msgstr "" @@ -5295,8 +5312,8 @@ msgstr "" #: src/features/inviteFriends/components/FollowersPromoBanner.tsx:49 #: src/screens/Settings/FindContactsSettings.tsx:81 -#: src/screens/Settings/Settings.tsx:217 -#: src/screens/Settings/Settings.tsx:220 +#: src/screens/Settings/Settings.tsx:218 +#: src/screens/Settings/Settings.tsx:221 msgid "Find and invite friends" msgstr "Find and invite friends" @@ -5378,8 +5395,8 @@ msgstr "Focus the search field" #: src/screens/PostThread/components/ThreadItemAnchorFollowButton.tsx:157 #: src/screens/Profile/Header/ProfileHeaderStandard.tsx:409 #: src/screens/VideoFeed/index.tsx:937 -#: src/view/com/notifications/NotificationFeedItem.tsx:864 -#: src/view/com/notifications/NotificationFeedItem.tsx:871 +#: src/view/com/notifications/NotificationFeedItem.tsx:909 +#: src/view/com/notifications/NotificationFeedItem.tsx:916 msgid "Follow" msgstr "" @@ -5434,8 +5451,8 @@ msgstr "" #: src/components/ProfileCard.tsx:544 #: src/screens/PostThread/components/ThreadItemAnchorFollowButton.tsx:155 #: src/screens/Profile/Header/ProfileHeaderStandard.tsx:407 -#: src/view/com/notifications/NotificationFeedItem.tsx:864 -#: src/view/com/notifications/NotificationFeedItem.tsx:871 +#: src/view/com/notifications/NotificationFeedItem.tsx:909 +#: src/view/com/notifications/NotificationFeedItem.tsx:916 msgid "Follow back" msgstr "" @@ -5493,8 +5510,8 @@ msgstr "" #: src/screens/PostThread/components/ThreadItemAnchorFollowButton.tsx:160 #: src/screens/Profile/Header/ProfileHeaderStandard.tsx:405 #: src/screens/VideoFeed/index.tsx:935 -#: src/view/com/notifications/NotificationFeedItem.tsx:842 -#: src/view/com/notifications/NotificationFeedItem.tsx:859 +#: src/view/com/notifications/NotificationFeedItem.tsx:888 +#: src/view/com/notifications/NotificationFeedItem.tsx:904 msgid "Following" msgstr "" @@ -5505,11 +5522,11 @@ msgid "Following" msgstr "" #. placeholder {0}: sanitizeDisplayName( profile.displayName || profile.handle, moderation.ui('displayName'), ) -#. placeholder {0}: sanitizeDisplayName( profile.displayName || profile.handle, ) #. placeholder {0}: sanitizeDisplayName( profile.displayName || profile.handle, moderation.ui('displayName'), ) +#. placeholder {0}: sanitizeDisplayName( profile.displayName || profile.handle, ) #: src/components/ProfileCard.tsx:500 #: src/screens/Profile/Header/ProfileHeaderStandard.tsx:247 -#: src/view/com/notifications/NotificationFeedItem.tsx:791 +#: src/view/com/notifications/NotificationFeedItem.tsx:839 msgid "Following {0}" msgstr "" @@ -5828,7 +5845,7 @@ msgstr "" msgid "Go to {0}'s profile" msgstr "Go to {0}'s profile" -#: src/view/com/notifications/NotificationFeedItem.tsx:256 +#: src/view/com/notifications/NotificationFeedItem.tsx:259 msgid "Go to {firstAuthorName}'s profile" msgstr "" @@ -5973,6 +5990,10 @@ msgstr "Group name" msgid "Group name is too long. {MAX_GROUP_NAME_GRAPHEME_LENGTH, plural, other {The maximum number of characters is #.}}" msgstr "Group name is too long. {MAX_GROUP_NAME_GRAPHEME_LENGTH, plural, other {The maximum number of characters is #.}}" +#: src/screens/Settings/Settings.tsx:467 +msgid "GrowthBook" +msgstr "GrowthBook" + #: src/components/moderation/ReportDialog/utils/useReportOptions.ts:219 msgid "Hacking or system attacks" msgstr "" @@ -6055,8 +6076,8 @@ msgstr "" msgid "Held by Bluesky for 7 days to prevent abuse, then deleted" msgstr "" -#: src/screens/Settings/Settings.tsx:256 -#: src/screens/Settings/Settings.tsx:260 +#: src/screens/Settings/Settings.tsx:257 +#: src/screens/Settings/Settings.tsx:261 #: src/view/shell/desktop/RightNav.tsx:130 #: src/view/shell/desktop/RightNav.tsx:133 #: src/view/shell/Drawer.tsx:443 @@ -6097,8 +6118,8 @@ msgstr "" msgid "Hidden list" msgstr "" -#: src/components/interstitials/FeedTrendingTopics.tsx:156 -#: src/components/interstitials/Trending.tsx:144 +#: src/components/interstitials/FeedTrendingTopics.tsx:168 +#: src/components/interstitials/Trending.tsx:149 #: src/components/interstitials/TrendingVideos.tsx:138 #: src/components/moderation/ContentHider.tsx:217 #: src/components/moderation/LabelPreference.tsx:141 @@ -6108,12 +6129,12 @@ msgstr "" #: src/lib/moderation/useLabelBehaviorDescription.ts:23 #: src/lib/moderation/useLabelBehaviorDescription.ts:28 #: src/lib/moderation/useLabelBehaviorDescription.ts:33 -#: src/screens/Search/modules/ExploreTrendingTopics.tsx:90 -#: src/view/shell/desktop/SidebarTrendingTopics.tsx:139 +#: src/screens/Search/modules/ExploreTrendingTopics.tsx:106 +#: src/view/shell/desktop/SidebarTrendingTopics.tsx:173 msgid "Hide" msgstr "" -#: src/view/com/notifications/NotificationFeedItem.tsx:956 +#: src/view/com/notifications/NotificationFeedItem.tsx:1001 msgctxt "action" msgid "Hide" msgstr "" @@ -6169,14 +6190,14 @@ msgstr "" msgid "Hide translation" msgstr "Hide translation" -#: src/components/interstitials/Trending.tsx:126 +#: src/components/interstitials/Trending.tsx:131 msgid "Hide trending topics" msgstr "" -#: src/components/interstitials/FeedTrendingTopics.tsx:154 -#: src/components/interstitials/Trending.tsx:142 -#: src/screens/Search/modules/ExploreTrendingTopics.tsx:88 -#: src/view/shell/desktop/SidebarTrendingTopics.tsx:137 +#: src/components/interstitials/FeedTrendingTopics.tsx:166 +#: src/components/interstitials/Trending.tsx:147 +#: src/screens/Search/modules/ExploreTrendingTopics.tsx:104 +#: src/view/shell/desktop/SidebarTrendingTopics.tsx:171 msgid "Hide trending topics?" msgstr "" @@ -6184,7 +6205,7 @@ msgstr "" msgid "Hide trending videos?" msgstr "" -#: src/view/com/notifications/NotificationFeedItem.tsx:947 +#: src/view/com/notifications/NotificationFeedItem.tsx:994 msgid "Hide user list" msgstr "" @@ -6242,7 +6263,7 @@ msgstr "" msgid "Hmmmm, we couldn't load that moderation service." msgstr "" -#: src/view/com/composer/state/video.ts:447 +#: src/view/com/composer/state/video.ts:461 msgid "Hold up! We’re gradually giving access to video, and you’re still waiting in line. Check back soon!" msgstr "" @@ -6603,7 +6624,7 @@ msgstr "" msgid "Invalid phone number" msgstr "" -#: src/components/moderation/ReportDialog/index.tsx:100 +#: src/components/moderation/ReportDialog/index.tsx:107 msgid "Invalid report subject" msgstr "" @@ -6814,8 +6835,8 @@ msgid "Language Settings" msgstr "" #: src/screens/Settings/LanguageSettings.tsx:98 -#: src/screens/Settings/Settings.tsx:240 -#: src/screens/Settings/Settings.tsx:243 +#: src/screens/Settings/Settings.tsx:241 +#: src/screens/Settings/Settings.tsx:244 msgid "Languages" msgstr "" @@ -7564,8 +7585,8 @@ msgstr "" msgid "Moderation" msgstr "" -#: src/screens/Settings/Settings.tsx:190 -#: src/screens/Settings/Settings.tsx:193 +#: src/screens/Settings/Settings.tsx:191 +#: src/screens/Settings/Settings.tsx:194 msgid "Moderation and content filters" msgstr "Moderation and content filters" @@ -7825,8 +7846,8 @@ msgstr "" msgid "Navigates to your profile" msgstr "" -#: src/components/moderation/ReportDialog/index.tsx:342 -#: src/components/moderation/ReportDialog/index.tsx:358 +#: src/components/moderation/ReportDialog/index.tsx:349 +#: src/components/moderation/ReportDialog/index.tsx:365 msgid "Need to report a copyright violation, legal request, or regulatory compliance issue?" msgstr "" @@ -7849,11 +7870,11 @@ msgctxt "nux-description" msgid "New" msgstr "New" -#: src/view/com/notifications/NotificationFeedItem.tsx:569 +#: src/view/com/notifications/NotificationFeedItem.tsx:570 msgid "New {postsCount, plural, one {post} other {posts}} from {firstAuthorLink}" msgstr "" -#: src/view/com/notifications/NotificationFeedItem.tsx:552 +#: src/view/com/notifications/NotificationFeedItem.tsx:554 msgid "New {postsCount, plural, one {post} other {posts}} from {firstAuthorName}" msgstr "" @@ -7963,11 +7984,11 @@ msgctxt "action" msgid "New post" msgstr "" -#: src/view/com/notifications/NotificationFeedItem.tsx:558 +#: src/view/com/notifications/NotificationFeedItem.tsx:559 msgid "New posts from {firstAuthorLink} and <0>{additionalAuthorsCount, plural, one {{formattedAuthorsCount} other} other {{formattedAuthorsCount} others}} " msgstr "New posts from {firstAuthorLink} and <0>{additionalAuthorsCount, plural, one {{formattedAuthorsCount} other} other {{formattedAuthorsCount} others}} " -#: src/view/com/notifications/NotificationFeedItem.tsx:543 +#: src/view/com/notifications/NotificationFeedItem.tsx:547 msgid "New posts from {firstAuthorName} and {additionalAuthorsCount, plural, one {{formattedAuthorsCount} other} other {{formattedAuthorsCount} others}}" msgstr "" @@ -7991,7 +8012,7 @@ msgid "Newest replies first" msgstr "" #: src/lib/interests.ts:67 -#: src/screens/Search/modules/ExploreTrendingTopics.tsx:233 +#: src/screens/Search/modules/ExploreTrendingTopics.tsx:248 msgid "News" msgstr "" @@ -8023,6 +8044,12 @@ msgstr "" msgid "Night" msgstr "Night" +#: src/components/moderation/ReportDialog/index.tsx:897 +#: src/components/moderation/ReportDialog/index.tsx:906 +msgctxt "Answer to a yes/no question" +msgid "No" +msgstr "No" + #: src/screens/Onboarding/StepFinished/ValuePropositionPager.shared.tsx:42 msgid "No ads, no invasive tracking, no engagement traps. Bluesky respects your time and attention." msgstr "" @@ -8104,11 +8131,11 @@ msgid "No lists" msgstr "" #. placeholder {0}: sanitizeDisplayName( profile.displayName || profile.handle, moderation.ui('displayName'), ) -#. placeholder {0}: sanitizeDisplayName( profile.displayName || profile.handle, ) #. placeholder {0}: sanitizeDisplayName( profile.displayName || profile.handle, moderation.ui('displayName'), ) +#. placeholder {0}: sanitizeDisplayName( profile.displayName || profile.handle, ) #: src/components/ProfileCard.tsx:523 #: src/screens/Profile/Header/ProfileHeaderStandard.tsx:273 -#: src/view/com/notifications/NotificationFeedItem.tsx:813 +#: src/view/com/notifications/NotificationFeedItem.tsx:860 msgid "No longer following {0}" msgstr "" @@ -8133,7 +8160,7 @@ msgstr "" msgid "No name" msgstr "" -#: src/view/com/notifications/NotificationFeed.tsx:125 +#: src/view/com/notifications/NotificationFeed.tsx:131 msgid "No notifications yet!" msgstr "" @@ -8269,7 +8296,7 @@ msgstr "" msgid "Nobody has reposted this yet. Maybe you should be the first!" msgstr "" -#: src/screens/StarterPack/Wizard/StepProfiles.tsx:107 +#: src/screens/StarterPack/Wizard/StepProfiles.tsx:110 msgid "Nobody was found. Try searching for someone else." msgstr "" @@ -8350,8 +8377,8 @@ msgstr "" #: src/screens/Notifications/ActivityList.tsx:31 #: src/screens/Settings/NotificationSettings/ActivityNotificationSettings.tsx:93 #: src/screens/Settings/NotificationSettings/index.tsx:125 -#: src/screens/Settings/Settings.tsx:198 -#: src/screens/Settings/Settings.tsx:201 +#: src/screens/Settings/Settings.tsx:199 +#: src/screens/Settings/Settings.tsx:202 #: src/view/screens/Notifications.tsx:128 #: src/view/shell/bottom-bar/BottomBar.tsx:279 #: src/view/shell/desktop/LeftNav.tsx:687 @@ -8418,7 +8445,7 @@ msgstr "On" msgid "on<0><1/><2><3/>" msgstr "" -#: src/screens/Settings/Settings.tsx:405 +#: src/screens/Settings/Settings.tsx:407 msgid "Onboarding reset" msgstr "" @@ -8618,7 +8645,7 @@ msgstr "" msgid "Open message options" msgstr "" -#: src/screens/Settings/Settings.tsx:470 +#: src/screens/Settings/Settings.tsx:480 msgid "Open moderation debug page" msgstr "" @@ -8656,12 +8683,11 @@ msgstr "" msgid "Open starter pack menu" msgstr "" -#: src/screens/Settings/Settings.tsx:463 -#: src/screens/Settings/Settings.tsx:477 +#: src/screens/Settings/Settings.tsx:473 msgid "Open storybook page" msgstr "" -#: src/screens/Settings/Settings.tsx:456 +#: src/screens/Settings/Settings.tsx:458 msgid "Open system log" msgstr "" @@ -8739,7 +8765,7 @@ msgstr "" msgid "Opens full image" msgstr "Opens full image" -#: src/screens/Settings/Settings.tsx:257 +#: src/screens/Settings/Settings.tsx:258 msgid "Opens helpdesk in browser" msgstr "" @@ -9125,7 +9151,7 @@ msgstr "" msgid "Please complete the verification captcha." msgstr "" -#: src/view/com/composer/state/video.ts:471 +#: src/view/com/composer/state/video.ts:485 msgid "Please confirm your email address to upload videos." msgstr "" @@ -9239,6 +9265,11 @@ msgstr "" msgid "Please sign in as @{0}" msgstr "" +#: src/components/moderation/ReportDialog/index.tsx:848 +msgctxt "english-only-resource" +msgid "Please submit your report through the Report non-consensual intimate imagery (NCII) form." +msgstr "Please submit your report through the Report non-consensual intimate imagery (NCII) form." + #: src/features/inviteFriends/InviteScannerScreen.web.tsx:40 msgid "Please use the Bluesky mobile app to scan a QR code." msgstr "Please use the Bluesky mobile app to scan a QR code." @@ -9260,7 +9291,7 @@ msgid "Please write your message below:" msgstr "" #: src/lib/interests.ts:70 -#: src/screens/Search/modules/ExploreTrendingTopics.tsx:227 +#: src/screens/Search/modules/ExploreTrendingTopics.tsx:242 msgid "Politics" msgstr "" @@ -9445,8 +9476,8 @@ msgstr "" msgid "Privacy" msgstr "" -#: src/screens/Settings/Settings.tsx:182 -#: src/screens/Settings/Settings.tsx:185 +#: src/screens/Settings/Settings.tsx:183 +#: src/screens/Settings/Settings.tsx:186 msgid "Privacy and security" msgstr "" @@ -9764,7 +9795,7 @@ msgstr "" #: src/screens/Messages/ConversationSettings/Member.tsx:162 #: src/screens/Messages/ConversationSettings/prompts.tsx:178 #: src/screens/Moderation/index.tsx:477 -#: src/screens/Settings/Settings.tsx:683 +#: src/screens/Settings/Settings.tsx:693 #: src/view/com/posts/PostFeedErrorMessage.tsx:221 msgid "Remove" msgstr "" @@ -9790,8 +9821,8 @@ msgstr "Remove {displayName}?" msgid "Remove {q}" msgstr "Remove {q}" -#: src/screens/Settings/Settings.tsx:664 -#: src/screens/Settings/Settings.tsx:667 +#: src/screens/Settings/Settings.tsx:674 +#: src/screens/Settings/Settings.tsx:677 msgid "Remove account" msgstr "" @@ -9851,7 +9882,7 @@ msgstr "Remove from chat" msgid "Remove from my feeds" msgstr "" -#: src/screens/Settings/Settings.tsx:677 +#: src/screens/Settings/Settings.tsx:687 msgid "Remove from quick access?" msgstr "" @@ -10092,8 +10123,8 @@ msgstr "" msgid "Report conversation" msgstr "" -#: src/components/moderation/ReportDialog/index.tsx:98 -#: src/components/moderation/ReportDialog/index.tsx:263 +#: src/components/moderation/ReportDialog/index.tsx:105 +#: src/components/moderation/ReportDialog/index.tsx:270 msgid "Report dialog" msgstr "" @@ -10312,8 +10343,8 @@ msgstr "" msgid "Resend Verification Email" msgstr "" -#: src/screens/Settings/Settings.tsx:499 -#: src/screens/Settings/Settings.tsx:501 +#: src/screens/Settings/Settings.tsx:509 +#: src/screens/Settings/Settings.tsx:511 msgid "Reset activity subscription nudge" msgstr "" @@ -10321,8 +10352,8 @@ msgstr "" msgid "Reset code" msgstr "" -#: src/screens/Settings/Settings.tsx:484 -#: src/screens/Settings/Settings.tsx:486 +#: src/screens/Settings/Settings.tsx:494 +#: src/screens/Settings/Settings.tsx:496 msgid "Reset onboarding state" msgstr "" @@ -10362,7 +10393,7 @@ msgstr "" #: src/components/contacts/screens/VerifyNumber.tsx:355 #: src/components/Error.tsx:60 #: src/components/Lists.tsx:115 -#: src/components/moderation/ReportDialog/index.tsx:297 +#: src/components/moderation/ReportDialog/index.tsx:304 #: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoFallback.tsx:56 #: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoFallback.tsx:59 #: src/components/StarterPack/ProfileStarterPacks.tsx:380 @@ -10385,7 +10416,7 @@ msgstr "" msgid "Retry" msgstr "" -#: src/components/moderation/ReportDialog/index.tsx:294 +#: src/components/moderation/ReportDialog/index.tsx:301 #: src/view/screens/Storybook/Admonitions.tsx:61 msgid "Retry loading report options" msgstr "" @@ -10522,8 +10553,8 @@ msgid "Saved to your feeds" msgstr "" #: src/components/NewskieDialog.tsx:141 -#: src/view/com/notifications/NotificationFeedItem.tsx:895 -#: src/view/com/notifications/NotificationFeedItem.tsx:920 +#: src/view/com/notifications/NotificationFeedItem.tsx:959 +#: src/view/com/notifications/NotificationFeedItem.tsx:967 msgid "Say hello!" msgstr "" @@ -10716,7 +10747,8 @@ msgstr "" #: src/components/FeedInterstitials.tsx:495 #: src/components/FeedInterstitials.tsx:554 -#: src/components/interstitials/FeedTrendingTopics.tsx:106 +#: src/components/interstitials/FeedTrendingTopics.tsx:112 +#: src/view/shell/desktop/SidebarTrendingTopics.tsx:80 msgid "See more" msgstr "" @@ -10724,7 +10756,8 @@ msgstr "" msgid "See more suggested profiles" msgstr "" -#: src/components/interstitials/FeedTrendingTopics.tsx:92 +#: src/components/interstitials/FeedTrendingTopics.tsx:98 +#: src/view/shell/desktop/SidebarTrendingTopics.tsx:66 msgid "See more trending topics" msgstr "See more trending topics" @@ -10764,7 +10797,7 @@ msgstr "" msgid "Select a color" msgstr "" -#: src/components/moderation/ReportDialog/index.tsx:380 +#: src/components/moderation/ReportDialog/index.tsx:387 msgid "Select a reason" msgstr "" @@ -10861,7 +10894,7 @@ msgstr "" msgid "Select languages" msgstr "" -#: src/components/moderation/ReportDialog/index.tsx:430 +#: src/components/moderation/ReportDialog/index.tsx:442 msgid "Select moderation service" msgstr "" @@ -10963,7 +10996,7 @@ msgstr "" msgid "Send post to..." msgstr "" -#: src/components/moderation/ReportDialog/index.tsx:818 +#: src/components/moderation/ReportDialog/index.tsx:950 msgid "Send report to {title}" msgstr "" @@ -11036,7 +11069,7 @@ msgid "Sets email for password reset" msgstr "" #: src/Navigation.tsx:215 -#: src/screens/Settings/Settings.tsx:99 +#: src/screens/Settings/Settings.tsx:100 #: src/view/shell/desktop/LeftNav.tsx:755 #: src/view/shell/Drawer.tsx:671 msgid "Settings" @@ -11351,7 +11384,7 @@ msgstr "" msgid "Shows information about when this post was created" msgstr "" -#: src/screens/Settings/Settings.tsx:124 +#: src/screens/Settings/Settings.tsx:125 msgid "Shows other accounts you can switch to" msgstr "" @@ -11425,9 +11458,9 @@ msgstr "Sign in to request access to this group chat." msgid "Sign in to view post" msgstr "" -#: src/screens/Settings/Settings.tsx:274 -#: src/screens/Settings/Settings.tsx:276 -#: src/screens/Settings/Settings.tsx:308 +#: src/screens/Settings/Settings.tsx:275 +#: src/screens/Settings/Settings.tsx:277 +#: src/screens/Settings/Settings.tsx:309 #: src/screens/SignupQueued.tsx:94 #: src/screens/SignupQueued.tsx:97 #: src/screens/Takendown.tsx:88 @@ -11441,7 +11474,7 @@ msgstr "" msgid "Sign Out" msgstr "" -#: src/screens/Settings/Settings.tsx:305 +#: src/screens/Settings/Settings.tsx:306 #: src/view/shell/desktop/LeftNav.tsx:224 msgid "Sign out?" msgstr "" @@ -11588,7 +11621,7 @@ msgstr "Someone was removed" msgid "Someone was removed from the group" msgstr "Someone was removed from the group" -#: src/components/moderation/ReportDialog/index.tsx:103 +#: src/components/moderation/ReportDialog/index.tsx:110 msgid "Something wasn't quite right with the data you're trying to report. Please contact support." msgstr "" @@ -11599,7 +11632,7 @@ msgid "Something went wrong" msgstr "" #: src/components/ageAssurance/AgeAssuranceInitDialog.tsx:139 -#: src/components/moderation/ReportDialog/index.tsx:289 +#: src/components/moderation/ReportDialog/index.tsx:296 #: src/screens/Deactivated.tsx:86 #: src/screens/Settings/components/DeactivateAccountDialog.tsx:59 #: src/view/screens/Storybook/Admonitions.tsx:56 @@ -11621,7 +11654,7 @@ msgstr "" msgid "Something went wrong. Please try again in a moment." msgstr "" -#: src/components/moderation/ReportDialog/index.tsx:247 +#: src/components/moderation/ReportDialog/index.tsx:254 msgid "Something went wrong. Please try again." msgstr "" @@ -11664,7 +11697,7 @@ msgid "Spam or other inauthentic behavior or deception" msgstr "" #: src/lib/interests.ts:72 -#: src/screens/Search/modules/ExploreTrendingTopics.tsx:225 +#: src/screens/Search/modules/ExploreTrendingTopics.tsx:240 msgid "Sports" msgstr "" @@ -11764,7 +11797,7 @@ msgstr "" msgid "Step {0} of {1}" msgstr "" -#: src/screens/Settings/Settings.tsx:410 +#: src/screens/Settings/Settings.tsx:412 msgid "Storage cleared, you need to restart the app now." msgstr "" @@ -11773,7 +11806,7 @@ msgid "Stored as part of a secure code for matching with others" msgstr "" #: src/Navigation.tsx:308 -#: src/screens/Settings/Settings.tsx:465 +#: src/screens/Settings/Settings.tsx:475 msgid "Storybook" msgstr "" @@ -11807,12 +11840,17 @@ msgstr "" msgid "Submit feedback" msgstr "Submit feedback" -#: src/components/moderation/ReportDialog/index.tsx:508 -#: src/components/moderation/ReportDialog/index.tsx:569 -#: src/components/moderation/ReportDialog/index.tsx:576 +#: src/components/moderation/ReportDialog/index.tsx:520 +#: src/components/moderation/ReportDialog/index.tsx:581 +#: src/components/moderation/ReportDialog/index.tsx:588 msgid "Submit report" msgstr "" +#: src/components/moderation/ReportDialog/index.tsx:828 +msgctxt "english-only-resource" +msgid "Submit your report through the Report non-consensual intimate imagery (NCII) form" +msgstr "Submit your report through the Report non-consensual intimate imagery (NCII) form" + #: src/screens/ProfileList/components/SubscribeMenu.tsx:75 msgid "Subscribe" msgstr "" @@ -11923,9 +11961,9 @@ msgstr "Suspended accounts cannot participate in chat." #: src/components/dialogs/SwitchAccount.tsx:47 #: src/components/dialogs/SwitchAccount.tsx:50 -#: src/screens/Settings/Settings.tsx:123 -#: src/screens/Settings/Settings.tsx:135 -#: src/screens/Settings/Settings.tsx:624 +#: src/screens/Settings/Settings.tsx:124 +#: src/screens/Settings/Settings.tsx:136 +#: src/screens/Settings/Settings.tsx:634 #: src/view/shell/desktop/LeftNav.tsx:262 msgid "Switch account" msgstr "" @@ -11949,7 +11987,7 @@ msgstr "" #: src/screens/Log.tsx:49 #: src/screens/Settings/AboutSettings.tsx:117 #: src/screens/Settings/AboutSettings.tsx:120 -#: src/screens/Settings/Settings.tsx:458 +#: src/screens/Settings/Settings.tsx:460 msgid "System log" msgstr "" @@ -12223,10 +12261,22 @@ msgid "The Privacy Policy has been moved to <0/>" msgstr "" #: src/view/com/composer/state/video.ts:429 -#: src/view/com/composer/state/video.ts:468 +msgid "The selected video has an unsupported aspect ratio." +msgstr "The selected video has an unsupported aspect ratio." + +#: src/view/com/composer/state/video.ts:443 +#: src/view/com/composer/state/video.ts:482 msgid "The selected video is larger than {VIDEO_MAX_SIZE_MB} MB. Please try again with a smaller file." msgstr "The selected video is larger than {VIDEO_MAX_SIZE_MB} MB. Please try again with a smaller file." +#: src/view/com/composer/state/video.ts:427 +msgid "The selected video is too long." +msgstr "The selected video is too long." + +#: src/view/com/composer/state/video.ts:431 +msgid "The selected video uses an unsupported format." +msgstr "The selected video uses an unsupported format." + #: src/lib/hooks/useCleanError.ts:41 #: src/lib/strings/errors.ts:18 msgid "The server appears to be experiencing issues. Please try again in a few moments." @@ -12299,12 +12349,12 @@ msgstr "" msgid "There was an issue contacting the server, please check your internet connection and try again." msgstr "" -#: src/view/com/notifications/NotificationFeed.tsx:133 +#: src/view/com/notifications/NotificationFeed.tsx:138 msgid "There was an issue fetching notifications. Tap here to try again." msgstr "" #: src/screens/Search/Explore.tsx:1015 -#: src/view/com/posts/PostFeed.tsx:826 +#: src/view/com/posts/PostFeed.tsx:834 msgid "There was an issue fetching posts. Tap here to try again." msgstr "" @@ -12785,7 +12835,7 @@ msgid "This will irreversibly delete your Bluesky account <0>{currentHandle} msgstr "" #. placeholder {0}: account.handle -#: src/screens/Settings/Settings.tsx:678 +#: src/screens/Settings/Settings.tsx:688 msgid "This will remove @{0} from the quick access list." msgstr "" @@ -12919,9 +12969,9 @@ msgstr "Translation to the same language is unavailable on your device." msgid "Tree view" msgstr "" -#: src/components/interstitials/FeedTrendingTopics.tsx:88 -#: src/screens/Search/modules/ExploreTrendingTopics.tsx:59 -#: src/view/shell/desktop/SidebarTrendingTopics.tsx:54 +#: src/components/interstitials/FeedTrendingTopics.tsx:94 +#: src/screens/Search/modules/ExploreTrendingTopics.tsx:71 +#: src/view/shell/desktop/SidebarTrendingTopics.tsx:63 msgid "Trending" msgstr "" @@ -12930,9 +12980,9 @@ msgstr "" msgid "Trending GIFs" msgstr "Trending GIFs" -#: src/components/interstitials/FeedTrendingTopics.tsx:115 -#: src/screens/Search/modules/ExploreTrendingTopics.tsx:62 -#: src/view/shell/desktop/SidebarTrendingTopics.tsx:61 +#: src/components/interstitials/FeedTrendingTopics.tsx:121 +#: src/screens/Search/modules/ExploreTrendingTopics.tsx:74 +#: src/view/shell/desktop/SidebarTrendingTopics.tsx:90 msgid "Trending options" msgstr "" @@ -13040,11 +13090,11 @@ msgstr "Unable to find the selected recipient." msgid "Unable to resolve handle" msgstr "" -#: src/screens/Settings/Settings.tsx:524 +#: src/screens/Settings/Settings.tsx:534 msgid "Unapply Pull Request" msgstr "" -#: src/screens/Settings/Settings.tsx:526 +#: src/screens/Settings/Settings.tsx:536 msgid "Unapply Pull Request {currentChannel}" msgstr "" @@ -13140,7 +13190,7 @@ msgstr "" msgid "Unfollows the user" msgstr "" -#: src/components/moderation/ReportDialog/index.tsx:492 +#: src/components/moderation/ReportDialog/index.tsx:504 msgid "Unfortunately, none of your subscribed labelers supports this report type." msgstr "" @@ -13268,8 +13318,8 @@ msgstr "" msgid "Unpinned list" msgstr "" -#: src/screens/Settings/Settings.tsx:491 -#: src/screens/Settings/Settings.tsx:493 +#: src/screens/Settings/Settings.tsx:501 +#: src/screens/Settings/Settings.tsx:503 msgid "Unsnooze email reminder" msgstr "" @@ -13641,12 +13691,17 @@ msgstr "" msgid "Version {0}" msgstr "" +#. When the source of a follow is a starter pack, i.e., 'via starter pack {starterPackName}'. +#: src/view/com/notifications/NotificationFeedItem.tsx:758 +msgid "via starter pack <0/><1>{starterPackName}" +msgstr "via starter pack <0/><1>{starterPackName}" + #: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerNative.tsx:95 #: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerNative.tsx:162 msgid "Video" msgstr "" -#: src/view/com/composer/state/video.ts:390 +#: src/view/com/composer/state/video.ts:433 msgid "Video failed to process" msgstr "" @@ -13665,7 +13720,7 @@ msgid "Video from {0}. Tap to play or pause the video" msgstr "" #: src/lib/interests.ts:62 -#: src/screens/Search/modules/ExploreTrendingTopics.tsx:229 +#: src/screens/Search/modules/ExploreTrendingTopics.tsx:244 msgid "Video Games" msgstr "" @@ -13766,6 +13821,10 @@ msgstr "" msgid "View full thread" msgstr "" +#: src/screens/Settings/Settings.tsx:465 +msgid "View GrowthBook information" +msgstr "View GrowthBook information" + #: src/screens/Messages/ConversationSettings/MembersAndRequests.tsx:48 msgid "View incoming group chat requests" msgstr "View incoming group chat requests" @@ -14002,7 +14061,7 @@ msgstr "" msgid "We sent an email to <0>{0} containing a link. Please click on it to complete the email verification process." msgstr "" -#: src/view/com/composer/state/video.ts:451 +#: src/view/com/composer/state/video.ts:465 msgid "We were unable to determine if you are allowed to upload videos. Please try again." msgstr "" @@ -14291,6 +14350,12 @@ msgstr "Wrong kind of conversation" msgid "www.mylivestream.tv" msgstr "" +#: src/components/moderation/ReportDialog/index.tsx:881 +#: src/components/moderation/ReportDialog/index.tsx:890 +msgctxt "Answer to a yes/no question" +msgid "Yes" +msgstr "Yes" + #. Accessibility label for the icon-only pill that filters the GIF picker to affirmation/agreement GIFs. #: src/features/gifPicker/components/GifCategoryPills.tsx:95 msgid "Yes GIFs" @@ -14372,7 +14437,7 @@ msgstr "" msgid "You are no longer live" msgstr "" -#: src/view/com/composer/state/video.ts:444 +#: src/view/com/composer/state/video.ts:458 msgid "You are not allowed to upload videos." msgstr "" @@ -14473,11 +14538,11 @@ msgstr "You can read chat history but can’t send new messages." msgid "You can select up to {MAX_GALLERY_IMAGES, plural, other {# images}} in total." msgstr "You can select up to {MAX_GALLERY_IMAGES, plural, other {# images}} in total." -#: src/components/interstitials/FeedTrendingTopics.tsx:155 -#: src/components/interstitials/Trending.tsx:143 +#: src/components/interstitials/FeedTrendingTopics.tsx:167 +#: src/components/interstitials/Trending.tsx:148 #: src/components/interstitials/TrendingVideos.tsx:137 -#: src/screens/Search/modules/ExploreTrendingTopics.tsx:89 -#: src/view/shell/desktop/SidebarTrendingTopics.tsx:138 +#: src/screens/Search/modules/ExploreTrendingTopics.tsx:105 +#: src/view/shell/desktop/SidebarTrendingTopics.tsx:172 msgid "You can update this later from your settings." msgstr "" @@ -14685,7 +14750,7 @@ msgstr "You own this chat" msgid "You previously deactivated @{0}." msgstr "" -#: src/screens/Settings/Settings.tsx:421 +#: src/screens/Settings/Settings.tsx:423 msgid "You probably want to restart the app now." msgstr "" @@ -14721,7 +14786,7 @@ msgstr "You replied to yourself" msgid "You requested to join" msgstr "You requested to join" -#: src/screens/Settings/Settings.tsx:306 +#: src/screens/Settings/Settings.tsx:307 #: src/view/shell/desktop/LeftNav.tsx:225 msgid "You will be signed out of all your accounts." msgstr "" @@ -14844,11 +14909,11 @@ msgstr "You’ve reached the maximum of {MAX_FILTERS, plural, one {# filter} oth msgid "You've reached the start of the active content." msgstr "" -#: src/view/com/composer/state/video.ts:455 +#: src/view/com/composer/state/video.ts:469 msgid "You've reached your daily limit for video uploads (too many bytes)" msgstr "" -#: src/view/com/composer/state/video.ts:459 +#: src/view/com/composer/state/video.ts:473 msgid "You've reached your daily limit for video uploads (too many videos)" msgstr "" @@ -14868,7 +14933,7 @@ msgstr "" msgid "Your account has been suspended" msgstr "" -#: src/view/com/composer/state/video.ts:463 +#: src/view/com/composer/state/video.ts:477 msgid "Your account is not yet old enough to upload videos. Please try again later." msgstr "" @@ -14904,11 +14969,11 @@ msgstr "" msgid "Your choice will be remembered for future links. You can change it at any time in settings." msgstr "" -#: src/view/com/notifications/NotificationFeedItem.tsx:380 +#: src/view/com/notifications/NotificationFeedItem.tsx:397 msgid "Your contact {firstAuthorLink} is on Bluesky" msgstr "" -#: src/view/com/notifications/NotificationFeedItem.tsx:378 +#: src/view/com/notifications/NotificationFeedItem.tsx:395 msgid "Your contact {firstAuthorName} is on Bluesky" msgstr "" @@ -15034,7 +15099,7 @@ msgid "Your reply was sent" msgstr "" #. placeholder {0}: state.selectedLabeler?.creator.displayName -#: src/components/moderation/ReportDialog/index.tsx:519 +#: src/components/moderation/ReportDialog/index.tsx:531 msgid "Your report will be sent to <0>{0}." msgstr "" From cc3c01267d791fea2854057c39ae5ed1abda2e1d Mon Sep 17 00:00:00 2001 From: Oleksii Bulenok <63914185+abulenok@users.noreply.github.com> Date: Thu, 30 Jul 2026 12:53:09 +0200 Subject: [PATCH 13/34] Fix alt text input field not growing more than 3 lines (#11328) --- src/view/com/composer/GifAltText.tsx | 1 - src/view/com/composer/photos/ImageAltTextDialog.tsx | 1 - 2 files changed, 2 deletions(-) diff --git a/src/view/com/composer/GifAltText.tsx b/src/view/com/composer/GifAltText.tsx index b7565e6b6d..35767a20ab 100644 --- a/src/view/com/composer/GifAltText.tsx +++ b/src/view/com/composer/GifAltText.tsx @@ -162,7 +162,6 @@ function AltTextInner({ onChangeText={onChange} defaultValue={altText} multiline - numberOfLines={3} autoFocus onKeyPress={({nativeEvent}) => { if (nativeEvent.key === 'Escape') { diff --git a/src/view/com/composer/photos/ImageAltTextDialog.tsx b/src/view/com/composer/photos/ImageAltTextDialog.tsx index 08de8d8d99..9d00aed097 100644 --- a/src/view/com/composer/photos/ImageAltTextDialog.tsx +++ b/src/view/com/composer/photos/ImageAltTextDialog.tsx @@ -131,7 +131,6 @@ const ImageAltTextInner = ({ }} defaultValue={altText} multiline - numberOfLines={3} autoFocus /> From 157a1893fdc9fed62303399d94af1917156b3841 Mon Sep 17 00:00:00 2001 From: Oleksii Bulenok <63914185+abulenok@users.noreply.github.com> Date: Thu, 30 Jul 2026 12:53:35 +0200 Subject: [PATCH 14/34] fix TextInput resizes on Android when the first chat is typed (#11318) --- src/components/forms/TextField.tsx | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/components/forms/TextField.tsx b/src/components/forms/TextField.tsx index 6e3c71bbc8..2512403ec3 100644 --- a/src/components/forms/TextField.tsx +++ b/src/components/forms/TextField.tsx @@ -267,7 +267,16 @@ export function createInput(Component: typeof TextInput) { ctx.onBlur() onBlur?.(e) }} - placeholder={placeholder === null ? undefined : placeholder || label} + /* + * Android sizes an empty input from the font's bounding box instead + * of `lineHeight`, so a field with no placeholder shrinks on the + * first keystroke. + */ + placeholder={ + placeholder === null + ? platform({android: ' '}) + : placeholder || label + } placeholderTextColor={t.palette.contrast_500} keyboardAppearance={t.name === 'light' ? 'light' : 'dark'} style={flattened} From 17c1ee4869888166943ac64f1a967a30738f129f Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Thu, 30 Jul 2026 17:40:49 +0300 Subject: [PATCH 15/34] Handle PR OTA version mismatches and failures (#11330) --- .github/workflows/pull-request-commit.yml | 15 +- oxlint-suppressions.json | 2 +- src/lib/hooks/useIntentHandler.ts | 10 +- src/lib/hooks/useOTAUpdates.test.ts | 215 ++++++++++++++++++++++ src/lib/hooks/useOTAUpdates.ts | 174 ++++++++++++++--- src/lib/hooks/useOTAUpdates.web.ts | 6 +- src/storage/schema.ts | 5 + src/view/shell/index.tsx | 2 + 8 files changed, 403 insertions(+), 26 deletions(-) create mode 100644 src/lib/hooks/useOTAUpdates.test.ts diff --git a/.github/workflows/pull-request-commit.yml b/.github/workflows/pull-request-commit.yml index afa3d4f46c..a06556ea15 100644 --- a/.github/workflows/pull-request-commit.yml +++ b/.github/workflows/pull-request-commit.yml @@ -275,6 +275,10 @@ jobs: permissions: id-token: write contents: read + outputs: + release-version: ${{ steps.env.outputs.release-version }} + ios-build-number: ${{ steps.build-info.outputs.BSKY_IOS_BUILD_NUMBER }} + android-build-number: ${{ steps.build-info.outputs.BSKY_ANDROID_VERSION_CODE }} steps: - name: ⬇️ Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -342,11 +346,18 @@ jobs: app-id: ${{ vars.SYNC_INTERNAL_APP_ID }} private-key: ${{ secrets.SYNC_INTERNAL_PK }} + - name: 🔢 Get native build numbers + id: build-info + run: bash scripts/setGitHubOutput.sh + - name: 🚀 Publish OTA to denis (S3) run: pnpm use-build-number bash scripts/denisPublish.sh env: RUNTIME_VERSION: '' CHANNEL_NAME: pull-request-${{ github.event.pull_request.number }} + # Pin the publish to the same values exposed in the install link. + BSKY_IOS_BUILD_NUMBER: ${{ steps.build-info.outputs.BSKY_IOS_BUILD_NUMBER }} + BSKY_ANDROID_VERSION_CODE: ${{ steps.build-info.outputs.BSKY_ANDROID_VERSION_CODE }} comment-pr-ota: name: Comment PR OTA install link @@ -362,6 +373,6 @@ jobs: message: | The OTA deployment for this PR was successful! You may now apply it by either scanning the QR code or opening the deep link below in your browser: - QR code for the PR OTA deployment + QR code for the PR OTA deployment - `bluesky://intent/apply-ota?channel=pull-request-${{ github.event.pull_request.number }}` + `bluesky://intent/apply-ota?channel=pull-request-${{ github.event.pull_request.number }}&releaseVersion=${{ needs.publish-pr-ota.outputs.release-version }}&iosBuildNumber=${{ needs.publish-pr-ota.outputs.ios-build-number }}&androidBuildNumber=${{ needs.publish-pr-ota.outputs.android-build-number }}` diff --git a/oxlint-suppressions.json b/oxlint-suppressions.json index a4af3a3233..9f8f3cc426 100644 --- a/oxlint-suppressions.json +++ b/oxlint-suppressions.json @@ -707,7 +707,7 @@ "count": 1 }, "typescript/no-misused-promises": { - "count": 4 + "count": 3 } }, "src/lib/hooks/usePermissions.ts": { diff --git a/src/lib/hooks/useIntentHandler.ts b/src/lib/hooks/useIntentHandler.ts index eaa6482830..61bc201467 100644 --- a/src/lib/hooks/useIntentHandler.ts +++ b/src/lib/hooks/useIntentHandler.ts @@ -84,10 +84,18 @@ export function useIntentHandler() { } case 'apply-ota': { const channel = params.get('channel') + const releaseVersion = params.get('releaseVersion') + const buildNumber = params.get( + IS_IOS ? 'iosBuildNumber' : 'androidBuildNumber', + ) + const appVersion = + releaseVersion && buildNumber + ? `${releaseVersion}.${buildNumber}` + : null if (!channel) { Alert.alert('Error', 'No channel provided to look for.') } else { - tryApplyUpdate(channel) + tryApplyUpdate(channel, appVersion) } return } diff --git a/src/lib/hooks/useOTAUpdates.test.ts b/src/lib/hooks/useOTAUpdates.test.ts new file mode 100644 index 0000000000..d174182a33 --- /dev/null +++ b/src/lib/hooks/useOTAUpdates.test.ts @@ -0,0 +1,215 @@ +import {Alert} from 'react-native' +import { + checkForUpdateAsync, + fetchUpdateAsync, + reloadAsync, + setExtraParamAsync, + useUpdates, +} from 'expo-updates' +import {act, renderHook, waitFor} from '@testing-library/react-native' + +import {logger} from '#/logger' +import {device} from '#/storage' +import { + useApplyPullRequestOTAUpdate, + useOTAUpdateRecovery, +} from './useOTAUpdates' + +jest.mock('expo-updates', () => ({ + checkForUpdateAsync: jest.fn(), + fetchUpdateAsync: jest.fn(), + isEnabled: true, + reloadAsync: jest.fn(), + setExtraParamAsync: jest.fn(), + UpdateCheckResultNotAvailableReason: { + UPDATE_PREVIOUSLY_FAILED: 'updatePreviouslyFailed', + }, + useUpdates: jest.fn(), +})) + +jest.mock('#/logger', () => ({ + logger: { + debug: jest.fn(), + error: jest.fn(), + }, +})) + +jest.mock('#/storage', () => ({ + device: { + get: jest.fn(), + remove: jest.fn(), + set: jest.fn(), + }, +})) + +const currentUpdate = { + channel: 'testflight', + emergencyLaunchReason: null, + isEmbeddedLaunch: false, + isEmergencyLaunch: false, + updateId: 'current-update', +} + +beforeEach(() => { + jest.clearAllMocks() + jest.mocked(useUpdates).mockReturnValue({ + currentlyRunning: currentUpdate, + } as ReturnType) + jest.mocked(setExtraParamAsync).mockResolvedValue(undefined) + jest.mocked(reloadAsync).mockResolvedValue(undefined) + jest.spyOn(Alert, 'alert').mockImplementation(() => {}) +}) + +describe('useApplyPullRequestOTAUpdate', () => { + it('warns before applying an OTA built for a different app version', async () => { + jest.mocked(checkForUpdateAsync).mockResolvedValue({ + isAvailable: true, + } as Awaited>) + jest.mocked(fetchUpdateAsync).mockResolvedValue({ + isNew: true, + isRollBackToEmbedded: false, + manifest: {id: 'mismatched-update'}, + } as Awaited>) + const {result} = renderHook(() => useApplyPullRequestOTAUpdate()) + + await act(() => result.current.tryApplyUpdate('pull-request-123', '0.0.0')) + + expect(checkForUpdateAsync).not.toHaveBeenCalled() + expect(Alert.alert).toHaveBeenCalledWith( + 'App Version Mismatch', + expect.stringContaining('Applying it anyway may cause'), + expect.arrayContaining([expect.objectContaining({text: 'Apply Anyway'})]), + ) + + const buttons = jest.mocked(Alert.alert).mock.calls[0][2] + act(() => buttons?.[1].onPress?.()) + + await waitFor(() => expect(reloadAsync).toHaveBeenCalled()) + expect(device.set).toHaveBeenCalledWith(['pendingOTAUpdate'], { + attemptedAt: expect.any(Number), + channel: 'pull-request-123', + updateId: 'mismatched-update', + }) + }) + + it('informs the user when checking for an OTA fails', async () => { + jest.mocked(checkForUpdateAsync).mockRejectedValue(new Error('offline')) + const {result} = renderHook(() => useApplyPullRequestOTAUpdate()) + + await act(() => result.current.tryApplyUpdate('pull-request-123')) + + expect(Alert.alert).toHaveBeenCalledWith( + 'Update Check Failed', + expect.stringContaining('Error: offline'), + ) + expect(result.current.pending).toBe(false) + }) + + it('informs the user when downloading an OTA fails', async () => { + jest.mocked(checkForUpdateAsync).mockResolvedValue({ + isAvailable: true, + } as Awaited>) + jest + .mocked(fetchUpdateAsync) + .mockRejectedValue(new Error('download failed')) + const {result} = renderHook(() => useApplyPullRequestOTAUpdate()) + + await act(() => result.current.tryApplyUpdate('pull-request-123')) + const buttons = jest.mocked(Alert.alert).mock.calls[0][2] + + act(() => buttons?.[1].onPress?.()) + + await waitFor(() => + expect(Alert.alert).toHaveBeenLastCalledWith( + 'Update Failed', + expect.stringContaining('Error: download failed'), + ), + ) + expect(device.set).not.toHaveBeenCalled() + expect(result.current.pending).toBe(false) + }) + + it('clears the recovery marker and informs the user when reloading fails', async () => { + jest.mocked(checkForUpdateAsync).mockResolvedValue({ + isAvailable: true, + } as Awaited>) + jest.mocked(fetchUpdateAsync).mockResolvedValue({ + isNew: true, + isRollBackToEmbedded: false, + manifest: {id: 'new-update'}, + } as Awaited>) + jest.mocked(reloadAsync).mockRejectedValue(new Error('reload failed')) + const {result} = renderHook(() => useApplyPullRequestOTAUpdate()) + + await act(() => result.current.tryApplyUpdate('pull-request-123')) + const buttons = jest.mocked(Alert.alert).mock.calls[0][2] + + act(() => buttons?.[1].onPress?.()) + + await waitFor(() => + expect(Alert.alert).toHaveBeenLastCalledWith( + 'Update Failed', + expect.stringContaining('Error: reload failed'), + ), + ) + expect(device.set).toHaveBeenCalledWith(['pendingOTAUpdate'], { + attemptedAt: expect.any(Number), + channel: 'pull-request-123', + updateId: 'new-update', + }) + expect(device.remove).toHaveBeenCalledWith(['pendingOTAUpdate']) + expect(result.current.pending).toBe(false) + }) +}) + +describe('useOTAUpdateRecovery', () => { + it('informs the user when Expo fell back from the attempted OTA', async () => { + jest.mocked(device.get).mockReturnValue({ + attemptedAt: Date.now(), + channel: 'pull-request-123', + updateId: 'failed-update', + }) + + renderHook(() => useOTAUpdateRecovery()) + + await waitFor(() => + expect(Alert.alert).toHaveBeenCalledWith( + 'Update Failed', + expect.stringContaining('PR #123 deployment could not start'), + ), + ) + expect(device.remove).toHaveBeenCalledWith(['pendingOTAUpdate']) + expect(logger.error).toHaveBeenCalledWith( + 'Custom OTA Update Failed to Launch', + expect.objectContaining({attemptedUpdateId: 'failed-update'}), + ) + }) + + it('recognizes a launched OTA when the update ID casing differs', () => { + jest.mocked(device.get).mockReturnValue({ + attemptedAt: Date.now(), + channel: 'pull-request-123', + updateId: currentUpdate.updateId.toUpperCase(), + }) + + renderHook(() => useOTAUpdateRecovery()) + + expect(device.remove).toHaveBeenCalledWith(['pendingOTAUpdate']) + expect(Alert.alert).not.toHaveBeenCalled() + expect(logger.error).not.toHaveBeenCalled() + }) + + it('silently clears a stale marker from an older OTA bundle', () => { + jest.mocked(device.get).mockReturnValue({ + attemptedAt: Date.now() - 10 * 60e3, + channel: 'pull-request-123', + updateId: 'older-update', + }) + + renderHook(() => useOTAUpdateRecovery()) + + expect(device.remove).toHaveBeenCalledWith(['pendingOTAUpdate']) + expect(Alert.alert).not.toHaveBeenCalled() + expect(logger.error).not.toHaveBeenCalled() + }) +}) diff --git a/src/lib/hooks/useOTAUpdates.ts b/src/lib/hooks/useOTAUpdates.ts index 069367dd33..a7e043aeb2 100644 --- a/src/lib/hooks/useOTAUpdates.ts +++ b/src/lib/hooks/useOTAUpdates.ts @@ -7,14 +7,17 @@ import { isEnabled, reloadAsync, setExtraParamAsync, + UpdateCheckResultNotAvailableReason, useUpdates, } from 'expo-updates' import {isNetworkError} from '#/lib/strings/errors' import {logger} from '#/logger' -import {IS_IOS, IS_TESTFLIGHT} from '#/env' +import {APP_VERSION, IS_IOS, IS_TESTFLIGHT} from '#/env' +import {device} from '#/storage' const MINIMUM_MINIMIZE_TIME = 15 * 60e3 +const OTA_RECOVERY_WINDOW = 5 * 60e3 /** * The channel this native build is expected to receive updates from. Anything @@ -28,6 +31,11 @@ const DEFAULT_CHANNEL = IS_TESTFLIGHT ? 'testflight' : 'production' */ const STANDARD_CHANNELS = ['production', 'testflight', 'development'] +function getDeploymentName(channel: string) { + const pullRequestNumber = channel.match(/^pull-request-(\d+)$/)?.[1] + return pullRequestNumber ? `PR #${pullRequestNumber}` : channel +} + async function setExtraParams() { await setExtraParamAsync( IS_IOS ? 'ios-build-number' : 'android-build-number', @@ -89,36 +97,116 @@ export function useApplyPullRequestOTAUpdate() { currentChannel && !STANDARD_CHANNELS.includes(currentChannel), ) - const tryApplyUpdate = async (channel: string) => { - setPending(true) - await setExtraParamsPullRequest(channel) - const res = await checkForUpdateAsync() - if (res.isAvailable) { + const tryApplyUpdate = async ( + channel: string, + declaredAppVersion?: string | null, + ) => { + const deploymentName = getDeploymentName(channel) + + const checkForDeployment = async () => { + await setExtraParamsPullRequest(channel) + const res = await checkForUpdateAsync() + if (!res.isAvailable) { + if ( + res.reason === + UpdateCheckResultNotAvailableReason.UPDATE_PREVIOUSLY_FAILED + ) { + Alert.alert( + 'Deployment Blocked', + `The ${deploymentName} deployment previously failed to start on this device, so the app will not try to apply it again.`, + ) + } else if (currentChannel !== channel) { + Alert.alert( + 'No Deployment Available', + `No new deployments of ${channel} are currently available for your current native build.`, + ) + } + } + return res.isAvailable + } + + const applyUpdate = () => { + setPending(true) + void (async () => { + try { + if (!(await checkForDeployment())) return + const fetchedUpdate = await fetchUpdateAsync() + if (!fetchedUpdate.isNew) { + throw new Error('Expo did not download a new update.') + } + + device.set(['pendingOTAUpdate'], { + attemptedAt: Date.now(), + channel, + updateId: fetchedUpdate.manifest.id, + }) + try { + await reloadAsync() + } catch (e) { + device.remove(['pendingOTAUpdate']) + throw e + } + } catch (e: unknown) { + const error = String(e) + logger.error('Internal OTA Update Error', {error}) + Alert.alert( + 'Update Failed', + `Could not apply the ${deploymentName} deployment: ${error}`, + ) + } finally { + setPending(false) + } + })() + } + + if (declaredAppVersion && declaredAppVersion !== APP_VERSION) { Alert.alert( - 'Deployment Available', - `A deployment of ${channel} is availalble. Applying this deployment may result in a bricked installation, in which case you will need to reinstall the app and may lose local data. Are you sure you want to proceed?`, + 'App Version Mismatch', + `This OTA update was built for a different version of the app.\n\nCurrent app version: ${APP_VERSION}\nOTA app version: ${declaredAppVersion}\n\nApplying it anyway may cause the app to stop working and require a reinstall.`, [ { - text: 'No', + text: 'Cancel', style: 'cancel', }, { - text: 'Relaunch', - style: 'default', - onPress: async () => { - await fetchUpdateAsync() - await reloadAsync() - }, + text: 'Apply Anyway', + style: 'destructive', + onPress: applyUpdate, }, ], ) - } else { - Alert.alert( - 'No Deployment Available', - `No new deployments of ${channel} are currently available for your current native build.`, - ) + return + } + + setPending(true) + try { + if (!(await checkForDeployment())) return + + Alert.alert( + `Apply update from ${deploymentName}?`, + 'The app will relaunch after the update is applied.', + [ + { + text: 'Cancel', + style: 'cancel', + }, + { + text: 'Apply', + style: 'default', + onPress: applyUpdate, + }, + ], + ) + } catch (e: unknown) { + const error = String(e) + logger.error('Internal OTA Update Error', {error}) + Alert.alert( + 'Update Check Failed', + `Could not check the ${deploymentName} deployment: ${error}`, + ) + } finally { + setPending(false) } - setPending(false) } /** @@ -161,6 +249,50 @@ export function useApplyPullRequestOTAUpdate() { } } +/** + * Reports when expo-updates recovered from a custom OTA that failed to launch. + * The attempted update ID is persisted before reload so the previous bundle can + * distinguish a successful relaunch from an automatic fallback. + */ +export function useOTAUpdateRecovery() { + const {currentlyRunning} = useUpdates() + + useEffect(() => { + const pendingUpdate = device.get(['pendingOTAUpdate']) + if (!pendingUpdate || !currentlyRunning) return + + device.remove(['pendingOTAUpdate']) + if ( + pendingUpdate.updateId.toLowerCase() === + currentlyRunning.updateId?.toLowerCase() + ) { + return + } + + // A fallback relaunch is immediate. A stale marker can be left by a + // successful runtime-compatible bundle that predates this hook. + if ( + typeof pendingUpdate.attemptedAt !== 'number' || + Date.now() - pendingUpdate.attemptedAt >= OTA_RECOVERY_WINDOW + ) { + return + } + + const deploymentName = getDeploymentName(pendingUpdate.channel) + logger.error('Custom OTA Update Failed to Launch', { + channel: pendingUpdate.channel, + attemptedUpdateId: pendingUpdate.updateId, + currentUpdateId: currentlyRunning.updateId, + isEmergencyLaunch: currentlyRunning.isEmergencyLaunch, + emergencyLaunchReason: currentlyRunning.emergencyLaunchReason, + }) + Alert.alert( + 'Update Failed', + `The ${deploymentName} deployment could not start. The app recovered by loading a working version instead.`, + ) + }, [currentlyRunning]) +} + export function useOTAUpdates() { const shouldReceiveUpdates = isEnabled && !__DEV__ diff --git a/src/lib/hooks/useOTAUpdates.web.ts b/src/lib/hooks/useOTAUpdates.web.ts index 6938a5b003..970deebf1b 100644 --- a/src/lib/hooks/useOTAUpdates.web.ts +++ b/src/lib/hooks/useOTAUpdates.web.ts @@ -1,7 +1,11 @@ export function useOTAUpdates() {} +export function useOTAUpdateRecovery() {} export function useApplyPullRequestOTAUpdate() { return { - tryApplyUpdate: async (_channel: string) => {}, + tryApplyUpdate: async ( + _channel: string, + _declaredAppVersion?: string | null, + ) => {}, restoreDefaultChannel: async () => {}, isCurrentlyRunningPullRequestDeployment: false, isCurrentlyRunningNonStandardChannel: false, diff --git a/src/storage/schema.ts b/src/storage/schema.ts index 33c6bc0b56..2f4424792d 100644 --- a/src/storage/schema.ts +++ b/src/storage/schema.ts @@ -63,6 +63,11 @@ export type Device = { activitySubscriptionsNudged?: boolean threadgateNudged?: boolean inviteFriendsFollowersPromoDismissed?: boolean + pendingOTAUpdate?: { + attemptedAt: number + channel: string + updateId: string + } /** * Selected color theme for the Invite Friends QR card. */ diff --git a/src/view/shell/index.tsx b/src/view/shell/index.tsx index 126ce485b6..9603236d4d 100644 --- a/src/view/shell/index.tsx +++ b/src/view/shell/index.tsx @@ -9,6 +9,7 @@ import {useNavigation, useNavigationState} from '@react-navigation/native' import {useDedupe} from '#/lib/hooks/useDedupe' import {useIntentHandler} from '#/lib/hooks/useIntentHandler' import {useNotificationsHandler} from '#/lib/hooks/useNotificationHandler' +import {useOTAUpdateRecovery} from '#/lib/hooks/useOTAUpdates' import {useNotificationsRegistration} from '#/lib/notifications/notifications' import {isStateAtTabRoot} from '#/lib/routes/helpers' import {useDialogFullyExpandedCountContext} from '#/state/dialogs' @@ -216,6 +217,7 @@ export function Shell() { const fullyExpandedCount = useDialogFullyExpandedCountContext() useIntentHandler() + useOTAUpdateRecovery() useEffect(() => { setSystemUITheme('theme', t) From 1c02724ac4e52de94b9541ce4179fd1ee18ad4a2 Mon Sep 17 00:00:00 2001 From: surfdude29 <149612116+surfdude29@users.noreply.github.com> Date: Thu, 30 Jul 2026 20:44:45 +0100 Subject: [PATCH 16/34] Use Spanish for Aragonese `intl-displaynames` (#11327) --- src/locale/i18n.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/locale/i18n.ts b/src/locale/i18n.ts index 42041ca9a4..3238cf5468 100644 --- a/src/locale/i18n.ts +++ b/src/locale/i18n.ts @@ -69,7 +69,9 @@ export async function dynamicActivate(locale: AppLanguage) { import('date-fns/locale/es').then(m => m.es), import('@formatjs/intl-pluralrules/locale-data/an.js'), import('@formatjs/intl-numberformat/locale-data/an.js'), - import('@formatjs/intl-displaynames/locale-data/an.js'), + // Aragonese locale data is missing + // see: https://github.com/bluesky-social/social-app/pull/11327 + import('@formatjs/intl-displaynames/locale-data/es.js'), ]) return dateLocale } From 07c34a6548c10b1fdd05c02ea03ab9a574ce1caa Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Thu, 30 Jul 2026 23:30:42 +0300 Subject: [PATCH 17/34] Fix PR OTA deployments reverting after apply (#11338) Co-authored-by: Claude Fable 5 --- src/lib/hooks/useIntentHandler.ts | 4 +- src/lib/hooks/useOTAUpdates.test.ts | 153 ++++++++++++++++++++++++++-- src/lib/hooks/useOTAUpdates.ts | 83 +++++++++++---- 3 files changed, 206 insertions(+), 34 deletions(-) diff --git a/src/lib/hooks/useIntentHandler.ts b/src/lib/hooks/useIntentHandler.ts index 61bc201467..71a3437ad2 100644 --- a/src/lib/hooks/useIntentHandler.ts +++ b/src/lib/hooks/useIntentHandler.ts @@ -94,9 +94,9 @@ export function useIntentHandler() { : null if (!channel) { Alert.alert('Error', 'No channel provided to look for.') - } else { - tryApplyUpdate(channel, appVersion) + return } + tryApplyUpdate(channel, appVersion) return } default: { diff --git a/src/lib/hooks/useOTAUpdates.test.ts b/src/lib/hooks/useOTAUpdates.test.ts index d174182a33..da0fe0fb56 100644 --- a/src/lib/hooks/useOTAUpdates.test.ts +++ b/src/lib/hooks/useOTAUpdates.test.ts @@ -4,11 +4,13 @@ import { fetchUpdateAsync, reloadAsync, setExtraParamAsync, + UpdateCheckResultNotAvailableReason, useUpdates, } from 'expo-updates' import {act, renderHook, waitFor} from '@testing-library/react-native' import {logger} from '#/logger' +import {APP_VERSION} from '#/env' import {device} from '#/storage' import { useApplyPullRequestOTAUpdate, @@ -22,6 +24,7 @@ jest.mock('expo-updates', () => ({ reloadAsync: jest.fn(), setExtraParamAsync: jest.fn(), UpdateCheckResultNotAvailableReason: { + NO_UPDATE_AVAILABLE_ON_SERVER: 'noUpdateAvailableOnServer', UPDATE_PREVIOUSLY_FAILED: 'updatePreviouslyFailed', }, useUpdates: jest.fn(), @@ -42,25 +45,156 @@ jest.mock('#/storage', () => ({ }, })) -const currentUpdate = { - channel: 'testflight', - emergencyLaunchReason: null, - isEmbeddedLaunch: false, - isEmergencyLaunch: false, - updateId: 'current-update', +/** + * `channel` here is the build-time constant baked into the native build, not the + * channel of the running bundle. `channel` is passed as the manifest metadata + * channel our update server stamps into every published update - omit it to + * simulate an embedded launch, which has no server manifest. + */ +function mockCurrentlyRunning({ + buildChannel = 'testflight', + channel, + updateId = 'current-update', +}: { + buildChannel?: string + channel?: string + updateId?: string +} = {}) { + const currentlyRunning = { + channel: buildChannel, + emergencyLaunchReason: null, + isEmbeddedLaunch: !channel, + isEmergencyLaunch: false, + updateId, + manifest: channel ? {id: updateId, metadata: {channel}} : undefined, + } + jest.mocked(useUpdates).mockReturnValue({ + currentlyRunning, + } as ReturnType) + return currentlyRunning } +const currentUpdate = {updateId: 'current-update'} + beforeEach(() => { jest.clearAllMocks() - jest.mocked(useUpdates).mockReturnValue({ - currentlyRunning: currentUpdate, - } as ReturnType) + mockCurrentlyRunning() jest.mocked(setExtraParamAsync).mockResolvedValue(undefined) jest.mocked(reloadAsync).mockResolvedValue(undefined) jest.spyOn(Alert, 'alert').mockImplementation(() => {}) }) describe('useApplyPullRequestOTAUpdate', () => { + it('detects a running PR deployment from the manifest metadata', () => { + mockCurrentlyRunning({ + buildChannel: 'testflight', + channel: 'pull-request-123', + }) + + const {result} = renderHook(() => useApplyPullRequestOTAUpdate()) + + expect(result.current.currentChannel).toBe('pull-request-123') + expect(result.current.isCurrentlyRunningPullRequestDeployment).toBe(true) + expect(result.current.isCurrentlyRunningNonStandardChannel).toBe(true) + }) + + it('treats a standard downloaded update as a standard channel', () => { + mockCurrentlyRunning({buildChannel: 'testflight', channel: 'testflight'}) + + const {result} = renderHook(() => useApplyPullRequestOTAUpdate()) + + expect(result.current.currentChannel).toBe('testflight') + expect(result.current.isCurrentlyRunningPullRequestDeployment).toBe(false) + expect(result.current.isCurrentlyRunningNonStandardChannel).toBe(false) + }) + + it('falls back to the build channel for an embedded launch', () => { + mockCurrentlyRunning({buildChannel: 'testflight'}) + + const {result} = renderHook(() => useApplyPullRequestOTAUpdate()) + + expect(result.current.currentChannel).toBe('testflight') + expect(result.current.isCurrentlyRunningNonStandardChannel).toBe(false) + }) + + it('reports no channel when updates are disabled', () => { + mockCurrentlyRunning({buildChannel: ''}) + + const {result} = renderHook(() => useApplyPullRequestOTAUpdate()) + + expect(result.current.currentChannel).toBeUndefined() + expect(result.current.isCurrentlyRunningNonStandardChannel).toBe(false) + }) + + it('stays quiet when already running the latest of the requested channel', async () => { + mockCurrentlyRunning({ + buildChannel: 'testflight', + channel: 'pull-request-123', + }) + jest.mocked(checkForUpdateAsync).mockResolvedValue({ + isAvailable: false, + reason: UpdateCheckResultNotAvailableReason.NO_UPDATE_AVAILABLE_ON_SERVER, + } as Awaited>) + const {result} = renderHook(() => useApplyPullRequestOTAUpdate()) + + await act(() => result.current.tryApplyUpdate('pull-request-123')) + + expect(Alert.alert).not.toHaveBeenCalled() + }) + + it('warns when no deployment is available for a different channel', async () => { + mockCurrentlyRunning({ + buildChannel: 'testflight', + channel: 'pull-request-123', + }) + jest.mocked(checkForUpdateAsync).mockResolvedValue({ + isAvailable: false, + reason: UpdateCheckResultNotAvailableReason.NO_UPDATE_AVAILABLE_ON_SERVER, + } as Awaited>) + const {result} = renderHook(() => useApplyPullRequestOTAUpdate()) + + await act(() => result.current.tryApplyUpdate('pull-request-456')) + + expect(Alert.alert).toHaveBeenCalledWith( + 'No Deployment Available', + expect.stringContaining('pull-request-456'), + ) + }) + + it('stays silent on a re-fired intent even when the app version differs', async () => { + mockCurrentlyRunning({ + buildChannel: 'testflight', + channel: 'pull-request-123', + }) + jest.mocked(checkForUpdateAsync).mockResolvedValue({ + isAvailable: false, + reason: UpdateCheckResultNotAvailableReason.NO_UPDATE_AVAILABLE_ON_SERVER, + } as Awaited>) + const {result} = renderHook(() => useApplyPullRequestOTAUpdate()) + + await act(() => result.current.tryApplyUpdate('pull-request-123', '0.0.0')) + + expect(Alert.alert).not.toHaveBeenCalled() + expect(fetchUpdateAsync).not.toHaveBeenCalled() + }) + + it('prompts to apply an available update when the app version matches', async () => { + jest.mocked(checkForUpdateAsync).mockResolvedValue({ + isAvailable: true, + } as Awaited>) + const {result} = renderHook(() => useApplyPullRequestOTAUpdate()) + + await act(() => + result.current.tryApplyUpdate('pull-request-123', APP_VERSION), + ) + + expect(Alert.alert).toHaveBeenCalledWith( + 'Apply update from PR #123?', + expect.stringContaining('relaunch'), + expect.arrayContaining([expect.objectContaining({text: 'Apply'})]), + ) + }) + it('warns before applying an OTA built for a different app version', async () => { jest.mocked(checkForUpdateAsync).mockResolvedValue({ isAvailable: true, @@ -74,7 +208,6 @@ describe('useApplyPullRequestOTAUpdate', () => { await act(() => result.current.tryApplyUpdate('pull-request-123', '0.0.0')) - expect(checkForUpdateAsync).not.toHaveBeenCalled() expect(Alert.alert).toHaveBeenCalledWith( 'App Version Mismatch', expect.stringContaining('Applying it anyway may cause'), diff --git a/src/lib/hooks/useOTAUpdates.ts b/src/lib/hooks/useOTAUpdates.ts index a7e043aeb2..7b7224bcb0 100644 --- a/src/lib/hooks/useOTAUpdates.ts +++ b/src/lib/hooks/useOTAUpdates.ts @@ -3,6 +3,7 @@ import {Alert, AppState, type AppStateStatus} from 'react-native' import {nativeBuildVersion} from 'expo-application' import { checkForUpdateAsync, + type CurrentlyRunningInfo, fetchUpdateAsync, isEnabled, reloadAsync, @@ -36,6 +37,32 @@ function getDeploymentName(channel: string) { return pullRequestNumber ? `PR #${pullRequestNumber}` : channel } +/** + * The channel of the update bundle that is actually running. The + * `currentlyRunning.channel` constant only reflects the channel baked into the + * native build config, so a manually applied deployment (e.g. a pull request + * channel) must be detected from the manifest metadata our update server stamps + * into every published update. Embedded launches have no server manifest and + * fall back to the build constant. + */ +function getRunningChannel( + currentlyRunning: CurrentlyRunningInfo | undefined, +): string | undefined { + /* + * `metadata` is typed as a bare `object` by expo-manifests, and is absent + * entirely from embedded manifests, so narrow it ourselves. + */ + const manifest = currentlyRunning?.manifest as + | {metadata?: {channel?: unknown}} + | undefined + const channel = manifest?.metadata?.channel + if (typeof channel === 'string' && channel) { + return channel + } + // The build constant is an empty string rather than null when unconfigured. + return currentlyRunning?.channel || undefined +} + async function setExtraParams() { await setExtraParamAsync( IS_IOS ? 'ios-build-number' : 'android-build-number', @@ -85,12 +112,12 @@ async function updateTestflight() { export function useApplyPullRequestOTAUpdate() { const {currentlyRunning} = useUpdates() const [pending, setPending] = useState(false) - const currentChannel = currentlyRunning?.channel + const currentChannel = getRunningChannel(currentlyRunning) const isCurrentlyRunningPullRequestDeployment = currentChannel?.startsWith('pull-request') /* * Covers pull request deployments as well as any other channel we manually - * applied an update from. Note that `channel` is null when updates are + * applied an update from. Note that the channel is undefined when updates are * disabled (e.g. in dev), in which case there's nothing to restore. */ const isCurrentlyRunningNonStandardChannel = Boolean( @@ -141,6 +168,12 @@ export function useApplyPullRequestOTAUpdate() { updateId: fetchedUpdate.manifest.id, }) try { + /* + * TODO: once expo-linking is upgraded to >= 57, enable this so the + * re-delivered initial URL doesn't trigger a redundant silent check + * after the reload. + */ + // Linking.clearInitialURL() await reloadAsync() } catch (e) { device.remove(['pendingOTAUpdate']) @@ -159,29 +192,35 @@ export function useApplyPullRequestOTAUpdate() { })() } - if (declaredAppVersion && declaredAppVersion !== APP_VERSION) { - Alert.alert( - 'App Version Mismatch', - `This OTA update was built for a different version of the app.\n\nCurrent app version: ${APP_VERSION}\nOTA app version: ${declaredAppVersion}\n\nApplying it anyway may cause the app to stop working and require a reinstall.`, - [ - { - text: 'Cancel', - style: 'cancel', - }, - { - text: 'Apply Anyway', - style: 'destructive', - onPress: applyUpdate, - }, - ], - ) - return - } - + /* + * Check before prompting about anything, so that re-running this while + * already on the newest update of `channel` stays silent. Reloading into an + * update re-delivers the deep link that triggered it, and the same link may + * also just be tapped again. + */ setPending(true) try { if (!(await checkForDeployment())) return + if (declaredAppVersion && declaredAppVersion !== APP_VERSION) { + Alert.alert( + 'App Version Mismatch', + `This OTA update was built for a different version of the app.\n\nCurrent app version: ${APP_VERSION}\nOTA app version: ${declaredAppVersion}\n\nApplying it anyway may cause the app to stop working and require a reinstall.`, + [ + { + text: 'Cancel', + style: 'cancel', + }, + { + text: 'Apply Anyway', + style: 'destructive', + onPress: applyUpdate, + }, + ], + ) + return + } + Alert.alert( `Apply update from ${deploymentName}?`, 'The app will relaunch after the update is applied.', @@ -301,7 +340,7 @@ export function useOTAUpdates() { const ranInitialCheck = useRef(false) const timeout = useRef(undefined) const {currentlyRunning, isUpdatePending} = useUpdates() - const currentChannel = currentlyRunning?.channel + const currentChannel = getRunningChannel(currentlyRunning) const setCheckTimeout = useCallback(() => { timeout.current = setTimeout(async () => { From 03f8020ee273c19a6044010c2e74b8e3f5cf140d Mon Sep 17 00:00:00 2001 From: pfrazee <1270099+pfrazee@users.noreply.github.com> Date: Fri, 31 Jul 2026 03:11:52 +0000 Subject: [PATCH 18/34] Nightly source-language update --- src/locale/locales/en/messages.po | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/locale/locales/en/messages.po b/src/locale/locales/en/messages.po index a33bebf50e..487fd0cb2c 100644 --- a/src/locale/locales/en/messages.po +++ b/src/locale/locales/en/messages.po @@ -1171,7 +1171,7 @@ msgstr "" #: src/view/com/composer/GifAltText.tsx:76 #: src/view/com/composer/GifAltText.tsx:150 -#: src/view/com/composer/GifAltText.tsx:217 +#: src/view/com/composer/GifAltText.tsx:216 #: src/view/com/composer/photos/Gallery.tsx:201 #: src/view/com/composer/photos/Gallery.tsx:236 #: src/view/com/composer/photos/ImageAltTextDialog.tsx:95 @@ -1556,8 +1556,8 @@ msgid "Alt text describes images for blind and low-vision users, and helps give msgstr "" #. placeholder {0}: i18n.number(MAX_ALT_TEXT) -#: src/view/com/composer/GifAltText.tsx:185 -#: src/view/com/composer/photos/ImageAltTextDialog.tsx:149 +#: src/view/com/composer/GifAltText.tsx:184 +#: src/view/com/composer/photos/ImageAltTextDialog.tsx:148 msgid "Alt text will be truncated. {MAX_ALT_TEXT, plural, other {Limit: {0} characters.}}" msgstr "" @@ -10465,12 +10465,12 @@ msgstr "Sad GIFs" #: src/screens/SavedFeeds.tsx:124 #: src/screens/SavedFeeds.tsx:315 #: src/screens/Settings/components/ChangeHandleDialog.tsx:269 -#: src/view/com/composer/GifAltText.tsx:199 -#: src/view/com/composer/GifAltText.tsx:208 +#: src/view/com/composer/GifAltText.tsx:198 +#: src/view/com/composer/GifAltText.tsx:207 #: src/view/com/composer/photos/EditImageDialog.web.tsx:63 #: src/view/com/composer/photos/EditImageDialog.web.tsx:76 -#: src/view/com/composer/photos/ImageAltTextDialog.tsx:163 -#: src/view/com/composer/photos/ImageAltTextDialog.tsx:173 +#: src/view/com/composer/photos/ImageAltTextDialog.tsx:162 +#: src/view/com/composer/photos/ImageAltTextDialog.tsx:172 msgid "Save" msgstr "" From 87a177bbe8bfa1fe8a88a2b88e4f52ee505a8a05 Mon Sep 17 00:00:00 2001 From: Oleksii Bulenok <63914185+abulenok@users.noreply.github.com> Date: Fri, 31 Jul 2026 17:55:08 +0200 Subject: [PATCH 19/34] Fix profile badge sometimes displayed below on Android (#11337) --- src/screens/Profile/Header/DisplayName.tsx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/screens/Profile/Header/DisplayName.tsx b/src/screens/Profile/Header/DisplayName.tsx index 684a9fe53b..8bf50fee2d 100644 --- a/src/screens/Profile/Header/DisplayName.tsx +++ b/src/screens/Profile/Header/DisplayName.tsx @@ -37,6 +37,11 @@ export function ProfileHeaderDisplayName({ + {/* + * TODO: Workaround for a rounding bug in Android RN. + * Fixed upstream in RN main (facebook/react-native#56651); remove this + * once we are on a release that contains it (0.86.0 should be good). + */}{' '} ) From ce28129674fadce357b1b74049fb6cff083aa608 Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Fri, 31 Jul 2026 19:39:26 +0300 Subject: [PATCH 20/34] Add nicer loading screen while OTA is loading (#9499) --- src/lib/hooks/useOTAUpdates.test.ts | 6 +++ src/lib/hooks/useOTAUpdates.ts | 53 +++++++++++++++++---- src/screens/Settings/components/OTAInfo.tsx | 7 ++- 3 files changed, 57 insertions(+), 9 deletions(-) diff --git a/src/lib/hooks/useOTAUpdates.test.ts b/src/lib/hooks/useOTAUpdates.test.ts index da0fe0fb56..871cac0e63 100644 --- a/src/lib/hooks/useOTAUpdates.test.ts +++ b/src/lib/hooks/useOTAUpdates.test.ts @@ -45,6 +45,12 @@ jest.mock('#/storage', () => ({ }, })) +jest.mock('#/alf', () => ({ + useTheme: jest.fn().mockImplementation(() => ({ + scheme: 'light', + })), +})) + /** * `channel` here is the build-time constant baked into the native build, not the * channel of the running bundle. `channel` is passed as the manifest metadata diff --git a/src/lib/hooks/useOTAUpdates.ts b/src/lib/hooks/useOTAUpdates.ts index 7b7224bcb0..180102c2cd 100644 --- a/src/lib/hooks/useOTAUpdates.ts +++ b/src/lib/hooks/useOTAUpdates.ts @@ -1,5 +1,10 @@ import {useCallback, useEffect, useRef, useState} from 'react' -import {Alert, AppState, type AppStateStatus} from 'react-native' +import { + Alert, + AppState, + type AppStateStatus, + Image as RNImage, +} from 'react-native' import {nativeBuildVersion} from 'expo-application' import { checkForUpdateAsync, @@ -7,6 +12,7 @@ import { fetchUpdateAsync, isEnabled, reloadAsync, + type ReloadScreenOptions, setExtraParamAsync, UpdateCheckResultNotAvailableReason, useUpdates, @@ -14,6 +20,7 @@ import { import {isNetworkError} from '#/lib/strings/errors' import {logger} from '#/logger' +import {useTheme} from '#/alf' import {APP_VERSION, IS_IOS, IS_TESTFLIGHT} from '#/env' import {device} from '#/storage' @@ -83,7 +90,7 @@ async function setExtraParamsPullRequest(channel: string) { await setExtraParamAsync('channel', channel) } -async function updateTestflight() { +async function updateTestflight(scheme: 'light' | 'dark') { await setExtraParams() const res = await checkForUpdateAsync() @@ -101,7 +108,9 @@ async function updateTestflight() { text: 'Relaunch', style: 'default', onPress: async () => { - await reloadAsync() + await reloadAsync({ + reloadScreenOptions: splash(scheme), + }) }, }, ], @@ -110,6 +119,7 @@ async function updateTestflight() { } export function useApplyPullRequestOTAUpdate() { + const t = useTheme() const {currentlyRunning} = useUpdates() const [pending, setPending] = useState(false) const currentChannel = getRunningChannel(currentlyRunning) @@ -174,7 +184,9 @@ export function useApplyPullRequestOTAUpdate() { * after the reload. */ // Linking.clearInitialURL() - await reloadAsync() + await reloadAsync({ + reloadScreenOptions: splash(t.scheme), + }) } catch (e) { device.remove(['pendingOTAUpdate']) throw e @@ -335,6 +347,7 @@ export function useOTAUpdateRecovery() { export function useOTAUpdates() { const shouldReceiveUpdates = isEnabled && !__DEV__ + const t = useTheme() const appState = useRef('active') const lastMinimize = useRef(0) const ranInitialCheck = useRef(false) @@ -366,13 +379,13 @@ export function useOTAUpdates() { const onIsTestFlight = useCallback(async () => { try { - await updateTestflight() + await updateTestflight(t.scheme) } catch (err: any) { if (!isNetworkError(err)) { logger.error('Internal OTA Update Error', {safeMessage: err}) } } - }, []) + }, [t.scheme]) useEffect(() => { // We don't need to check anything if the current update is a PR update @@ -414,7 +427,9 @@ export function useOTAUpdates() { // chances are that there isn't anything important going on in the current session. if (lastMinimize.current <= Date.now() - MINIMUM_MINIMIZE_TIME) { if (isUpdatePending) { - await reloadAsync() + await reloadAsync({ + reloadScreenOptions: splash(t.scheme), + }) } else { setCheckTimeout() } @@ -431,5 +446,27 @@ export function useOTAUpdates() { clearTimeout(timeout.current) subscription.remove() } - }, [isUpdatePending, currentChannel, setCheckTimeout]) + }, [isUpdatePending, currentChannel, setCheckTimeout, t.scheme]) +} + +/** + * Splash screen for while the app is updating + */ +export const splash = (scheme: 'light' | 'dark') => { + const source = + scheme === 'light' + ? require('../../../assets/splash/splash.png') + : require('../../../assets/splash/splash-dark.png') + + return { + image: RNImage.resolveAssetSource(source).uri, + imageFullScreen: true, + imageResizeMode: 'cover', + backgroundColor: scheme === 'light' ? '#006AFF' : '#002861', + spinner: { + enabled: true, + color: '#ffffff', + size: 'large', + }, + } satisfies ReloadScreenOptions } diff --git a/src/screens/Settings/components/OTAInfo.tsx b/src/screens/Settings/components/OTAInfo.tsx index 9ab1962b97..25b2c828d9 100644 --- a/src/screens/Settings/components/OTAInfo.tsx +++ b/src/screens/Settings/components/OTAInfo.tsx @@ -4,6 +4,8 @@ import {useLingui} from '@lingui/react' import {Trans} from '@lingui/react/macro' import {useMutation, useQuery} from '@tanstack/react-query' +import {splash} from '#/lib/hooks/useOTAUpdates' +import {useTheme} from '#/alf' import {Button, ButtonIcon, ButtonText} from '#/components/Button' import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as RetryIcon} from '#/components/icons/ArrowRotate' import {Shapes_Stroke2_Corner0_Rounded as ShapesIcon} from '#/components/icons/Shapes' @@ -13,6 +15,7 @@ import * as SettingsList from '../components/SettingsList' export function OTAInfo() { const {_} = useLingui() + const t = useTheme() const { data: isAvailable, isPending: isPendingInfo, @@ -31,7 +34,9 @@ export function OTAInfo() { useMutation({ mutationFn: async () => { await Updates.fetchUpdateAsync() - await Updates.reloadAsync() + await Updates.reloadAsync({ + reloadScreenOptions: splash(t.scheme), + }) }, onError: error => Toast.show(`Failed to update: ${error.message}`, { From f99a17bb6e218045894ffab55cb79ea44917f165 Mon Sep 17 00:00:00 2001 From: DS Boyce <260543580+ds-boyce@users.noreply.github.com> Date: Fri, 31 Jul 2026 10:01:14 -0700 Subject: [PATCH 21/34] Add additional tests for groupNotifications (#11332) --- .../notifications/__tests__/util.test.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/state/queries/notifications/__tests__/util.test.ts b/src/state/queries/notifications/__tests__/util.test.ts index d7bcb5e5d0..3122770a40 100644 --- a/src/state/queries/notifications/__tests__/util.test.ts +++ b/src/state/queries/notifications/__tests__/util.test.ts @@ -35,6 +35,23 @@ function makeFollowNotification( } describe('groupNotifications', () => { + it('does not group a starter pack follow with an organic follow', () => { + const pack = 'at://did:plc:alice/app.bsky.graph.starterpack/a' + + const grouped = groupNotifications([ + makeFollowNotification('did:plc:a'), + makeFollowNotification('did:plc:b', pack), + ]) + + expect(grouped).toHaveLength(2) + expect(grouped[0].notification.author.did).toBe('did:plc:a') + expect(grouped[0].notification.starterPack).toBeUndefined() + expect(grouped[0].additional).toBeUndefined() + expect(grouped[1].notification.author.did).toBe('did:plc:b') + expect(grouped[1].notification.starterPack?.uri).toBe(pack) + expect(grouped[1].additional).toBeUndefined() + }) + it('groups follows by starter pack', () => { const packA = 'at://did:plc:alice/app.bsky.graph.starterpack/a' const packB = 'at://did:plc:bob/app.bsky.graph.starterpack/b' From d58be1adeacd21c61a94f404fedff4366cbced42 Mon Sep 17 00:00:00 2001 From: pfrazee <1270099+pfrazee@users.noreply.github.com> Date: Sat, 1 Aug 2026 03:10:42 +0000 Subject: [PATCH 22/34] Nightly source-language update --- src/locale/locales/en/messages.po | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/locale/locales/en/messages.po b/src/locale/locales/en/messages.po index 487fd0cb2c..62f2d09f7d 100644 --- a/src/locale/locales/en/messages.po +++ b/src/locale/locales/en/messages.po @@ -5241,7 +5241,7 @@ msgctxt "toast" msgid "Feeds updated!" msgstr "" -#: src/screens/Settings/components/OTAInfo.tsx:61 +#: src/screens/Settings/components/OTAInfo.tsx:66 msgid "Fetch update" msgstr "" @@ -8827,19 +8827,19 @@ msgstr "" msgid "Or, sign in to one of your other accounts." msgstr "" -#: src/screens/Settings/components/OTAInfo.tsx:55 +#: src/screens/Settings/components/OTAInfo.tsx:60 msgid "OTA status: ..." msgstr "" -#: src/screens/Settings/components/OTAInfo.tsx:51 +#: src/screens/Settings/components/OTAInfo.tsx:56 msgid "OTA status: Available!" msgstr "" -#: src/screens/Settings/components/OTAInfo.tsx:53 +#: src/screens/Settings/components/OTAInfo.tsx:58 msgid "OTA status: Error fetching update" msgstr "" -#: src/screens/Settings/components/OTAInfo.tsx:57 +#: src/screens/Settings/components/OTAInfo.tsx:62 msgid "OTA status: None available" msgstr "" @@ -13357,8 +13357,8 @@ msgstr "Until" msgid "Up to 50 people" msgstr "Up to 50 people" -#: src/screens/Settings/components/OTAInfo.tsx:61 -#: src/screens/Settings/components/OTAInfo.tsx:77 +#: src/screens/Settings/components/OTAInfo.tsx:66 +#: src/screens/Settings/components/OTAInfo.tsx:82 msgid "Update" msgstr "" From c2ceb8057f593db1de40bf5ae5518e7164e19cd5 Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Mon, 3 Aug 2026 16:25:42 +0300 Subject: [PATCH 23/34] Handle camera cancellation (#11322) --- src/lib/media/picker.tsx | 6 +++--- src/view/com/composer/photos/OpenCameraBtn.tsx | 3 +++ src/view/com/feeds/ComposerPrompt.tsx | 3 +++ src/view/com/util/UserAvatar.tsx | 16 ++++++++-------- src/view/com/util/UserBanner.tsx | 16 ++++++++-------- 5 files changed, 25 insertions(+), 19 deletions(-) diff --git a/src/lib/media/picker.tsx b/src/lib/media/picker.tsx index c9a52b8bad..926b2e9560 100644 --- a/src/lib/media/picker.tsx +++ b/src/lib/media/picker.tsx @@ -17,11 +17,11 @@ export async function openCamera(customOpts: ImagePickerOptions) { } const res = await launchCameraAsync(opts) - if (!res || !res.assets) { - throw new Error('Camera was closed before taking a photo') + if (res.canceled) { + return } - const asset = res?.assets[0] + const asset = res.assets[0] return { path: asset.uri, diff --git a/src/view/com/composer/photos/OpenCameraBtn.tsx b/src/view/com/composer/photos/OpenCameraBtn.tsx index 6365765fc9..cf087b59bb 100644 --- a/src/view/com/composer/photos/OpenCameraBtn.tsx +++ b/src/view/com/composer/photos/OpenCameraBtn.tsx @@ -32,6 +32,9 @@ export function OpenCameraBtn({disabled, onAdd}: OpenCameraBtnProps) { const img = await openCamera({ aspect: [1, 1], }) + if (!img) { + return + } // If we don't have permissions it's fine, we just wont save it. The post itself will still have access to // the image even without these permissions diff --git a/src/view/com/feeds/ComposerPrompt.tsx b/src/view/com/feeds/ComposerPrompt.tsx index b221b0fae3..a2effdf231 100644 --- a/src/view/com/feeds/ComposerPrompt.tsx +++ b/src/view/com/feeds/ComposerPrompt.tsx @@ -115,6 +115,9 @@ export function ComposerPrompt() { const image = await openCamera({ mediaTypes: 'images', }) + if (!image) { + return + } const imageUris = [ { diff --git a/src/view/com/util/UserAvatar.tsx b/src/view/com/util/UserAvatar.tsx index f139542857..98819bf8d6 100644 --- a/src/view/com/util/UserAvatar.tsx +++ b/src/view/com/util/UserAvatar.tsx @@ -391,14 +391,14 @@ let EditableUserAvatar = ({ return } - onSelectNewAvatar( - await compressIfNeeded( - await openCamera({ - aspect: [1, 1], - }), - IMAGE_SIZE_CONFIG_2K_1MB, - ), - ) + const image = await openCamera({ + aspect: [1, 1], + }) + if (!image) { + return + } + + onSelectNewAvatar(await compressIfNeeded(image, IMAGE_SIZE_CONFIG_2K_1MB)) }, [onSelectNewAvatar, requestCameraAccessIfNeeded]) const onOpenLibrary = useCallback(async () => { diff --git a/src/view/com/util/UserBanner.tsx b/src/view/com/util/UserBanner.tsx index ff4b6631bd..964cc2ba57 100644 --- a/src/view/com/util/UserBanner.tsx +++ b/src/view/com/util/UserBanner.tsx @@ -58,14 +58,14 @@ export function UserBanner({ if (!(await requestCameraAccessIfNeeded())) { return } - onSelectNewBanner?.( - await compressIfNeeded( - await openCamera({ - aspect: [3, 1], - }), - IMAGE_SIZE_CONFIG_2K_1MB, - ), - ) + const image = await openCamera({ + aspect: [3, 1], + }) + if (!image) { + return + } + + onSelectNewBanner?.(await compressIfNeeded(image, IMAGE_SIZE_CONFIG_2K_1MB)) }, [onSelectNewBanner, requestCameraAccessIfNeeded]) const onOpenLibrary = useCallback(async () => { From e99e04a4580341345bcc499bb0a47a3c63c8048e Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Mon, 3 Aug 2026 16:26:06 +0300 Subject: [PATCH 24/34] Simplify inline badge presentation, fix alignment (#11352) --- .../notifications/NotificationFeedItem.tsx | 38 +++++++------------ 1 file changed, 13 insertions(+), 25 deletions(-) diff --git a/src/view/com/notifications/NotificationFeedItem.tsx b/src/view/com/notifications/NotificationFeedItem.tsx index 49abf2f3ff..c8efc82678 100644 --- a/src/view/com/notifications/NotificationFeedItem.tsx +++ b/src/view/com/notifications/NotificationFeedItem.tsx @@ -250,7 +250,6 @@ let NotificationFeedItem = ({ t.atoms.text, a.font_semi_bold, a.text_md, - a.leading_tight, web({direction: 'ltr', unicodeBidi: 'isolate'}), ]} to={firstAuthor.href} @@ -260,18 +259,8 @@ let NotificationFeedItem = ({ {forceLTR(firstAuthorName)} @@ -315,7 +304,7 @@ let NotificationFeedItem = ({ notificationContent = hasMultipleAuthors ? ( {firstAuthorLink} and{' '} - + {firstAuthorLink} and{' '} - + {firstAuthorLink} and{' '} - + {firstAuthorLink} and{' '} - + {firstAuthorLink} and{' '} - + {firstAuthorLink} and{' '} - + {firstAuthorLink} and{' '} - + {firstAuthorLink} and{' '} - + {firstAuthorLink} and{' '} - + New posts from {firstAuthorLink} and{' '} - + @@ -1171,7 +1159,7 @@ function AdditionalPostText({post}: {post?: AppBskyFeedDefs.PostView}) { {text?.length > 0 && ( {text} From 1cc942a869356ecd9b5090356ce22d727f3f7705 Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Mon, 3 Aug 2026 18:00:25 +0300 Subject: [PATCH 25/34] Classify report dialog errors (#11354) --- .../moderation/ReportDialog/errors.test.ts | 107 +++++++++++++++++ .../moderation/ReportDialog/errors.ts | 111 ++++++++++++++++++ .../moderation/ReportDialog/index.tsx | 36 +++++- src/logger/__tests__/logger.test.ts | 14 +++ src/logger/transports/sentry.ts | 4 +- src/logger/types.ts | 6 + 6 files changed, 272 insertions(+), 6 deletions(-) create mode 100644 src/components/moderation/ReportDialog/errors.test.ts create mode 100644 src/components/moderation/ReportDialog/errors.ts diff --git a/src/components/moderation/ReportDialog/errors.test.ts b/src/components/moderation/ReportDialog/errors.test.ts new file mode 100644 index 0000000000..4a469c5eb2 --- /dev/null +++ b/src/components/moderation/ReportDialog/errors.test.ts @@ -0,0 +1,107 @@ +import {XRPCError} from '@atproto/api' + +import {classifyReportError} from './errors' + +describe('classifyReportError', () => { + it('treats account takedown as an expected rejection', () => { + const result = classifyReportError( + new XRPCError( + 403, + 'AccountTakedown', + 'Report not accepted from takendown account', + ), + ) + + expect(result).toMatchObject({ + kind: 'account-takedown', + shouldReport: false, + fingerprint: ['{{ default }}', 'report-dialog:account-takedown'], + tags: { + report_error_kind: 'account-takedown', + report_error_bucket: 'account-takedown', + report_xrpc_error: 'AccountTakedown', + report_http_status: 403, + }, + }) + }) + + it.each([ + { + error: new XRPCError( + 502, + 'InternalServerError', + 'Failed to perform upstream request', + ), + bucket: 'upstream-fetch', + }, + { + error: new XRPCError(502, 'UpstreamFailure', 'Internal Server Error'), + bucket: 'upstream-internal', + }, + { + error: new XRPCError( + 502, + 'UpstreamFailure', + 'Upstream server responded with a 502 error', + ), + bucket: 'upstream-http-502', + }, + { + error: new XRPCError( + 504, + 'UpstreamTimeout', + 'Upstream server responded with a 504 error', + ), + bucket: 'upstream-http-504', + }, + ])('classifies $bucket as unavailable', ({error, bucket}) => { + expect(classifyReportError(error)).toMatchObject({ + kind: 'service-unavailable', + shouldReport: true, + fingerprint: ['{{ default }}', `report-dialog:${bucket}`], + }) + }) + + it('classifies an invalid reason type separately', () => { + const result = classifyReportError( + new XRPCError( + 400, + 'InvalidRequest', + 'Invalid reason type: tools.ozone.report.defs#reasonOther', + ), + ) + + expect(result).toMatchObject({ + kind: 'invalid-reason-type', + shouldReport: true, + fingerprint: ['{{ default }}', 'report-dialog:invalid-reason-type'], + }) + }) + + it.each([400, 404])( + 'separates a non-retryable upstream %i without calling it temporary', + status => { + const result = classifyReportError( + new XRPCError( + 502, + 'UpstreamFailure', + `Upstream server responded with a ${status} error`, + ), + ) + + expect(result).toMatchObject({ + kind: 'unexpected', + shouldReport: true, + fingerprint: ['{{ default }}', `report-dialog:upstream-http-${status}`], + }) + }, + ) + + it('classifies non-XRPC errors as unexpected', () => { + expect(classifyReportError(new Error('boom'))).toMatchObject({ + kind: 'unexpected', + shouldReport: true, + fingerprint: ['{{ default }}', 'report-dialog:unexpected'], + }) + }) +}) diff --git a/src/components/moderation/ReportDialog/errors.ts b/src/components/moderation/ReportDialog/errors.ts new file mode 100644 index 0000000000..2dc71c8d23 --- /dev/null +++ b/src/components/moderation/ReportDialog/errors.ts @@ -0,0 +1,111 @@ +import {XRPCError} from '@atproto/api' + +import {isRetryableHttpStatus, shouldRetryError} from '#/lib/strings/errors' + +export type ReportErrorKind = + | 'account-takedown' + | 'invalid-reason-type' + | 'service-unavailable' + | 'unexpected' + +export type ReportErrorClassification = { + kind: ReportErrorKind + shouldReport: boolean + fingerprint: string[] + tags: Record +} + +export function classifyReportError(error: unknown): ReportErrorClassification { + if (!(error instanceof XRPCError)) { + return classification('unexpected', 'unexpected', true) + } + + const xrpcTags = { + report_xrpc_error: error.error, + report_http_status: error.status, + } + + if (error.error === 'AccountTakedown') { + return classification( + 'account-takedown', + 'account-takedown', + false, + xrpcTags, + ) + } + + if (error.message.startsWith('Invalid reason type')) { + return classification( + 'invalid-reason-type', + 'invalid-reason-type', + true, + xrpcTags, + ) + } + + if (error.message === 'Failed to perform upstream request') { + return classification( + 'service-unavailable', + 'upstream-fetch', + true, + xrpcTags, + ) + } + + if (error.message === 'Internal Server Error') { + return classification( + 'service-unavailable', + 'upstream-internal', + true, + xrpcTags, + ) + } + + const upstreamStatus = error.message.match( + /^Upstream server responded with a (\d{3}) error$/, + )?.[1] + if (upstreamStatus) { + return classification( + isRetryableHttpStatus(Number(upstreamStatus)) + ? 'service-unavailable' + : 'unexpected', + `upstream-http-${upstreamStatus}`, + true, + xrpcTags, + ) + } + + if (shouldRetryError(error)) { + return classification( + 'service-unavailable', + `xrpc-retryable-${error.status}`, + true, + xrpcTags, + ) + } + + return classification( + 'unexpected', + `xrpc-other-${error.status}`, + true, + xrpcTags, + ) +} + +function classification( + kind: ReportErrorKind, + bucket: string, + shouldReport: boolean, + tags: Record = {}, +): ReportErrorClassification { + return { + kind, + shouldReport, + fingerprint: ['{{ default }}', `report-dialog:${bucket}`], + tags: { + report_error_kind: kind, + report_error_bucket: bucket, + ...tags, + }, + } +} diff --git a/src/components/moderation/ReportDialog/index.tsx b/src/components/moderation/ReportDialog/index.tsx index dfb3e781a9..50c780630e 100644 --- a/src/components/moderation/ReportDialog/index.tsx +++ b/src/components/moderation/ReportDialog/index.tsx @@ -45,6 +45,7 @@ import { SUPPORT_PAGE, } from './const' import {useCopyForSubject} from './copy' +import {classifyReportError} from './errors' import { getNciiQualificationOutcome, initialState, @@ -244,14 +245,39 @@ function Inner(props: ReportDialogProps) { }) }, 1e3) } catch (err) { - const e = err as Error + const e = err instanceof Error ? err : new Error(String(err)) + const classification = classifyReportError(e) + const tags = { + ...classification.tags, + report_subject_type: props.subject.type, + report_labeler: state.selectedLabeler?.creator.did, + report_reason: state.selectedOption?.reason, + } + ax.metric('reportDialog:failure', {}) - logger.error(e, { - source: 'ReportDialog', - }) + + if (classification.shouldReport) { + logger.error(e, { + source: 'ReportDialog', + fingerprint: classification.fingerprint, + tags, + }) + } else { + logger.warn('Report rejected for taken down account', {tags}) + } + + let error = l`Something went wrong. Please try again.` + if (classification.kind === 'account-takedown') { + error = l`Your account cannot submit reports while it is taken down.` + } else if (classification.kind === 'invalid-reason-type') { + error = l`This moderation service does not support that report reason. Please choose a different reason or moderation service.` + } else if (classification.kind === 'service-unavailable') { + error = l`The moderation service is temporarily unavailable. Please try again later.` + } + dispatch({ type: 'setError', - error: l`Something went wrong. Please try again.`, + error, }) } finally { setIsPending(false) diff --git a/src/logger/__tests__/logger.test.ts b/src/logger/__tests__/logger.test.ts index d7bda5ad23..ed46301f6e 100644 --- a/src/logger/__tests__/logger.test.ts +++ b/src/logger/__tests__/logger.test.ts @@ -242,6 +242,20 @@ describe('general functionality', () => { __context__: 'logger', }, }) + + const fingerprint = ['{{ default }}', 'report-dialog:upstream-fetch'] + sentryTransport( + LogLevel.Error, + Logger.Context.ReportDialog, + e, + {fingerprint}, + timestamp, + ) + expect(Sentry.captureException).toHaveBeenLastCalledWith(e, { + tags: {category: 'report-dialog'}, + extra: {__context__: 'report-dialog'}, + fingerprint, + }) }) test('sentryTransport serializes errors', () => { diff --git a/src/logger/transports/sentry.ts b/src/logger/transports/sentry.ts index 1764e91541..92a7b92f67 100644 --- a/src/logger/transports/sentry.ts +++ b/src/logger/transports/sentry.ts @@ -7,7 +7,7 @@ export const sentryTransport: Transport = ( level, context, message, - {type, tags, ...metadata}, + {type, tags, fingerprint, ...metadata}, timestamp, ) => { // Skip debug messages entirely for now - esb @@ -70,6 +70,7 @@ export const sentryTransport: Transport = ( level: severity, tags: _tags, extra: meta, + ...(fingerprint ? {fingerprint} : {}), }) } } else { @@ -84,6 +85,7 @@ export const sentryTransport: Transport = ( Sentry.captureException(message, { tags: _tags, extra: meta, + ...(fingerprint ? {fingerprint} : {}), }) } } diff --git a/src/logger/types.ts b/src/logger/types.ts index cc700dc58b..7d3c897477 100644 --- a/src/logger/types.ts +++ b/src/logger/types.ts @@ -82,6 +82,12 @@ export type Metadata = { [key: string]: number | string | boolean | null | undefined } + /** + * Passed through to Sentry as a custom fingerprint. Include + * `{{ default }}` to preserve Sentry's default grouping and add dimensions. + */ + fingerprint?: string[] + /** * Any additional data, passed through to Sentry as `extra` param on * exceptions, or the `data` param on breadcrumbs. From 863fdcd8c1e24df2a7664ee096eb0cd5495d9340 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 3 Aug 2026 23:33:04 +0300 Subject: [PATCH 26/34] Bump the actions group with 6 updates (#11370) --- .github/workflows/build-and-push-bskyweb-aws.yaml | 2 +- .github/workflows/build-and-push-bskyweb-ghcr.yaml | 2 +- .github/workflows/build-and-push-embedr-aws.yaml | 2 +- .github/workflows/build-and-push-link-aws.yaml | 2 +- .github/workflows/build-and-push-ogcard-aws.yaml | 2 +- .github/workflows/bundle-deploy-eas-update.yml | 2 +- .github/workflows/claude-mention.yml | 4 ++-- .github/workflows/claude-review.yml | 4 ++-- .github/workflows/nightly-update-source-languages.yaml | 2 +- .github/workflows/pull-request-commit.yml | 4 ++-- .github/workflows/zizmor.yml | 2 +- 11 files changed, 14 insertions(+), 14 deletions(-) diff --git a/.github/workflows/build-and-push-bskyweb-aws.yaml b/.github/workflows/build-and-push-bskyweb-aws.yaml index f6d7949825..4148640240 100644 --- a/.github/workflows/build-and-push-bskyweb-aws.yaml +++ b/.github/workflows/build-and-push-bskyweb-aws.yaml @@ -28,7 +28,7 @@ jobs: uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 - name: 🔑 Log into registry ${{ env.REGISTRY }} - uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0 + uses: docker/login-action@371161bbe7024a29a25c5e19bfcbc0804fe9ad2c # v4.5.2 with: registry: ${{ env.REGISTRY }} username: ${{ env.USERNAME}} diff --git a/.github/workflows/build-and-push-bskyweb-ghcr.yaml b/.github/workflows/build-and-push-bskyweb-ghcr.yaml index 15e1da8a59..c905a92602 100644 --- a/.github/workflows/build-and-push-bskyweb-ghcr.yaml +++ b/.github/workflows/build-and-push-bskyweb-ghcr.yaml @@ -29,7 +29,7 @@ jobs: uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 - name: 🔑 Log into registry ${{ env.REGISTRY }} - uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0 + uses: docker/login-action@371161bbe7024a29a25c5e19bfcbc0804fe9ad2c # v4.5.2 with: registry: ${{ env.REGISTRY }} username: ${{ env.USERNAME }} diff --git a/.github/workflows/build-and-push-embedr-aws.yaml b/.github/workflows/build-and-push-embedr-aws.yaml index 5b7193cf4e..d5df3eda26 100644 --- a/.github/workflows/build-and-push-embedr-aws.yaml +++ b/.github/workflows/build-and-push-embedr-aws.yaml @@ -28,7 +28,7 @@ jobs: uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 - name: 🔑 Log into registry ${{ env.REGISTRY }} - uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0 + uses: docker/login-action@371161bbe7024a29a25c5e19bfcbc0804fe9ad2c # v4.5.2 with: registry: ${{ env.REGISTRY }} username: ${{ env.USERNAME}} diff --git a/.github/workflows/build-and-push-link-aws.yaml b/.github/workflows/build-and-push-link-aws.yaml index 5f3daafd7c..04cb0c48be 100644 --- a/.github/workflows/build-and-push-link-aws.yaml +++ b/.github/workflows/build-and-push-link-aws.yaml @@ -28,7 +28,7 @@ jobs: uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 - name: 🔑 Log into registry ${{ env.REGISTRY }} - uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0 + uses: docker/login-action@371161bbe7024a29a25c5e19bfcbc0804fe9ad2c # v4.5.2 with: registry: ${{ env.REGISTRY }} username: ${{ env.USERNAME}} diff --git a/.github/workflows/build-and-push-ogcard-aws.yaml b/.github/workflows/build-and-push-ogcard-aws.yaml index 7c7b228e49..08c49f3227 100644 --- a/.github/workflows/build-and-push-ogcard-aws.yaml +++ b/.github/workflows/build-and-push-ogcard-aws.yaml @@ -28,7 +28,7 @@ jobs: uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 - name: 🔑 Log into registry ${{ env.REGISTRY }} - uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0 + uses: docker/login-action@371161bbe7024a29a25c5e19bfcbc0804fe9ad2c # v4.5.2 with: registry: ${{ env.REGISTRY }} username: ${{ env.USERNAME}} diff --git a/.github/workflows/bundle-deploy-eas-update.yml b/.github/workflows/bundle-deploy-eas-update.yml index 26581e597c..4bb8e7c795 100644 --- a/.github/workflows/bundle-deploy-eas-update.yml +++ b/.github/workflows/bundle-deploy-eas-update.yml @@ -253,7 +253,7 @@ jobs: - name: ☁️ Configure AWS credentials (denis) if: ${{ !steps.fingerprint.outputs.includes-changes && !steps.version.outputs.version-changed }} - uses: aws-actions/configure-aws-credentials@517a711dbcd0e402f90c77e7e2f81e849156e31d # v6.2.2 + uses: aws-actions/configure-aws-credentials@e6de054238d6b7531b4efff3b6587d9aade6a06c # v6.2.3 with: role-to-assume: arn:aws:iam::007404326489:role/denis-ci-publish aws-region: us-east-2 diff --git a/.github/workflows/claude-mention.yml b/.github/workflows/claude-mention.yml index c4e4cbcb7d..5ddf02b157 100644 --- a/.github/workflows/claude-mention.yml +++ b/.github/workflows/claude-mention.yml @@ -59,13 +59,13 @@ jobs: fetch-depth: 1 - name: ☁️ Configure AWS credentials (OIDC) - uses: aws-actions/configure-aws-credentials@517a711dbcd0e402f90c77e7e2f81e849156e31d # v6.2.2 + uses: aws-actions/configure-aws-credentials@e6de054238d6b7531b4efff3b6587d9aade6a06c # v6.2.3 with: role-to-assume: ${{ secrets.AWS_BEDROCK_REVIEW_ROLE_ARN }} aws-region: us-east-2 - name: 🤖 Claude - uses: anthropics/claude-code-action@b76a0776ae74036e77cd11018083743453d7ad35 # v1.0.179 + uses: anthropics/claude-code-action@be7b93b1907a4abad570368f3c74b6fe3807510b # v1.0.183 with: use_bedrock: 'true' additional_permissions: | diff --git a/.github/workflows/claude-review.yml b/.github/workflows/claude-review.yml index de15b75fed..bcb9a09e3c 100644 --- a/.github/workflows/claude-review.yml +++ b/.github/workflows/claude-review.yml @@ -45,13 +45,13 @@ jobs: fetch-depth: 1 - name: ☁️ Configure AWS credentials (OIDC) - uses: aws-actions/configure-aws-credentials@517a711dbcd0e402f90c77e7e2f81e849156e31d # v6.2.2 + uses: aws-actions/configure-aws-credentials@e6de054238d6b7531b4efff3b6587d9aade6a06c # v6.2.3 with: role-to-assume: ${{ secrets.AWS_BEDROCK_REVIEW_ROLE_ARN }} aws-region: us-east-2 - name: 🤖 Claude review - uses: anthropics/claude-code-action@b76a0776ae74036e77cd11018083743453d7ad35 # v1.0.179 + uses: anthropics/claude-code-action@be7b93b1907a4abad570368f3c74b6fe3807510b # v1.0.183 with: use_bedrock: 'true' additional_permissions: | diff --git a/.github/workflows/nightly-update-source-languages.yaml b/.github/workflows/nightly-update-source-languages.yaml index e11ff6b7d4..2fb3fffd78 100644 --- a/.github/workflows/nightly-update-source-languages.yaml +++ b/.github/workflows/nightly-update-source-languages.yaml @@ -35,7 +35,7 @@ jobs: commit_message: Nightly source-language update file_pattern: ./src/locale/locales/en/messages.po - name: 🚀 Push source lang to Crowdin - uses: crowdin/github-action@e0c8f73cdc0fafde9396e056c5038217000a32d1 # v2.16.4 + uses: crowdin/github-action@c7af9bc98b01694653031fef2a0dc6c7888ce9bc # v2.17.0 with: upload_sources: true upload_sources_args: "-b main" diff --git a/.github/workflows/pull-request-commit.yml b/.github/workflows/pull-request-commit.yml index a06556ea15..16566672a6 100644 --- a/.github/workflows/pull-request-commit.yml +++ b/.github/workflows/pull-request-commit.yml @@ -185,7 +185,7 @@ jobs: - name: 📷 Check fingerprint and install dependencies id: fingerprint timeout-minutes: 5 - uses: bluesky-social/github-actions/fingerprint-native@b5556913e4aef3964cfd5936d0add3fc0d809bdb # v0.2.0 + uses: bluesky-social/github-actions/fingerprint-native@abc6a46eb4badf243f55bfd7d6cec42722456300 # v0.3.0 with: profile: pull-request @@ -312,7 +312,7 @@ jobs: pnpm export - name: ☁️ Configure AWS credentials (denis, PR-scoped) - uses: aws-actions/configure-aws-credentials@517a711dbcd0e402f90c77e7e2f81e849156e31d # v6.2.2 + uses: aws-actions/configure-aws-credentials@e6de054238d6b7531b4efff3b6587d9aade6a06c # v6.2.3 with: role-to-assume: arn:aws:iam::007404326489:role/denis-ci-publish-pr aws-region: us-east-2 diff --git a/.github/workflows/zizmor.yml b/.github/workflows/zizmor.yml index 12ff6ed2c7..dac6025ee2 100644 --- a/.github/workflows/zizmor.yml +++ b/.github/workflows/zizmor.yml @@ -26,7 +26,7 @@ jobs: persist-credentials: false - name: 🛡️ Run zizmor - uses: zizmorcore/zizmor-action@6599ee8b7a49aef6a770f63d261d214911a7ce02 # v0.6.0 + uses: zizmorcore/zizmor-action@6fc4b006235f201fdab3722e17240ab420d580e5 # v0.6.1 with: # Annotate the PR directly instead of uploading SARIF to the # security tab, and fail the check on any finding From a1f094534ad3e6427dd828b6a08c2b932900adcc Mon Sep 17 00:00:00 2001 From: pfrazee <1270099+pfrazee@users.noreply.github.com> Date: Tue, 4 Aug 2026 03:10:42 +0000 Subject: [PATCH 27/34] Nightly source-language update --- src/locale/locales/en/messages.po | 232 ++++++++++++++++-------------- 1 file changed, 122 insertions(+), 110 deletions(-) diff --git a/src/locale/locales/en/messages.po b/src/locale/locales/en/messages.po index 62f2d09f7d..c71c8c5256 100644 --- a/src/locale/locales/en/messages.po +++ b/src/locale/locales/en/messages.po @@ -483,167 +483,167 @@ msgstr "" msgid "{filterCount, plural, one {+# filter} other {+# filters}}" msgstr "{filterCount, plural, one {+# filter} other {+# filters}}" -#: src/view/com/notifications/NotificationFeedItem.tsx:378 +#: src/view/com/notifications/NotificationFeedItem.tsx:367 msgid "{firstAuthorLink} and <0>{additionalAuthorsCount, plural, one {{formattedAuthorsCount} other} other {{formattedAuthorsCount} others}} followed you" msgstr "" -#: src/view/com/notifications/NotificationFeedItem.tsx:410 +#: src/view/com/notifications/NotificationFeedItem.tsx:399 msgid "{firstAuthorLink} and <0>{additionalAuthorsCount, plural, one {{formattedAuthorsCount} other} other {{formattedAuthorsCount} others}} liked your custom feed" msgstr "" -#: src/view/com/notifications/NotificationFeedItem.tsx:316 +#: src/view/com/notifications/NotificationFeedItem.tsx:305 msgid "{firstAuthorLink} and <0>{additionalAuthorsCount, plural, one {{formattedAuthorsCount} other} other {{formattedAuthorsCount} others}} liked your post" msgstr "" -#: src/view/com/notifications/NotificationFeedItem.tsx:507 +#: src/view/com/notifications/NotificationFeedItem.tsx:496 msgid "{firstAuthorLink} and <0>{additionalAuthorsCount, plural, one {{formattedAuthorsCount} other} other {{formattedAuthorsCount} others}} liked your repost" msgstr "" -#: src/view/com/notifications/NotificationFeedItem.tsx:482 +#: src/view/com/notifications/NotificationFeedItem.tsx:471 msgid "{firstAuthorLink} and <0>{additionalAuthorsCount, plural, one {{formattedAuthorsCount} other} other {{formattedAuthorsCount} others}} removed their verifications from your account" msgstr "" -#: src/view/com/notifications/NotificationFeedItem.tsx:338 +#: src/view/com/notifications/NotificationFeedItem.tsx:327 msgid "{firstAuthorLink} and <0>{additionalAuthorsCount, plural, one {{formattedAuthorsCount} other} other {{formattedAuthorsCount} others}} reposted your post" msgstr "" -#: src/view/com/notifications/NotificationFeedItem.tsx:529 +#: src/view/com/notifications/NotificationFeedItem.tsx:518 msgid "{firstAuthorLink} and <0>{additionalAuthorsCount, plural, one {{formattedAuthorsCount} other} other {{formattedAuthorsCount} others}} reposted your repost" msgstr "" -#: src/view/com/notifications/NotificationFeedItem.tsx:432 +#: src/view/com/notifications/NotificationFeedItem.tsx:421 msgid "{firstAuthorLink} and <0>{additionalAuthorsCount, plural, one {{formattedAuthorsCount} other} other {{formattedAuthorsCount} others}} signed up with your starter pack" msgstr "" -#: src/view/com/notifications/NotificationFeedItem.tsx:459 +#: src/view/com/notifications/NotificationFeedItem.tsx:448 msgid "{firstAuthorLink} and <0>{additionalAuthorsCount, plural, one {{formattedAuthorsCount} other} other {{formattedAuthorsCount} others}} verified you" msgstr "" -#: src/view/com/notifications/NotificationFeedItem.tsx:390 +#: src/view/com/notifications/NotificationFeedItem.tsx:379 msgid "{firstAuthorLink} followed you" msgstr "" -#: src/view/com/notifications/NotificationFeedItem.tsx:362 +#: src/view/com/notifications/NotificationFeedItem.tsx:351 msgid "{firstAuthorLink} followed you back" msgstr "" -#: src/view/com/notifications/NotificationFeedItem.tsx:422 +#: src/view/com/notifications/NotificationFeedItem.tsx:411 msgid "{firstAuthorLink} liked your custom feed" msgstr "" -#: src/view/com/notifications/NotificationFeedItem.tsx:328 +#: src/view/com/notifications/NotificationFeedItem.tsx:317 msgid "{firstAuthorLink} liked your post" msgstr "" -#: src/view/com/notifications/NotificationFeedItem.tsx:519 +#: src/view/com/notifications/NotificationFeedItem.tsx:508 msgid "{firstAuthorLink} liked your repost" msgstr "" -#: src/view/com/notifications/NotificationFeedItem.tsx:494 +#: src/view/com/notifications/NotificationFeedItem.tsx:483 msgid "{firstAuthorLink} removed their verification from your account" msgstr "" -#: src/view/com/notifications/NotificationFeedItem.tsx:350 +#: src/view/com/notifications/NotificationFeedItem.tsx:339 msgid "{firstAuthorLink} reposted your post" msgstr "" -#: src/view/com/notifications/NotificationFeedItem.tsx:541 +#: src/view/com/notifications/NotificationFeedItem.tsx:530 msgid "{firstAuthorLink} reposted your repost" msgstr "" -#: src/view/com/notifications/NotificationFeedItem.tsx:444 +#: src/view/com/notifications/NotificationFeedItem.tsx:433 msgid "{firstAuthorLink} signed up with your starter pack" msgstr "" -#: src/view/com/notifications/NotificationFeedItem.tsx:471 +#: src/view/com/notifications/NotificationFeedItem.tsx:460 msgid "{firstAuthorLink} verified you" msgstr "" -#: src/view/com/notifications/NotificationFeedItem.tsx:372 +#: src/view/com/notifications/NotificationFeedItem.tsx:361 msgid "{firstAuthorName} and {additionalAuthorsCount, plural, one {{formattedAuthorsCount} other} other {{formattedAuthorsCount} others}} followed you" msgstr "" -#: src/view/com/notifications/NotificationFeedItem.tsx:366 +#: src/view/com/notifications/NotificationFeedItem.tsx:355 msgid "{firstAuthorName} and {additionalAuthorsCount, plural, one {{formattedAuthorsCount} other} other {{formattedAuthorsCount} others}} followed you via starter pack {starterPackName}" msgstr "{firstAuthorName} and {additionalAuthorsCount, plural, one {{formattedAuthorsCount} other} other {{formattedAuthorsCount} others}} followed you via starter pack {starterPackName}" -#: src/view/com/notifications/NotificationFeedItem.tsx:404 +#: src/view/com/notifications/NotificationFeedItem.tsx:393 msgid "{firstAuthorName} and {additionalAuthorsCount, plural, one {{formattedAuthorsCount} other} other {{formattedAuthorsCount} others}} liked your custom feed" msgstr "" -#: src/view/com/notifications/NotificationFeedItem.tsx:310 +#: src/view/com/notifications/NotificationFeedItem.tsx:299 msgid "{firstAuthorName} and {additionalAuthorsCount, plural, one {{formattedAuthorsCount} other} other {{formattedAuthorsCount} others}} liked your post" msgstr "" -#: src/view/com/notifications/NotificationFeedItem.tsx:501 +#: src/view/com/notifications/NotificationFeedItem.tsx:490 msgid "{firstAuthorName} and {additionalAuthorsCount, plural, one {{formattedAuthorsCount} other} other {{formattedAuthorsCount} others}} liked your repost" msgstr "" -#: src/view/com/notifications/NotificationFeedItem.tsx:476 +#: src/view/com/notifications/NotificationFeedItem.tsx:465 msgid "{firstAuthorName} and {additionalAuthorsCount, plural, one {{formattedAuthorsCount} other} other {{formattedAuthorsCount} others}} removed their verifications from your account" msgstr "" -#: src/view/com/notifications/NotificationFeedItem.tsx:332 +#: src/view/com/notifications/NotificationFeedItem.tsx:321 msgid "{firstAuthorName} and {additionalAuthorsCount, plural, one {{formattedAuthorsCount} other} other {{formattedAuthorsCount} others}} reposted your post" msgstr "" -#: src/view/com/notifications/NotificationFeedItem.tsx:523 +#: src/view/com/notifications/NotificationFeedItem.tsx:512 msgid "{firstAuthorName} and {additionalAuthorsCount, plural, one {{formattedAuthorsCount} other} other {{formattedAuthorsCount} others}} reposted your repost" msgstr "" -#: src/view/com/notifications/NotificationFeedItem.tsx:426 +#: src/view/com/notifications/NotificationFeedItem.tsx:415 msgid "{firstAuthorName} and {additionalAuthorsCount, plural, one {{formattedAuthorsCount} other} other {{formattedAuthorsCount} others}} signed up with your starter pack" msgstr "" -#: src/view/com/notifications/NotificationFeedItem.tsx:453 +#: src/view/com/notifications/NotificationFeedItem.tsx:442 msgid "{firstAuthorName} and {additionalAuthorsCount, plural, one {{formattedAuthorsCount} other} other {{formattedAuthorsCount} others}} verified you" msgstr "" -#: src/view/com/notifications/NotificationFeedItem.tsx:376 +#: src/view/com/notifications/NotificationFeedItem.tsx:365 msgid "{firstAuthorName} followed you" msgstr "" -#: src/view/com/notifications/NotificationFeedItem.tsx:361 +#: src/view/com/notifications/NotificationFeedItem.tsx:350 msgid "{firstAuthorName} followed you back" msgstr "" -#: src/view/com/notifications/NotificationFeedItem.tsx:360 +#: src/view/com/notifications/NotificationFeedItem.tsx:349 msgid "{firstAuthorName} followed you back via starter pack {starterPackName}" msgstr "{firstAuthorName} followed you back via starter pack {starterPackName}" -#: src/view/com/notifications/NotificationFeedItem.tsx:370 +#: src/view/com/notifications/NotificationFeedItem.tsx:359 msgid "{firstAuthorName} followed you via starter pack {starterPackName}" msgstr "{firstAuthorName} followed you via starter pack {starterPackName}" -#: src/view/com/notifications/NotificationFeedItem.tsx:408 +#: src/view/com/notifications/NotificationFeedItem.tsx:397 msgid "{firstAuthorName} liked your custom feed" msgstr "" -#: src/view/com/notifications/NotificationFeedItem.tsx:314 +#: src/view/com/notifications/NotificationFeedItem.tsx:303 msgid "{firstAuthorName} liked your post" msgstr "" -#: src/view/com/notifications/NotificationFeedItem.tsx:505 +#: src/view/com/notifications/NotificationFeedItem.tsx:494 msgid "{firstAuthorName} liked your repost" msgstr "" -#: src/view/com/notifications/NotificationFeedItem.tsx:480 +#: src/view/com/notifications/NotificationFeedItem.tsx:469 msgid "{firstAuthorName} removed their verification from your account" msgstr "" -#: src/view/com/notifications/NotificationFeedItem.tsx:336 +#: src/view/com/notifications/NotificationFeedItem.tsx:325 msgid "{firstAuthorName} reposted your post" msgstr "" -#: src/view/com/notifications/NotificationFeedItem.tsx:527 +#: src/view/com/notifications/NotificationFeedItem.tsx:516 msgid "{firstAuthorName} reposted your repost" msgstr "" -#: src/view/com/notifications/NotificationFeedItem.tsx:430 +#: src/view/com/notifications/NotificationFeedItem.tsx:419 msgid "{firstAuthorName} signed up with your starter pack" msgstr "" -#: src/view/com/notifications/NotificationFeedItem.tsx:457 +#: src/view/com/notifications/NotificationFeedItem.tsx:446 msgid "{firstAuthorName} verified you" msgstr "" @@ -1228,7 +1228,7 @@ msgstr "Add filter" msgid "Add group chat members" msgstr "Add group chat members" -#: src/view/com/feeds/ComposerPrompt.tsx:223 +#: src/view/com/feeds/ComposerPrompt.tsx:226 msgid "Add image" msgstr "" @@ -1243,8 +1243,8 @@ msgstr "" msgid "Add members" msgstr "Add members" -#: src/components/moderation/ReportDialog/index.tsx:540 -#: src/components/moderation/ReportDialog/index.tsx:544 +#: src/components/moderation/ReportDialog/index.tsx:566 +#: src/components/moderation/ReportDialog/index.tsx:570 msgid "Add more details (optional)" msgstr "" @@ -1350,7 +1350,7 @@ msgstr "" msgid "Additional details (limit 1000 characters)" msgstr "" -#: src/components/moderation/ReportDialog/index.tsx:558 +#: src/components/moderation/ReportDialog/index.tsx:584 msgid "Additional details (limit 300 characters)" msgstr "" @@ -1677,8 +1677,8 @@ msgstr "An issue occurred starting the group chat, please try again." #: src/components/hooks/useFollowMethods.ts:52 #: src/components/ProfileCard.tsx:510 #: src/components/ProfileCard.tsx:532 -#: src/view/com/notifications/NotificationFeedItem.tsx:846 -#: src/view/com/notifications/NotificationFeedItem.tsx:867 +#: src/view/com/notifications/NotificationFeedItem.tsx:834 +#: src/view/com/notifications/NotificationFeedItem.tsx:855 msgid "An issue occurred, please try again." msgstr "" @@ -1942,7 +1942,7 @@ msgstr "" msgid "Are you sure?" msgstr "" -#: src/components/moderation/ReportDialog/index.tsx:815 +#: src/components/moderation/ReportDialog/index.tsx:841 msgid "Are you the person depicted, or an authorized representative acting on behalf of the person depicted?" msgstr "Are you the person depicted, or an authorized representative acting on behalf of the person depicted?" @@ -2434,7 +2434,7 @@ msgstr "" #. placeholder {0}: sanitizeHandle(handle, '@') #. placeholder {0}: sanitizeHandle(item.feed.creator.handle, '@') #: src/components/LabelingServiceCard/index.tsx:62 -#: src/components/moderation/ReportDialog/index.tsx:979 +#: src/components/moderation/ReportDialog/index.tsx:1005 #: src/screens/Messages/JoinRequest.tsx:177 #: src/screens/Search/components/StarterPackCard.tsx:112 #: src/screens/Search/Explore.tsx:960 @@ -2483,7 +2483,7 @@ msgstr "" msgid "By you" msgstr "" -#: src/view/com/composer/photos/OpenCameraBtn.tsx:65 +#: src/view/com/composer/photos/OpenCameraBtn.tsx:68 msgid "Camera" msgstr "" @@ -2626,7 +2626,7 @@ msgstr "" msgid "Change hosting provider" msgstr "Change hosting provider" -#: src/components/moderation/ReportDialog/index.tsx:457 +#: src/components/moderation/ReportDialog/index.tsx:483 msgid "Change moderation service" msgstr "" @@ -2639,11 +2639,11 @@ msgstr "" msgid "Change password dialog" msgstr "" -#: src/components/moderation/ReportDialog/index.tsx:319 +#: src/components/moderation/ReportDialog/index.tsx:345 msgid "Change report category" msgstr "" -#: src/components/moderation/ReportDialog/index.tsx:398 +#: src/components/moderation/ReportDialog/index.tsx:424 msgid "Change report reason" msgstr "" @@ -3078,11 +3078,11 @@ msgstr "" msgid "Closes post composer and discards post draft" msgstr "" -#: src/view/com/notifications/NotificationFeedItem.tsx:612 +#: src/view/com/notifications/NotificationFeedItem.tsx:601 msgid "Collapse list of users" msgstr "" -#: src/view/com/notifications/NotificationFeedItem.tsx:995 +#: src/view/com/notifications/NotificationFeedItem.tsx:983 msgid "Collapses list of users for a given notification" msgstr "" @@ -3122,7 +3122,7 @@ msgid "Complete the challenge" msgstr "" #: src/lib/hotkeys/index.tsx:66 -#: src/view/com/feeds/ComposerPrompt.tsx:147 +#: src/view/com/feeds/ComposerPrompt.tsx:150 #: src/view/shell/desktop/LeftNav.tsx:587 msgid "Compose new post" msgstr "" @@ -3680,8 +3680,8 @@ msgstr "Create or modify an invite link for this group chat" #. Accessibility label for button to create a moderation report for the selected option #. placeholder {0}: option.title -#: src/components/moderation/ReportDialog/index.tsx:721 -#: src/components/moderation/ReportDialog/index.tsx:766 +#: src/components/moderation/ReportDialog/index.tsx:747 +#: src/components/moderation/ReportDialog/index.tsx:792 msgid "Create report for {0}" msgstr "" @@ -4727,7 +4727,7 @@ msgstr "" msgid "Expand alt text" msgstr "" -#: src/view/com/notifications/NotificationFeedItem.tsx:613 +#: src/view/com/notifications/NotificationFeedItem.tsx:602 msgid "Expand list of users" msgstr "" @@ -5395,8 +5395,8 @@ msgstr "Focus the search field" #: src/screens/PostThread/components/ThreadItemAnchorFollowButton.tsx:157 #: src/screens/Profile/Header/ProfileHeaderStandard.tsx:409 #: src/screens/VideoFeed/index.tsx:937 -#: src/view/com/notifications/NotificationFeedItem.tsx:909 -#: src/view/com/notifications/NotificationFeedItem.tsx:916 +#: src/view/com/notifications/NotificationFeedItem.tsx:897 +#: src/view/com/notifications/NotificationFeedItem.tsx:904 msgid "Follow" msgstr "" @@ -5451,8 +5451,8 @@ msgstr "" #: src/components/ProfileCard.tsx:544 #: src/screens/PostThread/components/ThreadItemAnchorFollowButton.tsx:155 #: src/screens/Profile/Header/ProfileHeaderStandard.tsx:407 -#: src/view/com/notifications/NotificationFeedItem.tsx:909 -#: src/view/com/notifications/NotificationFeedItem.tsx:916 +#: src/view/com/notifications/NotificationFeedItem.tsx:897 +#: src/view/com/notifications/NotificationFeedItem.tsx:904 msgid "Follow back" msgstr "" @@ -5510,8 +5510,8 @@ msgstr "" #: src/screens/PostThread/components/ThreadItemAnchorFollowButton.tsx:160 #: src/screens/Profile/Header/ProfileHeaderStandard.tsx:405 #: src/screens/VideoFeed/index.tsx:935 -#: src/view/com/notifications/NotificationFeedItem.tsx:888 -#: src/view/com/notifications/NotificationFeedItem.tsx:904 +#: src/view/com/notifications/NotificationFeedItem.tsx:876 +#: src/view/com/notifications/NotificationFeedItem.tsx:892 msgid "Following" msgstr "" @@ -5526,7 +5526,7 @@ msgstr "" #. placeholder {0}: sanitizeDisplayName( profile.displayName || profile.handle, ) #: src/components/ProfileCard.tsx:500 #: src/screens/Profile/Header/ProfileHeaderStandard.tsx:247 -#: src/view/com/notifications/NotificationFeedItem.tsx:839 +#: src/view/com/notifications/NotificationFeedItem.tsx:827 msgid "Following {0}" msgstr "" @@ -5845,7 +5845,7 @@ msgstr "" msgid "Go to {0}'s profile" msgstr "Go to {0}'s profile" -#: src/view/com/notifications/NotificationFeedItem.tsx:259 +#: src/view/com/notifications/NotificationFeedItem.tsx:258 msgid "Go to {firstAuthorName}'s profile" msgstr "" @@ -6134,7 +6134,7 @@ msgstr "" msgid "Hide" msgstr "" -#: src/view/com/notifications/NotificationFeedItem.tsx:1001 +#: src/view/com/notifications/NotificationFeedItem.tsx:989 msgctxt "action" msgid "Hide" msgstr "" @@ -6205,7 +6205,7 @@ msgstr "" msgid "Hide trending videos?" msgstr "" -#: src/view/com/notifications/NotificationFeedItem.tsx:994 +#: src/view/com/notifications/NotificationFeedItem.tsx:982 msgid "Hide user list" msgstr "" @@ -6624,7 +6624,7 @@ msgstr "" msgid "Invalid phone number" msgstr "" -#: src/components/moderation/ReportDialog/index.tsx:107 +#: src/components/moderation/ReportDialog/index.tsx:108 msgid "Invalid report subject" msgstr "" @@ -7846,8 +7846,8 @@ msgstr "" msgid "Navigates to your profile" msgstr "" -#: src/components/moderation/ReportDialog/index.tsx:349 -#: src/components/moderation/ReportDialog/index.tsx:365 +#: src/components/moderation/ReportDialog/index.tsx:375 +#: src/components/moderation/ReportDialog/index.tsx:391 msgid "Need to report a copyright violation, legal request, or regulatory compliance issue?" msgstr "" @@ -7870,11 +7870,11 @@ msgctxt "nux-description" msgid "New" msgstr "New" -#: src/view/com/notifications/NotificationFeedItem.tsx:570 +#: src/view/com/notifications/NotificationFeedItem.tsx:559 msgid "New {postsCount, plural, one {post} other {posts}} from {firstAuthorLink}" msgstr "" -#: src/view/com/notifications/NotificationFeedItem.tsx:554 +#: src/view/com/notifications/NotificationFeedItem.tsx:543 msgid "New {postsCount, plural, one {post} other {posts}} from {firstAuthorName}" msgstr "" @@ -7984,11 +7984,11 @@ msgctxt "action" msgid "New post" msgstr "" -#: src/view/com/notifications/NotificationFeedItem.tsx:559 +#: src/view/com/notifications/NotificationFeedItem.tsx:548 msgid "New posts from {firstAuthorLink} and <0>{additionalAuthorsCount, plural, one {{formattedAuthorsCount} other} other {{formattedAuthorsCount} others}} " msgstr "New posts from {firstAuthorLink} and <0>{additionalAuthorsCount, plural, one {{formattedAuthorsCount} other} other {{formattedAuthorsCount} others}} " -#: src/view/com/notifications/NotificationFeedItem.tsx:547 +#: src/view/com/notifications/NotificationFeedItem.tsx:536 msgid "New posts from {firstAuthorName} and {additionalAuthorsCount, plural, one {{formattedAuthorsCount} other} other {{formattedAuthorsCount} others}}" msgstr "" @@ -8044,8 +8044,8 @@ msgstr "" msgid "Night" msgstr "Night" -#: src/components/moderation/ReportDialog/index.tsx:897 -#: src/components/moderation/ReportDialog/index.tsx:906 +#: src/components/moderation/ReportDialog/index.tsx:923 +#: src/components/moderation/ReportDialog/index.tsx:932 msgctxt "Answer to a yes/no question" msgid "No" msgstr "No" @@ -8135,7 +8135,7 @@ msgstr "" #. placeholder {0}: sanitizeDisplayName( profile.displayName || profile.handle, ) #: src/components/ProfileCard.tsx:523 #: src/screens/Profile/Header/ProfileHeaderStandard.tsx:273 -#: src/view/com/notifications/NotificationFeedItem.tsx:860 +#: src/view/com/notifications/NotificationFeedItem.tsx:848 msgid "No longer following {0}" msgstr "" @@ -8573,7 +8573,7 @@ msgstr "Open advanced search options" msgid "Open avatar creator" msgstr "" -#: src/view/com/feeds/ComposerPrompt.tsx:201 +#: src/view/com/feeds/ComposerPrompt.tsx:204 msgid "Open camera" msgstr "" @@ -8724,7 +8724,7 @@ msgstr "" msgid "Opens alt text dialog" msgstr "" -#: src/view/com/composer/photos/OpenCameraBtn.tsx:66 +#: src/view/com/composer/photos/OpenCameraBtn.tsx:69 msgid "Opens camera on device" msgstr "" @@ -8740,7 +8740,7 @@ msgstr "" msgid "Opens composer" msgstr "" -#: src/view/com/feeds/ComposerPrompt.tsx:202 +#: src/view/com/feeds/ComposerPrompt.tsx:205 msgid "Opens device camera" msgstr "" @@ -8769,7 +8769,7 @@ msgstr "Opens full image" msgid "Opens helpdesk in browser" msgstr "" -#: src/view/com/feeds/ComposerPrompt.tsx:224 +#: src/view/com/feeds/ComposerPrompt.tsx:227 msgid "Opens image picker" msgstr "" @@ -8803,7 +8803,7 @@ msgstr "Opens the GIF picker dialog" msgid "Opens the invite friends sheet to share your profile" msgstr "Opens the invite friends sheet to share your profile" -#: src/view/com/feeds/ComposerPrompt.tsx:148 +#: src/view/com/feeds/ComposerPrompt.tsx:151 msgid "Opens the post composer" msgstr "" @@ -9265,7 +9265,7 @@ msgstr "" msgid "Please sign in as @{0}" msgstr "" -#: src/components/moderation/ReportDialog/index.tsx:848 +#: src/components/moderation/ReportDialog/index.tsx:874 msgctxt "english-only-resource" msgid "Please submit your report through the Report non-consensual intimate imagery (NCII) form." msgstr "Please submit your report through the Report non-consensual intimate imagery (NCII) form." @@ -10123,8 +10123,8 @@ msgstr "" msgid "Report conversation" msgstr "" -#: src/components/moderation/ReportDialog/index.tsx:105 -#: src/components/moderation/ReportDialog/index.tsx:270 +#: src/components/moderation/ReportDialog/index.tsx:106 +#: src/components/moderation/ReportDialog/index.tsx:296 msgid "Report dialog" msgstr "" @@ -10393,7 +10393,7 @@ msgstr "" #: src/components/contacts/screens/VerifyNumber.tsx:355 #: src/components/Error.tsx:60 #: src/components/Lists.tsx:115 -#: src/components/moderation/ReportDialog/index.tsx:304 +#: src/components/moderation/ReportDialog/index.tsx:330 #: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoFallback.tsx:56 #: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoFallback.tsx:59 #: src/components/StarterPack/ProfileStarterPacks.tsx:380 @@ -10416,7 +10416,7 @@ msgstr "" msgid "Retry" msgstr "" -#: src/components/moderation/ReportDialog/index.tsx:301 +#: src/components/moderation/ReportDialog/index.tsx:327 #: src/view/screens/Storybook/Admonitions.tsx:61 msgid "Retry loading report options" msgstr "" @@ -10553,8 +10553,8 @@ msgid "Saved to your feeds" msgstr "" #: src/components/NewskieDialog.tsx:141 -#: src/view/com/notifications/NotificationFeedItem.tsx:959 -#: src/view/com/notifications/NotificationFeedItem.tsx:967 +#: src/view/com/notifications/NotificationFeedItem.tsx:947 +#: src/view/com/notifications/NotificationFeedItem.tsx:955 msgid "Say hello!" msgstr "" @@ -10797,7 +10797,7 @@ msgstr "" msgid "Select a color" msgstr "" -#: src/components/moderation/ReportDialog/index.tsx:387 +#: src/components/moderation/ReportDialog/index.tsx:413 msgid "Select a reason" msgstr "" @@ -10894,7 +10894,7 @@ msgstr "" msgid "Select languages" msgstr "" -#: src/components/moderation/ReportDialog/index.tsx:442 +#: src/components/moderation/ReportDialog/index.tsx:468 msgid "Select moderation service" msgstr "" @@ -10996,7 +10996,7 @@ msgstr "" msgid "Send post to..." msgstr "" -#: src/components/moderation/ReportDialog/index.tsx:950 +#: src/components/moderation/ReportDialog/index.tsx:976 msgid "Send report to {title}" msgstr "" @@ -11621,7 +11621,7 @@ msgstr "Someone was removed" msgid "Someone was removed from the group" msgstr "Someone was removed from the group" -#: src/components/moderation/ReportDialog/index.tsx:110 +#: src/components/moderation/ReportDialog/index.tsx:111 msgid "Something wasn't quite right with the data you're trying to report. Please contact support." msgstr "" @@ -11632,7 +11632,7 @@ msgid "Something went wrong" msgstr "" #: src/components/ageAssurance/AgeAssuranceInitDialog.tsx:139 -#: src/components/moderation/ReportDialog/index.tsx:296 +#: src/components/moderation/ReportDialog/index.tsx:322 #: src/screens/Deactivated.tsx:86 #: src/screens/Settings/components/DeactivateAccountDialog.tsx:59 #: src/view/screens/Storybook/Admonitions.tsx:56 @@ -11654,7 +11654,7 @@ msgstr "" msgid "Something went wrong. Please try again in a moment." msgstr "" -#: src/components/moderation/ReportDialog/index.tsx:254 +#: src/components/moderation/ReportDialog/index.tsx:269 msgid "Something went wrong. Please try again." msgstr "" @@ -11840,13 +11840,13 @@ msgstr "" msgid "Submit feedback" msgstr "Submit feedback" -#: src/components/moderation/ReportDialog/index.tsx:520 -#: src/components/moderation/ReportDialog/index.tsx:581 -#: src/components/moderation/ReportDialog/index.tsx:588 +#: src/components/moderation/ReportDialog/index.tsx:546 +#: src/components/moderation/ReportDialog/index.tsx:607 +#: src/components/moderation/ReportDialog/index.tsx:614 msgid "Submit report" msgstr "" -#: src/components/moderation/ReportDialog/index.tsx:828 +#: src/components/moderation/ReportDialog/index.tsx:854 msgctxt "english-only-resource" msgid "Submit your report through the Report non-consensual intimate imagery (NCII) form" msgstr "Submit your report through the Report non-consensual intimate imagery (NCII) form" @@ -12252,6 +12252,10 @@ msgstr "" msgid "The member limit has been reached." msgstr "The member limit has been reached." +#: src/components/moderation/ReportDialog/index.tsx:275 +msgid "The moderation service is temporarily unavailable. Please try again later." +msgstr "The moderation service is temporarily unavailable. Please try again later." + #: src/view/com/composer/select-language/SuggestedLanguage.tsx:400 msgid "The post you’re replying to was marked as being written in {suggestedLanguageName} by its author. Would you like to reply in <0>{suggestedLanguageName}?" msgstr "The post you’re replying to was marked as being written in {suggestedLanguageName} by its author. Would you like to reply in <0>{suggestedLanguageName}?" @@ -12705,6 +12709,10 @@ msgstr "This message is hidden because this user is blocking you." msgid "This message is hidden because you are blocking this user." msgstr "This message is hidden because you are blocking this user." +#: src/components/moderation/ReportDialog/index.tsx:273 +msgid "This moderation service does not support that report reason. Please choose a different reason or moderation service." +msgstr "This moderation service does not support that report reason. Please choose a different reason or moderation service." + #: src/screens/Profile/ErrorState.tsx:41 msgid "This moderation service is unavailable. See below for more details. If this issue persists, contact us." msgstr "" @@ -13190,7 +13198,7 @@ msgstr "" msgid "Unfollows the user" msgstr "" -#: src/components/moderation/ReportDialog/index.tsx:504 +#: src/components/moderation/ReportDialog/index.tsx:530 msgid "Unfortunately, none of your subscribed labelers supports this report type." msgstr "" @@ -13692,7 +13700,7 @@ msgid "Version {0}" msgstr "" #. When the source of a follow is a starter pack, i.e., 'via starter pack {starterPackName}'. -#: src/view/com/notifications/NotificationFeedItem.tsx:758 +#: src/view/com/notifications/NotificationFeedItem.tsx:746 msgid "via starter pack <0/><1>{starterPackName}" msgstr "via starter pack <0/><1>{starterPackName}" @@ -13781,7 +13789,7 @@ msgstr "" #: src/screens/Profile/components/ProfileFeedHeader.tsx:505 #: src/screens/Search/components/SearchProfileCard.tsx:37 #: src/screens/VideoFeed/index.tsx:880 -#: src/view/com/notifications/NotificationFeedItem.tsx:619 +#: src/view/com/notifications/NotificationFeedItem.tsx:608 msgid "View {0}'s profile" msgstr "" @@ -14228,7 +14236,7 @@ msgstr "what’s up" #: src/view/com/auth/SplashScreen.web.tsx:100 #: src/view/com/composer/Composer.tsx:1607 -#: src/view/com/feeds/ComposerPrompt.tsx:192 +#: src/view/com/feeds/ComposerPrompt.tsx:195 msgid "What's up?" msgstr "" @@ -14350,8 +14358,8 @@ msgstr "Wrong kind of conversation" msgid "www.mylivestream.tv" msgstr "" -#: src/components/moderation/ReportDialog/index.tsx:881 -#: src/components/moderation/ReportDialog/index.tsx:890 +#: src/components/moderation/ReportDialog/index.tsx:907 +#: src/components/moderation/ReportDialog/index.tsx:916 msgctxt "Answer to a yes/no question" msgid "Yes" msgstr "Yes" @@ -14925,6 +14933,10 @@ msgstr "" msgid "Your account" msgstr "" +#: src/components/moderation/ReportDialog/index.tsx:271 +msgid "Your account cannot submit reports while it is taken down." +msgstr "Your account cannot submit reports while it is taken down." + #: src/screens/Settings/components/DeleteAccountDialog.tsx:128 msgid "Your account has been deleted, see ya! ✌️" msgstr "" @@ -14969,11 +14981,11 @@ msgstr "" msgid "Your choice will be remembered for future links. You can change it at any time in settings." msgstr "" -#: src/view/com/notifications/NotificationFeedItem.tsx:397 +#: src/view/com/notifications/NotificationFeedItem.tsx:386 msgid "Your contact {firstAuthorLink} is on Bluesky" msgstr "" -#: src/view/com/notifications/NotificationFeedItem.tsx:395 +#: src/view/com/notifications/NotificationFeedItem.tsx:384 msgid "Your contact {firstAuthorName} is on Bluesky" msgstr "" @@ -15099,7 +15111,7 @@ msgid "Your reply was sent" msgstr "" #. placeholder {0}: state.selectedLabeler?.creator.displayName -#: src/components/moderation/ReportDialog/index.tsx:531 +#: src/components/moderation/ReportDialog/index.tsx:557 msgid "Your report will be sent to <0>{0}." msgstr "" From f1cfa37babe6e96334f16d748a1e8edfbc9b8446 Mon Sep 17 00:00:00 2001 From: Spence Pope Date: Tue, 4 Aug 2026 10:45:44 -0400 Subject: [PATCH 28/34] APP-2764: Remove unnecessary top padding on GIF posts (#11321) --- src/components/images/Gallery/maybeApplyGalleryOffsetStyles.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/components/images/Gallery/maybeApplyGalleryOffsetStyles.ts b/src/components/images/Gallery/maybeApplyGalleryOffsetStyles.ts index 6ac2c0086f..eea54b5ed9 100644 --- a/src/components/images/Gallery/maybeApplyGalleryOffsetStyles.ts +++ b/src/components/images/Gallery/maybeApplyGalleryOffsetStyles.ts @@ -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( @@ -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 From ec49988fa791021c538fdcebd692c535a75e7135 Mon Sep 17 00:00:00 2001 From: smileyhead Date: Tue, 4 Aug 2026 19:47:31 +0200 Subject: [PATCH 29/34] =?UTF-8?q?Add=20unique=20context=20to=20=E2=80=98Pe?= =?UTF-8?q?ople=20I=20follow=E2=80=99=20in=20FromDropdown.tsx=20(#11275)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Byte --- .../Search/components/AdvancedSearchDialog/FromDropdown.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/screens/Search/components/AdvancedSearchDialog/FromDropdown.tsx b/src/screens/Search/components/AdvancedSearchDialog/FromDropdown.tsx index abcaf59285..835cc41c6f 100644 --- a/src/screens/Search/components/AdvancedSearchDialog/FromDropdown.tsx +++ b/src/screens/Search/components/AdvancedSearchDialog/FromDropdown.tsx @@ -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 = From 5494f7deb59197610b00f78e88eb8cb65587f10b Mon Sep 17 00:00:00 2001 From: Alex Benzer Date: Tue, 4 Aug 2026 14:57:43 -0700 Subject: [PATCH 30/34] Test requiring at least one interest in onboarding (#11391) Co-authored-by: DS Boyce <260543580+ds-boyce@users.noreply.github.com> --- oxlint-suppressions.json | 11 -- src/analytics/features/types.ts | 1 + src/analytics/metrics/types.ts | 1 + .../Onboarding/StepInterests/index.tsx | 107 ++++++++++++++---- 4 files changed, 86 insertions(+), 34 deletions(-) diff --git a/oxlint-suppressions.json b/oxlint-suppressions.json index 9f8f3cc426..2bc0ab246f 100644 --- a/oxlint-suppressions.json +++ b/oxlint-suppressions.json @@ -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 diff --git a/src/analytics/features/types.ts b/src/analytics/features/types.ts index b7c70fa9a7..353073216b 100644 --- a/src/analytics/features/types.ts +++ b/src/analytics/features/types.ts @@ -22,6 +22,7 @@ export enum Features { 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', diff --git a/src/analytics/metrics/types.ts b/src/analytics/metrics/types.ts index 740865326a..592f9a563d 100644 --- a/src/analytics/metrics/types.ts +++ b/src/analytics/metrics/types.ts @@ -143,6 +143,7 @@ export type Events = { selectedInterests: string[] selectedInterestsLength: number } + 'onboarding:interests:disabledNextPressed': {} 'onboarding:suggestedAccounts:tabPressed': { tab: string } diff --git a/src/screens/Onboarding/StepInterests/index.tsx b/src/screens/Onboarding/StepInterests/index.tsx index 2b9afa0630..10611c92d5 100644 --- a/src/screens/Onboarding/StepInterests/index.tsx +++ b/src/screens/Onboarding/StepInterests/index.tsx @@ -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( 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 = ( + + ) + return ( @@ -59,14 +98,21 @@ export function StepInterests() { What are your interests? - We'll use this to help customize your experience. + {interestRequired ? ( + + Choose at least one. We'll use this to customize your experience. + You can change these anytime. + + ) : ( + We'll use this to help customize your experience. + )} + label={l`Select your interests from the options below`}> {interests.map(interest => ( - + + {missingRequiredInterest ? ( + + + + + {continueButton} + + + + + Choose at least one interest. + + + ) : ( + continueButton + )} + ) From 5db6b4e2fd310ede76c28d4ab8b1f5852a14aa07 Mon Sep 17 00:00:00 2001 From: pfrazee <1270099+pfrazee@users.noreply.github.com> Date: Wed, 5 Aug 2026 03:10:14 +0000 Subject: [PATCH 31/34] Nightly source-language update --- src/locale/locales/en/messages.po | 41 ++++++++++++++++++++++++------- 1 file changed, 32 insertions(+), 9 deletions(-) diff --git a/src/locale/locales/en/messages.po b/src/locale/locales/en/messages.po index c71c8c5256..f82413f9f7 100644 --- a/src/locale/locales/en/messages.po +++ b/src/locale/locales/en/messages.po @@ -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} 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 "" From 0ccc0297168b79a1bea5b2c7532b7aaea19f3ef9 Mon Sep 17 00:00:00 2001 From: Spence Pope Date: Wed, 5 Aug 2026 10:15:06 -0400 Subject: [PATCH 32/34] Allow feature-gated 10-minute video uploads (#11388) --- src/analytics/features/types.ts | 1 + src/lib/constants.ts | 1 + src/lib/media/picker.shared.ts | 4 +- src/view/com/composer/Composer.tsx | 41 ++++++++++++++++++--- src/view/com/composer/SelectMediaButton.tsx | 32 +++++++++++++--- 5 files changed, 66 insertions(+), 13 deletions(-) diff --git a/src/analytics/features/types.ts b/src/analytics/features/types.ts index 353073216b..62fe079b7b 100644 --- a/src/analytics/features/types.ts +++ b/src/analytics/features/types.ts @@ -19,6 +19,7 @@ export enum Features { PostThreadKnownLikersEnable = 'post_thread:known_likers:enable', PostThreadKnownLikersFetchEnable = 'post_thread:known_likers:fetch:enable', CustomLogoJapanEnable = 'custom_logo:japan:enable', + VideoAllow10MinuteEnable = 'video:allow-10-minute:enable', VideoMultipartUploadEnable = 'video:multipart_upload:enable', SearchStarterPacksV2Enable = 'search_starter_packs_v2:enable', FollowSortEnable = 'follow_sort:enable', diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 80d8639ff2..bc079c7af5 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -194,6 +194,7 @@ export const VIDEO_SERVICE = 'https://video.bsky.app' export const VIDEO_SERVICE_DID = 'did:web:video.bsky.app' export const VIDEO_MAX_DURATION_MS = 3 * 60 * 1000 // 3 minutes in milliseconds +export const VIDEO_10_MINUTE_MAX_DURATION_MS = 10 * 60 * 1000 /** * Maximum size of a video in megabytes, _not_ mebibytes. Backend uses * ISO megabytes. diff --git a/src/lib/media/picker.shared.ts b/src/lib/media/picker.shared.ts index d7d8dab440..63e2a1e06c 100644 --- a/src/lib/media/picker.shared.ts +++ b/src/lib/media/picker.shared.ts @@ -47,8 +47,10 @@ export async function openPicker(opts?: ImagePickerOptions) { export async function openUnifiedPicker({ selectionCountRemaining, + videoMaxDurationMs = VIDEO_MAX_DURATION_MS, }: { selectionCountRemaining: number + videoMaxDurationMs?: number }) { return await launchImageLibraryAsync({ exif: false, @@ -61,6 +63,6 @@ export async function openUnifiedPicker({ preferredAssetRepresentationMode: UIImagePickerPreferredAssetRepresentationMode.Automatic, videoExportPreset: VideoExportPreset.Passthrough, - videoMaxDuration: VIDEO_MAX_DURATION_MS / 1000, + videoMaxDuration: videoMaxDurationMs / 1000, }) } diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx index 026697fe25..b080441230 100644 --- a/src/view/com/composer/Composer.tsx +++ b/src/view/com/composer/Composer.tsx @@ -70,6 +70,7 @@ import { MAX_GRAPHEME_LENGTH, SUPPORTED_MIME_TYPES, type SupportedMimeTypes, + VIDEO_10_MINUTE_MAX_DURATION_MS, VIDEO_MAX_DURATION_MS, } from '#/lib/constants' import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' @@ -267,6 +268,12 @@ export const ComposePost = ({ const {currentAccount} = useSession() const t = useTheme() const ax = useAnalytics() + const allow10MinuteVideos = ax.features.enabled( + ax.features.VideoAllow10MinuteEnable, + ) + const videoMaxDurationMs = allow10MinuteVideos + ? VIDEO_10_MINUTE_MAX_DURATION_MS + : VIDEO_MAX_DURATION_MS const agent = useAgent() const queryClient = useQueryClient() const currentDid = currentAccount!.did @@ -434,7 +441,7 @@ export const ComposePost = ({ * Fail early on duration so we don't spend time compressing a video the * server would reject anyway. */ - if (asset.duration != null && asset.duration > VIDEO_MAX_DURATION_MS) { + if (asset.duration != null && asset.duration > videoMaxDurationMs) { composerDispatch({ type: 'update_post', postId: postId, @@ -442,7 +449,9 @@ export const ComposePost = ({ type: 'embed_update_video', videoAction: { type: 'to_error', - error: l`Videos must be less than 3 minutes long.`, + error: allow10MinuteVideos + ? l`Videos must be 10 minutes or less.` + : l`Videos must be less than 3 minutes long.`, signal: abortController.signal, }, }, @@ -469,7 +478,16 @@ export const ComposePost = ({ telemetry, ) }, - [l, i18n, agent, currentDid, composerDispatch, ax.metric], + [ + l, + i18n, + agent, + currentDid, + composerDispatch, + ax.metric, + videoMaxDurationMs, + allow10MinuteVideos, + ], ) const onInitVideo = useNonReactiveCallback(() => { @@ -566,7 +584,7 @@ export const ComposePost = ({ }, }) - if (asset.duration != null && asset.duration > VIDEO_MAX_DURATION_MS) { + if (asset.duration != null && asset.duration > videoMaxDurationMs) { composerDispatch({ type: 'update_post', postId, @@ -574,7 +592,9 @@ export const ComposePost = ({ type: 'embed_update_video', videoAction: { type: 'to_error', - error: l`Videos must be less than 3 minutes long.`, + error: allow10MinuteVideos + ? l`Videos must be 10 minutes or less.` + : l`Videos must be less than 3 minutes long.`, signal: abortController.signal, }, }, @@ -646,7 +666,16 @@ export const ComposePost = ({ }) } }, - [l, i18n, agent, currentDid, composerDispatch, ax.metric], + [ + l, + i18n, + agent, + currentDid, + composerDispatch, + ax.metric, + videoMaxDurationMs, + allow10MinuteVideos, + ], ) const handleSelectDraft = useCallback( diff --git a/src/view/com/composer/SelectMediaButton.tsx b/src/view/com/composer/SelectMediaButton.tsx index d3cb92db7c..34c0101875 100644 --- a/src/view/com/composer/SelectMediaButton.tsx +++ b/src/view/com/composer/SelectMediaButton.tsx @@ -6,6 +6,7 @@ import {msg, plural} from '@lingui/core/macro' import {useLingui} from '@lingui/react' import { + VIDEO_10_MINUTE_MAX_DURATION_MS, VIDEO_MAX_DURATION_MS, VIDEO_MAX_SIZE, VIDEO_MAX_SIZE_MB, @@ -22,6 +23,7 @@ import {Button} from '#/components/Button' import {useSheetWrapper} from '#/components/Dialog/sheet-wrapper' import {Image_Stroke2_Corner0_Rounded as ImageIcon} from '#/components/icons/Image' import * as toast from '#/components/Toast' +import {useAnalytics} from '#/analytics' import {IS_NATIVE, IS_WEB} from '#/env' import {isAnimatedGif} from './videos/isAnimatedGif' import {hasWebCodecs} from './videos/metadata' @@ -236,9 +238,11 @@ async function processImagePickerAssets( { selectionCountRemaining, allowedAssetTypes, + videoMaxDurationMs, }: { selectionCountRemaining: number allowedAssetTypes: AssetType | undefined + videoMaxDurationMs: number }, ) { /* @@ -362,7 +366,7 @@ async function processImagePickerAssets( supportedAssets[0].duration = supportedAssets[0].duration * 1000 } - if (supportedAssets[0].duration > VIDEO_MAX_DURATION_MS) { + if (supportedAssets[0].duration > videoMaxDurationMs) { errors.add(SelectedAssetError.VideoTooLong) supportedAssets = [] } @@ -393,6 +397,13 @@ export function SelectMediaButton({ autoOpen, }: SelectMediaButtonProps) { const {_} = useLingui() + const ax = useAnalytics() + const allow10MinuteVideos = ax.features.enabled( + ax.features.VideoAllow10MinuteEnable, + ) + const videoMaxDurationMs = allow10MinuteVideos + ? VIDEO_10_MINUTE_MAX_DURATION_MS + : VIDEO_MAX_DURATION_MS const {requestPhotoAccessIfNeeded} = usePhotoLibraryPermission() const {requestVideoAccessIfNeeded} = useVideoLibraryPermission() const sheetWrapper = useSheetWrapper() @@ -412,6 +423,7 @@ export function SelectMediaButton({ } = await processImagePickerAssets(rawAssets, { selectionCountRemaining, allowedAssetTypes, + videoMaxDurationMs, }) /* @@ -436,9 +448,9 @@ export function SelectMediaButton({ [SelectedAssetError.MaxVideos]: _( msg`You can only select one video at a time.`, ), - [SelectedAssetError.VideoTooLong]: _( - msg`Videos must be less than 3 minutes long.`, - ), + [SelectedAssetError.VideoTooLong]: allow10MinuteVideos + ? _(msg`Videos must be 10 minutes or less.`) + : _(msg`Videos must be less than 3 minutes long.`), [SelectedAssetError.MaxGIFs]: _( msg`You can only select one GIF at a time.`, ), @@ -458,7 +470,14 @@ export function SelectMediaButton({ errors, }) }, - [_, onSelectAssets, selectionCountRemaining, allowedAssetTypes], + [ + _, + onSelectAssets, + selectionCountRemaining, + allowedAssetTypes, + videoMaxDurationMs, + allow10MinuteVideos, + ], ) const onPressSelectMedia = useCallback(async () => { @@ -481,7 +500,7 @@ export function SelectMediaButton({ } const {assets, canceled} = await sheetWrapper( - openUnifiedPicker({selectionCountRemaining}), + openUnifiedPicker({selectionCountRemaining, videoMaxDurationMs}), ) if (canceled) return @@ -494,6 +513,7 @@ export function SelectMediaButton({ sheetWrapper, processSelectedAssets, selectionCountRemaining, + videoMaxDurationMs, ]) useEffect(() => { From 17a8fe87c209afa4c05d3446b98e1cfd07af84ae Mon Sep 17 00:00:00 2001 From: Spence Pope Date: Wed, 5 Aug 2026 10:15:16 -0400 Subject: [PATCH 33/34] APP-2793: harden multipart video uploads (#11366) --- src/lib/media/video/multipart/api.ts | 7 ++- src/lib/media/video/multipart/constants.ts | 13 ++++- src/lib/media/video/multipart/upload.ts | 32 +++++++++-- src/lib/media/video/multipart/uploadPart.ts | 53 ++++++++++++++++--- .../media/video/multipart/uploadParts.test.ts | 31 +++++++++++ src/lib/media/video/multipart/uploadParts.ts | 7 ++- src/lib/media/video/multipart/utils.ts | 6 +++ 7 files changed, 133 insertions(+), 16 deletions(-) diff --git a/src/lib/media/video/multipart/api.ts b/src/lib/media/video/multipart/api.ts index 811696b3e3..c6a268ffe1 100644 --- a/src/lib/media/video/multipart/api.ts +++ b/src/lib/media/video/multipart/api.ts @@ -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({ route: '/xrpc/app.bsky.video.abortUpload', token, + signal, body: {jobId}, }) } diff --git a/src/lib/media/video/multipart/constants.ts b/src/lib/media/video/multipart/constants.ts index 2aaa37b8c2..2592a9d7db 100644 --- a/src/lib/media/video/multipart/constants.ts +++ b/src/lib/media/video/multipart/constants.ts @@ -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 diff --git a/src/lib/media/video/multipart/upload.ts b/src/lib/media/video/multipart/upload.ts index af2d71d596..2b45e6d2c6 100644 --- a/src/lib/media/video/multipart/upload.ts +++ b/src/lib/media/video/multipart/upload.ts @@ -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 { - 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 diff --git a/src/lib/media/video/multipart/uploadPart.ts b/src/lib/media/video/multipart/uploadPart.ts index e3c57fc2b2..d0a910682c 100644 --- a/src/lib/media/video/multipart/uploadPart.ts +++ b/src/lib/media/video/multipart/uploadPart.ts @@ -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>) => { + 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', { diff --git a/src/lib/media/video/multipart/uploadParts.test.ts b/src/lib/media/video/multipart/uploadParts.test.ts index 7965a28df9..b580f14de2 100644 --- a/src/lib/media/video/multipart/uploadParts.test.ts +++ b/src/lib/media/video/multipart/uploadParts.test.ts @@ -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, diff --git a/src/lib/media/video/multipart/uploadParts.ts b/src/lib/media/video/multipart/uploadParts.ts index cda502fb54..041ecff1b9 100644 --- a/src/lib/media/video/multipart/uploadParts.ts +++ b/src/lib/media/video/multipart/uploadParts.ts @@ -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) } } } diff --git a/src/lib/media/video/multipart/utils.ts b/src/lib/media/video/multipart/utils.ts index 4184ab5666..05c42858f9 100644 --- a/src/lib/media/video/multipart/utils.ts +++ b/src/lib/media/video/multipart/utils.ts @@ -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) +} From 32ec5330b0ed8f6b1f473cc75358cc20ee0c6ce6 Mon Sep 17 00:00:00 2001 From: Spence Pope Date: Wed, 5 Aug 2026 10:15:26 -0400 Subject: [PATCH 34/34] Enrich fatal HLS errors in Sentry (#11359) --- .../VideoEmbedInnerWeb.shared.ts | 16 ++++++- .../VideoEmbedInner/VideoEmbedInnerWeb.tsx | 48 ++++++++++++++++++- .../Post/Embed/VideoEmbed/index.web.tsx | 15 +++++- src/view/com/util/ErrorBoundary.tsx | 7 ++- 4 files changed, 82 insertions(+), 4 deletions(-) diff --git a/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb.shared.ts b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb.shared.ts index d7c44b0d91..9184ccf37c 100644 --- a/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb.shared.ts +++ b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb.shared.ts @@ -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 + constructor({ + detail, + type, + cause, + diagnostics, + }: { + detail: string + type: string + cause: Error + diagnostics: Record + }) { super(cause.message, {cause}) this.detail = detail + this.type = type + this.diagnostics = diagnostics } } diff --git a/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb.tsx b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb.tsx index 6ece8f5a9b..0599240816 100644 --- a/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb.tsx +++ b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb.tsx @@ -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) diff --git a/src/components/Post/Embed/VideoEmbed/index.web.tsx b/src/components/Post/Embed/VideoEmbed/index.web.tsx index ee44ba33e8..0eb622bad7 100644 --- a/src/components/Post/Embed/VideoEmbed/index.web.tsx +++ b/src/components/Post/Embed/VideoEmbed/index.web.tsx @@ -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}) { /> )} - + ReactNode + getErrorMetadata?: (error: Error) => Metadata style?: StyleProp } @@ -29,7 +31,10 @@ export class ErrorBoundary extends Component { } public componentDidCatch(error: Error, errorInfo: ErrorInfo) { - logger.error(error, {errorInfo}) + logger.error(error, { + errorInfo, + ...this.props.getErrorMetadata?.(error), + }) } public render() {