From 296b74d7815df0187dc9218182a1d7b474bf87ec Mon Sep 17 00:00:00 2001 From: Tomasz Zawadzki Date: Thu, 27 Aug 2026 17:06:01 +0200 Subject: [PATCH 01/14] Unblock React Compiler for 13 components by hoisting finally blocks (#11540) Co-authored-by: Claude Opus 5 (1M context) --- src/App.tsx | 3 +- src/App.web.tsx | 3 +- .../PostControls/PostMenu/PostMenuItems.tsx | 39 +++++++++---------- .../dialogs/DeviceLocationRequestDialog.tsx | 3 +- .../dialogs/PostInteractionSettingsDialog.tsx | 3 +- .../moderation/ReportDialog/index.tsx | 3 +- src/lib/hooks/useAccountSwitcher.ts | 3 +- src/screens/Deactivated.tsx | 3 +- src/screens/List/ListHiddenScreen.tsx | 3 +- src/screens/Login/ChooseAccountForm.tsx | 3 +- .../components/ChangePasswordDialog.tsx | 6 +-- .../components/DeactivateAccountDialog.tsx | 3 +- .../components/DisableEmail2FADialog.tsx | 6 +-- .../Settings/components/ExportCarDialog.tsx | 6 +-- src/screens/Signup/StepCaptcha/index.tsx | 3 +- src/screens/SignupQueued.tsx | 3 +- .../com/notifications/NotificationFeed.tsx | 3 +- .../notifications/NotificationFeedItem.tsx | 3 +- src/view/shell/desktop/LeftNav.tsx | 3 +- 19 files changed, 39 insertions(+), 63 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 2af2146d92..2f0a702039 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -126,9 +126,8 @@ function InnerApp() { } } catch (e) { logger.warn(`session: resume failed`, {message: e}) - } finally { - setIsReady(true) } + setIsReady(true) } const account = readLastActiveAccount() void onLaunch(account) diff --git a/src/App.web.tsx b/src/App.web.tsx index 8070c4d333..ef6ac5e4c5 100644 --- a/src/App.web.tsx +++ b/src/App.web.tsx @@ -105,9 +105,8 @@ function InnerApp() { } } catch (e) { logger.warn('session: resumeSession failed', {message: e}) - } finally { - setIsReady(true) } + setIsReady(true) } const account = readLastActiveAccount() void onLaunch(account) diff --git a/src/components/PostControls/PostMenu/PostMenuItems.tsx b/src/components/PostControls/PostMenu/PostMenuItems.tsx index 5cf9b0b5a4..cbe23d6724 100644 --- a/src/components/PostControls/PostMenu/PostMenuItems.tsx +++ b/src/components/PostControls/PostMenu/PostMenuItems.tsx @@ -429,14 +429,13 @@ let PostMenuItems = ({ type: 'error', }) } - } finally { - ax.metric('postMenu:blockAccount', { - uri: postUri, - authorDid: postAuthor.did, - logContext, - feedDescriptor: feedFeedback.feedDescriptor, - }) } + ax.metric('postMenu:blockAccount', { + uri: postUri, + authorDid: postAuthor.did, + logContext, + feedDescriptor: feedFeedback.feedDescriptor, + }) } const onMuteAuthor = async () => { @@ -452,14 +451,13 @@ let PostMenuItems = ({ type: 'error', }) } - } finally { - ax.metric('postMenu:unmuteAccount', { - uri: postUri, - authorDid: postAuthor.did, - logContext, - feedDescriptor: feedFeedback.feedDescriptor, - }) } + ax.metric('postMenu:unmuteAccount', { + uri: postUri, + authorDid: postAuthor.did, + logContext, + feedDescriptor: feedFeedback.feedDescriptor, + }) } else { try { await queueMute() @@ -472,14 +470,13 @@ let PostMenuItems = ({ type: 'error', }) } - } finally { - ax.metric('postMenu:muteAccount', { - uri: postUri, - authorDid: postAuthor.did, - logContext, - feedDescriptor: feedFeedback.feedDescriptor, - }) } + ax.metric('postMenu:muteAccount', { + uri: postUri, + authorDid: postAuthor.did, + logContext, + feedDescriptor: feedFeedback.feedDescriptor, + }) } } diff --git a/src/components/dialogs/DeviceLocationRequestDialog.tsx b/src/components/dialogs/DeviceLocationRequestDialog.tsx index 05d9d99539..9b40d60d52 100644 --- a/src/components/dialogs/DeviceLocationRequestDialog.tsx +++ b/src/components/dialogs/DeviceLocationRequestDialog.tsx @@ -95,9 +95,8 @@ function DeviceLocationRequestDialogInner({onLocationAcquired}: Props) { safeMessage: e.message, }) } - } finally { - setIsRequesting(false) } + setIsRequesting(false) } return ( diff --git a/src/components/dialogs/PostInteractionSettingsDialog.tsx b/src/components/dialogs/PostInteractionSettingsDialog.tsx index 4b04c9fefa..ac64ed331f 100644 --- a/src/components/dialogs/PostInteractionSettingsDialog.tsx +++ b/src/components/dialogs/PostInteractionSettingsDialog.tsx @@ -241,9 +241,8 @@ export function PostInteractionSettingsDialogControlledInner( type: 'error', }, ) - } finally { - setIsSaving(false) } + setIsSaving(false) }, [ _, ax, diff --git a/src/components/moderation/ReportDialog/index.tsx b/src/components/moderation/ReportDialog/index.tsx index f9d29831f0..7fa0426577 100644 --- a/src/components/moderation/ReportDialog/index.tsx +++ b/src/components/moderation/ReportDialog/index.tsx @@ -319,9 +319,8 @@ function Inner( type: 'setError', error, }) - } finally { - setIsPending(false) } + setIsPending(false) }, [logger, submitReport, props, state, ax, l, videoTimestampSeconds]) useCallOnce(() => { diff --git a/src/lib/hooks/useAccountSwitcher.ts b/src/lib/hooks/useAccountSwitcher.ts index 106e92d37a..7d21b8fcd5 100644 --- a/src/lib/hooks/useAccountSwitcher.ts +++ b/src/lib/hooks/useAccountSwitcher.ts @@ -54,9 +54,8 @@ export function useAccountSwitcher() { Toast.show(_(msg`Please sign in as @${account.handle}`), { type: 'warning', }) - } finally { - setPendingDid(null) } + setPendingDid(null) }, [_, ax, resumeSession, requestSwitchToAccount, pendingDid], ) diff --git a/src/screens/Deactivated.tsx b/src/screens/Deactivated.tsx index 3be42add78..90811232b5 100644 --- a/src/screens/Deactivated.tsx +++ b/src/screens/Deactivated.tsx @@ -95,9 +95,8 @@ export function Deactivated() { logger.error(e, { message: 'Failed to activate account', }) - } finally { - setPending(false) } + setPending(false) }, [_, pdsClient, refreshSession, setPending, setError, queryClient]) return ( diff --git a/src/screens/List/ListHiddenScreen.tsx b/src/screens/List/ListHiddenScreen.tsx index f562ad1d96..01c8fdab4a 100644 --- a/src/screens/List/ListHiddenScreen.tsx +++ b/src/screens/List/ListHiddenScreen.tsx @@ -103,9 +103,8 @@ export function ListHiddenScreen({ msg`There was an issue. Please check your internet connection and try again.`, ), ) - } finally { - setIsProcessing(false) } + setIsProcessing(false) } return ( diff --git a/src/screens/Login/ChooseAccountForm.tsx b/src/screens/Login/ChooseAccountForm.tsx index 317a3bcdc3..036384ede6 100644 --- a/src/screens/Login/ChooseAccountForm.tsx +++ b/src/screens/Login/ChooseAccountForm.tsx @@ -58,9 +58,8 @@ export const ChooseAccountForm = ({ }) // Move to login form. onSelectAccount(account) - } finally { - setPendingDid(null) } + setPendingDid(null) }, [ currentAccount, diff --git a/src/screens/Settings/components/ChangePasswordDialog.tsx b/src/screens/Settings/components/ChangePasswordDialog.tsx index 3015b402b5..f43c62f775 100644 --- a/src/screens/Settings/components/ChangePasswordDialog.tsx +++ b/src/screens/Settings/components/ChangePasswordDialog.tsx @@ -102,9 +102,8 @@ function Inner() { logger.error('Failed to request password reset', {safeMessage: e}) setError(cleanError(e)) } - } finally { - setIsProcessing(false) } + setIsProcessing(false) } const onChangePassword = async () => { @@ -151,9 +150,8 @@ function Inner() { logger.error('Failed to set new password', {safeMessage: e}) setError(cleanError(e)) } - } finally { - setIsProcessing(false) } + setIsProcessing(false) } const onBlur = () => { diff --git a/src/screens/Settings/components/DeactivateAccountDialog.tsx b/src/screens/Settings/components/DeactivateAccountDialog.tsx index eaae97659e..acb969601e 100644 --- a/src/screens/Settings/components/DeactivateAccountDialog.tsx +++ b/src/screens/Settings/components/DeactivateAccountDialog.tsx @@ -64,9 +64,8 @@ function DeactivateAccountDialogInner({ logger.error(e, { message: 'Failed to deactivate account', }) - } finally { - setPending(false) } + setPending(false) }, [client, control, logoutCurrentAccount, _, setPending]) return ( diff --git a/src/screens/Settings/components/DisableEmail2FADialog.tsx b/src/screens/Settings/components/DisableEmail2FADialog.tsx index c633c82029..a0a9f0126d 100644 --- a/src/screens/Settings/components/DisableEmail2FADialog.tsx +++ b/src/screens/Settings/components/DisableEmail2FADialog.tsx @@ -49,9 +49,8 @@ export function DisableEmail2FADialog({ setStage(Stages.ConfirmCode) } catch (e) { setError(cleanError(String(e))) - } finally { - setIsProcessing(false) } + setIsProcessing(false) } const onConfirmDisable = async () => { @@ -80,9 +79,8 @@ export function DisableEmail2FADialog({ } else { setError(cleanError(e)) } - } finally { - setIsProcessing(false) } + setIsProcessing(false) } return ( diff --git a/src/screens/Settings/components/ExportCarDialog.tsx b/src/screens/Settings/components/ExportCarDialog.tsx index 290f380157..402ffa29ed 100644 --- a/src/screens/Settings/components/ExportCarDialog.tsx +++ b/src/screens/Settings/components/ExportCarDialog.tsx @@ -53,9 +53,8 @@ export function ExportCarDialog({ } catch (e) { logger.error('Error occurred while downloading CAR file', {message: e}) Toast.show(l`Error occurred while saving file`, {type: 'error'}) - } finally { - setLoading(false) } + setLoading(false) }, [l, currentAccount, pdsClient]) const downloadChatData = useCallback(async () => { @@ -84,9 +83,8 @@ export function ExportCarDialog({ } catch (e) { logger.error('Error occurred while downloading chat data', {message: e}) Toast.show(l`Error occurred while saving file`, {type: 'error'}) - } finally { - setLoading(false) } + setLoading(false) }, [l, currentAccount, chatClient]) return ( diff --git a/src/screens/Signup/StepCaptcha/index.tsx b/src/screens/Signup/StepCaptcha/index.tsx index 43e536c7ae..cdf627abfb 100644 --- a/src/screens/Signup/StepCaptcha/index.tsx +++ b/src/screens/Signup/StepCaptcha/index.tsx @@ -51,9 +51,8 @@ export function StepCaptchaNative() { } catch (err) { const e = err as Error logger.error(e) - } finally { - setReady(true) } + setReady(true) })() }, []) diff --git a/src/screens/SignupQueued.tsx b/src/screens/SignupQueued.tsx index e0bf7f2edd..58a3793050 100644 --- a/src/screens/SignupQueued.tsx +++ b/src/screens/SignupQueued.tsx @@ -60,9 +60,8 @@ export function SignupQueued() { } } catch (e: any) { logger.error('Failed to check signup queue', {err: e.toString()}) - } finally { - setProcessing(false) } + setProcessing(false) }, [ setProcessing, setEstimatedTime, diff --git a/src/view/com/notifications/NotificationFeed.tsx b/src/view/com/notifications/NotificationFeed.tsx index 82b5008078..a6c60a72ec 100644 --- a/src/view/com/notifications/NotificationFeed.tsx +++ b/src/view/com/notifications/NotificationFeed.tsx @@ -103,9 +103,8 @@ export function NotificationFeed({ logger.error('Failed to refresh notifications feed', { message: err, }) - } finally { - setIsPTRing(false) } + setIsPTRing(false) }, [refreshNotifications, setIsPTRing]) const onEndReached = useCallback(async () => { diff --git a/src/view/com/notifications/NotificationFeedItem.tsx b/src/view/com/notifications/NotificationFeedItem.tsx index 0d56dfb873..502074a34a 100644 --- a/src/view/com/notifications/NotificationFeedItem.tsx +++ b/src/view/com/notifications/NotificationFeedItem.tsx @@ -920,9 +920,8 @@ function SayHelloBtn({profile}: {profile: app.bsky.actor.defs.ProfileView}) { }) } catch (e) { logger.error('Failed to get conversation', {safeMessage: e}) - } finally { - setIsLoading(false) } + setIsLoading(false) } if ( diff --git a/src/view/shell/desktop/LeftNav.tsx b/src/view/shell/desktop/LeftNav.tsx index 585a4afff5..7ad99d805f 100644 --- a/src/view/shell/desktop/LeftNav.tsx +++ b/src/view/shell/desktop/LeftNav.tsx @@ -559,9 +559,8 @@ function ComposeBtn({minimal}: {minimal: boolean}) { handle = await fetchHandle(handle) } catch (e) { handle = undefined - } finally { - setIsFetchingHandle(false) } + setIsFetchingHandle(false) } if ( From b9bff931a37ce21be772714713112660321fa490 Mon Sep 17 00:00:00 2001 From: Tomasz Zawadzki Date: Thu, 27 Aug 2026 17:07:34 +0200 Subject: [PATCH 02/14] Unblock React Compiler for 4 components with deferred ref access (#11549) Co-authored-by: Claude Opus 5 (1M context) --- src/components/forms/SearchInput.tsx | 8 +++++++- src/components/forms/TextField.tsx | 13 ++++++++----- src/lib/hooks/useDraggableScrollView.ts | 7 ++++++- src/lib/merge-refs.ts | 2 +- .../StepFinished/ValuePropositionPager.tsx | 12 ++++++++++-- 5 files changed, 32 insertions(+), 10 deletions(-) diff --git a/src/components/forms/SearchInput.tsx b/src/components/forms/SearchInput.tsx index d77b62de9d..c4a77069c7 100644 --- a/src/components/forms/SearchInput.tsx +++ b/src/components/forms/SearchInput.tsx @@ -47,7 +47,13 @@ export function SearchInput({ mergeRefs([internalRef, ref])(node)} label={label || l`Search`} value={value} placeholder={l`Search`} diff --git a/src/components/forms/TextField.tsx b/src/components/forms/TextField.tsx index 936a8a1bec..9c077f8342 100644 --- a/src/components/forms/TextField.tsx +++ b/src/components/forms/TextField.tsx @@ -26,6 +26,7 @@ import { import {useInteractionState} from '#/components/hooks/useInteractionState' import {type Props as SVGIconProps} from '#/components/icons/common' import {Text} from '#/components/Typography' +import {IS_WEB} from '#/env' const Context = createContext<{ inputRef: React.RefObject | null> | null @@ -101,11 +102,13 @@ export function Root({children, isInvalid = false, style}: RootProps) { {zIndex: 0}, style, ]} - {...web({ - onClick: () => inputRef.current?.focus(), - onMouseOver: onHoverIn, - onMouseOut: onHoverOut, - })}> + {...(IS_WEB + ? { + onClick: () => inputRef.current?.focus(), + onMouseOver: onHoverIn, + onMouseOut: onHoverOut, + } + : {})}> {children} diff --git a/src/lib/hooks/useDraggableScrollView.ts b/src/lib/hooks/useDraggableScrollView.ts index a8b8d0a9a2..8008b52713 100644 --- a/src/lib/hooks/useDraggableScrollView.ts +++ b/src/lib/hooks/useDraggableScrollView.ts @@ -74,8 +74,13 @@ export function useDraggableScroll< } }, [cursor]) + /* + * Deferred into the callback so the merge does not happen during the render of + * whichever component uses this hook - see SearchInput for the full reasoning. + */ const refs = useMemo( - () => mergeRefs(outerRef ? [ref, outerRef] : [ref]), + () => (node: Scrollable | null) => + mergeRefs(outerRef ? [ref, outerRef] : [ref])(node), [ref, outerRef], ) diff --git a/src/lib/merge-refs.ts b/src/lib/merge-refs.ts index d2b187e1e9..8e92298f7a 100644 --- a/src/lib/merge-refs.ts +++ b/src/lib/merge-refs.ts @@ -31,7 +31,7 @@ function assignRef( * @returns The function `mergeRefs` is being returned. It takes an array of mutable or legacy refs and * returns a ref callback function that can be used to merge multiple refs into a single ref. */ -export function mergeRefs(refs: (Ref | undefined)[]): Ref { +export function mergeRefs(refs: (Ref | undefined)[]): RefCallback { return (value: T | null) => { const cleanups: (() => void)[] = [] diff --git a/src/screens/Onboarding/StepFinished/ValuePropositionPager.tsx b/src/screens/Onboarding/StepFinished/ValuePropositionPager.tsx index 93aa6acc2b..23f2931bd6 100644 --- a/src/screens/Onboarding/StepFinished/ValuePropositionPager.tsx +++ b/src/screens/Onboarding/StepFinished/ValuePropositionPager.tsx @@ -1,4 +1,4 @@ -import {useRef, useState} from 'react' +import {useEffect, useRef, useState} from 'react' import {View} from 'react-native' import PagerView from 'react-native-pager-view' import {type PagerViewOnPageSelectedEvent} from 'react-native-pager-view' @@ -26,9 +26,17 @@ export function ValuePropositionPager({ if (step !== activePage) { setActivePage(step) - ref.current?.setPage(step) } + /* + * In an effect rather than inline above: driving the pager is a side effect, + * and reading a ref during render is a Rules of React violation. `initialPage` + * already covers the first render, so the mount run is a no-op. + */ + useEffect(() => { + ref.current?.setPage(step) + }, [step]) + const images = [PROP_1[t.name], PROP_2[t.name], PROP_3[t.name]] return ( From f298a4ef5e400a758bce5223c17b7bdf98994674 Mon Sep 17 00:00:00 2001 From: Tomasz Zawadzki Date: Thu, 27 Aug 2026 17:10:10 +0200 Subject: [PATCH 03/14] Unblock React Compiler for 6 components by removing render-phase ref access (#11544) Co-authored-by: Claude Opus 5 (1M context) --- src/components/ProgressGuide/FollowDialog.tsx | 10 ++------- src/components/WhoCanReply.tsx | 21 +++++-------------- .../contacts/components/OTPInput.tsx | 8 +++++-- src/lib/hooks/useAnimatedValue.ts | 16 +++++++------- src/lib/react-query.tsx | 6 +++--- src/state/messages/message-drafts.tsx | 7 +++---- 6 files changed, 27 insertions(+), 41 deletions(-) diff --git a/src/components/ProgressGuide/FollowDialog.tsx b/src/components/ProgressGuide/FollowDialog.tsx index 1ab2668ae5..5e6a3a97ca 100644 --- a/src/components/ProgressGuide/FollowDialog.tsx +++ b/src/components/ProgressGuide/FollowDialog.tsx @@ -270,10 +270,6 @@ function DialogInner({guide}: {guide?: Follow10ProgressGuide}) { // Track seen profiles const seenProfilesRef = useRef>(new Set()) - const itemsRef = useRef(items) - itemsRef.current = items - const selectedInterestRef = useRef(selectedInterest) - selectedInterestRef.current = selectedInterest const onViewableItemsChanged = useNonReactiveCallback( ({viewableItems}: {viewableItems: ViewToken[]}) => { @@ -282,7 +278,7 @@ function DialogInner({guide}: {guide?: Follow10ProgressGuide}) { if (item.type === 'profile') { if (!seenProfilesRef.current.has(item.profile.did)) { seenProfilesRef.current.add(item.profile.did) - const position = itemsRef.current.findIndex( + const position = items.findIndex( i => i.type === 'profile' && i.profile.did === item.profile.did, ) ax.metric('suggestedUser:seen', { @@ -292,9 +288,7 @@ function DialogInner({guide}: {guide?: Follow10ProgressGuide}) { position: position !== -1 ? position : 0, suggestedDid: item.profile.did, category: - selectedInterestRef.current === FOR_YOU_TAB - ? null - : selectedInterestRef.current, + selectedInterest === FOR_YOU_TAB ? null : selectedInterest, }) } } diff --git a/src/components/WhoCanReply.tsx b/src/components/WhoCanReply.tsx index 2202ed8e33..280cbd0cbb 100644 --- a/src/components/WhoCanReply.tsx +++ b/src/components/WhoCanReply.tsx @@ -1,11 +1,5 @@ import {Fragment, useMemo, useRef} from 'react' -import { - Keyboard, - Platform, - type StyleProp, - View, - type ViewStyle, -} from 'react-native' +import {Keyboard, type StyleProp, View, type ViewStyle} from 'react-native' import {AtUri} from '@atproto/syntax' import {msg} from '@lingui/core/macro' import {useLingui} from '@lingui/react' @@ -32,7 +26,7 @@ import {Group3_Stroke2_Corner0_Rounded as GroupIcon} from '#/components/icons/Gr import {InlineLinkText} from '#/components/Link' import {Text} from '#/components/Typography' import {useAnalytics} from '#/analytics' -import {IS_NATIVE} from '#/env' +import {IS_NATIVE, IS_WEB} from '#/env' import {app} from '#/lexicons' import * as bsky from '#/types/bsky' @@ -110,14 +104,9 @@ export function WhoCanReply({post, isThreadAuthor, style}: WhoCanReplyProps) { } onPress={onPressOpen} {...(isThreadAuthor - ? Platform.select({ - web: { - onHoverIn: prefetch, - }, - native: { - onPressIn: prefetch, - }, - }) + ? IS_WEB + ? {onHoverIn: prefetch} + : {onPressIn: prefetch} : {})} hitSlop={HITSLOP_10}> {({hovered, focused, pressed}) => ( diff --git a/src/components/contacts/components/OTPInput.tsx b/src/components/contacts/components/OTPInput.tsx index ef8944a575..94151a8257 100644 --- a/src/components/contacts/components/OTPInput.tsx +++ b/src/components/contacts/components/OTPInput.tsx @@ -9,7 +9,7 @@ import {msg} from '@lingui/core/macro' import {useLingui} from '@lingui/react' import {mergeRefs} from '#/lib/merge-refs' -import {atoms as a, ios, platform, useTheme} from '#/alf' +import {atoms as a, platform, useTheme} from '#/alf' import {useInteractionState} from '#/components/hooks/useInteractionState' import {Text} from '#/components/Typography' import {IS_ANDROID, IS_IOS} from '#/env' @@ -94,7 +94,11 @@ export function OTPInput({ setTimeout(() => innerRef.current?.focus(), 100))} + onLayout={ + IS_IOS + ? () => setTimeout(() => innerRef.current?.focus(), 100) + : undefined + } autoFocus={IS_ANDROID} accessible accessibilityLabel={label} diff --git a/src/lib/hooks/useAnimatedValue.ts b/src/lib/hooks/useAnimatedValue.ts index eb567c02b1..eb23051d23 100644 --- a/src/lib/hooks/useAnimatedValue.ts +++ b/src/lib/hooks/useAnimatedValue.ts @@ -1,12 +1,12 @@ -import {useRef} from 'react' +import {useState} from 'react' import {Animated} from 'react-native' export function useAnimatedValue(initialValue: number) { - const lazyRef = useRef(undefined) - - if (lazyRef.current === undefined) { - lazyRef.current = new Animated.Value(initialValue) - } - - return lazyRef.current + /* + * A lazy `useState` initialiser rather than a lazily-populated ref: both + * construct once and keep the same instance, but reading a ref during render + * is a Rules of React violation. + */ + const [value] = useState(() => new Animated.Value(initialValue)) + return value } diff --git a/src/lib/react-query.tsx b/src/lib/react-query.tsx index adfa483677..f2c101d2b8 100644 --- a/src/lib/react-query.tsx +++ b/src/lib/react-query.tsx @@ -1,4 +1,4 @@ -import {useEffect, useRef, useState} from 'react' +import {useEffect, useState} from 'react' import {AppState, type AppStateStatus} from 'react-native' import {createAsyncStoragePersister} from '@tanstack/query-async-storage-persister' import { @@ -168,8 +168,8 @@ function QueryProviderInner({ children: React.ReactNode currentDid: string | undefined }) { - const initialDid = useRef(currentDid) - if (currentDid !== initialDid.current) { + const [initialDid] = useState(currentDid) + if (currentDid !== initialDid) { throw Error( 'Something is very wrong. Expected did to be stable due to key above.', ) diff --git a/src/state/messages/message-drafts.tsx b/src/state/messages/message-drafts.tsx index bc5ecbca98..6f2b1122df 100644 --- a/src/state/messages/message-drafts.tsx +++ b/src/state/messages/message-drafts.tsx @@ -2,9 +2,9 @@ import { createContext, useContext, useEffect, + useEffectEvent, useMemo, useReducer, - useRef, } from 'react' import {useCurrentConvoId} from './current-convo-id' @@ -44,8 +44,7 @@ export function useMessageDraft() { export function useSaveMessageDraft(message: string) { const {currentConvoId} = useCurrentConvoId() const {dispatch} = useMessageDraftsContext() - const messageRef = useRef(message) - messageRef.current = message + const getMessage = useEffectEvent(() => message) useEffect(() => { return () => { @@ -53,7 +52,7 @@ export function useSaveMessageDraft(message: string) { dispatch({ type: 'set', convoId: currentConvoId, - draft: messageRef.current, + draft: getMessage(), }) } } From cfd6d1e07e931506a261046cf8be5b1c4b6bb017 Mon Sep 17 00:00:00 2001 From: rafael Date: Thu, 27 Aug 2026 12:35:38 -0300 Subject: [PATCH 04/14] Fix pagination (#11448) Co-authored-by: Eric Bailey --- oxlint-suppressions.json | 4 +- src/lib/api/feed/custom.test.ts | 40 +++++++ src/lib/api/feed/custom.ts | 8 +- src/lib/api/feed/likes.ts | 4 +- src/lib/api/feed/merge.test.ts | 106 ++++++++++++++++++ src/lib/api/feed/merge.ts | 24 ++-- src/state/queries/notifications/feed.ts | 54 +-------- src/state/queries/post-feed.ts | 58 ++-------- src/state/queries/util.test.tsx | 100 +++++++++++++++++ src/state/queries/util.ts | 77 +++++++++++++ src/view/com/feeds/ProfileFeedgens.tsx | 2 +- src/view/com/lists/ListMembers.tsx | 2 +- src/view/com/lists/ProfileLists.tsx | 2 +- .../screens/ModerationBlockedAccounts.tsx | 2 +- src/view/screens/ModerationMutedAccounts.tsx | 2 +- 15 files changed, 363 insertions(+), 122 deletions(-) create mode 100644 src/lib/api/feed/custom.test.ts create mode 100644 src/lib/api/feed/merge.test.ts create mode 100644 src/state/queries/util.test.tsx diff --git a/oxlint-suppressions.json b/oxlint-suppressions.json index bef12f4397..1bb22bde7a 100644 --- a/oxlint-suppressions.json +++ b/oxlint-suppressions.json @@ -1191,7 +1191,7 @@ "count": 2 }, "typescript/no-floating-promises": { - "count": 2 + "count": 1 }, "typescript/no-unsafe-member-access": { "count": 2 @@ -1226,7 +1226,7 @@ "count": 3 }, "typescript/no-floating-promises": { - "count": 3 + "count": 2 }, "typescript/no-unsafe-member-access": { "count": 3 diff --git a/src/lib/api/feed/custom.test.ts b/src/lib/api/feed/custom.test.ts new file mode 100644 index 0000000000..10be09310e --- /dev/null +++ b/src/lib/api/feed/custom.test.ts @@ -0,0 +1,40 @@ +import {type Client} from '@atproto/lex' + +import {CustomFeedAPI} from './custom' + +jest.mock('#/state/preferences/languages', () => ({ + getAppLanguageAsContentLanguage: () => '', + getContentLanguages: () => [], +})) + +jest.mock('./utils', () => ({ + createBskyTopicsHeader: () => ({}), + isBlueskyOwnedFeed: () => false, +})) + +describe('CustomFeedAPI', () => { + it('preserves the cursor from an empty logged-out fallback page', async () => { + const originalFetch = global.fetch + const fetchMock: jest.MockedFunction = jest + .fn() + .mockResolvedValueOnce(Response.json({feed: []})) + .mockResolvedValueOnce(Response.json({feed: [], cursor: 'next'})) + global.fetch = fetchMock + const api = new CustomFeedAPI({ + client: {did: undefined} as unknown as Client, + feedParams: { + feed: 'at://did:example:feed/app.bsky.feed.generator/test', + }, + }) + + try { + await expect(api.fetch({cursor: undefined, limit: 10})).resolves.toEqual({ + cursor: 'next', + feed: [], + }) + expect(fetchMock).toHaveBeenCalledTimes(2) + } finally { + global.fetch = originalFetch + } + }) +}) diff --git a/src/lib/api/feed/custom.ts b/src/lib/api/feed/custom.ts index f4a5d4862f..86b1c12c64 100644 --- a/src/lib/api/feed/custom.ts +++ b/src/lib/api/feed/custom.ts @@ -96,7 +96,7 @@ export class CustomFeedAPI implements FeedAPI { const feed = data.feed.length > limit ? data.feed.slice(0, limit) : data.feed return { - cursor: feed.length ? data.cursor : undefined, + cursor: data.cursor, feed, } } @@ -154,11 +154,7 @@ async function loggedOutFetch( // no data, try again with language headers removed data = await getFeedOrNull(params, '') - if (data?.feed?.length) { - return data - } - - return null + return data } /** diff --git a/src/lib/api/feed/likes.ts b/src/lib/api/feed/likes.ts index d8bba416ac..0492d33f8d 100644 --- a/src/lib/api/feed/likes.ts +++ b/src/lib/api/feed/likes.ts @@ -48,10 +48,8 @@ export class LikesFeedAPI implements FeedAPI { cursor, limit, }) - // HACKFIX: the API incorrectly returns a cursor when there are no items -sfn - const isEmptyPage = data.feed.length === 0 return { - cursor: isEmptyPage ? undefined : data.cursor, + cursor: data.cursor, feed: data.feed, } } diff --git a/src/lib/api/feed/merge.test.ts b/src/lib/api/feed/merge.test.ts new file mode 100644 index 0000000000..6964f9536b --- /dev/null +++ b/src/lib/api/feed/merge.test.ts @@ -0,0 +1,106 @@ +import {type Client} from '@atproto/lex' + +import {app} from '#/lexicons' +import {MergeFeedAPI} from './merge' + +const post = {} as app.bsky.feed.defs.FeedViewPost + +describe('MergeFeedAPI', () => { + it('drains a terminal following queue without restarting the source', async () => { + const api = new MergeFeedAPI({ + client: {} as Client, + feedParams: {}, + feedTuners: [], + }) + api.reset() + api.following.queue = [post, post, post] + api.following.hasMore = false + + const first = await api.fetch({cursor: 'started', limit: 2}) + const second = await api.fetch({cursor: first.cursor, limit: 2}) + + expect(first.feed).toHaveLength(2) + expect(first.cursor).toBeDefined() + expect(second.feed).toHaveLength(1) + expect(second.cursor).toBeUndefined() + }) + + it('stops when the following source repeats its cursor', async () => { + const call = jest + .fn() + .mockResolvedValueOnce({feed: [], cursor: 'a'}) + .mockResolvedValueOnce({feed: [], cursor: 'a'}) + const api = new MergeFeedAPI({ + client: {call} as unknown as Client, + feedParams: {}, + feedTuners: [], + }) + + const first = await api.fetch({cursor: undefined, limit: 1}) + const second = await api.fetch({cursor: first.cursor, limit: 1}) + + expect(first.cursor).toBeDefined() + expect(second.cursor).toBeUndefined() + expect(call).toHaveBeenCalledTimes(2) + }) + + it('stops when the following source returns an empty cursor', async () => { + const call = jest.fn().mockResolvedValue({feed: [], cursor: ''}) + const api = new MergeFeedAPI({ + client: {call} as unknown as Client, + feedParams: {}, + feedTuners: [], + }) + + const result = await api.fetch({cursor: undefined, limit: 1}) + + expect(result.cursor).toBeUndefined() + expect(call).toHaveBeenCalledTimes(1) + }) + + it('stops when the following source cycles to an earlier cursor', async () => { + const call = jest + .fn() + .mockResolvedValueOnce({feed: [], cursor: 'a'}) + .mockResolvedValueOnce({feed: [], cursor: 'b'}) + .mockResolvedValueOnce({feed: [], cursor: 'a'}) + const api = new MergeFeedAPI({ + client: {call} as unknown as Client, + feedParams: {}, + feedTuners: [], + }) + + const first = await api.fetch({cursor: undefined, limit: 1}) + const second = await api.fetch({cursor: first.cursor, limit: 1}) + const third = await api.fetch({cursor: second.cursor, limit: 1}) + + expect(third.cursor).toBeUndefined() + expect(call).toHaveBeenCalledTimes(3) + }) + + it('drains a terminal custom-feed queue without restarting the source', async () => { + const call = jest.fn() + const api = new MergeFeedAPI({ + client: {call} as unknown as Client, + feedParams: { + mergeFeedEnabled: true, + mergeFeedSources: [ + 'at://did:example:feed/app.bsky.feed.generator/test', + ], + }, + feedTuners: [], + }) + api.reset() + api.following.hasMore = false + api.customFeeds[0].queue = [post, post, post] + api.customFeeds[0].hasMore = false + + const first = await api.fetch({cursor: 'started', limit: 2}) + const second = await api.fetch({cursor: first.cursor, limit: 2}) + + expect(first.feed).toHaveLength(2) + expect(second.feed).toHaveLength(1) + expect(second.cursor).toBeUndefined() + expect(call).not.toHaveBeenCalled() + }) +}) diff --git a/src/lib/api/feed/merge.ts b/src/lib/api/feed/merge.ts index 085c0e1b70..4d7dd7d88f 100644 --- a/src/lib/api/feed/merge.ts +++ b/src/lib/api/feed/merge.ts @@ -108,8 +108,8 @@ export class MergeFeedAPI implements FeedAPI { const promises = [] - // always keep following topped up - if (this.following.numReady < limit) { + // always keep following topped up while the source has another page + if (this.following.hasMore && this.following.numReady < limit) { await this.following.fetchNext(60) } @@ -125,7 +125,7 @@ export class MergeFeedAPI implements FeedAPI { !this.following.hasMore && this.following.numReady < limit if (this.params.mergeFeedEnabled || outOfFollows) { for (const feed of feeds) { - if (feed.numReady < 5) { + if (feed.hasMore && feed.numReady < 5) { promises.push(feed.fetchNext(10)) } } @@ -145,8 +145,12 @@ export class MergeFeedAPI implements FeedAPI { } } + const hasMore = + this.following.hasMore || + this.following.numReady > 0 || + this.customFeeds.some(feed => feed.hasMore || feed.numReady > 0) return { - cursor: String(this.itemCursor), + cursor: hasMore ? String(this.itemCursor) : undefined, feed: posts, } } @@ -155,8 +159,8 @@ export class MergeFeedAPI implements FeedAPI { const i = this.itemCursor++ const candidateFeeds = this.customFeeds.filter(f => f.numReady > 0) const canSample = candidateFeeds.length > 0 - const hasFollows = this.following.hasMore const hasFollowsReady = this.following.numReady > 0 + const hasFollows = this.following.hasMore || hasFollowsReady // this condition establishes the frequency that custom feeds are woven into follows const shouldSample = @@ -187,6 +191,7 @@ class MergeFeedSource { feedTuners: FeedTunerFn[] sourceInfo: ReasonFeedSource | undefined cursor: string | undefined = undefined + seenCursors = new Set() queue: app.bsky.feed.defs.FeedViewPost[] = [] hasMore = true @@ -221,11 +226,16 @@ class MergeFeedSource { const page = await this._getFeed(this.cursor, n) if (page) { this.cursor = page.cursor - if (page.feed.length) { - this.queue = this.queue.concat(page.feed) + const cursor = this.cursor + if (cursor) { + this.hasMore = !this.seenCursors.has(cursor) + this.seenCursors.add(cursor) } else { this.hasMore = false } + if (page.feed.length) { + this.queue = this.queue.concat(page.feed) + } } else { this.hasMore = false } diff --git a/src/state/queries/notifications/feed.ts b/src/state/queries/notifications/feed.ts index 978c363df5..2a7877b797 100644 --- a/src/state/queries/notifications/feed.ts +++ b/src/state/queries/notifications/feed.ts @@ -16,7 +16,7 @@ * 3. Don't call this query's `refetch()` if you're trying to sync latest; call `checkUnread()` instead. */ -import {useCallback, useEffect, useMemo, useRef} from 'react' +import {useCallback, useMemo, useRef} from 'react' import {AtUri} from '@atproto/syntax' import {moderatePost} from '@bsky/sdk/moderation' import { @@ -37,6 +37,7 @@ import { didOrHandleUriMatches, embedViewRecordToPostView, getEmbeddedPost, + useAutoPagination, } from '../util' import {type FeedPage} from './types' import {useUnreadNotificationsApi} from './unread' @@ -220,54 +221,9 @@ export function useNotificationFeedQuery(opts: { ), }) - // The server may end up returning an empty page, a page with too few items, - // or a page with items that end up getting filtered out. When we fetch pages, - // we'll keep track of how many items we actually hope to see. If the server - // doesn't return enough items, we're going to continue asking for more items. - const lastItemCount = useRef(0) - const wantedItemCount = useRef(0) - const autoPaginationAttemptCount = useRef(0) - useEffect(() => { - const {data, isLoading, isRefetching, isFetchingNextPage, hasNextPage} = - query - // Count the items that we already have. - let itemCount = 0 - for (const page of data?.pages || []) { - itemCount += page.items.length - } - - // If items got truncated, reset the state we're tracking below. - if (itemCount !== lastItemCount.current) { - if (itemCount < lastItemCount.current) { - wantedItemCount.current = itemCount - } - lastItemCount.current = itemCount - } - - // Now track how many items we really want, and fetch more if needed. - if (isLoading || isRefetching) { - // During the initial fetch, we want to get an entire page's worth of items. - wantedItemCount.current = PAGE_SIZE - } else if (isFetchingNextPage) { - if (itemCount > wantedItemCount.current) { - // We have more items than wantedItemCount, so wantedItemCount must be out of date. - // Some other code must have called fetchNextPage(), for example, from onEndReached. - // Adjust the wantedItemCount to reflect that we want one more full page of items. - wantedItemCount.current = itemCount + PAGE_SIZE - } - } else if (hasNextPage) { - // At this point we're not fetching anymore, so it's time to make a decision. - // If we didn't receive enough items from the server, paginate again until we do. - if (itemCount < wantedItemCount.current) { - autoPaginationAttemptCount.current++ - if (autoPaginationAttemptCount.current < 50 /* failsafe */) { - query.fetchNextPage() - } - } else { - autoPaginationAttemptCount.current = 0 - } - } - }, [query]) + const itemCount = + query.data?.pages.reduce((count, page) => count + page.items.length, 0) ?? 0 + useAutoPagination(query, itemCount, PAGE_SIZE) return query } diff --git a/src/state/queries/post-feed.ts b/src/state/queries/post-feed.ts index 5b63b4f06c..aff1db34e9 100644 --- a/src/state/queries/post-feed.ts +++ b/src/state/queries/post-feed.ts @@ -1,4 +1,4 @@ -import {useCallback, useEffect, useMemo, useRef} from 'react' +import {useCallback, useMemo, useRef} from 'react' import {AppState} from 'react-native' import {type Client} from '@atproto/lex' import {type AtIdentifierString, AtUri, type AtUriString} from '@atproto/syntax' @@ -46,6 +46,7 @@ import { didOrHandleUriMatches, embedViewRecordToPostView, getEmbeddedPost, + useAutoPagination, } from './util' type ActorDid = string @@ -368,56 +369,13 @@ export function usePostFeedQuery( ), }) - // The server may end up returning an empty page, a page with too few items, - // or a page with items that end up getting filtered out. When we fetch pages, - // we'll keep track of how many items we actually hope to see. If the server - // doesn't return enough items, we're going to continue asking for more items. - const lastItemCount = useRef(0) - const wantedItemCount = useRef(0) - const autoPaginationAttemptCount = useRef(0) - useEffect(() => { - const {data, isLoading, isRefetching, isFetchingNextPage, hasNextPage} = - query - // Count the items that we already have. - let itemCount = 0 - for (const page of data?.pages || []) { - for (const slice of page.slices) { - itemCount += slice.items.length - } + let itemCount = 0 + for (const page of query.data?.pages || []) { + for (const slice of page.slices) { + itemCount += slice.items.length } - - // If items got truncated, reset the state we're tracking below. - if (itemCount !== lastItemCount.current) { - if (itemCount < lastItemCount.current) { - wantedItemCount.current = itemCount - } - lastItemCount.current = itemCount - } - - // Now track how many items we really want, and fetch more if needed. - if (isLoading || isRefetching) { - // During the initial fetch, we want to get an entire page's worth of items. - wantedItemCount.current = MIN_POSTS - } else if (isFetchingNextPage) { - if (itemCount > wantedItemCount.current) { - // We have more items than wantedItemCount, so wantedItemCount must be out of date. - // Some other code must have called fetchNextPage(), for example, from onEndReached. - // Adjust the wantedItemCount to reflect that we want one more full page of items. - wantedItemCount.current = itemCount + MIN_POSTS - } - } else if (hasNextPage) { - // At this point we're not fetching anymore, so it's time to make a decision. - // If we didn't receive enough items from the server, paginate again until we do. - if (itemCount < wantedItemCount.current) { - autoPaginationAttemptCount.current++ - if (autoPaginationAttemptCount.current < 50 /* failsafe */) { - query.fetchNextPage() - } - } else { - autoPaginationAttemptCount.current = 0 - } - } - }, [query]) + } + useAutoPagination(query, itemCount, MIN_POSTS) return query } diff --git a/src/state/queries/util.test.tsx b/src/state/queries/util.test.tsx new file mode 100644 index 0000000000..695ada0697 --- /dev/null +++ b/src/state/queries/util.test.tsx @@ -0,0 +1,100 @@ +import {renderHook} from '@testing-library/react-native' + +import {useAutoPagination} from './util' + +function query(overrides: Record = {}) { + return { + data: {pageParams: [undefined]}, + isLoading: false, + isRefetching: false, + isFetchingNextPage: false, + hasNextPage: true, + fetchNextPage: jest.fn().mockResolvedValue(undefined), + ...overrides, + } +} + +describe('useAutoPagination', () => { + it('fetches another page when visible items are missing', () => { + const value = query() + + renderHook(() => useAutoPagination(value, 0, 10)) + + expect(value.fetchNextPage).toHaveBeenCalledTimes(1) + }) + + it('stops when the requested number of items is visible', () => { + const value = query() + + renderHook(() => useAutoPagination(value, 10, 10)) + + expect(value.fetchNextPage).not.toHaveBeenCalled() + }) + + it('stops when the server repeats a cursor', () => { + const value = query({data: {pageParams: [undefined, 'a', 'a']}}) + + renderHook(() => useAutoPagination(value, 0, 10)) + + expect(value.fetchNextPage).not.toHaveBeenCalled() + }) + + it('stops when structured page params repeat a cursor', () => { + const value = query({ + data: {pageParams: [undefined, {cursor: 'a'}, {cursor: 'a'}]}, + }) + + renderHook(() => useAutoPagination(value, 0, 10)) + + expect(value.fetchNextPage).not.toHaveBeenCalled() + }) + + it('stops when a cursor repeats non-adjacently', () => { + const value = query({data: {pageParams: [undefined, 'a', 'b', 'a']}}) + + renderHook(() => useAutoPagination(value, 0, 10)) + + expect(value.fetchNextPage).not.toHaveBeenCalled() + }) + + it('fills one page after switching to a smaller cached query', () => { + const first = query({hasNextPage: false}) + const second = query() + let value = first + let itemCount = 10 + const {rerender} = renderHook(() => useAutoPagination(value, itemCount, 10)) + + value = second + itemCount = 5 + rerender(undefined) + + expect(second.fetchNextPage).toHaveBeenCalledTimes(1) + }) + + it('resets the attempt limit after switching to cached data with the same item count', () => { + const fetchNextPage = jest.fn().mockResolvedValue(undefined) + const data = {pageParams: [undefined]} + let value = query({fetchNextPage, data}) + const itemCount = 0 + const {rerender} = renderHook(() => useAutoPagination(value, itemCount, 10)) + + for (let i = 1; i < 50; i++) { + value = query({ + fetchNextPage, + data, + }) + rerender(undefined) + } + expect(fetchNextPage).toHaveBeenCalledTimes(49) + + const second = query({ + data: { + pageParams: Array.from({length: 51}, (_, i) => `new-cursor-${i}`), + }, + }) + value = second + rerender(undefined) + + expect(second.fetchNextPage).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/state/queries/util.ts b/src/state/queries/util.ts index a3fe12aec7..718b6cf7b8 100644 --- a/src/state/queries/util.ts +++ b/src/state/queries/util.ts @@ -1,3 +1,4 @@ +import {useEffect, useRef} from 'react' import {type AtUri} from '@atproto/syntax' import { type InfiniteData, @@ -8,6 +9,82 @@ import { import {app} from '#/lexicons' import * as bsky from '#/types/bsky' +type AutoPaginationQuery = { + data?: {pageParams: unknown[]} + isLoading: boolean + isRefetching: boolean + isFetchingNextPage: boolean + hasNextPage: boolean + fetchNextPage: () => Promise +} + +export function useAutoPagination( + query: AutoPaginationQuery, + itemCount: number, + pageSize: number, +) { + const lastItemCount = useRef(0) + const lastPageParams = useRef(query.data?.pageParams) + const wantedItemCount = useRef(pageSize) + const attemptCount = useRef(0) + + useEffect(() => { + const cursorOf = (param: unknown) => + param && typeof param === 'object' && 'cursor' in param + ? param.cursor + : param + const pageParams = query.data?.pageParams + const previousPageParams = lastPageParams.current + const continuedPagination = + pageParams && + previousPageParams && + pageParams.length > previousPageParams.length && + previousPageParams.every((param, index) => + Object.is(cursorOf(param), cursorOf(pageParams[index])), + ) + if ( + pageParams !== previousPageParams && + previousPageParams && + !continuedPagination + ) { + wantedItemCount.current = pageSize + attemptCount.current = 0 + } + lastPageParams.current = pageParams + + if (itemCount !== lastItemCount.current) { + attemptCount.current = 0 + if (itemCount < lastItemCount.current) { + wantedItemCount.current = Math.max(itemCount, pageSize) + } + lastItemCount.current = itemCount + } + + if (query.isLoading || query.isRefetching) { + wantedItemCount.current = pageSize + attemptCount.current = 0 + } else if (query.isFetchingNextPage) { + if (itemCount > wantedItemCount.current) { + wantedItemCount.current = itemCount + pageSize + } + } else if (query.hasNextPage) { + if (itemCount < wantedItemCount.current) { + const currentCursor = cursorOf(pageParams?.at(-1)) + const repeatedCursor = pageParams + ?.slice(0, -1) + .some(param => Object.is(cursorOf(param), currentCursor)) + if (repeatedCursor) return + attemptCount.current++ + if (attemptCount.current < 50) { + void query.fetchNextPage() + } + } else { + attemptCount.current = 0 + } + } + }, [itemCount, pageSize, query]) +} + export type StructuredQueryKey> = readonly [ string, T, diff --git a/src/view/com/feeds/ProfileFeedgens.tsx b/src/view/com/feeds/ProfileFeedgens.tsx index effa9ad430..2082f76961 100644 --- a/src/view/com/feeds/ProfileFeedgens.tsx +++ b/src/view/com/feeds/ProfileFeedgens.tsx @@ -79,7 +79,7 @@ export function ProfileFeedgens({ error, refetch, } = useProfileFeedgensQuery(did, opts) - const isEmpty = !isPending && !data?.pages[0]?.feeds.length + const isEmpty = !isPending && !data?.pages.some(page => page.feeds.length) const {data: preferences} = usePreferencesQuery() const navigation = useNavigation() const {currentAccount} = useSession() diff --git a/src/view/com/lists/ListMembers.tsx b/src/view/com/lists/ListMembers.tsx index 2f24dfec09..a7327700c6 100644 --- a/src/view/com/lists/ListMembers.tsx +++ b/src/view/com/lists/ListMembers.tsx @@ -90,7 +90,7 @@ export function ListMembers({ hasNextPage, isFetchingNextPage, } = useListMembersQuery(list) - const isEmpty = !isFetching && !data?.pages[0].items.length + const isEmpty = !isFetching && !data?.pages.some(page => page.items.length) const isOwner = currentAccount && data?.pages[0].list.creator.did === currentAccount.did diff --git a/src/view/com/lists/ProfileLists.tsx b/src/view/com/lists/ProfileLists.tsx index afc4c63791..373cb60427 100644 --- a/src/view/com/lists/ProfileLists.tsx +++ b/src/view/com/lists/ProfileLists.tsx @@ -79,7 +79,7 @@ export function ProfileLists({ error, refetch, } = useProfileListsQuery(did, opts) - const isEmpty = !isPending && !data?.pages[0]?.lists.length + const isEmpty = !isPending && !data?.pages.some(page => page.lists.length) const {data: preferences} = usePreferencesQuery() const navigation = useNavigation() const {currentAccount} = useSession() diff --git a/src/view/screens/ModerationBlockedAccounts.tsx b/src/view/screens/ModerationBlockedAccounts.tsx index da3463233c..b35ba4d862 100644 --- a/src/view/screens/ModerationBlockedAccounts.tsx +++ b/src/view/screens/ModerationBlockedAccounts.tsx @@ -36,7 +36,7 @@ export function ModerationBlockedAccounts({}: Props) { fetchNextPage, isFetchingNextPage, } = useMyBlockedAccountsQuery() - const isEmpty = !isFetching && !data?.pages[0]?.blocks.length + const isEmpty = !isFetching && !data?.pages.some(page => page.blocks.length) const profiles = useMemo(() => { if (data?.pages) { return data.pages.flatMap(page => page.blocks) diff --git a/src/view/screens/ModerationMutedAccounts.tsx b/src/view/screens/ModerationMutedAccounts.tsx index cfdc4a7a80..7184d31b84 100644 --- a/src/view/screens/ModerationMutedAccounts.tsx +++ b/src/view/screens/ModerationMutedAccounts.tsx @@ -36,7 +36,7 @@ export function ModerationMutedAccounts({}: Props) { fetchNextPage, isFetchingNextPage, } = useMyMutedAccountsQuery() - const isEmpty = !isFetching && !data?.pages[0]?.mutes.length + const isEmpty = !isFetching && !data?.pages.some(page => page.mutes.length) const profiles = useMemo(() => { if (data?.pages) { return data.pages.flatMap(page => page.mutes) From 41f2d203c36233e2261422a42e5d141bcb00e8d6 Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Thu, 27 Aug 2026 19:06:58 +0300 Subject: [PATCH 05/14] Handle AppView errors after quote posts (#11579) --- src/view/com/composer/Composer.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx index 8738b1e1ed..4a60efa4f0 100644 --- a/src/view/com/composer/Composer.tsx +++ b/src/view/com/composer/Composer.tsx @@ -1213,7 +1213,7 @@ export const ComposePost = ({ if (initQuote) { // We want to wait for the quote count to update before we call `onPost`, which will refetch data void whenAppViewReady(client, initQuote.uri, res => { - const anchor = res.thread.at(0) + const anchor = res?.thread.at(0) if ( bsky.isType(app.bsky.unspecced.defs.threadItemPost, anchor?.value) && anchor.value.post.quoteCount !== initQuote.quoteCount @@ -2478,7 +2478,9 @@ function useKeyboardVerticalOffset() { async function whenAppViewReady( client: Client, uri: string, - fn: (res: app.bsky.unspecced.getPostThreadV2.$OutputBody) => boolean, + fn: ( + res: app.bsky.unspecced.getPostThreadV2.$OutputBody | undefined, + ) => boolean, ) { await until( 5, // 5 tries From 9d76e42606127b70c8d74e4e0ce75542100465d1 Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Thu, 27 Aug 2026 19:14:18 +0300 Subject: [PATCH 06/14] APP-2971: Fix onboarding avatar compression after Expo 57 (#11580) --- src/lib/media/uriSize.ts | 9 +++++++++ src/lib/media/uriSize.web.ts | 5 +++++ src/screens/Onboarding/StepProfile/index.tsx | 4 ++-- 3 files changed, 16 insertions(+), 2 deletions(-) create mode 100644 src/lib/media/uriSize.ts create mode 100644 src/lib/media/uriSize.web.ts diff --git a/src/lib/media/uriSize.ts b/src/lib/media/uriSize.ts new file mode 100644 index 0000000000..7ac2898180 --- /dev/null +++ b/src/lib/media/uriSize.ts @@ -0,0 +1,9 @@ +import {getInfoAsync} from 'expo-file-system/legacy' + +export async function getUriSize(uri: string): Promise { + const info = await getInfoAsync(uri) + if (!info.exists) { + throw new Error('Failed to read image size') + } + return info.size +} diff --git a/src/lib/media/uriSize.web.ts b/src/lib/media/uriSize.web.ts new file mode 100644 index 0000000000..47df9e5a76 --- /dev/null +++ b/src/lib/media/uriSize.web.ts @@ -0,0 +1,5 @@ +export async function getUriSize(uri: string): Promise { + const response = await fetch(uri) + const blob = await response.blob() + return blob.size +} diff --git a/src/screens/Onboarding/StepProfile/index.tsx b/src/screens/Onboarding/StepProfile/index.tsx index 988edd0910..9239f337d1 100644 --- a/src/screens/Onboarding/StepProfile/index.tsx +++ b/src/screens/Onboarding/StepProfile/index.tsx @@ -22,7 +22,7 @@ import {IMAGE_SIZE_CONFIG_2K_1MB} from '#/lib/constants' import {usePhotoLibraryPermission} from '#/lib/hooks/usePermissions' import {compressIfNeeded} from '#/lib/media/manip' import {openCropper} from '#/lib/media/picker' -import {getDataUriSize} from '#/lib/media/util' +import {getUriSize} from '#/lib/media/uriSize' import {useRequestNotificationsPermission} from '#/lib/notifications/notifications' import {isCancelledError} from '#/lib/strings/errors' import {logger} from '#/logger' @@ -133,7 +133,7 @@ export function StepProfile() { height: rendered.height, width: rendered.width, path: result.uri, - size: getDataUriSize(result.uri), + size: await getUriSize(result.uri), }, ] } catch { From 5eb5ac480007e1026bc4b0ef28e4d9b39a41ca63 Mon Sep 17 00:00:00 2001 From: surfdude29 <149612116+surfdude29@users.noreply.github.com> Date: Thu, 27 Aug 2026 17:19:40 +0100 Subject: [PATCH 07/14] Fix support link cutoff in signup footer on mobile (#11059) --- src/screens/Signup/index.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/screens/Signup/index.tsx b/src/screens/Signup/index.tsx index 644af956a2..b5a8ccad59 100644 --- a/src/screens/Signup/index.tsx +++ b/src/screens/Signup/index.tsx @@ -219,6 +219,7 @@ export function Signup({onPressBack}: {onPressBack: () => void}) { a.w_full, a.py_lg, a.flex_row, + a.flex_wrap, a.gap_md, a.align_center, ]}> @@ -227,7 +228,7 @@ export function Signup({onPressBack}: {onPressBack: () => void}) { style={ gtMobile ? [a.flex_1, a.flex, a.flex_row, a.justify_end] - : [] + : [a.flex_shrink] }> Date: Fri, 28 Aug 2026 04:52:31 +0000 Subject: [PATCH 08/14] Nightly source-language update --- src/locale/locales/en/messages.po | 598 +++++++++++++++--------------- 1 file changed, 299 insertions(+), 299 deletions(-) diff --git a/src/locale/locales/en/messages.po b/src/locale/locales/en/messages.po index 122b0b4724..9309af40ad 100644 --- a/src/locale/locales/en/messages.po +++ b/src/locale/locales/en/messages.po @@ -474,11 +474,11 @@ msgstr "{date} at {time}" msgid "{displayName}'s Starter Pack" msgstr "" -#: src/screens/SignupQueued.tsx:226 +#: src/screens/SignupQueued.tsx:225 msgid "{estimatedTimeHrs, plural, one {hour} other {hours}}" msgstr "" -#: src/screens/SignupQueued.tsx:232 +#: src/screens/SignupQueued.tsx:231 msgid "{estimatedTimeMins, plural, one {minute} other {minutes}}" msgstr "" @@ -890,7 +890,7 @@ msgid "<0>{0} is included in your starter pack" msgstr "" #. placeholder {0}: list.name -#: src/components/WhoCanReply.tsx:346 +#: src/components/WhoCanReply.tsx:335 msgid "<0>{0} members" msgstr "" @@ -1084,7 +1084,7 @@ msgstr "" msgid "Account is deactivated" msgstr "" -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:466 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:464 #: src/view/com/profile/ProfileMenu.tsx:159 msgctxt "toast" msgid "Account muted" @@ -1120,7 +1120,7 @@ msgctxt "toast" msgid "Account unfollowed" msgstr "" -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:446 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:445 #: src/view/com/profile/ProfileMenu.tsx:146 msgctxt "toast" msgid "Account unmuted" @@ -1174,7 +1174,7 @@ msgid "Add a user to this list" msgstr "" #: src/components/dialogs/SwitchAccount.tsx:56 -#: src/screens/Deactivated.tsx:189 +#: src/screens/Deactivated.tsx:188 msgid "Add account" msgstr "" @@ -1252,8 +1252,8 @@ msgstr "" msgid "Add members" msgstr "Add members" -#: src/components/moderation/ReportDialog/index.tsx:606 -#: src/components/moderation/ReportDialog/index.tsx:610 +#: src/components/moderation/ReportDialog/index.tsx:605 +#: src/components/moderation/ReportDialog/index.tsx:609 msgid "Add more details (optional)" msgstr "" @@ -1359,7 +1359,7 @@ msgstr "" msgid "Additional details (limit 1000 characters)" msgstr "" -#: src/components/moderation/ReportDialog/index.tsx:624 +#: src/components/moderation/ReportDialog/index.tsx:623 msgid "Additional details (limit 300 characters)" msgstr "" @@ -1472,7 +1472,7 @@ msgstr "" msgid "Allow access to your direct messages" msgstr "" -#: src/components/dialogs/PostInteractionSettingsDialog.tsx:448 +#: src/components/dialogs/PostInteractionSettingsDialog.tsx:447 msgid "Allow anyone to reply" msgstr "" @@ -1486,8 +1486,8 @@ msgstr "Allow direct messages from" msgid "Allow group chat invites from" msgstr "Allow group chat invites from" -#: src/components/dialogs/DeviceLocationRequestDialog.tsx:128 -#: src/components/dialogs/DeviceLocationRequestDialog.tsx:134 +#: src/components/dialogs/DeviceLocationRequestDialog.tsx:127 +#: src/components/dialogs/DeviceLocationRequestDialog.tsx:133 msgid "Allow location access" msgstr "" @@ -1496,24 +1496,24 @@ msgstr "" msgid "Allow others to be notified of your posts" msgstr "" -#: src/components/dialogs/PostInteractionSettingsDialog.tsx:501 +#: src/components/dialogs/PostInteractionSettingsDialog.tsx:500 msgid "Allow people you follow to reply" msgstr "" -#: src/components/dialogs/PostInteractionSettingsDialog.tsx:515 +#: src/components/dialogs/PostInteractionSettingsDialog.tsx:514 msgid "Allow people you mention to reply" msgstr "" -#: src/components/dialogs/PostInteractionSettingsDialog.tsx:633 +#: src/components/dialogs/PostInteractionSettingsDialog.tsx:632 msgid "Allow quote posts" msgstr "" #. placeholder {0}: list.name -#: src/components/dialogs/PostInteractionSettingsDialog.tsx:595 +#: src/components/dialogs/PostInteractionSettingsDialog.tsx:594 msgid "Allow users in {0} to reply" msgstr "" -#: src/components/dialogs/PostInteractionSettingsDialog.tsx:487 +#: src/components/dialogs/PostInteractionSettingsDialog.tsx:486 msgid "Allow your followers to reply" msgstr "" @@ -1522,8 +1522,8 @@ msgid "Allows access to direct messages" msgstr "" #: src/screens/Login/ForgotPasswordForm.tsx:178 -#: src/screens/Settings/components/ChangePasswordDialog.tsx:241 -#: src/screens/Settings/components/ChangePasswordDialog.tsx:247 +#: src/screens/Settings/components/ChangePasswordDialog.tsx:239 +#: src/screens/Settings/components/ChangePasswordDialog.tsx:245 msgid "Already have a code?" msgstr "" @@ -1571,7 +1571,7 @@ msgid "Alt text will be truncated. {MAX_ALT_TEXT, plural, other {Limit: {0} char msgstr "" #. placeholder {0}: currentAccount?.email || '(no email)' -#: src/screens/Settings/components/DisableEmail2FADialog.tsx:102 +#: src/screens/Settings/components/DisableEmail2FADialog.tsx:100 msgid "An email has been sent to {0}. It includes a confirmation code which you can enter below." msgstr "" @@ -1615,7 +1615,7 @@ msgstr "" msgid "An error occurred while loading the video. Please try again." msgstr "" -#: src/components/dialogs/PostInteractionSettingsDialog.tsx:578 +#: src/components/dialogs/PostInteractionSettingsDialog.tsx:577 msgid "An error occurred while loading your lists :/" msgstr "" @@ -1700,7 +1700,7 @@ msgstr "" msgid "an unknown labeler" msgstr "" -#: src/components/WhoCanReply.tsx:367 +#: src/components/WhoCanReply.tsx:356 msgid "and" msgstr "" @@ -1733,7 +1733,7 @@ msgstr "" msgid "Any date" msgstr "Any date" -#: src/components/dialogs/PostInteractionSettingsDialog.tsx:454 +#: src/components/dialogs/PostInteractionSettingsDialog.tsx:453 msgid "Anyone" msgstr "" @@ -1951,7 +1951,7 @@ msgstr "" msgid "Are you sure?" msgstr "" -#: src/components/moderation/ReportDialog/index.tsx:929 +#: src/components/moderation/ReportDialog/index.tsx:928 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?" @@ -1977,12 +1977,12 @@ msgstr "Ask apps and sites not to show my account to logged-out users" msgid "Ask apps to hide my posts from algorithmic recommendations" msgstr "Ask apps to hide my posts from algorithmic recommendations" -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:619 -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:621 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:616 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:618 msgid "Assign topic for algo" msgstr "" -#: src/screens/Settings/components/ChangePasswordDialog.tsx:213 +#: src/screens/Settings/components/ChangePasswordDialog.tsx:211 msgid "At least 8 characters" msgstr "" @@ -2044,8 +2044,8 @@ msgstr "Avatar creator" #: src/components/dms/InitiateChatFlow.tsx:870 #: src/components/moderation/AppealForm.tsx:156 #: src/components/moderation/AppealForm.tsx:157 -#: src/screens/Login/ChooseAccountForm.tsx:96 -#: src/screens/Login/ChooseAccountForm.tsx:100 +#: src/screens/Login/ChooseAccountForm.tsx:95 +#: src/screens/Login/ChooseAccountForm.tsx:99 #: src/screens/Login/ForgotPasswordForm.tsx:123 #: src/screens/Login/ForgotPasswordForm.tsx:128 #: src/screens/Login/LoginForm.tsx:512 @@ -2058,8 +2058,8 @@ msgstr "Avatar creator" #: src/screens/Messages/components/InviteLinkDialog.tsx:301 #: src/screens/Messages/Inbox.tsx:232 #: src/screens/Profile/Header/Shell.tsx:175 -#: src/screens/Settings/components/ChangePasswordDialog.tsx:277 -#: src/screens/Settings/components/ChangePasswordDialog.tsx:286 +#: src/screens/Settings/components/ChangePasswordDialog.tsx:275 +#: src/screens/Settings/components/ChangePasswordDialog.tsx:284 #: src/screens/Signup/BackNextButtons.tsx:42 #: src/screens/StarterPack/Wizard/index.tsx:311 #: src/view/com/composer/drafts/DraftsListDialog.tsx:87 @@ -2191,8 +2191,8 @@ msgstr "Block {displayName}" #: src/components/dms/ConvoMenu.tsx:284 #: src/components/dms/ConvoMenu.tsx:288 -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:768 -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:770 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:765 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:767 #: src/screens/Messages/components/RequestButtons.tsx:154 #: src/screens/Messages/components/RequestButtons.tsx:156 #: src/view/com/profile/ProfileMenu.tsx:536 @@ -2453,7 +2453,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:1093 +#: src/components/moderation/ReportDialog/index.tsx:1092 #: src/screens/Messages/JoinRequest.tsx:185 #: src/screens/Search/components/StarterPackCard.tsx:104 #: src/screens/Search/Explore.tsx:957 @@ -2515,8 +2515,8 @@ msgstr "Camera access needed" #: src/components/ageAssurance/AgeAssuranceAppealDialog.tsx:141 #: src/components/contacts/screens/GetContacts.tsx:294 #: src/components/contacts/screens/GetContacts.tsx:299 -#: src/components/dialogs/DeviceLocationRequestDialog.tsx:141 -#: src/components/dialogs/DeviceLocationRequestDialog.tsx:146 +#: src/components/dialogs/DeviceLocationRequestDialog.tsx:140 +#: src/components/dialogs/DeviceLocationRequestDialog.tsx:145 #: src/components/dialogs/EmailDialog/screens/Manage2FA/Enable.tsx:122 #: src/components/dialogs/EmailDialog/screens/Manage2FA/Enable.tsx:128 #: src/components/dialogs/InAppBrowserConsent.tsx:99 @@ -2537,7 +2537,7 @@ msgstr "Camera access needed" #: src/features/liveNow/components/GoLiveDialog.tsx:254 #: src/features/liveNow/components/GoLiveDialog.tsx:260 #: src/lib/media/picker.tsx:38 -#: src/screens/Deactivated.tsx:155 +#: src/screens/Deactivated.tsx:154 #: src/screens/Messages/components/InviteLinkDialog.tsx:497 #: src/screens/Messages/components/InviteLinkDialog.tsx:501 #: src/screens/Messages/ConversationSettings/prompts.tsx:108 @@ -2554,8 +2554,8 @@ msgstr "Camera access needed" #: src/screens/Settings/components/BetaFeaturesFeedbackDialog.tsx:152 #: src/screens/Settings/components/ChangeHandleDialog.tsx:80 #: src/screens/Settings/components/ChangeHandleDialog.tsx:87 -#: src/screens/Settings/components/ChangePasswordDialog.tsx:252 -#: src/screens/Settings/components/ChangePasswordDialog.tsx:258 +#: src/screens/Settings/components/ChangePasswordDialog.tsx:250 +#: src/screens/Settings/components/ChangePasswordDialog.tsx:256 #: src/screens/Settings/Settings.tsx:312 #: src/screens/Takendown.tsx:100 #: src/screens/Takendown.tsx:103 @@ -2571,7 +2571,7 @@ msgstr "" msgid "Cancel quote post" msgstr "" -#: src/screens/Deactivated.tsx:149 +#: src/screens/Deactivated.tsx:148 msgid "Cancel reactivation and sign out" msgstr "" @@ -2645,24 +2645,24 @@ msgstr "" msgid "Change hosting provider" msgstr "Change hosting provider" -#: src/components/moderation/ReportDialog/index.tsx:523 +#: src/components/moderation/ReportDialog/index.tsx:522 msgid "Change moderation service" msgstr "" -#: src/screens/Settings/components/ChangePasswordDialog.tsx:266 -#: src/screens/Settings/components/ChangePasswordDialog.tsx:272 +#: src/screens/Settings/components/ChangePasswordDialog.tsx:264 +#: src/screens/Settings/components/ChangePasswordDialog.tsx:270 msgid "Change password" msgstr "" -#: src/screens/Settings/components/ChangePasswordDialog.tsx:169 +#: src/screens/Settings/components/ChangePasswordDialog.tsx:167 msgid "Change password dialog" msgstr "" -#: src/components/moderation/ReportDialog/index.tsx:385 +#: src/components/moderation/ReportDialog/index.tsx:384 msgid "Change report category" msgstr "" -#: src/components/moderation/ReportDialog/index.tsx:464 +#: src/components/moderation/ReportDialog/index.tsx:463 msgid "Change report reason" msgstr "" @@ -2694,7 +2694,7 @@ msgstr "" #: src/lib/hooks/useNotificationHandler.ts:133 #: src/Navigation.tsx:515 #: src/view/shell/bottom-bar/BottomBar.tsx:242 -#: src/view/shell/desktop/LeftNav.tsx:698 +#: src/view/shell/desktop/LeftNav.tsx:697 #: src/view/shell/Drawer.tsx:528 msgid "Chat" msgstr "" @@ -2807,8 +2807,8 @@ msgstr "" msgid "Check back later!" msgstr "Check back later!" -#: src/screens/SignupQueued.tsx:86 -#: src/screens/SignupQueued.tsx:90 +#: src/screens/SignupQueued.tsx:85 +#: src/screens/SignupQueued.tsx:89 msgid "Check my status" msgstr "" @@ -2925,7 +2925,7 @@ msgstr "Clear GIF search" msgid "Clear image cache" msgstr "" -#: src/components/forms/SearchInput.tsx:87 +#: src/components/forms/SearchInput.tsx:93 msgid "Clear search query" msgstr "" @@ -3022,21 +3022,21 @@ msgstr "" #: src/components/intents/GroupChatJoinDialog.tsx:286 #: src/components/NewskieDialog.tsx:170 #: src/components/NewskieDialog.tsx:176 -#: src/components/ProgressGuide/FollowDialog.tsx:462 +#: src/components/ProgressGuide/FollowDialog.tsx:456 #: src/components/StarterPack/Wizard/WizardEditListDialog.tsx:119 #: src/components/StarterPack/Wizard/WizardEditListDialog.tsx:125 #: src/components/TrendingTopics.tsx:55 #: src/components/verification/VerificationsDialog.tsx:146 #: src/components/verification/VerifierDialog.tsx:147 -#: src/components/WhoCanReply.tsx:229 -#: src/components/WhoCanReply.tsx:236 +#: src/components/WhoCanReply.tsx:218 +#: src/components/WhoCanReply.tsx:225 #: src/features/gifPicker/components/GifPickerErrorBoundary.tsx:45 #: src/features/liveNow/components/EditLiveDialog.tsx:212 #: src/features/liveNow/components/EditLiveDialog.tsx:218 #: src/screens/Messages/components/InviteLinkDialog.tsx:521 #: src/screens/Messages/components/InviteLinkDialog.tsx:526 -#: src/screens/Settings/components/ChangePasswordDialog.tsx:292 -#: src/screens/Settings/components/ChangePasswordDialog.tsx:297 +#: src/screens/Settings/components/ChangePasswordDialog.tsx:290 +#: src/screens/Settings/components/ChangePasswordDialog.tsx:295 #: src/view/com/feeds/MissingFeed.tsx:211 #: src/view/com/feeds/MissingFeed.tsx:218 msgid "Close" @@ -3121,7 +3121,7 @@ msgstr "" msgid "Collapse list of users" msgstr "" -#: src/view/com/notifications/NotificationFeedItem.tsx:974 +#: src/view/com/notifications/NotificationFeedItem.tsx:973 msgid "Collapses list of users for a given notification" msgstr "" @@ -3162,7 +3162,7 @@ msgstr "" #: src/lib/hotkeys/index.tsx:66 #: src/view/com/feeds/ComposerPrompt.tsx:149 -#: src/view/shell/desktop/LeftNav.tsx:587 +#: src/view/shell/desktop/LeftNav.tsx:586 msgid "Compose new post" msgstr "" @@ -3175,11 +3175,11 @@ msgstr "" msgid "Compose reply" msgstr "" -#: src/view/com/composer/Composer.tsx:2677 +#: src/view/com/composer/Composer.tsx:2679 msgid "Compressing GIF..." msgstr "" -#: src/view/com/composer/Composer.tsx:2679 +#: src/view/com/composer/Composer.tsx:2681 msgid "Compressing video..." msgstr "" @@ -3193,18 +3193,18 @@ msgstr "" #: src/components/Prompt.tsx:211 #: src/components/Prompt.tsx:214 -#: src/screens/Settings/components/DisableEmail2FADialog.tsx:194 -#: src/screens/Settings/components/DisableEmail2FADialog.tsx:197 +#: src/screens/Settings/components/DisableEmail2FADialog.tsx:192 +#: src/screens/Settings/components/DisableEmail2FADialog.tsx:195 msgid "Confirm" msgstr "" #: src/components/dialogs/EmailDialog/components/TokenField.tsx:37 #: src/screens/Login/LoginForm.tsx:443 -#: src/screens/Settings/components/ChangePasswordDialog.tsx:191 -#: src/screens/Settings/components/ChangePasswordDialog.tsx:195 +#: src/screens/Settings/components/ChangePasswordDialog.tsx:189 +#: src/screens/Settings/components/ChangePasswordDialog.tsx:193 #: src/screens/Settings/components/DeleteAccountDialog.tsx:250 -#: src/screens/Settings/components/DisableEmail2FADialog.tsx:153 -#: src/screens/Settings/components/DisableEmail2FADialog.tsx:159 +#: src/screens/Settings/components/DisableEmail2FADialog.tsx:151 +#: src/screens/Settings/components/DisableEmail2FADialog.tsx:157 msgid "Confirmation code" msgstr "" @@ -3237,8 +3237,8 @@ msgid "Contact our support team" msgstr "" #: src/components/ageAssurance/AgeAssuranceInitDialog.tsx:160 -#: src/screens/Signup/index.tsx:240 -#: src/screens/Signup/index.tsx:243 +#: src/screens/Signup/index.tsx:241 +#: src/screens/Signup/index.tsx:244 msgid "Contact support" msgstr "" @@ -3486,8 +3486,8 @@ msgstr "" msgid "Copy post at:// URI" msgstr "" -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:574 -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:576 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:571 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:573 msgid "Copy post text" msgstr "" @@ -3719,8 +3719,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:835 -#: src/components/moderation/ReportDialog/index.tsx:880 +#: src/components/moderation/ReportDialog/index.tsx:834 +#: src/components/moderation/ReportDialog/index.tsx:879 msgid "Create report for {0}" msgstr "" @@ -3742,7 +3742,7 @@ msgstr "" msgid "Created {0}" msgstr "" -#: src/screens/List/ListHiddenScreen.tsx:131 +#: src/screens/List/ListHiddenScreen.tsx:130 msgid "Creator has been blocked" msgstr "" @@ -3809,7 +3809,7 @@ msgstr "Day" #: src/screens/Settings/AccountSettings.tsx:177 #: src/screens/Settings/AccountSettings.tsx:182 -#: src/screens/Settings/components/DeactivateAccountDialog.tsx:74 +#: src/screens/Settings/components/DeactivateAccountDialog.tsx:73 msgid "Deactivate account" msgstr "" @@ -3834,7 +3834,7 @@ msgid "Default icons" msgstr "" #: src/components/dms/MessageOverlays.tsx:184 -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:824 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:821 #: src/screens/ProfileList/components/MoreOptionsMenu.tsx:276 #: src/screens/Settings/AppPasswords.tsx:213 #: src/screens/StarterPack/StarterPackScreen.tsx:604 @@ -3904,8 +3904,8 @@ msgstr "" msgid "Delete my account" msgstr "" -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:808 -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:810 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:805 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:807 #: src/view/com/composer/Composer.tsx:1735 msgid "Delete post" msgstr "" @@ -3923,7 +3923,7 @@ msgstr "" msgid "Delete this list?" msgstr "" -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:821 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:818 msgid "Delete this post?" msgstr "" @@ -3964,12 +3964,12 @@ msgstr "Description (1000 characters max)" msgid "Descriptive alt text" msgstr "" -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:712 -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:722 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:709 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:719 msgid "Detach quote" msgstr "" -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:857 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:854 msgid "Detach quote post?" msgstr "" @@ -3992,7 +3992,7 @@ msgstr "" msgid "Device failed to translate :(" msgstr "Device failed to translate :(" -#: src/components/WhoCanReply.tsx:215 +#: src/components/WhoCanReply.tsx:204 msgid "Dialog: adjust who can interact with this post" msgstr "" @@ -4022,7 +4022,7 @@ msgstr "Disable captions" msgid "Disable email 2FA" msgstr "" -#: src/screens/Settings/components/DisableEmail2FADialog.tsx:98 +#: src/screens/Settings/components/DisableEmail2FADialog.tsx:96 msgid "Disable Email 2FA" msgstr "" @@ -4036,11 +4036,11 @@ msgstr "" msgid "Disable link" msgstr "Disable link" -#: src/components/dialogs/PostInteractionSettingsDialog.tsx:625 +#: src/components/dialogs/PostInteractionSettingsDialog.tsx:624 msgid "Disable quote posts of this post" msgstr "" -#: src/components/dialogs/PostInteractionSettingsDialog.tsx:462 +#: src/components/dialogs/PostInteractionSettingsDialog.tsx:461 msgid "Disable replies entirely" msgstr "" @@ -4110,7 +4110,7 @@ msgstr "" msgid "Dismiss banner" msgstr "" -#: src/view/com/composer/Composer.tsx:2619 +#: src/view/com/composer/Composer.tsx:2621 msgid "Dismiss error" msgstr "" @@ -4243,11 +4243,11 @@ msgstr "Download" msgid "Download Bluesky" msgstr "" -#: src/screens/Settings/components/ExportCarDialog.tsx:155 +#: src/screens/Settings/components/ExportCarDialog.tsx:153 msgid "Download chat data" msgstr "" -#: src/screens/Settings/components/ExportCarDialog.tsx:160 +#: src/screens/Settings/components/ExportCarDialog.tsx:158 msgctxt "button" msgid "Download chat data" msgstr "Download chat data" @@ -4261,11 +4261,11 @@ msgstr "Download image" msgid "Download invite QR code" msgstr "Download invite QR code" -#: src/screens/Settings/components/ExportCarDialog.tsx:124 +#: src/screens/Settings/components/ExportCarDialog.tsx:122 msgid "Download profile data" msgstr "Download profile data" -#: src/screens/Settings/components/ExportCarDialog.tsx:129 +#: src/screens/Settings/components/ExportCarDialog.tsx:127 msgctxt "button" msgid "Download profile data" msgstr "Download profile data" @@ -4368,8 +4368,8 @@ msgstr "Edit group name" msgid "Edit image" msgstr "" -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:789 -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:802 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:786 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:799 msgid "Edit interaction settings" msgstr "" @@ -4424,7 +4424,7 @@ msgid "Edit People" msgstr "" #: src/components/dialogs/PostInteractionSettingsDialog.tsx:113 -#: src/components/dialogs/PostInteractionSettingsDialog.tsx:263 +#: src/components/dialogs/PostInteractionSettingsDialog.tsx:262 msgid "Edit post interaction settings" msgstr "" @@ -4453,7 +4453,7 @@ msgstr "Edit this group chat’s name" msgid "Edit user list" msgstr "" -#: src/components/WhoCanReply.tsx:109 +#: src/components/WhoCanReply.tsx:103 msgid "Edit who can reply" msgstr "" @@ -4465,7 +4465,7 @@ msgstr "" msgid "Education" msgstr "" -#: src/screens/List/ListHiddenScreen.tsx:145 +#: src/screens/List/ListHiddenScreen.tsx:144 msgid "Either the creator of this list has blocked you or you have blocked the creator." msgstr "" @@ -4474,7 +4474,7 @@ msgstr "" msgid "Email" msgstr "" -#: src/screens/Settings/components/DisableEmail2FADialog.tsx:68 +#: src/screens/Settings/components/DisableEmail2FADialog.tsx:67 msgctxt "toast" msgid "Email 2FA disabled" msgstr "" @@ -4573,7 +4573,7 @@ msgstr "" msgid "Enable push notifications" msgstr "" -#: src/components/dialogs/PostInteractionSettingsDialog.tsx:626 +#: src/components/dialogs/PostInteractionSettingsDialog.tsx:625 msgid "Enable quote posts of this post" msgstr "" @@ -4668,7 +4668,7 @@ msgstr "" msgid "Entertainment" msgstr "" -#: src/view/com/composer/Composer.tsx:2697 +#: src/view/com/composer/Composer.tsx:2699 #: src/view/com/util/error/ErrorScreen.tsx:40 msgid "Error" msgstr "" @@ -4690,11 +4690,11 @@ msgid "Error message" msgstr "" #: src/screens/Settings/components/ExportCarDialog.tsx:55 -#: src/screens/Settings/components/ExportCarDialog.tsx:86 +#: src/screens/Settings/components/ExportCarDialog.tsx:85 msgid "Error occurred while saving file" msgstr "" -#: src/screens/Signup/StepCaptcha/index.tsx:127 +#: src/screens/Signup/StepCaptcha/index.tsx:126 msgid "Error receiving captcha response." msgstr "" @@ -4702,11 +4702,11 @@ msgstr "" msgid "Error: {error}" msgstr "" -#: src/components/WhoCanReply.tsx:78 +#: src/components/WhoCanReply.tsx:72 msgid "Everybody can reply" msgstr "" -#: src/components/WhoCanReply.tsx:272 +#: src/components/WhoCanReply.tsx:261 msgid "Everybody can reply to this post." msgstr "" @@ -4818,7 +4818,7 @@ msgstr "" #: src/Navigation.tsx:775 #: src/screens/Search/Shell.tsx:549 -#: src/view/shell/desktop/LeftNav.tsx:677 +#: src/view/shell/desktop/LeftNav.tsx:676 #: src/view/shell/Drawer.tsx:476 msgid "Explore" msgstr "" @@ -4830,7 +4830,7 @@ msgstr "" #: src/screens/Messages/Settings.tsx:329 #: src/screens/Messages/Settings.tsx:338 -#: src/screens/Settings/components/ExportCarDialog.tsx:136 +#: src/screens/Settings/components/ExportCarDialog.tsx:134 msgid "Export my chat data" msgstr "Export my chat data" @@ -4839,7 +4839,7 @@ msgstr "Export my chat data" msgid "Export my data" msgstr "" -#: src/screens/Settings/components/ExportCarDialog.tsx:103 +#: src/screens/Settings/components/ExportCarDialog.tsx:101 msgid "Export my profile data" msgstr "Export my profile data" @@ -5255,7 +5255,7 @@ msgstr "" #: src/screens/StarterPack/StarterPackScreen.tsx:194 #: src/view/screens/Feeds.tsx:504 #: src/view/screens/Profile.tsx:240 -#: src/view/shell/desktop/LeftNav.tsx:712 +#: src/view/shell/desktop/LeftNav.tsx:711 #: src/view/shell/Drawer.tsx:592 msgid "Feeds" msgstr "" @@ -5277,7 +5277,7 @@ msgid "Fetch update" msgstr "" #: src/screens/Settings/components/ExportCarDialog.tsx:51 -#: src/screens/Settings/components/ExportCarDialog.tsx:82 +#: src/screens/Settings/components/ExportCarDialog.tsx:81 msgid "File saved successfully!" msgstr "" @@ -5361,7 +5361,7 @@ msgstr "" #. Starter packs suggested to the user for them to follow #: src/components/ProgressGuide/FollowDialog.tsx:72 #: src/components/ProgressGuide/FollowDialog.tsx:80 -#: src/components/ProgressGuide/FollowDialog.tsx:448 +#: src/components/ProgressGuide/FollowDialog.tsx:442 #: src/screens/Onboarding/StepSuggestedStarterpacks/index.tsx:43 msgid "Find people to follow" msgstr "" @@ -5795,7 +5795,7 @@ msgstr "" msgid "GIF" msgstr "" -#: src/view/com/composer/Composer.tsx:2702 +#: src/view/com/composer/Composer.tsx:2704 msgid "GIF uploaded" msgstr "" @@ -5830,7 +5830,7 @@ msgid "Go back" msgstr "" #: src/components/Error.tsx:72 -#: src/screens/List/ListHiddenScreen.tsx:228 +#: src/screens/List/ListHiddenScreen.tsx:227 #: src/screens/Profile/ErrorState.tsx:63 #: src/screens/Profile/ErrorState.tsx:67 #: src/screens/StarterPack/StarterPackScreen.tsx:796 @@ -6099,7 +6099,7 @@ msgstr "" msgid "Have we got your location wrong? <0>Tap here to update your location with GPS." msgstr "Have we got your location wrong? <0>Tap here to update your location with GPS." -#: src/screens/Signup/index.tsx:238 +#: src/screens/Signup/index.tsx:239 msgid "Having trouble?" msgstr "" @@ -6153,7 +6153,7 @@ msgstr "" #: src/components/moderation/ContentHider.tsx:217 #: src/components/moderation/LabelPreference.tsx:141 #: src/components/moderation/PostHider.tsx:137 -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:832 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:829 #: src/lib/moderation/useLabelBehaviorDescription.ts:18 #: src/lib/moderation/useLabelBehaviorDescription.ts:23 #: src/lib/moderation/useLabelBehaviorDescription.ts:28 @@ -6161,7 +6161,7 @@ msgstr "" msgid "Hide" msgstr "" -#: src/view/com/notifications/NotificationFeedItem.tsx:980 +#: src/view/com/notifications/NotificationFeedItem.tsx:979 msgctxt "action" msgid "Hide" msgstr "" @@ -6174,7 +6174,7 @@ msgstr "" msgid "Hide group chat updates" msgstr "Hide group chat updates" -#: src/components/dialogs/PostInteractionSettingsDialog.tsx:530 +#: src/components/dialogs/PostInteractionSettingsDialog.tsx:529 msgid "Hide lists" msgstr "" @@ -6182,18 +6182,18 @@ msgstr "" msgid "Hide password" msgstr "Hide password" +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:669 #: src/components/PostControls/PostMenu/PostMenuItems.tsx:672 -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:675 msgid "Hide post for me" msgstr "" -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:686 -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:696 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:683 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:693 msgid "Hide reply for everyone" msgstr "" +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:669 #: src/components/PostControls/PostMenu/PostMenuItems.tsx:672 -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:675 msgid "Hide reply for me" msgstr "" @@ -6206,19 +6206,19 @@ msgstr "Hide reposts in feeds" msgid "Hide this card" msgstr "" -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:829 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:826 msgid "Hide this post?" msgstr "" -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:829 -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:864 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:826 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:861 msgid "Hide this reply?" msgstr "" #: src/components/Post/Translated/index.tsx:213 #: src/components/Post/Translated/index.tsx:347 -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:549 -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:551 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:546 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:548 msgid "Hide translation" msgstr "Hide translation" @@ -6230,7 +6230,7 @@ msgstr "" msgid "Hide trending videos?" msgstr "" -#: src/view/com/notifications/NotificationFeedItem.tsx:973 +#: src/view/com/notifications/NotificationFeedItem.tsx:972 msgid "Hide user list" msgstr "" @@ -6295,7 +6295,7 @@ msgstr "" #: src/Navigation.tsx:770 #: src/Navigation.tsx:791 #: src/view/shell/bottom-bar/BottomBar.tsx:196 -#: src/view/shell/desktop/LeftNav.tsx:667 +#: src/view/shell/desktop/LeftNav.tsx:666 #: src/view/shell/Drawer.tsx:502 msgid "Home" msgstr "" @@ -6341,8 +6341,8 @@ msgstr "" msgid "I consent to Bluesky using my contacts for mutual friend discovery and to retain hashed data for matching until I opt out." msgstr "" -#: src/screens/Settings/components/DisableEmail2FADialog.tsx:142 -#: src/screens/Settings/components/DisableEmail2FADialog.tsx:145 +#: src/screens/Settings/components/DisableEmail2FADialog.tsx:140 +#: src/screens/Settings/components/DisableEmail2FADialog.tsx:143 msgid "I have a code" msgstr "" @@ -6392,7 +6392,7 @@ msgstr "" msgid "If you need to update your email, <0>click here." msgstr "" -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:822 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:819 msgid "If you remove this post, you won't be able to recover it." msgstr "" @@ -6413,7 +6413,7 @@ msgstr "" msgid "If you're a developer, you can host your own server." msgstr "" -#: src/screens/Settings/components/DeactivateAccountDialog.tsx:93 +#: src/screens/Settings/components/DeactivateAccountDialog.tsx:92 msgid "If you're trying to change your handle or email, do so before you deactivate." msgstr "" @@ -6583,7 +6583,7 @@ msgid "Include posts made until this date" msgstr "Include posts made until this date" #. Checkbox shown when reporting a video. The value is the current playback position, formatted as minutes:seconds. -#: src/components/moderation/ReportDialog/index.tsx:702 +#: src/components/moderation/ReportDialog/index.tsx:701 msgid "Include video timestamp ({videoTimestamp})" msgstr "Include video timestamp ({videoTimestamp})" @@ -6632,7 +6632,7 @@ msgid "Introducing saved posts AKA bookmarks" msgstr "" #: src/screens/Login/LoginForm.tsx:155 -#: src/screens/Settings/components/DisableEmail2FADialog.tsx:79 +#: src/screens/Settings/components/DisableEmail2FADialog.tsx:78 msgid "Invalid 2FA confirmation code." msgstr "" @@ -6772,7 +6772,7 @@ msgid "It's just you right now! Add more people to your starter pack by searchin msgstr "" #. placeholder {0}: videoState.jobId -#: src/view/com/composer/Composer.tsx:2638 +#: src/view/com/composer/Composer.tsx:2640 msgid "Job ID: {0}" msgstr "" @@ -6826,8 +6826,8 @@ msgstr "" msgid "KWS website" msgstr "" -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:627 -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:629 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:624 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:626 msgid "Label reply for algo" msgstr "Label reply for algo" @@ -7039,7 +7039,7 @@ msgstr "Leaving this chat will lock it permanently and you won’t be able to re msgid "Left group chat." msgstr "Left group chat." -#: src/screens/SignupQueued.tsx:165 +#: src/screens/SignupQueued.tsx:164 msgid "left to go." msgstr "" @@ -7212,7 +7212,7 @@ msgstr "" msgid "List description is too long. {DESCRIPTION_MAX_GRAPHEMES, plural, other {The maximum number of characters is #.}}" msgstr "" -#: src/screens/List/ListHiddenScreen.tsx:133 +#: src/screens/List/ListHiddenScreen.tsx:132 msgid "List has been hidden" msgstr "" @@ -7253,7 +7253,7 @@ msgstr "" #: src/view/screens/Lists.tsx:60 #: src/view/screens/Profile.tsx:234 #: src/view/screens/Profile.tsx:242 -#: src/view/shell/desktop/LeftNav.tsx:722 +#: src/view/shell/desktop/LeftNav.tsx:721 #: src/view/shell/Drawer.tsx:607 msgid "Lists" msgstr "" @@ -7323,11 +7323,11 @@ msgctxt "placeholder" msgid "Loading chat…" msgstr "Loading chat…" -#: src/components/dialogs/PostInteractionSettingsDialog.tsx:572 +#: src/components/dialogs/PostInteractionSettingsDialog.tsx:571 msgid "Loading lists..." msgstr "" -#: src/components/dialogs/PostInteractionSettingsDialog.tsx:276 +#: src/components/dialogs/PostInteractionSettingsDialog.tsx:275 msgid "Loading post interaction settings..." msgstr "" @@ -7507,7 +7507,7 @@ msgstr "Members" msgid "Members can still read chat history but can’t send new messages." msgstr "Members can still read chat history but can’t send new messages." -#: src/components/WhoCanReply.tsx:313 +#: src/components/WhoCanReply.tsx:302 msgid "mentioned users" msgstr "" @@ -7732,8 +7732,8 @@ msgstr "" msgid "Mute {0}" msgstr "Mute {0}" -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:751 -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:757 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:748 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:754 #: src/view/com/profile/ProfileMenu.tsx:512 #: src/view/com/profile/ProfileMenu.tsx:519 msgid "Mute account" @@ -7789,13 +7789,13 @@ msgstr "" msgid "Mute this word until you unmute it" msgstr "" +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:639 #: src/components/PostControls/PostMenu/PostMenuItems.tsx:642 -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:645 msgid "Mute thread" msgstr "" -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:655 -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:657 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:652 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:654 msgid "Mute words & tags" msgstr "" @@ -7883,8 +7883,8 @@ msgstr "" msgid "Navigates to your profile" msgstr "" -#: src/components/moderation/ReportDialog/index.tsx:415 -#: src/components/moderation/ReportDialog/index.tsx:431 +#: src/components/moderation/ReportDialog/index.tsx:414 +#: src/components/moderation/ReportDialog/index.tsx:430 msgid "Need to report a copyright violation, legal request, or regulatory compliance issue?" msgstr "" @@ -8001,8 +8001,8 @@ msgid "New messages" msgstr "New messages" #: src/screens/Login/SetNewPasswordForm.tsx:135 -#: src/screens/Settings/components/ChangePasswordDialog.tsx:208 -#: src/screens/Settings/components/ChangePasswordDialog.tsx:212 +#: src/screens/Settings/components/ChangePasswordDialog.tsx:206 +#: src/screens/Settings/components/ChangePasswordDialog.tsx:210 msgid "New password" msgstr "" @@ -8016,7 +8016,7 @@ msgid "New post" msgstr "" #: src/view/com/feeds/FeedPage.tsx:179 -#: src/view/shell/desktop/LeftNav.tsx:598 +#: src/view/shell/desktop/LeftNav.tsx:597 msgctxt "action" msgid "New post" msgstr "" @@ -8081,8 +8081,8 @@ msgstr "" msgid "Night" msgstr "Night" -#: src/components/moderation/ReportDialog/index.tsx:1011 -#: src/components/moderation/ReportDialog/index.tsx:1020 +#: src/components/moderation/ReportDialog/index.tsx:1010 +#: src/components/moderation/ReportDialog/index.tsx:1019 msgctxt "Answer to a yes/no question" msgid "No" msgstr "No" @@ -8197,7 +8197,7 @@ msgstr "" msgid "No name" msgstr "" -#: src/view/com/notifications/NotificationFeed.tsx:131 +#: src/view/com/notifications/NotificationFeed.tsx:130 msgid "No notifications yet!" msgstr "" @@ -8218,7 +8218,7 @@ msgctxt "enable for" msgid "No one" msgstr "" -#: src/components/WhoCanReply.tsx:296 +#: src/components/WhoCanReply.tsx:285 msgid "No one but the author can quote this post." msgstr "" @@ -8316,7 +8316,7 @@ msgstr "No translation received from your device." msgid "No video posts yet" msgstr "" -#: src/components/dialogs/PostInteractionSettingsDialog.tsx:468 +#: src/components/dialogs/PostInteractionSettingsDialog.tsx:467 msgid "Nobody" msgstr "" @@ -8418,7 +8418,7 @@ msgstr "" #: src/screens/Settings/Settings.tsx:204 #: src/view/screens/Notifications.tsx:128 #: src/view/shell/bottom-bar/BottomBar.tsx:279 -#: src/view/shell/desktop/LeftNav.tsx:687 +#: src/view/shell/desktop/LeftNav.tsx:686 #: src/view/shell/Drawer.tsx:555 msgid "Notifications" msgstr "" @@ -8525,7 +8525,7 @@ msgid "One or more videos is missing alt text." msgstr "" #. placeholder {0}: settings.map((rule, i) => ( )) -#: src/components/WhoCanReply.tsx:276 +#: src/components/WhoCanReply.tsx:265 msgid "Only {0} can reply." msgstr "" @@ -8672,8 +8672,8 @@ msgstr "Open group chat" msgid "Open group chat settings" msgstr "Open group chat settings" -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:559 -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:565 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:556 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:562 msgid "Open in Google Translate" msgstr "Open in Google Translate" @@ -8860,11 +8860,11 @@ msgstr "" msgid "Options:" msgstr "" -#: src/screens/Deactivated.tsx:197 +#: src/screens/Deactivated.tsx:196 msgid "Or, continue with another account." msgstr "" -#: src/screens/Deactivated.tsx:184 +#: src/screens/Deactivated.tsx:183 msgid "Or, sign in to one of your other accounts." msgstr "" @@ -8970,7 +8970,7 @@ msgstr "" msgid "Password changed" msgstr "" -#: src/screens/Settings/components/ChangePasswordDialog.tsx:127 +#: src/screens/Settings/components/ChangePasswordDialog.tsx:126 msgid "Password must be at least 8 characters long." msgstr "" @@ -9047,11 +9047,11 @@ msgstr "People I follow can join instantly" msgid "People I follow can request to join" msgstr "People I follow can request to join" -#: src/components/dialogs/PostInteractionSettingsDialog.tsx:507 +#: src/components/dialogs/PostInteractionSettingsDialog.tsx:506 msgid "People you follow" msgstr "" -#: src/components/dialogs/PostInteractionSettingsDialog.tsx:521 +#: src/components/dialogs/PostInteractionSettingsDialog.tsx:520 msgid "People you mention" msgstr "" @@ -9108,8 +9108,8 @@ msgstr "" msgid "Pin to Home" msgstr "" -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:518 -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:523 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:515 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:520 msgid "Pin to your profile" msgstr "" @@ -9209,7 +9209,7 @@ msgstr "" msgid "Please enter a password." msgstr "" -#: src/screens/Settings/components/ChangePasswordDialog.tsx:122 +#: src/screens/Settings/components/ChangePasswordDialog.tsx:121 msgid "Please enter a password. It must be at least 8 characters long." msgstr "" @@ -9310,7 +9310,7 @@ msgstr "" msgid "Please sign in as @{0}" msgstr "" -#: src/components/moderation/ReportDialog/index.tsx:962 +#: src/components/moderation/ReportDialog/index.tsx:961 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." @@ -9417,7 +9417,7 @@ msgstr "" msgid "Post Hidden by You" msgstr "" -#: src/components/dialogs/PostInteractionSettingsDialog.tsx:682 +#: src/components/dialogs/PostInteractionSettingsDialog.tsx:681 msgid "Post interaction settings" msgstr "" @@ -9559,11 +9559,11 @@ msgstr "" msgid "Privacy violation of a minor" msgstr "" -#: src/view/com/composer/Composer.tsx:2691 +#: src/view/com/composer/Composer.tsx:2693 msgid "Processing GIF..." msgstr "" -#: src/view/com/composer/Composer.tsx:2693 +#: src/view/com/composer/Composer.tsx:2695 msgid "Processing video..." msgstr "" @@ -9576,7 +9576,7 @@ msgid "profile" msgstr "" #: src/view/shell/bottom-bar/BottomBar.tsx:322 -#: src/view/shell/desktop/LeftNav.tsx:745 +#: src/view/shell/desktop/LeftNav.tsx:744 #: src/view/shell/Drawer.tsx:92 #: src/view/shell/Drawer.tsx:658 msgid "Profile" @@ -9700,8 +9700,8 @@ msgstr "" msgid "Rate limit exceeded. Please try again later." msgstr "" -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:711 -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:721 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:708 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:718 msgid "Re-attach quote" msgstr "" @@ -9722,7 +9722,7 @@ msgstr "" msgid "Reactions" msgstr "Reactions" -#: src/screens/Deactivated.tsx:138 +#: src/screens/Deactivated.tsx:137 msgid "Reactivate your account" msgstr "" @@ -9937,8 +9937,8 @@ msgstr "" msgid "Remove from quick access?" msgstr "" -#: src/screens/List/ListHiddenScreen.tsx:174 -#: src/screens/List/ListHiddenScreen.tsx:178 +#: src/screens/List/ListHiddenScreen.tsx:173 +#: src/screens/List/ListHiddenScreen.tsx:177 msgid "Remove from saved feeds" msgstr "" @@ -10105,11 +10105,11 @@ msgstr "Replied-to message, tap to scroll to it" msgid "Replies" msgstr "" -#: src/components/WhoCanReply.tsx:80 +#: src/components/WhoCanReply.tsx:74 msgid "Replies disabled" msgstr "" -#: src/components/WhoCanReply.tsx:274 +#: src/components/WhoCanReply.tsx:263 msgid "Replies to this post are disabled." msgstr "" @@ -10139,7 +10139,7 @@ msgstr "" msgid "Reply Hidden by You" msgstr "" -#: src/components/dialogs/PostInteractionSettingsDialog.tsx:415 +#: src/components/dialogs/PostInteractionSettingsDialog.tsx:414 msgid "Reply settings are chosen by the author of the thread" msgstr "" @@ -10175,7 +10175,7 @@ msgid "Report conversation" msgstr "" #: src/components/moderation/ReportDialog/index.tsx:138 -#: src/components/moderation/ReportDialog/index.tsx:336 +#: src/components/moderation/ReportDialog/index.tsx:335 msgid "Report dialog" msgstr "" @@ -10193,8 +10193,8 @@ msgstr "" msgid "Report message" msgstr "" -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:777 -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:779 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:774 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:776 msgid "Report post" msgstr "" @@ -10325,8 +10325,8 @@ msgstr "Request access to group chat" msgid "Request approved." msgstr "Request approved." -#: src/screens/Settings/components/ChangePasswordDialog.tsx:230 -#: src/screens/Settings/components/ChangePasswordDialog.tsx:236 +#: src/screens/Settings/components/ChangePasswordDialog.tsx:228 +#: src/screens/Settings/components/ChangePasswordDialog.tsx:234 msgid "Request code" msgstr "" @@ -10395,8 +10395,8 @@ msgstr "" msgid "Resend code in <0>00:{0}" msgstr "" -#: src/screens/Settings/components/DisableEmail2FADialog.tsx:182 -#: src/screens/Settings/components/DisableEmail2FADialog.tsx:185 +#: src/screens/Settings/components/DisableEmail2FADialog.tsx:180 +#: src/screens/Settings/components/DisableEmail2FADialog.tsx:183 msgid "Resend email" msgstr "" @@ -10458,7 +10458,7 @@ msgstr "" #: src/components/contacts/screens/VerifyNumber.tsx:358 #: src/components/Error.tsx:60 #: src/components/Lists.tsx:115 -#: src/components/moderation/ReportDialog/index.tsx:370 +#: src/components/moderation/ReportDialog/index.tsx:369 #: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoFallback.tsx:56 #: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoFallback.tsx:59 #: src/components/StarterPack/ProfileStarterPacks.tsx:380 @@ -10481,13 +10481,13 @@ msgstr "" msgid "Retry" msgstr "" -#: src/components/moderation/ReportDialog/index.tsx:367 +#: src/components/moderation/ReportDialog/index.tsx:366 #: src/view/screens/Storybook/Admonitions.tsx:61 msgid "Retry loading report options" msgstr "" #: src/components/Error.tsx:68 -#: src/screens/List/ListHiddenScreen.tsx:223 +#: src/screens/List/ListHiddenScreen.tsx:222 #: src/screens/StarterPack/StarterPackScreen.tsx:790 msgid "Return to previous page" msgstr "" @@ -10519,8 +10519,8 @@ msgstr "Sad GIFs" #: src/components/dialogs/BirthDateSettings.tsx:200 #: src/components/dialogs/lists/CreateOrEditListDialog.tsx:317 #: src/components/dialogs/lists/CreateOrEditListDialog.tsx:332 -#: src/components/dialogs/PostInteractionSettingsDialog.tsx:665 -#: src/components/dialogs/PostInteractionSettingsDialog.tsx:670 +#: src/components/dialogs/PostInteractionSettingsDialog.tsx:664 +#: src/components/dialogs/PostInteractionSettingsDialog.tsx:669 #: src/components/StarterPack/QrCodeDialog.tsx:208 #: src/features/liveNow/components/EditLiveDialog.tsx:199 #: src/features/liveNow/components/EditLiveDialog.tsx:206 @@ -10585,8 +10585,8 @@ msgstr "" msgid "Save QR code" msgstr "" -#: src/components/dialogs/PostInteractionSettingsDialog.tsx:646 -#: src/components/dialogs/PostInteractionSettingsDialog.tsx:652 +#: src/components/dialogs/PostInteractionSettingsDialog.tsx:645 +#: src/components/dialogs/PostInteractionSettingsDialog.tsx:651 msgid "Save these options for next time" msgstr "" @@ -10595,7 +10595,7 @@ msgstr "" msgid "Save to my feeds" msgstr "" -#: src/view/shell/desktop/LeftNav.tsx:732 +#: src/view/shell/desktop/LeftNav.tsx:731 #: src/view/shell/Drawer.tsx:633 msgctxt "link to bookmarks screen" msgid "Saved" @@ -10618,8 +10618,8 @@ msgid "Saved to your feeds" msgstr "" #: src/components/NewskieDialog.tsx:142 -#: src/view/com/notifications/NotificationFeedItem.tsx:938 -#: src/view/com/notifications/NotificationFeedItem.tsx:946 +#: src/view/com/notifications/NotificationFeedItem.tsx:937 +#: src/view/com/notifications/NotificationFeedItem.tsx:945 msgid "Say hello!" msgstr "" @@ -10663,8 +10663,8 @@ msgid "Scroll to top" msgstr "" #: src/components/dialogs/SearchablePeopleList.tsx:663 -#: src/components/forms/SearchInput.tsx:51 -#: src/components/forms/SearchInput.tsx:53 +#: src/components/forms/SearchInput.tsx:57 +#: src/components/forms/SearchInput.tsx:59 #: src/screens/Search/components/AdvancedSearchDialog/index.tsx:218 #: src/screens/Search/components/AdvancedSearchDialog/index.tsx:224 #: src/screens/Search/Shell.tsx:549 @@ -10681,7 +10681,7 @@ msgstr "" msgid "Search @{0}'s posts" msgstr "" -#: src/components/ProgressGuide/FollowDialog.tsx:708 +#: src/components/ProgressGuide/FollowDialog.tsx:702 msgid "Search by name or interest" msgstr "" @@ -10694,12 +10694,12 @@ msgid "Search feeds" msgstr "" #. Accessibility label for a tab that searches for accounts in a category (e.g. Art, Video Games, Sports, etc.) that are suggested for the user to follow. The tab is not currently active and can be selected. -#: src/components/ProgressGuide/FollowDialog.tsx:505 +#: src/components/ProgressGuide/FollowDialog.tsx:499 msgid "Search for \"{interestsDisplayName}\"" msgstr "" #. Accessibility label for a tab that searches for accounts in a category (e.g. Art, Video Games, Sports, etc.) that are suggested for the user to follow. The tab is currently selected. -#: src/components/ProgressGuide/FollowDialog.tsx:500 +#: src/components/ProgressGuide/FollowDialog.tsx:494 msgid "Search for \"{interestsDisplayName}\" (active)" msgstr "" @@ -10758,7 +10758,7 @@ msgstr "" #: src/components/dialogs/SearchablePeopleList.tsx:683 #: src/components/dms/components/UserSearchInput.tsx:62 -#: src/components/ProgressGuide/FollowDialog.tsx:727 +#: src/components/ProgressGuide/FollowDialog.tsx:721 msgid "Search profiles" msgstr "" @@ -10772,7 +10772,7 @@ msgstr "" #: src/components/dialogs/SearchablePeopleList.tsx:684 #: src/components/dms/components/UserSearchInput.tsx:63 -#: src/components/ProgressGuide/FollowDialog.tsx:728 +#: src/components/ProgressGuide/FollowDialog.tsx:722 msgid "Searches for profiles" msgstr "" @@ -10867,11 +10867,11 @@ msgstr "" msgid "Select a color" msgstr "" -#: src/components/moderation/ReportDialog/index.tsx:453 +#: src/components/moderation/ReportDialog/index.tsx:452 msgid "Select a reason" msgstr "" -#: src/screens/Login/ChooseAccountForm.tsx:79 +#: src/screens/Login/ChooseAccountForm.tsx:78 msgid "Select account" msgstr "" @@ -10923,11 +10923,11 @@ msgstr "Select filter type (currently: {0})" msgid "Select from an existing account" msgstr "" -#: src/components/dialogs/PostInteractionSettingsDialog.tsx:550 +#: src/components/dialogs/PostInteractionSettingsDialog.tsx:549 msgid "Select from your lists" msgstr "" -#: src/components/dialogs/PostInteractionSettingsDialog.tsx:552 +#: src/components/dialogs/PostInteractionSettingsDialog.tsx:551 msgid "Select from your lists <0>{numberOfListsSelected, plural, other {(# selected)}}" msgstr "" @@ -10964,7 +10964,7 @@ msgstr "" msgid "Select languages" msgstr "" -#: src/components/moderation/ReportDialog/index.tsx:508 +#: src/components/moderation/ReportDialog/index.tsx:507 msgid "Select moderation service" msgstr "" @@ -11066,7 +11066,7 @@ msgstr "" msgid "Send post to..." msgstr "" -#: src/components/moderation/ReportDialog/index.tsx:1064 +#: src/components/moderation/ReportDialog/index.tsx:1063 msgid "Send report to {title}" msgstr "" @@ -11075,8 +11075,8 @@ msgid "Send the message from your phone" msgstr "" #: src/components/dialogs/EmailDialog/screens/Verify.tsx:304 -#: src/screens/Settings/components/DisableEmail2FADialog.tsx:129 -#: src/screens/Settings/components/DisableEmail2FADialog.tsx:132 +#: src/screens/Settings/components/DisableEmail2FADialog.tsx:127 +#: src/screens/Settings/components/DisableEmail2FADialog.tsx:130 msgid "Send verification email" msgstr "" @@ -11110,7 +11110,7 @@ msgstr "" msgid "Set new password" msgstr "" -#: src/components/dialogs/PostInteractionSettingsDialog.tsx:478 +#: src/components/dialogs/PostInteractionSettingsDialog.tsx:477 msgid "Set precisely which groups of people can reply to your post" msgstr "" @@ -11118,7 +11118,7 @@ msgstr "" msgid "Set up your account" msgstr "" -#: src/components/dialogs/PostInteractionSettingsDialog.tsx:428 +#: src/components/dialogs/PostInteractionSettingsDialog.tsx:427 msgid "Set who can reply to your post" msgstr "" @@ -11140,7 +11140,7 @@ msgstr "" #: src/Navigation.tsx:221 #: src/screens/Settings/Settings.tsx:102 -#: src/view/shell/desktop/LeftNav.tsx:755 +#: src/view/shell/desktop/LeftNav.tsx:754 #: src/view/shell/Drawer.tsx:671 msgid "Settings" msgstr "" @@ -11350,7 +11350,7 @@ msgstr "" #: src/components/moderation/ScreenHider.tsx:178 #: src/features/liveNow/components/LiveStatusDialog.tsx:315 #: src/features/liveNow/components/LiveStatusDialog.tsx:319 -#: src/screens/List/ListHiddenScreen.tsx:194 +#: src/screens/List/ListHiddenScreen.tsx:193 #: src/screens/VideoFeed/index.tsx:740 #: src/screens/VideoFeed/index.tsx:746 msgid "Show anyway" @@ -11378,16 +11378,16 @@ msgstr "" msgid "Show group chat updates" msgstr "Show group chat updates" -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:605 -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:607 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:602 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:604 msgid "Show less like this" msgstr "" -#: src/screens/List/ListHiddenScreen.tsx:190 +#: src/screens/List/ListHiddenScreen.tsx:189 msgid "Show list anyway" msgstr "" -#: src/components/dialogs/PostInteractionSettingsDialog.tsx:531 +#: src/components/dialogs/PostInteractionSettingsDialog.tsx:530 msgid "Show lists of users to select from" msgstr "" @@ -11395,8 +11395,8 @@ msgstr "" msgid "Show more" msgstr "Show more" -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:597 -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:599 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:594 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:596 msgid "Show more like this" msgstr "" @@ -11422,8 +11422,8 @@ msgstr "" msgid "Show replies as" msgstr "" -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:685 -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:695 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:682 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:692 msgid "Show reply for everyone" msgstr "" @@ -11499,12 +11499,12 @@ msgstr "" msgid "Sign in as {0}" msgstr "" -#: src/screens/Login/ChooseAccountForm.tsx:84 +#: src/screens/Login/ChooseAccountForm.tsx:83 msgid "Sign in as…" msgstr "Sign in as…" -#: src/screens/Deactivated.tsx:200 -#: src/screens/Deactivated.tsx:206 +#: src/screens/Deactivated.tsx:199 +#: src/screens/Deactivated.tsx:205 msgid "Sign in or create an account" msgstr "" @@ -11528,16 +11528,16 @@ msgstr "" msgid "Sign in to request access to this group chat." msgstr "Sign in to request access to this group chat." -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:583 -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:585 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:580 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:582 msgid "Sign in to view post" msgstr "" #: src/screens/Settings/Settings.tsx:277 #: src/screens/Settings/Settings.tsx:279 #: src/screens/Settings/Settings.tsx:311 -#: src/screens/SignupQueued.tsx:101 -#: src/screens/SignupQueued.tsx:104 +#: src/screens/SignupQueued.tsx:100 +#: src/screens/SignupQueued.tsx:103 #: src/screens/Takendown.tsx:86 #: src/view/shell/desktop/LeftNav.tsx:227 #: src/view/shell/desktop/LeftNav.tsx:282 @@ -11641,7 +11641,7 @@ msgstr "" msgid "Some other feeds you might like" msgstr "" -#: src/components/WhoCanReply.tsx:81 +#: src/components/WhoCanReply.tsx:75 msgid "Some people can reply" msgstr "" @@ -11702,7 +11702,7 @@ msgid "Something went wrong" msgstr "" #: src/components/ageAssurance/AgeAssuranceInitDialog.tsx:141 -#: src/components/moderation/ReportDialog/index.tsx:362 +#: src/components/moderation/ReportDialog/index.tsx:361 #: src/screens/Deactivated.tsx:92 #: src/screens/Settings/components/DeactivateAccountDialog.tsx:60 #: src/view/screens/Storybook/Admonitions.tsx:56 @@ -11736,8 +11736,8 @@ msgstr "" msgid "Sorry, we're unable to load account suggestions at this time." msgstr "" -#: src/App.tsx:139 -#: src/App.web.tsx:118 +#: src/App.tsx:138 +#: src/App.web.tsx:117 msgid "Sorry! Your session expired. Please sign in again." msgstr "" @@ -11905,13 +11905,13 @@ msgstr "" msgid "Submit feedback" msgstr "Submit feedback" -#: src/components/moderation/ReportDialog/index.tsx:586 -#: src/components/moderation/ReportDialog/index.tsx:659 -#: src/components/moderation/ReportDialog/index.tsx:666 +#: src/components/moderation/ReportDialog/index.tsx:585 +#: src/components/moderation/ReportDialog/index.tsx:658 +#: src/components/moderation/ReportDialog/index.tsx:665 msgid "Submit report" msgstr "" -#: src/components/moderation/ReportDialog/index.tsx:942 +#: src/components/moderation/ReportDialog/index.tsx:941 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" @@ -12064,7 +12064,7 @@ msgstr "" msgid "Take the conversation private." msgstr "Take the conversation private." -#: src/components/dialogs/DeviceLocationRequestDialog.tsx:110 +#: src/components/dialogs/DeviceLocationRequestDialog.tsx:109 msgid "Tap below to allow Bluesky to access your GPS location. We will then use that data to more accurately determine the content and features available in your region." msgstr "" @@ -12405,7 +12405,7 @@ msgstr "" msgid "There is no invite link for this group chat." msgstr "There is no invite link for this group chat." -#: src/screens/Settings/components/DeactivateAccountDialog.tsx:87 +#: src/screens/Settings/components/DeactivateAccountDialog.tsx:86 msgid "There is no time limit for account deactivation, come back any time." msgstr "" @@ -12434,7 +12434,7 @@ msgstr "" msgid "There was an issue contacting the server, please check your internet connection and try again." msgstr "" -#: src/view/com/notifications/NotificationFeed.tsx:138 +#: src/view/com/notifications/NotificationFeed.tsx:137 msgid "There was an issue fetching notifications. Tap here to try again." msgstr "" @@ -12472,8 +12472,8 @@ msgstr "" #. placeholder {0}: e.toString() #: src/components/PostControls/PostMenu/PostMenuItems.tsx:428 -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:451 -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:471 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:450 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:469 #: src/screens/Messages/ConversationSettings/Member.tsx:71 #: src/screens/Messages/ConversationSettings/MemberMenu.tsx:114 #: src/screens/Messages/ConversationSettings/MemberMenu.tsx:127 @@ -12514,11 +12514,11 @@ msgstr "" msgid "There was an unexpected issue in the application. Please let us know if this happened to you!" msgstr "" -#: src/screens/SignupQueued.tsx:140 +#: src/screens/SignupQueued.tsx:139 msgid "There's been a rush of new users to Bluesky! We'll activate your account as soon as we can." msgstr "" -#: src/components/dialogs/PostInteractionSettingsDialog.tsx:657 +#: src/components/dialogs/PostInteractionSettingsDialog.tsx:656 msgid "These are your default settings" msgstr "" @@ -12626,7 +12626,7 @@ msgstr "" msgid "This code is invalid. Resend to get a new code." msgstr "" -#: src/screens/Settings/components/ChangePasswordDialog.tsx:149 +#: src/screens/Settings/components/ChangePasswordDialog.tsx:148 msgid "This confirmation code is not valid. Please try again." msgstr "" @@ -12677,7 +12677,7 @@ msgstr "This exact phrase" msgid "This feature allows users to receive notifications for your new posts and replies. Who do you want to enable this for?" msgstr "" -#: src/screens/Settings/components/ExportCarDialog.tsx:172 +#: src/screens/Settings/components/ExportCarDialog.tsx:170 msgid "This feature is in beta. You can read more about repository exports in <0>this blogpost." msgstr "" @@ -12766,11 +12766,11 @@ msgid "This link is taking you to the following website:" msgstr "" #. placeholder {0}: sanitizeHandle(list.creator.handle, '@') -#: src/screens/List/ListHiddenScreen.tsx:155 +#: src/screens/List/ListHiddenScreen.tsx:154 msgid "This list – created by <0>{0} – contains possible violations of Bluesky's community guidelines in its name or description." msgstr "" -#: src/screens/List/ListHiddenScreen.tsx:150 +#: src/screens/List/ListHiddenScreen.tsx:149 msgid "This list – created by you – contains possible violations of Bluesky's community guidelines in its name or description." msgstr "" @@ -12810,7 +12810,7 @@ msgstr "This person is blocking you" msgid "This post claims to have been created on <0>{0}, but was first seen by Bluesky on <1>{1}." msgstr "" -#: src/components/WhoCanReply.tsx:267 +#: src/components/WhoCanReply.tsx:256 msgid "This post has an unknown type of threadgate on it. Your app may be out of date." msgstr "" @@ -12822,7 +12822,7 @@ msgstr "" msgid "This post was deleted by its author" msgstr "" -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:830 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:827 msgid "This post will be hidden from feeds and threads. This cannot be undone." msgstr "" @@ -12834,7 +12834,7 @@ msgstr "" msgid "This profile is only visible to logged-in users. It won't be visible to people who aren't signed in." msgstr "" -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:865 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:862 msgid "This reply will be sorted into a hidden section at the bottom of your thread and will mute notifications for subsequent replies - both for yourself and others." msgstr "" @@ -12930,7 +12930,7 @@ msgstr "" msgid "This will remove @{0} from the quick access list." msgstr "" -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:858 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:855 msgid "This will remove your post from this quote post for all users, and replace it with a placeholder." msgstr "" @@ -12979,7 +12979,7 @@ msgstr "Title" msgid "Title (100 characters max)" msgstr "Title (100 characters max)" -#: src/screens/Settings/components/DisableEmail2FADialog.tsx:108 +#: src/screens/Settings/components/DisableEmail2FADialog.tsx:106 msgid "To disable the email 2FA method, please verify your access to the email address." msgstr "" @@ -13038,8 +13038,8 @@ msgstr "" #: src/components/dms/MessageContextMenu.tsx:192 #: src/components/Post/Translated/index.tsx:147 #: src/components/Post/Translated/index.tsx:154 -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:560 -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:566 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:557 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:563 msgid "Translate" msgstr "" @@ -13051,8 +13051,8 @@ msgstr "Translated" msgid "Translating" msgstr "Translating" -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:541 -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:543 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:538 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:540 msgid "Translating…" msgstr "Translating…" @@ -13160,7 +13160,7 @@ msgid "Unable to connect. Please check your internet connection and try again." msgstr "" #: src/screens/Settings/components/ChangePasswordDialog.tsx:98 -#: src/screens/Settings/components/ChangePasswordDialog.tsx:143 +#: src/screens/Settings/components/ChangePasswordDialog.tsx:142 msgid "Unable to contact your service. Please check your internet connection and try again." msgstr "" @@ -13294,7 +13294,7 @@ msgstr "" msgid "Unfollows the user" msgstr "" -#: src/components/moderation/ReportDialog/index.tsx:570 +#: src/components/moderation/ReportDialog/index.tsx:569 msgid "Unfortunately, none of your subscribed labelers supports this report type." msgstr "" @@ -13353,8 +13353,8 @@ msgstr "" msgid "Unmute {0}" msgstr "Unmute {0}" -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:750 -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:756 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:747 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:753 #: src/view/com/profile/ProfileMenu.tsx:511 #: src/view/com/profile/ProfileMenu.tsx:517 msgid "Unmute account" @@ -13373,8 +13373,8 @@ msgstr "" msgid "Unmute this group chat" msgstr "Unmute this group chat" +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:639 #: src/components/PostControls/PostMenu/PostMenuItems.tsx:642 -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:645 msgid "Unmute thread" msgstr "" @@ -13399,8 +13399,8 @@ msgstr "" msgid "Unpin from home" msgstr "" -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:518 -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:523 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:515 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:520 msgid "Unpin from profile" msgstr "" @@ -13431,8 +13431,8 @@ msgstr "" msgid "Unsubscribe" msgstr "" -#: src/screens/List/ListHiddenScreen.tsx:202 -#: src/screens/List/ListHiddenScreen.tsx:212 +#: src/screens/List/ListHiddenScreen.tsx:201 +#: src/screens/List/ListHiddenScreen.tsx:211 msgid "Unsubscribe from list" msgstr "" @@ -13500,7 +13500,7 @@ msgstr "" #: src/ageAssurance/components/NoAccessScreen.tsx:208 #: src/components/ageAssurance/AgeAssuranceAccountCard.tsx:252 #: src/components/dialogs/DeviceLocationRequestDialog.tsx:41 -#: src/components/dialogs/DeviceLocationRequestDialog.tsx:106 +#: src/components/dialogs/DeviceLocationRequestDialog.tsx:105 msgid "Update your location" msgstr "Update your location" @@ -13541,7 +13541,7 @@ msgstr "" msgid "Upload from Library" msgstr "" -#: src/view/com/composer/Composer.tsx:2684 +#: src/view/com/composer/Composer.tsx:2686 msgid "Uploading GIF..." msgstr "" @@ -13555,7 +13555,7 @@ msgstr "" msgid "Uploading link thumbnail..." msgstr "" -#: src/view/com/composer/Composer.tsx:2686 +#: src/view/com/composer/Composer.tsx:2688 msgid "Uploading video..." msgstr "" @@ -13675,12 +13675,12 @@ msgid "Username or email address" msgstr "" #. placeholder {0}: post.author.handle -#: src/components/WhoCanReply.tsx:330 +#: src/components/WhoCanReply.tsx:319 msgid "users followed by <0>@{0}" msgstr "" #. placeholder {0}: post.author.handle -#: src/components/WhoCanReply.tsx:317 +#: src/components/WhoCanReply.tsx:306 msgid "users following <0>@{0}" msgstr "" @@ -13844,7 +13844,7 @@ msgstr "" msgid "Video settings" msgstr "" -#: src/view/com/composer/Composer.tsx:2704 +#: src/view/com/composer/Composer.tsx:2706 msgid "Video uploaded" msgstr "" @@ -13907,7 +13907,7 @@ msgstr "View {publicationTitle}" msgid "View blocked user's profile" msgstr "" -#: src/screens/Settings/components/ExportCarDialog.tsx:176 +#: src/screens/Settings/components/ExportCarDialog.tsx:174 msgid "View blogpost for more details" msgstr "" @@ -14131,7 +14131,7 @@ msgstr "" msgid "We don't store your friends' phone numbers or send any messages" msgstr "" -#: src/screens/SignupQueued.tsx:170 +#: src/screens/SignupQueued.tsx:169 msgid "We estimate {estimatedTime} until your account is ready." msgstr "" @@ -14190,7 +14190,7 @@ msgstr "" msgid "We were unable to receive the verification due to a connection issue. It may arrive later. If it does, your account will update automatically." msgstr "" -#: src/screens/SignupQueued.tsx:174 +#: src/screens/SignupQueued.tsx:173 msgid "We will let you know when your account is ready." msgstr "" @@ -14313,7 +14313,7 @@ msgstr "" msgid "We've confirmed your age assurance status. You can now close this dialog." msgstr "" -#: src/screens/Deactivated.tsx:122 +#: src/screens/Deactivated.tsx:121 msgid "Welcome back!" msgstr "" @@ -14344,7 +14344,7 @@ msgstr "" msgid "When you tap on a check, you’ll see which organizations have granted verification." msgstr "" -#: src/components/WhoCanReply.tsx:219 +#: src/components/WhoCanReply.tsx:208 msgid "Who can interact with this post?" msgstr "" @@ -14352,8 +14352,8 @@ msgstr "" msgid "Who can join this group chat and how" msgstr "Who can join this group chat and how" -#: src/components/dialogs/PostInteractionSettingsDialog.tsx:424 -#: src/components/WhoCanReply.tsx:109 +#: src/components/dialogs/PostInteractionSettingsDialog.tsx:423 +#: src/components/WhoCanReply.tsx:103 msgid "Who can reply" msgstr "" @@ -14458,8 +14458,8 @@ msgstr "Wrong kind of conversation" msgid "www.mylivestream.tv" msgstr "" -#: src/components/moderation/ReportDialog/index.tsx:995 -#: src/components/moderation/ReportDialog/index.tsx:1004 +#: src/components/moderation/ReportDialog/index.tsx:994 +#: src/components/moderation/ReportDialog/index.tsx:1003 msgctxt "Answer to a yes/no question" msgid "Yes" msgstr "Yes" @@ -14469,8 +14469,8 @@ msgstr "Yes" msgid "Yes GIFs" msgstr "Yes GIFs" -#: src/screens/Settings/components/DeactivateAccountDialog.tsx:106 -#: src/screens/Settings/components/DeactivateAccountDialog.tsx:108 +#: src/screens/Settings/components/DeactivateAccountDialog.tsx:105 +#: src/screens/Settings/components/DeactivateAccountDialog.tsx:107 msgid "Yes, deactivate" msgstr "" @@ -14482,15 +14482,15 @@ msgstr "" msgid "Yes, delete this starter pack" msgstr "" -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:860 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:857 msgid "Yes, detach" msgstr "" -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:867 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:864 msgid "Yes, hide" msgstr "" -#: src/screens/Deactivated.tsx:144 +#: src/screens/Deactivated.tsx:143 msgid "Yes, reactivate my account" msgstr "" @@ -14532,7 +14532,7 @@ msgstr "" msgid "You are currently unable to access Bluesky's Age Assurance flow. Please <0>contact our moderation team if you believe this is an error." msgstr "" -#: src/screens/SignupQueued.tsx:167 +#: src/screens/SignupQueued.tsx:166 msgid "You are in line." msgstr "" @@ -14582,7 +14582,7 @@ msgstr "" msgid "You can also discover new Custom Feeds to follow." msgstr "" -#: src/screens/Settings/components/ExportCarDialog.tsx:145 +#: src/screens/Settings/components/ExportCarDialog.tsx:143 msgid "You can also download your chat data as a \"JSONL\" file. This file only includes chat messages that you have sent and does not include chat messages that you have received." msgstr "" @@ -14633,7 +14633,7 @@ msgstr "" msgid "You can only select one video at a time." msgstr "" -#: src/screens/Deactivated.tsx:130 +#: src/screens/Deactivated.tsx:129 msgid "You can reactivate your account to continue logging in. Your profile and posts will be visible to other users." msgstr "" @@ -14670,7 +14670,7 @@ msgstr "" msgid "You don't follow any users who follow @{name}." msgstr "" -#: src/components/dialogs/PostInteractionSettingsDialog.tsx:586 +#: src/components/dialogs/PostInteractionSettingsDialog.tsx:585 msgid "You don't have any lists yet." msgstr "" @@ -14708,7 +14708,7 @@ msgstr "" #: src/screens/Login/SetNewPasswordForm.tsx:49 #: src/screens/Login/SetNewPasswordForm.tsx:94 -#: src/screens/Settings/components/ChangePasswordDialog.tsx:115 +#: src/screens/Settings/components/ChangePasswordDialog.tsx:114 msgid "You have entered an invalid code. It should look like XXXXX-XXXXX." msgstr "" @@ -14851,7 +14851,7 @@ msgid "You own this chat" msgstr "You own this chat" #. placeholder {0}: currentAccount?.handle -#: src/screens/Deactivated.tsx:125 +#: src/screens/Deactivated.tsx:124 msgid "You previously deactivated @{0}." msgstr "" @@ -14964,7 +14964,7 @@ msgstr "You're all set! However, certain features may still be restricted until msgid "You’re in control" msgstr "You’re in control" -#: src/screens/SignupQueued.tsx:137 +#: src/screens/SignupQueued.tsx:136 msgid "You're in line" msgstr "" @@ -15054,7 +15054,7 @@ msgstr "Your account is too new" msgid "Your account must be at least 7 days old to create a new group chat." msgstr "Your account must be at least 7 days old to create a new group chat." -#: src/screens/Settings/components/ExportCarDialog.tsx:113 +#: src/screens/Settings/components/ExportCarDialog.tsx:111 msgid "Your account repository, containing all public data records, can be downloaded as a \"CAR\" file. This file does not include media embeds, such as images, or your private data, which must be fetched separately." msgstr "" @@ -15120,7 +15120,7 @@ msgstr "" msgid "Your first like!" msgstr "" -#: src/components/dialogs/PostInteractionSettingsDialog.tsx:493 +#: src/components/dialogs/PostInteractionSettingsDialog.tsx:492 msgid "Your followers" msgstr "" @@ -15194,7 +15194,7 @@ msgstr "" msgid "Your preferred language" msgstr "" -#: src/screens/Onboarding/StepFinished/ValuePropositionPager.tsx:103 +#: src/screens/Onboarding/StepFinished/ValuePropositionPager.tsx:111 #: src/screens/Onboarding/StepFinished/ValuePropositionPager.web.tsx:54 msgid "Your profile picture" msgstr "" @@ -15203,7 +15203,7 @@ msgstr "" msgid "Your profile picture surrounded by concentric circles of other users' profile pictures" msgstr "" -#: src/screens/Settings/components/DeactivateAccountDialog.tsx:76 +#: src/screens/Settings/components/DeactivateAccountDialog.tsx:75 msgid "Your profile, posts, feeds, and lists will no longer be visible to other Bluesky users. You can reactivate your account at any time by logging in." msgstr "" @@ -15212,7 +15212,7 @@ msgid "Your reply was sent" msgstr "" #. placeholder {0}: state.selectedLabeler?.creator.displayName -#: src/components/moderation/ReportDialog/index.tsx:597 +#: src/components/moderation/ReportDialog/index.tsx:596 msgid "Your report will be sent to <0>{0}." msgstr "" From e91665d118c746ad562ff8135ecbebc8c44a9915 Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Fri, 28 Aug 2026 15:03:03 +0300 Subject: [PATCH 09/14] Keep chat reaction bar on screen (#11532) --- src/components/ContextMenu/index.tsx | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/src/components/ContextMenu/index.tsx b/src/components/ContextMenu/index.tsx index 95492a385a..294ddbd96e 100644 --- a/src/components/ContextMenu/index.tsx +++ b/src/components/ContextMenu/index.tsx @@ -530,7 +530,7 @@ export function AuxiliaryView({ transform: [ { translateY: - (ensureOnScreenTranslationSV.get() || translationSV.get()) * + Math.max(ensureOnScreenTranslationSV.get(), translationSV.get()) * animationSV.get(), }, {scale: interpolate(animationSV.get(), [0, 1], [0.2, 1])}, @@ -544,21 +544,17 @@ export function AuxiliaryView({ const onLayout = useCallback(() => { if (!measurement) return - let translation = 0 - // vibes based, just assuming it'll fit within this space. revisit if we use // AuxiliaryView for something tall const TOP_INSET = topInset + 80 const distanceMessageFromTop = measurement.y - TOP_INSET - if (distanceMessageFromTop < 0) { - translation = -distanceMessageFromTop - } + const minimumTranslation = -distanceMessageFromTop // normally, the context menu is responsible for measuring itself and moving everything into the right place // however, in auxiliary-only mode, that doesn't happen, so we need to do it ourselves here if (mode === 'auxiliary-only') { - translationSV.set(translation) + translationSV.set(Math.max(minimumTranslation, 0)) ensureOnScreenTranslationSV.set(0) } // however, we also need to make sure that for super tall triggers, we don't go off the screen @@ -567,7 +563,7 @@ export function AuxiliaryView({ // we'll just have to live with it for now, fixing it would be possible but be a large complexity // increase for an edge case else { - ensureOnScreenTranslationSV.set(translation) + ensureOnScreenTranslationSV.set(minimumTranslation) } }, [mode, measurement, translationSV, topInset, ensureOnScreenTranslationSV]) From 00dc9598fc54cbffaffd1df103bf75d20a1882e5 Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Fri, 28 Aug 2026 15:03:15 +0300 Subject: [PATCH 10/14] Forward labeler headers to chat (#11555) --- src/state/queries/preferences/index.ts | 11 ++--- src/state/session/__tests__/clients-test.ts | 36 ++++++++++----- .../session/__tests__/moderation-test.ts | 45 +++++++++++++++++++ .../additional-moderation-authorities.ts | 8 ++-- src/state/session/clients.ts | 7 +-- src/state/session/moderation.ts | 7 +-- 6 files changed, 89 insertions(+), 25 deletions(-) create mode 100644 src/state/session/__tests__/moderation-test.ts diff --git a/src/state/queries/preferences/index.ts b/src/state/queries/preferences/index.ts index 912a87a1ba..d94342e40b 100644 --- a/src/state/queries/preferences/index.ts +++ b/src/state/queries/preferences/index.ts @@ -38,7 +38,7 @@ import { type UsePreferencesQueryResponse, } from '#/state/queries/preferences/types' import {createQueryKey} from '#/state/queries/util' -import {useAppviewClient, usePdsClient} from '#/state/session' +import {useAppviewClient, useChatClient, usePdsClient} from '#/state/session' import {applyLabelersToClient, saveLabelers} from '#/state/session/moderation' import {useAgeAssurance} from '#/ageAssurance' import {makeAgeRestrictedModerationPrefs} from '#/ageAssurance/util' @@ -58,6 +58,7 @@ export const preferencesQueryKey = createQueryKey( export function usePreferencesQuery() { const client = usePdsClient() const appviewClient = useAppviewClient() + const chatClient = useChatClient() const aa = useAgeAssurance() const query = useQuery({ @@ -85,12 +86,12 @@ export function usePreferencesQuery() { * from a labeler would not affect server-attached labels until the * session bundle was rebuilt. * - * The subscriptions go on the appview client, which is what stamps - * `atproto-accept-labelers` on its own requests. The Bluesky moderation - * DID is dropped so the globally redacted authority is not also listed - * unredacted. + * The subscriptions go on both services that hydrate moderated content. + * The Bluesky moderation DID is dropped so the globally redacted + * authority is not also listed unredacted. */ applyLabelersToClient(appviewClient, labelerDids) + applyLabelersToClient(chatClient, labelerDids) /* * `BskyPreferences` is now the sdk's own type, so the assembled diff --git a/src/state/session/__tests__/clients-test.ts b/src/state/session/__tests__/clients-test.ts index 50cf70fede..ec0e8d99c4 100644 --- a/src/state/session/__tests__/clients-test.ts +++ b/src/state/session/__tests__/clients-test.ts @@ -251,19 +251,35 @@ describe('buildChatClient', () => { expect(headers.get('authorization')).toBe('Bearer access-jwt') }) - it('emits no labeler header', async () => { - /* the global authorities do not apply: a chat call is not an appview read */ + it('emits a global app labeler once, redacted', async () => { + const client = buildChatClient(makeSession(fetchMock)) configureGlobalAppLabelers(['did:plc:global-labeler']) - await buildChatClient(makeSession(fetchMock)) - .call(chat.bsky.convo.listConvos, {}) - .catch(() => {}) + await client.call(chat.bsky.convo.listConvos, {}).catch(() => {}) - expect( - headersFor(fetchMock, 'chat.bsky.convo.listConvos').get( - 'atproto-accept-labelers', - ), - ).toBeNull() + const labelers = headersFor(fetchMock, 'chat.bsky.convo.listConvos').get( + 'atproto-accept-labelers', + ) + const entries = labelers! + .split(',') + .map(l => l.trim()) + .filter(l => l.includes('did:plc:global-labeler')) + expect(entries).toEqual(['did:plc:global-labeler;redact']) + }) + + it('emits an account subscription exactly once', async () => { + const client = buildChatClient(makeSession(fetchMock)) + client.setLabelers(['did:plc:labeler']) + + await client.call(chat.bsky.convo.listConvos, {}).catch(() => {}) + + const labelers = headersFor(fetchMock, 'chat.bsky.convo.listConvos').get( + 'atproto-accept-labelers', + ) + const entries = labelers! + .split(',') + .filter(l => l.includes('did:plc:labeler')) + expect(entries).toHaveLength(1) }) }) diff --git a/src/state/session/__tests__/moderation-test.ts b/src/state/session/__tests__/moderation-test.ts new file mode 100644 index 0000000000..4c7b1c8263 --- /dev/null +++ b/src/state/session/__tests__/moderation-test.ts @@ -0,0 +1,45 @@ +import {type Client} from '@atproto/lex' +import {api} from '@bsky/sdk' +import {beforeEach, describe, expect, it, jest} from '@jest/globals' + +jest.mock('#/storage', () => ({ + account: { + get: jest.fn(), + set: jest.fn(), + }, + device: { + get: jest.fn(), + }, +})) + +import {account} from '#/storage' +import {configureGlobalAppLabelers} from '../additional-moderation-authorities' +import {configureModerationForAccount} from '../moderation' +import {makeAccount} from './mock-fetch' + +describe('configureModerationForAccount', () => { + beforeEach(() => { + jest.clearAllMocks() + configureGlobalAppLabelers([]) + }) + + it('applies cached account labelers to appview and chat', () => { + const appviewClient = {setLabelers: jest.fn()} as unknown as Client + const chatClient = {setLabelers: jest.fn()} as unknown as Client + jest + .mocked(account.get) + .mockReturnValue(['did:plc:account-labeler', api.moderation.did]) + + configureModerationForAccount( + {appviewClient, chatClient}, + makeAccount({handle: 'alice.example.com'}), + ) + + expect(appviewClient.setLabelers).toHaveBeenCalledWith([ + 'did:plc:account-labeler', + ]) + expect(chatClient.setLabelers).toHaveBeenCalledWith([ + 'did:plc:account-labeler', + ]) + }) +}) diff --git a/src/state/session/additional-moderation-authorities.ts b/src/state/session/additional-moderation-authorities.ts index 76d1039610..8d3e7e4d00 100644 --- a/src/state/session/additional-moderation-authorities.ts +++ b/src/state/session/additional-moderation-authorities.ts @@ -98,10 +98,10 @@ export function configureAdditionalModerationAuthorities() { * reads, so a request carries the same `;redact` authorities whether or not * there is a session behind it. * - * It is a single global producer by design. The PDS and chat clients opt out - * with `appLabelers: null` (see `clients.ts`) because those services take no - * moderation authorities, leaving exactly one producer on an appview request and - * none elsewhere. + * It is a single global producer by design. The PDS client opts out with + * `appLabelers: null` (see `clients.ts`) because that service takes no + * moderation authorities. Appview and chat requests each carry one copy from + * their respective clients. */ export function configureGlobalAppLabelers(dids: string[]) { Client.configure({appLabelers: dids as `did:${string}:${string}`[]}) diff --git a/src/state/session/clients.ts b/src/state/session/clients.ts index 026b2d8bd8..b1ba60be9e 100644 --- a/src/state/session/clients.ts +++ b/src/state/session/clients.ts @@ -54,12 +54,13 @@ export function buildPdsClient(agent: Agent): Client { * env-configurable `CHAT_PROXY_DID` rather than a hard-coded constant, so it can * be retargeted per environment. * - * `appLabelers: null` for the same reason as the PDS client: the chat service - * takes no moderation authorities. + * Unlike the PDS client, chat carries moderation authorities. The service uses + * them to hydrate labels on profiles embedded in conversation responses, so + * this client reads the global `Client.appLabelers` and receives the account's + * subscriptions through `configureModerationForAccount`. */ export function buildChatClient(agent: Agent): Client { return createLexClient(agent, { - appLabelers: null, service: CHAT_PROXY_SERVICE, }) } diff --git a/src/state/session/moderation.ts b/src/state/session/moderation.ts index 72afe204c2..40725ce11b 100644 --- a/src/state/session/moderation.ts +++ b/src/state/session/moderation.ts @@ -12,7 +12,7 @@ import { import {type SessionAccount} from './types' /** The moderation surface of a session bundle. */ -type ModerationSession = {appviewClient: Client} +type ModerationSession = {appviewClient: Client; chatClient: Client} /** * Cache an account's subscribed labeler DIDs. Called on every preferences @@ -45,8 +45,8 @@ export function readLabelers(did: string): string[] | undefined { * lex collects the two lists into a `Set` keyed on the suffixed string, so * neither dedupes against the other. * - * Only the appview client takes subscriptions - the PDS and chat clients suppress - * labelers entirely (see clients.ts). + * Appview and chat both take subscriptions. The PDS suppresses labelers because + * repo and identity requests do not hydrate moderated content (see clients.ts). */ export function applyLabelersToClient( client: Client, @@ -85,6 +85,7 @@ export function configureModerationForAccount( const labelerDids = readLabelers(account.did) if (labelerDids) { applyLabelersToClient(bundle.appviewClient, labelerDids) + applyLabelersToClient(bundle.chatClient, labelerDids) } else { // If there are no headers in the storage, we'll not send them on the initial requests. // If we wanted to fix this, we could block on the preferences query here. From d9d19c76780691a06603c3f31085d86b3efe35d7 Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Fri, 28 Aug 2026 18:21:11 +0300 Subject: [PATCH 11/14] Target Android 16 (API 36) (#11576) --- app.config.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app.config.js b/app.config.js index 22787090bb..bf10a1b7d8 100644 --- a/app.config.js +++ b/app.config.js @@ -271,8 +271,8 @@ module.exports = function (_config) { }, android: { compileSdkVersion: 36, - targetSdkVersion: 35, - buildToolsVersion: '35.0.0', + targetSdkVersion: 36, + buildToolsVersion: '36.0.0', buildReactNativeFromSource: IS_PRODUCTION, }, }, From e5a81402f3cf69cf2a547f17b4fa47ef4848fd17 Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Fri, 28 Aug 2026 18:25:37 +0300 Subject: [PATCH 12/14] Bump `@bsky/sdk` to latest (#11597) --- package.json | 2 +- pnpm-lock.yaml | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index 1ac7f45e11..a4b4ad4556 100644 --- a/package.json +++ b/package.json @@ -117,7 +117,7 @@ "@bsky.app/tapper": "^0.6.1", "@bsky.app/video": "0.3.6", "@bsky.app/video-compressor": "0.2.0", - "@bsky/sdk": "1.0.1", + "@bsky/sdk": "1.1.0", "@emoji-mart/data": "^1.2.1", "@emoji-mart/react": "^1.1.1", "@expo/html-elements": "^0.12.5", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9af9df2865..a763b31309 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -297,8 +297,8 @@ importers: specifier: 0.2.0 version: 0.2.0(expo@57.0.8)(react-native@0.86.0(patch_hash=dd549527bb84c88acc7b0b1d521c9f4b666fda484c75cd0b282f8e9838524909)(@babel/core@7.29.0(supports-color@8.1.1))(@react-native/jest-preset@0.86.0(@babel/core@7.29.0(supports-color@8.1.1))(react@19.2.3)(supports-color@8.1.1))(@react-native/metro-config@0.86.0(@babel/core@7.29.0(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.18)(react@19.2.3)(supports-color@8.1.1))(react@19.2.3) '@bsky/sdk': - specifier: 1.0.1 - version: 1.0.1(@atproto/lex@0.3.8) + specifier: 1.1.0 + version: 1.1.0(@atproto/lex@0.3.8) '@emoji-mart/data': specifier: ^1.2.1 version: 1.2.1 @@ -1750,8 +1750,8 @@ packages: react: '*' react-native: '*' - '@bsky/sdk@1.0.1': - resolution: {integrity: sha512-COhrmIZFsUJ1gj4gvqm2UWkRhTbmXZAL1osTZKgSaFftD4Xy0RRl8S9SRluCxo4/VPw/R3p7NQZ4X3BQFbUjVQ==} + '@bsky/sdk@1.1.0': + resolution: {integrity: sha512-6ZSrhNa6KC0arcimBG79Otk+FGZ15Br1YaEeatncbJYfWXGsw9l9nPjZ38IVpSW7UqQ/uhZ+WSQGnVUAdekzaw==} engines: {node: '>=22.12.0'} peerDependencies: '@atproto/lex': ^0.3.0 @@ -10770,7 +10770,7 @@ snapshots: react: 19.2.3 react-native: 0.86.0(patch_hash=dd549527bb84c88acc7b0b1d521c9f4b666fda484c75cd0b282f8e9838524909)(@babel/core@7.29.0(supports-color@8.1.1))(@react-native/jest-preset@0.86.0(@babel/core@7.29.0(supports-color@8.1.1))(react@19.2.3)(supports-color@8.1.1))(@react-native/metro-config@0.86.0(@babel/core@7.29.0(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.18)(react@19.2.3)(supports-color@8.1.1) - '@bsky/sdk@1.0.1(@atproto/lex@0.3.8)': + '@bsky/sdk@1.1.0(@atproto/lex@0.3.8)': dependencies: '@atproto-labs/handle-resolver': 0.4.8 '@atproto/lex': 0.3.8 From e39d308fb7c928f222c154f69c042e787641b0de Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Fri, 28 Aug 2026 18:25:53 +0300 Subject: [PATCH 13/14] APP-2976: Remove react-native-compressor remnants (#11588) --- app.config.js | 1 - .../android/build.gradle | 15 - .../blueskyvideocompress/CodecSelector.kt | 55 -- .../ExpoBlueskyVideoCompressModule.kt | 62 -- .../blueskyvideocompress/InputSurface.kt | 96 --- .../blueskyvideocompress/OutputSurface.kt | 61 -- .../blueskyvideocompress/TextureRenderer.kt | 156 ----- .../blueskyvideocompress/VideoCompressor.kt | 582 ------------------ .../blueskyvideocompress/VideoProber.kt | 125 ---- .../expo-module.config.json | 9 - modules/expo-bluesky-video-compress/index.ts | 87 --- .../ios/CodecCapability.swift | 21 - .../ios/ExpoBlueskyVideoCompress.podspec | 20 - .../ios/ExpoBlueskyVideoCompressModule.swift | 74 --- .../ios/VideoCompressor.swift | 410 ------------ .../ios/VideoProber.swift | 105 ---- .../src/ExpoBlueskyVideoCompressModule.ts | 23 - .../expo-bluesky-video-compress/src/types.ts | 45 -- package.json | 1 - patches/react-native-compressor@1.13.0.patch | 59 -- .../react-native-compressor@1.13.0.patch.md | 5 - pnpm-lock.yaml | 17 - pnpm-workspace.yaml | 2 - src/lib/media/video/compress.ts | 2 - src/view/com/composer/Composer.tsx | 8 +- .../videos/VideoTranscodeBackdrop.tsx | 33 +- src/view/com/composer/videos/metadata.ts | 22 +- src/view/com/composer/videos/metadata.web.ts | 1 + 28 files changed, 49 insertions(+), 2048 deletions(-) delete mode 100644 modules/expo-bluesky-video-compress/android/build.gradle delete mode 100644 modules/expo-bluesky-video-compress/android/src/main/java/expo/modules/blueskyvideocompress/CodecSelector.kt delete mode 100644 modules/expo-bluesky-video-compress/android/src/main/java/expo/modules/blueskyvideocompress/ExpoBlueskyVideoCompressModule.kt delete mode 100644 modules/expo-bluesky-video-compress/android/src/main/java/expo/modules/blueskyvideocompress/InputSurface.kt delete mode 100644 modules/expo-bluesky-video-compress/android/src/main/java/expo/modules/blueskyvideocompress/OutputSurface.kt delete mode 100644 modules/expo-bluesky-video-compress/android/src/main/java/expo/modules/blueskyvideocompress/TextureRenderer.kt delete mode 100644 modules/expo-bluesky-video-compress/android/src/main/java/expo/modules/blueskyvideocompress/VideoCompressor.kt delete mode 100644 modules/expo-bluesky-video-compress/android/src/main/java/expo/modules/blueskyvideocompress/VideoProber.kt delete mode 100644 modules/expo-bluesky-video-compress/expo-module.config.json delete mode 100644 modules/expo-bluesky-video-compress/index.ts delete mode 100644 modules/expo-bluesky-video-compress/ios/CodecCapability.swift delete mode 100644 modules/expo-bluesky-video-compress/ios/ExpoBlueskyVideoCompress.podspec delete mode 100644 modules/expo-bluesky-video-compress/ios/ExpoBlueskyVideoCompressModule.swift delete mode 100644 modules/expo-bluesky-video-compress/ios/VideoCompressor.swift delete mode 100644 modules/expo-bluesky-video-compress/ios/VideoProber.swift delete mode 100644 modules/expo-bluesky-video-compress/src/ExpoBlueskyVideoCompressModule.ts delete mode 100644 modules/expo-bluesky-video-compress/src/types.ts delete mode 100644 patches/react-native-compressor@1.13.0.patch delete mode 100644 patches/react-native-compressor@1.13.0.patch.md diff --git a/app.config.js b/app.config.js index bf10a1b7d8..b9b4a42a9d 100644 --- a/app.config.js +++ b/app.config.js @@ -285,7 +285,6 @@ module.exports = function (_config) { sounds: PLATFORM === 'ios' ? ['assets/dm.aiff'] : ['assets/dm.mp3'], }, ], - 'react-native-compressor', [ '@bitdrift/react-native', { diff --git a/modules/expo-bluesky-video-compress/android/build.gradle b/modules/expo-bluesky-video-compress/android/build.gradle deleted file mode 100644 index b1b719263b..0000000000 --- a/modules/expo-bluesky-video-compress/android/build.gradle +++ /dev/null @@ -1,15 +0,0 @@ -plugins { - id 'com.android.library' - id 'expo-module-gradle-plugin' -} - -group = 'expo.modules.blueskyvideocompress' -version = '1.0.0' - -android { - namespace "expo.modules.blueskyvideocompress" - defaultConfig { - versionCode 1 - versionName "1.0.0" - } -} diff --git a/modules/expo-bluesky-video-compress/android/src/main/java/expo/modules/blueskyvideocompress/CodecSelector.kt b/modules/expo-bluesky-video-compress/android/src/main/java/expo/modules/blueskyvideocompress/CodecSelector.kt deleted file mode 100644 index f35698486a..0000000000 --- a/modules/expo-bluesky-video-compress/android/src/main/java/expo/modules/blueskyvideocompress/CodecSelector.kt +++ /dev/null @@ -1,55 +0,0 @@ -package expo.modules.blueskyvideocompress - -import android.media.MediaCodecInfo -import android.media.MediaCodecList -import android.media.MediaFormat -import android.os.Build - -object CodecSelector { - // Source: https://github.com/numandev1/react-native-compressor/blob/f949b0868055178e7c8753e05202f784b1bcd589/android/src/main/java/com/reactnativecompressor/Video/VideoCompressor/compressor/Compressor.kt#L500 - private val AVC_DENYLIST = setOf( - "c2.qti.avc.encoder" - ) - - private val SOFTWARE_PREFIXES = listOf( - "OMX.google.", - "c2.android.", - "c2.google." - ) - - data class EncoderInfo( - val name: String, - val mime: String, - val isHardware: Boolean - ) - - fun findEncoder(mime: String, requireHardware: Boolean): EncoderInfo? { - val codecList = MediaCodecList(MediaCodecList.REGULAR_CODECS) - val candidates = codecList.codecInfos - .filter { it.isEncoder } - .filter { it.supportedTypes.any { t -> t.equals(mime, ignoreCase = true) } } - .filter { !it.name.contains("secure", ignoreCase = true) } - .filter { !(mime == MediaFormat.MIMETYPE_VIDEO_AVC && AVC_DENYLIST.contains(it.name)) } - - val hardware = candidates.filter { isHardware(it) } - val selected = if (requireHardware) { - hardware.firstOrNull() - } else { - hardware.firstOrNull() ?: candidates.firstOrNull() - } - selected ?: return null - return EncoderInfo( - name = selected.name, - mime = mime, - isHardware = isHardware(selected) - ) - } - - private fun isHardware(info: MediaCodecInfo): Boolean { - return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - info.isHardwareAccelerated - } else { - SOFTWARE_PREFIXES.none { info.name.startsWith(it, ignoreCase = true) } - } - } -} diff --git a/modules/expo-bluesky-video-compress/android/src/main/java/expo/modules/blueskyvideocompress/ExpoBlueskyVideoCompressModule.kt b/modules/expo-bluesky-video-compress/android/src/main/java/expo/modules/blueskyvideocompress/ExpoBlueskyVideoCompressModule.kt deleted file mode 100644 index 30b1def7c6..0000000000 --- a/modules/expo-bluesky-video-compress/android/src/main/java/expo/modules/blueskyvideocompress/ExpoBlueskyVideoCompressModule.kt +++ /dev/null @@ -1,62 +0,0 @@ -package expo.modules.blueskyvideocompress - -import expo.modules.kotlin.modules.Module -import expo.modules.kotlin.modules.ModuleDefinition -import java.util.concurrent.ConcurrentHashMap - -class ExpoBlueskyVideoCompressModule : Module() { - private val activeCompressors = ConcurrentHashMap() - - override fun definition() = ModuleDefinition { - Name("ExpoBlueskyVideoCompress") - - Events("onProgress") - - AsyncFunction("probe") { uri: String -> - val context = appContext.reactContext - ?: throw Error("React context is null") - return@AsyncFunction VideoProber.probe(context, uri) - } - - AsyncFunction("compress") { uri: String, options: Map -> - val context = appContext.reactContext - ?: throw Error("React context is null") - val targetBitrate = (options["targetBitrate"] as? Number)?.toInt() ?: 0 - val maxSize = (options["maxSize"] as? Number)?.toInt() ?: 1920 - val codecPref = (options["codec"] as? String) ?: "auto" - val frameRateCap = ((options["frameRateCap"] as? Number)?.toInt() ?: 30).coerceAtLeast(1) - val jobId = (options["jobId"] as? Number)?.toInt() ?: 0 - - val compressor = VideoCompressor( - context = context, - uriString = uri, - targetBitrate = targetBitrate, - maxSize = maxSize, - codecPref = codecPref, - frameRateCap = frameRateCap, - jobId = jobId, - onProgress = { id, progress -> - sendEvent("onProgress", mapOf( - "id" to id, - "progress" to progress - )) - } - ) - - activeCompressors[jobId] = compressor - - try { - val result = compressor.compress() - activeCompressors.remove(jobId) - return@AsyncFunction result - } catch (e: Exception) { - activeCompressors.remove(jobId) - throw e - } - } - - Function("cancel") { jobId: Int -> - activeCompressors.remove(jobId)?.cancel() - } - } -} diff --git a/modules/expo-bluesky-video-compress/android/src/main/java/expo/modules/blueskyvideocompress/InputSurface.kt b/modules/expo-bluesky-video-compress/android/src/main/java/expo/modules/blueskyvideocompress/InputSurface.kt deleted file mode 100644 index e2eee4768c..0000000000 --- a/modules/expo-bluesky-video-compress/android/src/main/java/expo/modules/blueskyvideocompress/InputSurface.kt +++ /dev/null @@ -1,96 +0,0 @@ -package expo.modules.blueskyvideocompress - -import android.opengl.EGL14 -import android.opengl.EGLConfig -import android.opengl.EGLContext -import android.opengl.EGLDisplay -import android.opengl.EGLExt -import android.opengl.EGLSurface -import android.view.Surface - -class InputSurface(private val surface: Surface) { - private var eglDisplay: EGLDisplay = EGL14.EGL_NO_DISPLAY - private var eglContext: EGLContext = EGL14.EGL_NO_CONTEXT - private var eglSurface: EGLSurface = EGL14.EGL_NO_SURFACE - - init { - eglSetup() - } - - private fun eglSetup() { - eglDisplay = EGL14.eglGetDisplay(EGL14.EGL_DEFAULT_DISPLAY) - if (eglDisplay === EGL14.EGL_NO_DISPLAY) { - throw RuntimeException("unable to get EGL14 display") - } - - val version = IntArray(2) - if (!EGL14.eglInitialize(eglDisplay, version, 0, version, 1)) { - throw RuntimeException("unable to initialize EGL14") - } - - val attribList = intArrayOf( - EGL14.EGL_RED_SIZE, 8, - EGL14.EGL_GREEN_SIZE, 8, - EGL14.EGL_BLUE_SIZE, 8, - EGL14.EGL_ALPHA_SIZE, 8, - EGL14.EGL_RENDERABLE_TYPE, EGL14.EGL_OPENGL_ES2_BIT, - EGL14.EGL_SURFACE_TYPE, EGL14.EGL_WINDOW_BIT, - EGL14.EGL_NONE - ) - val configs = arrayOfNulls(1) - val numConfigs = IntArray(1) - EGL14.eglChooseConfig(eglDisplay, attribList, 0, configs, 0, 1, numConfigs, 0) - checkEglError("eglChooseConfig") - - val contextAttribs = intArrayOf( - EGL14.EGL_CONTEXT_CLIENT_VERSION, 2, - EGL14.EGL_NONE - ) - eglContext = EGL14.eglCreateContext( - eglDisplay, configs[0], EGL14.EGL_NO_CONTEXT, contextAttribs, 0 - ) - checkEglError("eglCreateContext") - - val surfaceAttribs = intArrayOf(EGL14.EGL_NONE) - eglSurface = EGL14.eglCreateWindowSurface( - eglDisplay, configs[0], surface, surfaceAttribs, 0 - ) - checkEglError("eglCreateWindowSurface") - } - - fun makeCurrent() { - EGL14.eglMakeCurrent(eglDisplay, eglSurface, eglSurface, eglContext) - checkEglError("eglMakeCurrent") - } - - fun swapBuffers(): Boolean { - return EGL14.eglSwapBuffers(eglDisplay, eglSurface) - } - - fun setPresentationTime(nsecs: Long) { - EGLExt.eglPresentationTimeANDROID(eglDisplay, eglSurface, nsecs) - } - - fun release() { - if (eglDisplay !== EGL14.EGL_NO_DISPLAY) { - EGL14.eglMakeCurrent( - eglDisplay, EGL14.EGL_NO_SURFACE, EGL14.EGL_NO_SURFACE, EGL14.EGL_NO_CONTEXT - ) - EGL14.eglDestroySurface(eglDisplay, eglSurface) - EGL14.eglDestroyContext(eglDisplay, eglContext) - EGL14.eglReleaseThread() - EGL14.eglTerminate(eglDisplay) - } - surface.release() - eglDisplay = EGL14.EGL_NO_DISPLAY - eglContext = EGL14.EGL_NO_CONTEXT - eglSurface = EGL14.EGL_NO_SURFACE - } - - private fun checkEglError(msg: String) { - val error = EGL14.eglGetError() - if (error != EGL14.EGL_SUCCESS) { - throw RuntimeException("$msg: EGL error: 0x${Integer.toHexString(error)}") - } - } -} diff --git a/modules/expo-bluesky-video-compress/android/src/main/java/expo/modules/blueskyvideocompress/OutputSurface.kt b/modules/expo-bluesky-video-compress/android/src/main/java/expo/modules/blueskyvideocompress/OutputSurface.kt deleted file mode 100644 index fdf8c6eca2..0000000000 --- a/modules/expo-bluesky-video-compress/android/src/main/java/expo/modules/blueskyvideocompress/OutputSurface.kt +++ /dev/null @@ -1,61 +0,0 @@ -package expo.modules.blueskyvideocompress - -import android.graphics.SurfaceTexture -import android.os.Handler -import android.os.HandlerThread -import android.view.Surface - -class OutputSurface : SurfaceTexture.OnFrameAvailableListener { - private val renderer = TextureRenderer() - private var surfaceTexture: SurfaceTexture? = null - private val stMatrix = FloatArray(16) - private val callbackThread = HandlerThread("OutputSurfaceCallbacks") - val surface: Surface - - @Volatile - private var frameAvailable = false - private val frameSyncObject = Object() - - init { - renderer.surfaceCreated() - callbackThread.start() - val handler = Handler(callbackThread.looper) - surfaceTexture = SurfaceTexture(renderer.getTextureId()).also { - it.setOnFrameAvailableListener(this, handler) - } - surface = Surface(surfaceTexture) - } - - fun release() { - surface.release() - surfaceTexture?.release() - surfaceTexture = null - callbackThread.quitSafely() - } - - fun awaitNewImage() { - val timeoutMs = 2500L - synchronized(frameSyncObject) { - while (!frameAvailable) { - frameSyncObject.wait(timeoutMs) - if (!frameAvailable) { - throw RuntimeException("Surface frame wait timed out") - } - } - frameAvailable = false - } - surfaceTexture!!.updateTexImage() - } - - fun drawImage() { - surfaceTexture!!.getTransformMatrix(stMatrix) - renderer.drawFrame(stMatrix) - } - - override fun onFrameAvailable(st: SurfaceTexture) { - synchronized(frameSyncObject) { - frameAvailable = true - frameSyncObject.notifyAll() - } - } -} diff --git a/modules/expo-bluesky-video-compress/android/src/main/java/expo/modules/blueskyvideocompress/TextureRenderer.kt b/modules/expo-bluesky-video-compress/android/src/main/java/expo/modules/blueskyvideocompress/TextureRenderer.kt deleted file mode 100644 index a0cb25bce1..0000000000 --- a/modules/expo-bluesky-video-compress/android/src/main/java/expo/modules/blueskyvideocompress/TextureRenderer.kt +++ /dev/null @@ -1,156 +0,0 @@ -package expo.modules.blueskyvideocompress - -import android.opengl.GLES11Ext -import android.opengl.GLES20 -import android.opengl.Matrix -import java.nio.ByteBuffer -import java.nio.ByteOrder -import java.nio.FloatBuffer - -class TextureRenderer { - companion object { - private const val FLOAT_SIZE_BYTES = 4 - private const val STRIDE_BYTES = 5 * FLOAT_SIZE_BYTES - private const val POS_OFFSET = 0 - private const val UV_OFFSET = 3 - - private val VERTICES = floatArrayOf( - -1.0f, -1.0f, 0f, 0f, 0f, - 1.0f, -1.0f, 0f, 1f, 0f, - -1.0f, 1.0f, 0f, 0f, 1f, - 1.0f, 1.0f, 0f, 1f, 1f, - ) - - private const val VERTEX_SHADER = """ - uniform mat4 uMVPMatrix; - uniform mat4 uSTMatrix; - attribute vec4 aPosition; - attribute vec4 aTextureCoord; - varying vec2 vTextureCoord; - void main() { - gl_Position = uMVPMatrix * aPosition; - vTextureCoord = (uSTMatrix * aTextureCoord).xy; - } - """ - - private const val FRAGMENT_SHADER = """ - #extension GL_OES_EGL_image_external : require - precision mediump float; - varying vec2 vTextureCoord; - uniform samplerExternalOES sTexture; - void main() { - gl_FragColor = texture2D(sTexture, vTextureCoord); - } - """ - } - - private val vertices: FloatBuffer = - ByteBuffer.allocateDirect(VERTICES.size * FLOAT_SIZE_BYTES) - .order(ByteOrder.nativeOrder()) - .asFloatBuffer() - .apply { put(VERTICES); position(0) } - - private val mvpMatrix = FloatArray(16) - private var program = 0 - private var textureId = -1 - private var uMVPMatrixHandle = 0 - private var uSTMatrixHandle = 0 - private var aPositionHandle = 0 - private var aTextureCoordHandle = 0 - - init { - Matrix.setIdentityM(mvpMatrix, 0) - } - - fun getTextureId(): Int = textureId - - fun surfaceCreated() { - program = createProgram(VERTEX_SHADER, FRAGMENT_SHADER) - - aPositionHandle = GLES20.glGetAttribLocation(program, "aPosition") - aTextureCoordHandle = GLES20.glGetAttribLocation(program, "aTextureCoord") - uMVPMatrixHandle = GLES20.glGetUniformLocation(program, "uMVPMatrix") - uSTMatrixHandle = GLES20.glGetUniformLocation(program, "uSTMatrix") - - val textures = IntArray(1) - GLES20.glGenTextures(1, textures, 0) - textureId = textures[0] - - GLES20.glBindTexture(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, textureId) - GLES20.glTexParameterf( - GLES11Ext.GL_TEXTURE_EXTERNAL_OES, - GLES20.GL_TEXTURE_MIN_FILTER, GLES20.GL_LINEAR.toFloat() - ) - GLES20.glTexParameterf( - GLES11Ext.GL_TEXTURE_EXTERNAL_OES, - GLES20.GL_TEXTURE_MAG_FILTER, GLES20.GL_LINEAR.toFloat() - ) - GLES20.glTexParameteri( - GLES11Ext.GL_TEXTURE_EXTERNAL_OES, - GLES20.GL_TEXTURE_WRAP_S, GLES20.GL_CLAMP_TO_EDGE - ) - GLES20.glTexParameteri( - GLES11Ext.GL_TEXTURE_EXTERNAL_OES, - GLES20.GL_TEXTURE_WRAP_T, GLES20.GL_CLAMP_TO_EDGE - ) - } - - fun drawFrame(stMatrix: FloatArray) { - GLES20.glClearColor(0f, 0f, 0f, 1f) - GLES20.glClear(GLES20.GL_DEPTH_BUFFER_BIT or GLES20.GL_COLOR_BUFFER_BIT) - - GLES20.glUseProgram(program) - GLES20.glActiveTexture(GLES20.GL_TEXTURE0) - GLES20.glBindTexture(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, textureId) - - vertices.position(POS_OFFSET) - GLES20.glVertexAttribPointer( - aPositionHandle, 3, GLES20.GL_FLOAT, false, STRIDE_BYTES, vertices - ) - GLES20.glEnableVertexAttribArray(aPositionHandle) - - vertices.position(UV_OFFSET) - GLES20.glVertexAttribPointer( - aTextureCoordHandle, 2, GLES20.GL_FLOAT, false, STRIDE_BYTES, vertices - ) - GLES20.glEnableVertexAttribArray(aTextureCoordHandle) - - GLES20.glUniformMatrix4fv(uMVPMatrixHandle, 1, false, mvpMatrix, 0) - GLES20.glUniformMatrix4fv(uSTMatrixHandle, 1, false, stMatrix, 0) - - GLES20.glDrawArrays(GLES20.GL_TRIANGLE_STRIP, 0, 4) - GLES20.glDisableVertexAttribArray(aPositionHandle) - GLES20.glDisableVertexAttribArray(aTextureCoordHandle) - } - - private fun createProgram(vertexSource: String, fragmentSource: String): Int { - val vertexShader = loadShader(GLES20.GL_VERTEX_SHADER, vertexSource) - val fragmentShader = loadShader(GLES20.GL_FRAGMENT_SHADER, fragmentSource) - val program = GLES20.glCreateProgram() - GLES20.glAttachShader(program, vertexShader) - GLES20.glAttachShader(program, fragmentShader) - GLES20.glLinkProgram(program) - val linkStatus = IntArray(1) - GLES20.glGetProgramiv(program, GLES20.GL_LINK_STATUS, linkStatus, 0) - if (linkStatus[0] != GLES20.GL_TRUE) { - val log = GLES20.glGetProgramInfoLog(program) - GLES20.glDeleteProgram(program) - throw RuntimeException("Could not link program: $log") - } - return program - } - - private fun loadShader(type: Int, source: String): Int { - val shader = GLES20.glCreateShader(type) - GLES20.glShaderSource(shader, source) - GLES20.glCompileShader(shader) - val compiled = IntArray(1) - GLES20.glGetShaderiv(shader, GLES20.GL_COMPILE_STATUS, compiled, 0) - if (compiled[0] == 0) { - val log = GLES20.glGetShaderInfoLog(shader) - GLES20.glDeleteShader(shader) - throw RuntimeException("Could not compile shader $type: $log") - } - return shader - } -} diff --git a/modules/expo-bluesky-video-compress/android/src/main/java/expo/modules/blueskyvideocompress/VideoCompressor.kt b/modules/expo-bluesky-video-compress/android/src/main/java/expo/modules/blueskyvideocompress/VideoCompressor.kt deleted file mode 100644 index e9c99bc9c4..0000000000 --- a/modules/expo-bluesky-video-compress/android/src/main/java/expo/modules/blueskyvideocompress/VideoCompressor.kt +++ /dev/null @@ -1,582 +0,0 @@ -package expo.modules.blueskyvideocompress - -import android.content.Context -import android.media.MediaCodec -import android.media.MediaCodecInfo -import android.media.MediaExtractor -import android.media.MediaFormat -import android.media.MediaMuxer -import android.net.Uri -import android.os.Build -import android.util.Log -import java.io.File -import java.nio.ByteBuffer -import java.util.UUID - -class VideoCompressor( - private val context: Context, - private val uriString: String, - private val targetBitrate: Int, - private val maxSize: Int, - private val codecPref: String, - private val frameRateCap: Int, - private val jobId: Int, - private val onProgress: (Int, Double) -> Unit -) { - companion object { - private const val TAG = "BskyVideoCompress" - private const val TIMEOUT_DEQUEUE = 100L - private const val I_FRAME_INTERVAL = 3 - } - - @Volatile - private var isCancelled = false - - fun cancel() { - isCancelled = true - } - - fun compress(): Map { - // 'auto' targets h264 — server pipeline is HLS, which favors h264 (HEVC needs - // fMP4 segments + commercial licensing). HEVC remains opt-in via codec: 'hevc'. - val tryHevc = codecPref == "hevc" - - if (tryHevc) { - try { - return doCompress(useHevc = true, allowSoftwareFallback = false) - } catch (e: Exception) { - if (codecPref == "hevc" || isCancelled) throw e - Log.w(TAG, "HEVC encode failed, falling back to h264", e) - } - } - return doCompress(useHevc = false, allowSoftwareFallback = true) - } - - private fun doCompress(useHevc: Boolean, allowSoftwareFallback: Boolean): Map { - val mime = if (useHevc) MediaFormat.MIMETYPE_VIDEO_HEVC else MediaFormat.MIMETYPE_VIDEO_AVC - val encoderInfo = CodecSelector.findEncoder(mime, requireHardware = !allowSoftwareFallback) - ?: throw RuntimeException("No encoder for $mime") - - try { - return runPipeline(encoderInfo, useHevc) - } catch (e: Exception) { - if (!allowSoftwareFallback || isCancelled || !encoderInfo.isHardware) throw e - Log.w(TAG, "Hardware encoder ${encoderInfo.name} failed, trying software", e) - val sw = CodecSelector.findEncoder(mime, requireHardware = false) - ?.takeIf { !it.isHardware } - ?: throw e - return runPipeline(sw, useHevc) - } - } - - private fun runPipeline( - encoderInfo: CodecSelector.EncoderInfo, - useHevc: Boolean - ): Map { - val outputFile = File(context.cacheDir, "${UUID.randomUUID()}.mp4") - - var extractor: MediaExtractor? = null - var muxer: MediaMuxer? = null - var encoder: MediaCodec? = null - var decoder: MediaCodec? = null - var inputSurface: InputSurface? = null - var outputSurface: OutputSurface? = null - var muxerStarted = false - var outputDims = Pair(0, 0) - var durationUs = 0L - - try { - val uri = Uri.parse(uriString) - extractor = MediaExtractor() - if (uriString.startsWith("content://") || uriString.startsWith("file://")) { - extractor.setDataSource(context, uri, null) - } else { - extractor.setDataSource(uriString) - } - - var videoTrackIndex = -1 - var audioTrackIndex = -1 - var videoFormat: MediaFormat? = null - var audioFormat: MediaFormat? = null - - for (i in 0 until extractor.trackCount) { - val format = extractor.getTrackFormat(i) - val trackMime = format.getString(MediaFormat.KEY_MIME) ?: continue - if (trackMime.startsWith("video/") && videoTrackIndex == -1) { - videoTrackIndex = i - videoFormat = format - } else if (trackMime.startsWith("audio/") && audioTrackIndex == -1) { - audioTrackIndex = i - audioFormat = format - } - } - - if (videoTrackIndex == -1 || videoFormat == null) { - throw RuntimeException("No video track found") - } - - val sourceWidth = videoFormat.getInteger(MediaFormat.KEY_WIDTH) - val sourceHeight = videoFormat.getInteger(MediaFormat.KEY_HEIGHT) - val rotation = if (videoFormat.containsKey(MediaFormat.KEY_ROTATION)) { - videoFormat.getInteger(MediaFormat.KEY_ROTATION) - } else 0 - durationUs = if (videoFormat.containsKey(MediaFormat.KEY_DURATION)) { - videoFormat.getLong(MediaFormat.KEY_DURATION) - } else 0L - val sourceFps = if (videoFormat.containsKey(MediaFormat.KEY_FRAME_RATE)) { - videoFormat.getInteger(MediaFormat.KEY_FRAME_RATE) - } else 30 - - outputDims = calculateOutputDims(sourceWidth, sourceHeight, rotation, maxSize) - val shouldPassthroughAudio = audioFormat != null && canPassthroughAudio(audioFormat) - val transcodedAudio: TranscodedAudio? = if ( - audioTrackIndex >= 0 && audioFormat != null && !shouldPassthroughAudio - ) { - transcodeAudioToAAC(audioTrackIndex, audioFormat) - } else null - muxer = MediaMuxer(outputFile.absolutePath, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4) - - val effectiveBitrate = if (targetBitrate > 0) { - targetBitrate - } else if (useHevc) 2_500_000 else 3_000_000 - - val encoderFormat = MediaFormat.createVideoFormat( - encoderInfo.mime, outputDims.first, outputDims.second - ).apply { - setInteger( - MediaFormat.KEY_COLOR_FORMAT, - MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface - ) - setInteger(MediaFormat.KEY_BIT_RATE, effectiveBitrate) - setInteger( - MediaFormat.KEY_BITRATE_MODE, - MediaCodecInfo.EncoderCapabilities.BITRATE_MODE_CBR - ) - setInteger(MediaFormat.KEY_FRAME_RATE, frameRateCap) - setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, I_FRAME_INTERVAL) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { - setInteger(MediaFormat.KEY_COLOR_STANDARD, MediaFormat.COLOR_STANDARD_BT709) - setInteger(MediaFormat.KEY_COLOR_TRANSFER, MediaFormat.COLOR_TRANSFER_SDR_VIDEO) - setInteger(MediaFormat.KEY_COLOR_RANGE, MediaFormat.COLOR_RANGE_LIMITED) - } - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - setInteger(MediaFormat.KEY_PRIORITY, 0) - setInteger(MediaFormat.KEY_OPERATING_RATE, frameRateCap) - if (useHevc) { - setInteger( - MediaFormat.KEY_PROFILE, - MediaCodecInfo.CodecProfileLevel.HEVCProfileMain - ) - } else { - setInteger( - MediaFormat.KEY_PROFILE, - MediaCodecInfo.CodecProfileLevel.AVCProfileHigh - ) - setInteger( - MediaFormat.KEY_LEVEL, - MediaCodecInfo.CodecProfileLevel.AVCLevel41 - ) - } - } - } - - encoder = MediaCodec.createByCodecName(encoderInfo.name) - encoder.configure(encoderFormat, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE) - val encoderInputSurface = encoder.createInputSurface() - inputSurface = InputSurface(encoderInputSurface) - inputSurface.makeCurrent() - outputSurface = OutputSurface() - encoder.start() - - decoder = MediaCodec.createDecoderByType( - videoFormat.getString(MediaFormat.KEY_MIME) ?: "video/avc" - ) - // Ask the decoder to tone-map HDR (HLG/PQ) sources to SDR. Vendors may - // ignore the hint, but where supported it produces correct BT.709 pixels - // for the encoder rather than HDR pixels mislabeled as SDR. - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - videoFormat.setInteger( - MediaFormat.KEY_COLOR_TRANSFER_REQUEST, - MediaFormat.COLOR_TRANSFER_SDR_VIDEO - ) - } - decoder.configure(videoFormat, outputSurface.surface, null, 0) - decoder.start() - extractor.selectTrack(videoTrackIndex) - - val frameDropEnabled = sourceFps > frameRateCap - val targetFrameIntervalUs = if (frameDropEnabled) 1_000_000L / frameRateCap else 0L - var nextTargetPtsUs = 0L - - var muxerVideoTrack = -1 - var muxerAudioTrack = -1 - - val bufferInfo = MediaCodec.BufferInfo() - var inputDone = false - var decoderDone = false - var outputDone = false - var lastProgressMs = 0L - - while (!outputDone && !isCancelled) { - if (!inputDone) { - val idx = decoder.dequeueInputBuffer(TIMEOUT_DEQUEUE) - if (idx >= 0) { - val buf = decoder.getInputBuffer(idx) - if (buf != null) { - val sz = extractor.readSampleData(buf, 0) - if (sz < 0) { - decoder.queueInputBuffer( - idx, 0, 0, 0, MediaCodec.BUFFER_FLAG_END_OF_STREAM - ) - inputDone = true - } else { - decoder.queueInputBuffer(idx, 0, sz, extractor.sampleTime, 0) - extractor.advance() - } - } - } - } - - if (!decoderDone) { - val status = decoder.dequeueOutputBuffer(bufferInfo, TIMEOUT_DEQUEUE) - if (status >= 0) { - val isEos = bufferInfo.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0 - val shouldRender = if (isEos) { - false - } else if (frameDropEnabled) { - if (bufferInfo.presentationTimeUs >= nextTargetPtsUs) { - nextTargetPtsUs = bufferInfo.presentationTimeUs + targetFrameIntervalUs - true - } else false - } else true - decoder.releaseOutputBuffer(status, shouldRender) - if (shouldRender) { - outputSurface.awaitNewImage() - outputSurface.drawImage() - inputSurface.setPresentationTime(bufferInfo.presentationTimeUs * 1000) - inputSurface.swapBuffers() - } - if (isEos) { - encoder.signalEndOfInputStream() - decoderDone = true - } - } - } - - var encoderDrained = false - while (!outputDone && !isCancelled && !encoderDrained) { - val encIdx = encoder.dequeueOutputBuffer(bufferInfo, 0) - when { - encIdx == MediaCodec.INFO_TRY_AGAIN_LATER -> encoderDrained = true - encIdx == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED -> { - if (!muxerStarted) { - muxerVideoTrack = muxer.addTrack(encoder.outputFormat) - if (audioTrackIndex >= 0 && audioFormat != null) { - if (shouldPassthroughAudio) { - muxerAudioTrack = muxer.addTrack(audioFormat) - } else if (transcodedAudio != null) { - muxerAudioTrack = muxer.addTrack(transcodedAudio.outputFormat) - } - } - muxer.start() - muxerStarted = true - } - } - encIdx >= 0 -> { - val data = encoder.getOutputBuffer(encIdx) - if (bufferInfo.flags and MediaCodec.BUFFER_FLAG_CODEC_CONFIG != 0) { - bufferInfo.size = 0 - } - if (data != null && bufferInfo.size > 0 && muxerStarted) { - muxer.writeSampleData(muxerVideoTrack, data, bufferInfo) - } - val isEos = bufferInfo.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0 - encoder.releaseOutputBuffer(encIdx, false) - if (isEos) { - outputDone = true - } else if (durationUs > 0) { - val now = System.currentTimeMillis() - if (now - lastProgressMs >= 100) { - lastProgressMs = now - val p = (bufferInfo.presentationTimeUs.toDouble() / durationUs) - .coerceIn(0.0, 1.0) - onProgress(jobId, p) - } - } - } - } - } - } - - if (audioTrackIndex >= 0 && muxerAudioTrack >= 0 && muxerStarted && !isCancelled) { - if (shouldPassthroughAudio) { - passthroughAudio(audioTrackIndex, muxer, muxerAudioTrack) - } else if (transcodedAudio != null) { - writeTranscodedAudio(transcodedAudio.samples, muxer, muxerAudioTrack) - } - } - } finally { - try { decoder?.stop() } catch (_: Exception) {} - try { decoder?.release() } catch (_: Exception) {} - try { encoder?.stop() } catch (_: Exception) {} - try { encoder?.release() } catch (_: Exception) {} - try { outputSurface?.release() } catch (_: Exception) {} - try { inputSurface?.release() } catch (_: Exception) {} - try { extractor?.release() } catch (_: Exception) {} - try { - if (muxerStarted) muxer?.stop() - muxer?.release() - } catch (_: Exception) {} - } - - if (isCancelled) { - outputFile.delete() - throw RuntimeException("Compression cancelled") - } - - onProgress(jobId, 1.0) - val durationSeconds = durationUs / 1_000_000.0 - - return mapOf( - "uri" to "file://${outputFile.absolutePath}", - "size" to outputFile.length(), - "mimeType" to "video/mp4", - "width" to outputDims.first, - "height" to outputDims.second, - "duration" to durationSeconds, - "codec" to if (useHevc) "hevc" else "h264" - ) - } - - private fun passthroughAudio(audioTrackIndex: Int, muxer: MediaMuxer, muxerAudioTrack: Int) { - val audioExtractor = MediaExtractor() - if (uriString.startsWith("content://") || uriString.startsWith("file://")) { - audioExtractor.setDataSource(context, Uri.parse(uriString), null) - } else { - audioExtractor.setDataSource(uriString) - } - audioExtractor.selectTrack(audioTrackIndex) - audioExtractor.seekTo(0, MediaExtractor.SEEK_TO_CLOSEST_SYNC) - - val buffer = ByteBuffer.allocate(256 * 1024) - val info = MediaCodec.BufferInfo() - try { - while (!isCancelled) { - val sz = audioExtractor.readSampleData(buffer, 0) - if (sz < 0) break - info.offset = 0 - info.size = sz - info.presentationTimeUs = audioExtractor.sampleTime - info.flags = audioExtractor.sampleFlags - muxer.writeSampleData(muxerAudioTrack, buffer, info) - audioExtractor.advance() - } - } finally { - audioExtractor.release() - } - } - - private fun canPassthroughAudio(format: MediaFormat): Boolean { - val mime = format.getString(MediaFormat.KEY_MIME) ?: return false - return mime == MediaFormat.MIMETYPE_AUDIO_AAC - } - - private data class TranscodedAudio( - val outputFormat: MediaFormat, - val samples: List - ) { - data class Sample( - val bytes: ByteArray, - val presentationTimeUs: Long, - val flags: Int - ) - } - - // Re-encode non-AAC source audio (Opus, Vorbis, etc.) to AAC so the mp4 muxer - // can take it. iOS always re-encodes to AAC; without this Android would drop - // the audio track entirely. - private fun transcodeAudioToAAC( - audioTrackIndex: Int, - sourceFormat: MediaFormat - ): TranscodedAudio? { - val sourceMime = sourceFormat.getString(MediaFormat.KEY_MIME) ?: return null - val sampleRate = if (sourceFormat.containsKey(MediaFormat.KEY_SAMPLE_RATE)) - sourceFormat.getInteger(MediaFormat.KEY_SAMPLE_RATE) else 44100 - val channelCount = if (sourceFormat.containsKey(MediaFormat.KEY_CHANNEL_COUNT)) - sourceFormat.getInteger(MediaFormat.KEY_CHANNEL_COUNT).coerceIn(1, 2) else 2 - - val audioExtractor = MediaExtractor() - if (uriString.startsWith("content://") || uriString.startsWith("file://")) { - audioExtractor.setDataSource(context, Uri.parse(uriString), null) - } else { - audioExtractor.setDataSource(uriString) - } - audioExtractor.selectTrack(audioTrackIndex) - - var decoder: MediaCodec? = null - var encoder: MediaCodec? = null - try { - decoder = MediaCodec.createDecoderByType(sourceMime) - decoder.configure(sourceFormat, null, null, 0) - decoder.start() - - val encoderFormat = MediaFormat.createAudioFormat( - MediaFormat.MIMETYPE_AUDIO_AAC, sampleRate, channelCount - ).apply { - setInteger( - MediaFormat.KEY_AAC_PROFILE, - MediaCodecInfo.CodecProfileLevel.AACObjectLC - ) - setInteger(MediaFormat.KEY_BIT_RATE, 128_000) - setInteger(MediaFormat.KEY_MAX_INPUT_SIZE, 256 * 1024) - } - encoder = MediaCodec.createEncoderByType(MediaFormat.MIMETYPE_AUDIO_AAC) - encoder.configure(encoderFormat, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE) - encoder.start() - - val samples = mutableListOf() - var outputFormat: MediaFormat? = null - var inputDone = false - var decoderDone = false - var encoderInputSignalled = false - var encoderDone = false - val info = MediaCodec.BufferInfo() - - while (!encoderDone && !isCancelled) { - if (!inputDone) { - val idx = decoder.dequeueInputBuffer(TIMEOUT_DEQUEUE) - if (idx >= 0) { - val buf = decoder.getInputBuffer(idx) - if (buf != null) { - val sz = audioExtractor.readSampleData(buf, 0) - if (sz < 0) { - decoder.queueInputBuffer( - idx, 0, 0, 0, MediaCodec.BUFFER_FLAG_END_OF_STREAM - ) - inputDone = true - } else { - decoder.queueInputBuffer( - idx, 0, sz, audioExtractor.sampleTime, audioExtractor.sampleFlags - ) - audioExtractor.advance() - } - } - } - } - - if (!decoderDone) { - val status = decoder.dequeueOutputBuffer(info, TIMEOUT_DEQUEUE) - when { - status == MediaCodec.INFO_TRY_AGAIN_LATER -> {} - status == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED -> {} - status >= 0 -> { - val isEos = info.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0 - val data = decoder.getOutputBuffer(status) - if (data != null && info.size > 0) { - val encInIdx = encoder.dequeueInputBuffer(TIMEOUT_DEQUEUE) - if (encInIdx >= 0) { - val encInBuf = encoder.getInputBuffer(encInIdx) - if (encInBuf != null) { - encInBuf.clear() - data.position(info.offset) - data.limit(info.offset + info.size) - encInBuf.put(data) - encoder.queueInputBuffer( - encInIdx, 0, info.size, info.presentationTimeUs, 0 - ) - } - } - } - decoder.releaseOutputBuffer(status, false) - if (isEos) { - if (!encoderInputSignalled) { - val encInIdx = encoder.dequeueInputBuffer(TIMEOUT_DEQUEUE * 10) - if (encInIdx >= 0) { - encoder.queueInputBuffer( - encInIdx, 0, 0, 0, MediaCodec.BUFFER_FLAG_END_OF_STREAM - ) - encoderInputSignalled = true - } - } - decoderDone = true - } - } - } - } - - val encOutIdx = encoder.dequeueOutputBuffer(info, TIMEOUT_DEQUEUE) - when { - encOutIdx == MediaCodec.INFO_TRY_AGAIN_LATER -> {} - encOutIdx == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED -> { - outputFormat = encoder.outputFormat - } - encOutIdx >= 0 -> { - val data = encoder.getOutputBuffer(encOutIdx) - val isEos = info.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0 - val isConfig = info.flags and MediaCodec.BUFFER_FLAG_CODEC_CONFIG != 0 - if (data != null && info.size > 0 && !isConfig) { - val bytes = ByteArray(info.size) - data.position(info.offset) - data.get(bytes, 0, info.size) - samples.add( - TranscodedAudio.Sample( - bytes = bytes, - presentationTimeUs = info.presentationTimeUs, - flags = info.flags and MediaCodec.BUFFER_FLAG_CODEC_CONFIG.inv() - ) - ) - } - encoder.releaseOutputBuffer(encOutIdx, false) - if (isEos) encoderDone = true - } - } - } - - val fmt = outputFormat ?: return null - return TranscodedAudio(fmt, samples) - } catch (e: Exception) { - Log.w(TAG, "Audio transcode failed; dropping audio", e) - return null - } finally { - try { decoder?.stop() } catch (_: Exception) {} - try { decoder?.release() } catch (_: Exception) {} - try { encoder?.stop() } catch (_: Exception) {} - try { encoder?.release() } catch (_: Exception) {} - audioExtractor.release() - } - } - - private fun writeTranscodedAudio( - samples: List, - muxer: MediaMuxer, - muxerAudioTrack: Int - ) { - val info = MediaCodec.BufferInfo() - for (sample in samples) { - if (isCancelled) break - val buffer = ByteBuffer.wrap(sample.bytes) - info.offset = 0 - info.size = sample.bytes.size - info.presentationTimeUs = sample.presentationTimeUs - info.flags = sample.flags - muxer.writeSampleData(muxerAudioTrack, buffer, info) - } - } - - private fun calculateOutputDims(srcW: Int, srcH: Int, rotation: Int, maxSize: Int): Pair { - val isRotated = rotation == 90 || rotation == 270 - val displayW = if (isRotated) srcH else srcW - val displayH = if (isRotated) srcW else srcH - val scale: Float = when { - displayW <= maxSize && displayH <= maxSize -> 1.0f - displayW > displayH -> maxSize.toFloat() / displayW - else -> maxSize.toFloat() / displayH - } - return Pair( - roundToEven((displayW * scale).toInt()), - roundToEven((displayH * scale).toInt()) - ) - } - - private fun roundToEven(v: Int): Int = if (v % 2 == 0) v else v - 1 -} diff --git a/modules/expo-bluesky-video-compress/android/src/main/java/expo/modules/blueskyvideocompress/VideoProber.kt b/modules/expo-bluesky-video-compress/android/src/main/java/expo/modules/blueskyvideocompress/VideoProber.kt deleted file mode 100644 index 7c214bddd0..0000000000 --- a/modules/expo-bluesky-video-compress/android/src/main/java/expo/modules/blueskyvideocompress/VideoProber.kt +++ /dev/null @@ -1,125 +0,0 @@ -package expo.modules.blueskyvideocompress - -import android.content.Context -import android.media.MediaExtractor -import android.media.MediaFormat -import android.media.MediaMetadataRetriever -import android.net.Uri -import android.os.Build - -object VideoProber { - fun probe(context: Context, uriString: String): Map { - val uri = Uri.parse(uriString) - val retriever = MediaMetadataRetriever() - - try { - if (uriString.startsWith("content://") || uriString.startsWith("file://")) { - retriever.setDataSource(context, uri) - } else { - retriever.setDataSource(uriString) - } - - val width = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH) - ?.toIntOrNull() ?: 0 - val height = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT) - ?.toIntOrNull() ?: 0 - val durationMs = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION) - ?.toLongOrNull() ?: 0L - val rotation = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION) - ?.toIntOrNull() ?: 0 - val bitrate = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_BITRATE) - ?.toIntOrNull() ?: 0 - val hasAudio = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_HAS_AUDIO) - ?.equals("yes") ?: false - val frameRate = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_CAPTURE_FRAMERATE) - ?.toFloatOrNull() ?: 0f - - val fileSize = getFileSize(context, uriString) - - val extractor = MediaExtractor() - var codec = "unknown" - var mimeType = "video/mp4" - var extractedFrameRate = frameRate - var isHDR = false - - try { - if (uriString.startsWith("content://") || uriString.startsWith("file://")) { - extractor.setDataSource(context, uri, null) - } else { - extractor.setDataSource(uriString) - } - - for (i in 0 until extractor.trackCount) { - val format = extractor.getTrackFormat(i) - val mime = format.getString(MediaFormat.KEY_MIME) - if (mime?.startsWith("video/") == true) { - mimeType = mime - codec = mime.removePrefix("video/") - if (format.containsKey(MediaFormat.KEY_FRAME_RATE)) { - extractedFrameRate = format.getInteger(MediaFormat.KEY_FRAME_RATE).toFloat() - } - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && - format.containsKey(MediaFormat.KEY_COLOR_TRANSFER) - ) { - val transfer = format.getInteger(MediaFormat.KEY_COLOR_TRANSFER) - isHDR = transfer == MediaFormat.COLOR_TRANSFER_HLG || - transfer == MediaFormat.COLOR_TRANSFER_ST2084 - } - // Dolby Vision tracks use codec-specific mimes that aren't covered by - // KEY_COLOR_TRANSFER on every device. - if (mime.contains("dolby-vision", ignoreCase = true)) { - isHDR = true - } - break - } - } - } finally { - extractor.release() - } - - val durationSeconds = durationMs / 1000.0 - val effectiveBitrate = if (bitrate > 0) { - bitrate - } else if (durationSeconds > 0 && fileSize > 0) { - (fileSize * 8 / durationSeconds).toInt() - } else { - 0 - } - - return mapOf( - "width" to width, - "height" to height, - "duration" to durationSeconds, - "bitrate" to effectiveBitrate, - "fileSize" to fileSize, - "mimeType" to mimeType, - "codec" to codec, - "hasAudio" to hasAudio, - "frameRate" to extractedFrameRate.toDouble(), - "rotation" to rotation, - "isHDR" to isHDR - ) - } finally { - retriever.release() - } - } - - private fun getFileSize(context: Context, uriString: String): Long { - return try { - if (uriString.startsWith("content://")) { - context.contentResolver.openFileDescriptor(Uri.parse(uriString), "r")?.use { - it.statSize - } ?: 0L - } else { - val path = if (uriString.startsWith("file://")) { - uriString.removePrefix("file://") - } else { - uriString - } - java.io.File(path).length() - } - } catch (_: Exception) { - 0L - } - } -} diff --git a/modules/expo-bluesky-video-compress/expo-module.config.json b/modules/expo-bluesky-video-compress/expo-module.config.json deleted file mode 100644 index c1c24a480d..0000000000 --- a/modules/expo-bluesky-video-compress/expo-module.config.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "platforms": ["ios", "android"], - "ios": { - "modules": ["ExpoBlueskyVideoCompressModule"] - }, - "android": { - "modules": ["expo.modules.blueskyvideocompress.ExpoBlueskyVideoCompressModule"] - } -} diff --git a/modules/expo-bluesky-video-compress/index.ts b/modules/expo-bluesky-video-compress/index.ts deleted file mode 100644 index 423d7f3ac2..0000000000 --- a/modules/expo-bluesky-video-compress/index.ts +++ /dev/null @@ -1,87 +0,0 @@ -import {type EventSubscription} from 'expo-modules-core' - -import NativeModule from './src/ExpoBlueskyVideoCompressModule' -import { - type CodecPreference, - type CompressCallbacks, - type CompressOptions, - type CompressResult, - type VideoMetadata, -} from './src/types' - -export type { - CodecPreference, - CompressCallbacks, - CompressOptions, - CompressResult, - VideoMetadata, -} - -class AbortError extends Error { - name = 'AbortError' - constructor() { - super('Aborted') - } -} - -let jobIdCounter = 0 - -export function probe(uri: string): Promise { - return NativeModule.probe(uri) -} - -export function compress( - uri: string, - options: CompressOptions = {}, - callbacks?: CompressCallbacks, -): Promise { - const jobId = ++jobIdCounter - let subscription: EventSubscription | undefined - - if (callbacks?.signal?.aborted) { - return Promise.reject(new AbortError()) - } - - const nativeOptions = { - targetBitrate: options.targetBitrate ?? 0, - maxSize: options.maxSize ?? 1920, - codec: options.codec ?? 'auto', - frameRateCap: options.frameRateCap ?? 30, - jobId, - } - - return new Promise((resolve, reject) => { - if (callbacks?.onProgress) { - subscription = NativeModule.addListener( - 'onProgress', - (event: {id: number; progress: number}) => { - if (event.id === jobId) { - callbacks.onProgress!(event.progress) - } - }, - ) - } - - const abortHandler = () => { - NativeModule.cancel(jobId) - subscription?.remove() - reject(new AbortError()) - } - - if (callbacks?.signal) { - callbacks.signal.addEventListener('abort', abortHandler, {once: true}) - } - - NativeModule.compress(uri, nativeOptions) - .then(result => { - callbacks?.signal?.removeEventListener('abort', abortHandler) - subscription?.remove() - resolve(result) - }) - .catch((error: unknown) => { - callbacks?.signal?.removeEventListener('abort', abortHandler) - subscription?.remove() - reject(error instanceof Error ? error : new Error(String(error))) - }) - }) -} diff --git a/modules/expo-bluesky-video-compress/ios/CodecCapability.swift b/modules/expo-bluesky-video-compress/ios/CodecCapability.swift deleted file mode 100644 index df95d9b7e8..0000000000 --- a/modules/expo-bluesky-video-compress/ios/CodecCapability.swift +++ /dev/null @@ -1,21 +0,0 @@ -import VideoToolbox - -enum CodecCapability { - static let isHardwareHEVCEncodeAvailable: Bool = { - var encoderListCF: CFArray? - let status = VTCopyVideoEncoderList(nil, &encoderListCF) - guard status == noErr, let encoderList = encoderListCF as? [[String: Any]] else { - return false - } - return encoderList.contains { encoder in - guard let codecTypeValue = encoder[kVTVideoEncoderList_CodecType as String] as? Int, - codecTypeValue == Int(kCMVideoCodecType_HEVC) else { - return false - } - if let isHardware = encoder[kVTVideoEncoderList_IsHardwareAccelerated as String] as? Bool { - return isHardware - } - return true - } - }() -} diff --git a/modules/expo-bluesky-video-compress/ios/ExpoBlueskyVideoCompress.podspec b/modules/expo-bluesky-video-compress/ios/ExpoBlueskyVideoCompress.podspec deleted file mode 100644 index 5bca8f0812..0000000000 --- a/modules/expo-bluesky-video-compress/ios/ExpoBlueskyVideoCompress.podspec +++ /dev/null @@ -1,20 +0,0 @@ -Pod::Spec.new do |s| - s.name = 'ExpoBlueskyVideoCompress' - s.version = '1.0.0' - s.summary = 'Hardware-accelerated video compression for Bluesky' - s.description = 'Hardware-accelerated h264/HEVC video compression using AVAssetReader/Writer and VideoToolbox on iOS' - s.author = '' - s.homepage = 'https://github.com/bluesky-social/social-app' - s.platforms = { :ios => '15.1' } - s.source = { git: '' } - s.static_framework = true - - s.dependency 'ExpoModulesCore' - - s.pod_target_xcconfig = { - 'DEFINES_MODULE' => 'YES', - 'SWIFT_COMPILATION_MODE' => 'wholemodule' - } - - s.source_files = "**/*.{h,m,mm,swift,hpp,cpp}" -end diff --git a/modules/expo-bluesky-video-compress/ios/ExpoBlueskyVideoCompressModule.swift b/modules/expo-bluesky-video-compress/ios/ExpoBlueskyVideoCompressModule.swift deleted file mode 100644 index 3bcea3c9f4..0000000000 --- a/modules/expo-bluesky-video-compress/ios/ExpoBlueskyVideoCompressModule.swift +++ /dev/null @@ -1,74 +0,0 @@ -import AVFoundation -import ExpoModulesCore - -public class ExpoBlueskyVideoCompressModule: Module { - private var activeCompressors: [Int: VideoCompressor] = [:] - private let activeCompressorsLock = NSLock() - - public func definition() -> ModuleDefinition { - Name("ExpoBlueskyVideoCompress") - - Events("onProgress") - - AsyncFunction("probe") { (uri: String) -> [String: Any] in - let url = URL(string: uri) ?? URL(fileURLWithPath: uri) - return try await VideoProber.probe(url: url) - } - - AsyncFunction("compress") { (uri: String, options: [String: Any]) -> [String: Any] in - let url = URL(string: uri) ?? URL(fileURLWithPath: uri) - let targetBitrate = options["targetBitrate"] as? Int ?? 0 - let maxSize = options["maxSize"] as? Int ?? 1920 - let codecPref = options["codec"] as? String ?? "auto" - let frameRateCap = max(1, options["frameRateCap"] as? Int ?? 30) - let jobId = options["jobId"] as? Int ?? 0 - - let compressor = VideoCompressor( - url: url, - targetBitrate: targetBitrate, - maxSize: maxSize, - codecPref: codecPref, - frameRateCap: frameRateCap, - jobId: jobId, - onProgress: { [weak self] id, progress in - self?.sendEvent("onProgress", [ - "id": id, - "progress": progress, - ]) - } - ) - - self.setCompressor(jobId, compressor) - - do { - let result = try await compressor.compress() - self.setCompressor(jobId, nil) - return result - } catch { - self.setCompressor(jobId, nil) - throw error - } - } - - Function("cancel") { (jobId: Int) in - self.cancelCompressor(jobId) - } - } - - private func setCompressor(_ jobId: Int, _ compressor: VideoCompressor?) { - activeCompressorsLock.lock() - defer { activeCompressorsLock.unlock() } - if let compressor = compressor { - activeCompressors[jobId] = compressor - } else { - activeCompressors.removeValue(forKey: jobId) - } - } - - private func cancelCompressor(_ jobId: Int) { - activeCompressorsLock.lock() - let compressor = activeCompressors.removeValue(forKey: jobId) - activeCompressorsLock.unlock() - compressor?.cancel() - } -} diff --git a/modules/expo-bluesky-video-compress/ios/VideoCompressor.swift b/modules/expo-bluesky-video-compress/ios/VideoCompressor.swift deleted file mode 100644 index e4a75398ea..0000000000 --- a/modules/expo-bluesky-video-compress/ios/VideoCompressor.swift +++ /dev/null @@ -1,410 +0,0 @@ -import AVFoundation -import VideoToolbox - -class VideoCompressor { - private let url: URL - private let targetBitrate: Int - private let maxSize: Int - private let codecPref: String - private let frameRateCap: Int - private let jobId: Int - private let onProgress: (Int, Double) -> Void - private var isCancelled = false - - init( - url: URL, - targetBitrate: Int, - maxSize: Int, - codecPref: String, - frameRateCap: Int, - jobId: Int, - onProgress: @escaping (Int, Double) -> Void - ) { - self.url = url - self.targetBitrate = targetBitrate - self.maxSize = maxSize - self.codecPref = codecPref - self.frameRateCap = frameRateCap - self.jobId = jobId - self.onProgress = onProgress - } - - func cancel() { - isCancelled = true - } - - func compress() async throws -> [String: Any] { - let asset = AVURLAsset( - url: url, - options: [AVURLAssetPreferPreciseDurationAndTimingKey: true] - ) - let duration = try await asset.load(.duration) - let totalSeconds = CMTimeGetSeconds(duration) - guard totalSeconds > 0 else { throw err("Invalid video duration", code: 2) } - - let videoTracks = try await asset.loadTracks(withMediaType: .video) - guard let videoTrack = videoTracks.first else { - throw err("No video track found", code: 1) - } - - let naturalSize = try await videoTrack.load(.naturalSize) - let preferredTransform = try await videoTrack.load(.preferredTransform) - - let rotatedRect = CGRect(origin: .zero, size: naturalSize).applying(preferredTransform) - let displaySize = CGSize( - width: abs(rotatedRect.width), - height: abs(rotatedRect.height) - ) - let outputSize = scaleEvenly(displaySize: displaySize, maxSize: maxSize) - - let audioTracks = try await asset.loadTracks(withMediaType: .audio) - - // 'auto' targets h264 — server pipeline is HLS, which favors h264 (HEVC needs - // fMP4 segments + commercial licensing). HEVC remains opt-in via codec: 'hevc'. - let useHEVC: Bool - switch codecPref { - case "hevc": useHEVC = true - default: useHEVC = false - } - let codecType: AVVideoCodecType = useHEVC ? .hevc : .h264 - let profileLevel: String = useHEVC - ? kVTProfileLevel_HEVC_Main_AutoLevel as String - : kVTProfileLevel_H264_High_AutoLevel as String - - let effectiveBitrate = targetBitrate > 0 - ? targetBitrate - : (useHEVC ? 2_500_000 : 3_000_000) - - let outputURL = FileManager.default.temporaryDirectory - .appendingPathComponent(UUID().uuidString) - .appendingPathExtension("mp4") - - let videoComposition = makeRotatingComposition( - videoTrack: videoTrack, - preferredTransform: preferredTransform, - naturalSize: naturalSize, - outputSize: outputSize, - duration: duration - ) - - let reader = try AVAssetReader(asset: asset) - - let videoReaderSettings: [String: Any] = [ - kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32BGRA - ] - let videoReaderOutput = AVAssetReaderVideoCompositionOutput( - videoTracks: [videoTrack], - videoSettings: videoReaderSettings - ) - videoReaderOutput.videoComposition = videoComposition - videoReaderOutput.alwaysCopiesSampleData = false - guard reader.canAdd(videoReaderOutput) else { - throw err("Cannot read video track", code: 3) - } - reader.add(videoReaderOutput) - - var compressionProps: [String: Any] = [ - AVVideoAverageBitRateKey: effectiveBitrate, - AVVideoProfileLevelKey: profileLevel, - AVVideoMaxKeyFrameIntervalKey: max(frameRateCap * 3, 30), - AVVideoExpectedSourceFrameRateKey: frameRateCap, - AVVideoAllowFrameReorderingKey: false, - kVTCompressionPropertyKey_RealTime as String: true, - ] - let peakBytesPerSecond = Int(Double(effectiveBitrate) / 8.0 * 1.5) - compressionProps[kVTCompressionPropertyKey_DataRateLimits as String] = [ - peakBytesPerSecond, 1.0 - ] as CFArray - - let videoColorProps: [String: Any] = [ - AVVideoColorPrimariesKey: AVVideoColorPrimaries_ITU_R_709_2, - AVVideoTransferFunctionKey: AVVideoTransferFunction_ITU_R_709_2, - AVVideoYCbCrMatrixKey: AVVideoYCbCrMatrix_ITU_R_709_2, - ] - - let videoWriterSettings: [String: Any] = [ - AVVideoCodecKey: codecType, - AVVideoWidthKey: outputSize.width, - AVVideoHeightKey: outputSize.height, - AVVideoColorPropertiesKey: videoColorProps, - AVVideoCompressionPropertiesKey: compressionProps, - ] - let videoWriterInput = AVAssetWriterInput( - mediaType: .video, - outputSettings: videoWriterSettings - ) - videoWriterInput.expectsMediaDataInRealTime = false - - let writer = try AVAssetWriter(outputURL: outputURL, fileType: .mp4) - writer.shouldOptimizeForNetworkUse = true - writer.metadata = [] - guard writer.canAdd(videoWriterInput) else { - throw err("Cannot write video track", code: 4) - } - writer.add(videoWriterInput) - - var audioReaderOutput: AVAssetReaderTrackOutput? - var audioWriterInput: AVAssetWriterInput? - if let audioTrack = audioTracks.first { - let audioDecoderSettings: [String: Any] = [ - AVFormatIDKey: kAudioFormatLinearPCM, - AVLinearPCMBitDepthKey: 16, - AVLinearPCMIsFloatKey: false, - AVLinearPCMIsBigEndianKey: false, - AVLinearPCMIsNonInterleaved: false, - ] - let audioOutput = AVAssetReaderTrackOutput( - track: audioTrack, - outputSettings: audioDecoderSettings - ) - audioOutput.alwaysCopiesSampleData = false - if reader.canAdd(audioOutput) { - reader.add(audioOutput) - audioReaderOutput = audioOutput - - let audioEncoderSettings: [String: Any] = [ - AVFormatIDKey: kAudioFormatMPEG4AAC, - AVSampleRateKey: 44100, - AVNumberOfChannelsKey: 2, - AVEncoderBitRateKey: 128_000, - ] - let audioInput = AVAssetWriterInput( - mediaType: .audio, - outputSettings: audioEncoderSettings - ) - audioInput.expectsMediaDataInRealTime = false - if writer.canAdd(audioInput) { - writer.add(audioInput) - audioWriterInput = audioInput - } - } - } - - guard reader.startReading() else { - throw reader.error ?? err("Reader failed to start", code: 8) - } - guard writer.startWriting() else { - throw writer.error ?? err("Writer failed to start", code: 9) - } - writer.startSession(atSourceTime: .zero) - - let minFrameIntervalSeconds = 1.0 / Double(frameRateCap) - let minFrameInterval = CMTime( - seconds: minFrameIntervalSeconds, - preferredTimescale: 600 - ) - - await withTaskGroup(of: Void.self) { group in - group.addTask { [self] in - await processVideoTrack( - readerOutput: videoReaderOutput, - writerInput: videoWriterInput, - totalDuration: totalSeconds, - minFrameInterval: minFrameInterval - ) - } - if let audioOutput = audioReaderOutput, let audioInput = audioWriterInput { - group.addTask { [self] in - await processAudioTrack( - readerOutput: audioOutput, - writerInput: audioInput - ) - } - } - await group.waitForAll() - } - - if isCancelled { - writer.cancelWriting() - try? FileManager.default.removeItem(at: outputURL) - throw err("Compression cancelled", code: 5) - } - - if reader.status == .failed { - let error = reader.error ?? err("Reader failed", code: 6) - writer.cancelWriting() - try? FileManager.default.removeItem(at: outputURL) - throw error - } - - await writer.finishWriting() - - if writer.status == .failed { - let error = writer.error ?? err("Writer failed", code: 7) - try? FileManager.default.removeItem(at: outputURL) - throw error - } - - onProgress(jobId, 1.0) - - let attributes = try FileManager.default.attributesOfItem(atPath: outputURL.path) - let fileSize = attributes[.size] as? Int ?? 0 - - return [ - "uri": outputURL.absoluteString, - "size": fileSize, - "mimeType": "video/mp4", - "width": outputSize.width, - "height": outputSize.height, - "duration": totalSeconds, - "codec": useHEVC ? "hevc" : "h264", - ] - } - - private func processVideoTrack( - readerOutput: AVAssetReaderOutput, - writerInput: AVAssetWriterInput, - totalDuration: Double, - minFrameInterval: CMTime - ) async { - var lastProgressTime: CFAbsoluteTime = 0 - var lastAppendedPTS: CMTime? - var finished = false - - await withCheckedContinuation { (continuation: CheckedContinuation) in - writerInput.requestMediaDataWhenReady( - on: DispatchQueue(label: "com.bsky.videocompress.video") - ) { - let finish = { - if !finished { - finished = true - writerInput.markAsFinished() - continuation.resume() - } - } - while writerInput.isReadyForMoreMediaData { - if finished { return } - if self.isCancelled { - finish() - return - } - guard let sampleBuffer = readerOutput.copyNextSampleBuffer() else { - finish() - return - } - - let pts = CMSampleBufferGetPresentationTimeStamp(sampleBuffer) - if let last = lastAppendedPTS { - let delta = CMTimeSubtract(pts, last) - if CMTimeCompare(delta, minFrameInterval) < 0 { - continue - } - } - lastAppendedPTS = pts - - if !writerInput.append(sampleBuffer) { - finish() - return - } - - let now = CFAbsoluteTimeGetCurrent() - if now - lastProgressTime >= 0.1 { - lastProgressTime = now - let progress = min(CMTimeGetSeconds(pts) / totalDuration, 1.0) - self.onProgress(self.jobId, progress) - } - } - } - } - } - - private func processAudioTrack( - readerOutput: AVAssetReaderOutput, - writerInput: AVAssetWriterInput - ) async { - var finished = false - - await withCheckedContinuation { (continuation: CheckedContinuation) in - writerInput.requestMediaDataWhenReady( - on: DispatchQueue(label: "com.bsky.videocompress.audio") - ) { - let finish = { - if !finished { - finished = true - writerInput.markAsFinished() - continuation.resume() - } - } - while writerInput.isReadyForMoreMediaData { - if finished { return } - if self.isCancelled { - finish() - return - } - guard let sampleBuffer = readerOutput.copyNextSampleBuffer() else { - finish() - return - } - if !writerInput.append(sampleBuffer) { - finish() - return - } - } - } - } - } - - private func makeRotatingComposition( - videoTrack: AVAssetTrack, - preferredTransform: CGAffineTransform, - naturalSize: CGSize, - outputSize: (width: Int, height: Int), - duration: CMTime - ) -> AVMutableVideoComposition { - let composition = AVMutableVideoComposition() - composition.renderSize = CGSize(width: outputSize.width, height: outputSize.height) - composition.frameDuration = CMTime(value: 1, timescale: Int32(frameRateCap)) - - let rotatedRect = CGRect(origin: .zero, size: naturalSize).applying(preferredTransform) - let translate = CGAffineTransform( - translationX: -rotatedRect.minX, - y: -rotatedRect.minY - ) - let displaySize = CGSize( - width: abs(rotatedRect.width), - height: abs(rotatedRect.height) - ) - let scaleX = CGFloat(outputSize.width) / displaySize.width - let scaleY = CGFloat(outputSize.height) / displaySize.height - let scale = CGAffineTransform(scaleX: scaleX, y: scaleY) - let combined = preferredTransform.concatenating(translate).concatenating(scale) - - let layerInstruction = AVMutableVideoCompositionLayerInstruction(assetTrack: videoTrack) - layerInstruction.setTransform(combined, at: .zero) - - let instruction = AVMutableVideoCompositionInstruction() - instruction.timeRange = CMTimeRange(start: .zero, duration: duration) - instruction.layerInstructions = [layerInstruction] - - composition.instructions = [instruction] - return composition - } - - private func scaleEvenly(displaySize: CGSize, maxSize: Int) -> (width: Int, height: Int) { - let cap = CGFloat(maxSize) - let scale: CGFloat - if displaySize.width <= cap && displaySize.height <= cap { - scale = 1.0 - } else if displaySize.width > displaySize.height { - scale = cap / displaySize.width - } else { - scale = cap / displaySize.height - } - return ( - roundToEven(Int(displaySize.width * scale)), - roundToEven(Int(displaySize.height * scale)) - ) - } - - private func roundToEven(_ value: Int) -> Int { - return value % 2 == 0 ? value : value - 1 - } - - private func err(_ message: String, code: Int) -> NSError { - return NSError( - domain: "ExpoBlueskyVideoCompress", - code: code, - userInfo: [NSLocalizedDescriptionKey: message] - ) - } -} diff --git a/modules/expo-bluesky-video-compress/ios/VideoProber.swift b/modules/expo-bluesky-video-compress/ios/VideoProber.swift deleted file mode 100644 index 4636ed7d3c..0000000000 --- a/modules/expo-bluesky-video-compress/ios/VideoProber.swift +++ /dev/null @@ -1,105 +0,0 @@ -import AVFoundation -import UniformTypeIdentifiers - -struct VideoProber { - static func probe(url: URL) async throws -> [String: Any] { - let asset = AVURLAsset(url: url) - - let duration = try await asset.load(.duration) - let tracks = try await asset.loadTracks(withMediaType: .video) - - guard let videoTrack = tracks.first else { - throw NSError( - domain: "ExpoBlueskyVideoCompress", - code: 1, - userInfo: [NSLocalizedDescriptionKey: "No video track found"] - ) - } - - let naturalSize = try await videoTrack.load(.naturalSize) - let preferredTransform = try await videoTrack.load(.preferredTransform) - let estimatedDataRate = try await videoTrack.load(.estimatedDataRate) - let nominalFrameRate = try await videoTrack.load(.nominalFrameRate) - let formatDescriptions = try await videoTrack.load(.formatDescriptions) - - var codec = "unknown" - var isHDR = false - if let formatDescription = formatDescriptions.first { - let subType = CMFormatDescriptionGetMediaSubType(formatDescription) - codec = fourCCToString(subType) - // Dolby Vision codecs are HDR by definition. - let dolbyVisionSubtypes: Set = ["dvhe", "dvh1", "dvav", "dva1"] - if dolbyVisionSubtypes.contains(codec) { - isHDR = true - } else if let extensions = CMFormatDescriptionGetExtensions(formatDescription) - as? [String: Any] - { - let transferKey = kCMFormatDescriptionExtension_TransferFunction as String - if let transfer = extensions[transferKey] as? String { - let hlg = kCMFormatDescriptionTransferFunction_ITU_R_2100_HLG as String - let pq = kCMFormatDescriptionTransferFunction_SMPTE_ST_2084_PQ as String - isHDR = transfer == hlg || transfer == pq - } - } - } - - let rotation = rotationFromTransform(preferredTransform) - let isRotated = rotation == 90 || rotation == 270 - let width = isRotated ? Int(naturalSize.height) : Int(naturalSize.width) - let height = isRotated ? Int(naturalSize.width) : Int(naturalSize.height) - - let audioTracks = try await asset.loadTracks(withMediaType: .audio) - let hasAudio = !audioTracks.isEmpty - - let fileSize: Int - if let attributes = try? FileManager.default.attributesOfItem(atPath: url.path), - let size = attributes[.size] as? Int { - fileSize = size - } else { - fileSize = 0 - } - - let mimeType: String - if let utType = UTType(filenameExtension: url.pathExtension) { - mimeType = utType.preferredMIMEType ?? "video/mp4" - } else { - mimeType = "video/mp4" - } - - let durationSeconds = CMTimeGetSeconds(duration) - var bitrate = Int(estimatedDataRate) - if bitrate == 0 && durationSeconds > 0 { - bitrate = Int(Double(fileSize * 8) / durationSeconds) - } - - return [ - "width": width, - "height": height, - "duration": durationSeconds, - "bitrate": bitrate, - "fileSize": fileSize, - "mimeType": mimeType, - "codec": codec, - "hasAudio": hasAudio, - "frameRate": nominalFrameRate, - "rotation": rotation, - "isHDR": isHDR - ] - } - - private static func rotationFromTransform(_ transform: CGAffineTransform) -> Int { - let angle = atan2(transform.b, transform.a) - let degrees = Int(round(angle * 180.0 / .pi)) - return ((degrees % 360) + 360) % 360 - } - - private static func fourCCToString(_ code: FourCharCode) -> String { - let chars: [Character] = [ - Character(UnicodeScalar((code >> 24) & 0xFF)!), - Character(UnicodeScalar((code >> 16) & 0xFF)!), - Character(UnicodeScalar((code >> 8) & 0xFF)!), - Character(UnicodeScalar(code & 0xFF)!) - ] - return String(chars).trimmingCharacters(in: .whitespaces) - } -} diff --git a/modules/expo-bluesky-video-compress/src/ExpoBlueskyVideoCompressModule.ts b/modules/expo-bluesky-video-compress/src/ExpoBlueskyVideoCompressModule.ts deleted file mode 100644 index dcf63c729f..0000000000 --- a/modules/expo-bluesky-video-compress/src/ExpoBlueskyVideoCompressModule.ts +++ /dev/null @@ -1,23 +0,0 @@ -import {type EventSubscription, requireNativeModule} from 'expo-modules-core' - -import { - type CompressResult, - type NativeCompressOptions, - type VideoMetadata, -} from './types' - -type ProgressEvent = {id: number; progress: number} - -interface ExpoBlueskyVideoCompressModule { - probe(uri: string): Promise - compress(uri: string, options: NativeCompressOptions): Promise - cancel(jobId: number): void - addListener( - eventName: 'onProgress', - listener: (event: ProgressEvent) => void, - ): EventSubscription -} - -export default requireNativeModule( - 'ExpoBlueskyVideoCompress', -) diff --git a/modules/expo-bluesky-video-compress/src/types.ts b/modules/expo-bluesky-video-compress/src/types.ts deleted file mode 100644 index df9b9ed1ac..0000000000 --- a/modules/expo-bluesky-video-compress/src/types.ts +++ /dev/null @@ -1,45 +0,0 @@ -export type CodecPreference = 'auto' | 'hevc' | 'h264' - -export type VideoMetadata = { - width: number - height: number - duration: number - bitrate: number - fileSize: number - mimeType: string - codec: string - hasAudio: boolean - frameRate: number - rotation: number - isHDR: boolean -} - -export type CompressOptions = { - targetBitrate?: number - maxSize?: number - codec?: CodecPreference - frameRateCap?: number -} - -export type CompressCallbacks = { - onProgress?: (progress: number) => void - signal?: AbortSignal -} - -export type CompressResult = { - uri: string - size: number - mimeType: string - width: number - height: number - duration: number - codec: 'h264' | 'hevc' -} - -export type NativeCompressOptions = { - targetBitrate: number - maxSize: number - codec: CodecPreference - frameRateCap: number - jobId: number -} diff --git a/package.json b/package.json index a4b4ad4556..32b58d9840 100644 --- a/package.json +++ b/package.json @@ -229,7 +229,6 @@ "react-is": "19", "react-keyed-flatten-children": "^5.0.0", "react-native": "0.86.0", - "react-native-compressor": "1.13.0", "react-native-date-picker": "^5.0.13", "react-native-device-attest": "^0.1.6", "react-native-drawer-layout": "^4.2.3", diff --git a/patches/react-native-compressor@1.13.0.patch b/patches/react-native-compressor@1.13.0.patch deleted file mode 100644 index 1a0f4c61d7..0000000000 --- a/patches/react-native-compressor@1.13.0.patch +++ /dev/null @@ -1,59 +0,0 @@ -diff --git a/android/build.gradle b/android/build.gradle -index 5071139f8ee5fbba085d2afe3b2093de8eda915c..84bee34a238c6510169f6b6bdb0fda0594c77136 100644 ---- a/android/build.gradle -+++ b/android/build.gradle -@@ -115,7 +115,6 @@ dependencies { - implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.4" - implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4" - implementation 'org.mp4parser:isoparser:1.9.56' -- implementation 'com.github.banketree:AndroidLame-kotlin:v0.0.1' - implementation 'javazoom:jlayer:1.0.1' - } - -diff --git a/android/src/main/java/com/reactnativecompressor/Audio/AudioCompressor.kt b/android/src/main/java/com/reactnativecompressor/Audio/AudioCompressor.kt -deleted file mode 100644 -index 9292d3ee50776bd9d7760b8dcf6d123d44b4e31b..0000000000000000000000000000000000000000 -diff --git a/android/src/main/java/com/reactnativecompressor/Audio/AudioExtractor.kt b/android/src/main/java/com/reactnativecompressor/Audio/AudioExtractor.kt -deleted file mode 100644 -index c6551828014437a14dc8f2f19488b647dba1bbe1..0000000000000000000000000000000000000000 -diff --git a/android/src/main/java/com/reactnativecompressor/Audio/AudioHelper.kt b/android/src/main/java/com/reactnativecompressor/Audio/AudioHelper.kt -deleted file mode 100644 -index 42040b4916573463415ef2f57789b3c4fa25d135..0000000000000000000000000000000000000000 -diff --git a/android/src/main/java/com/reactnativecompressor/Audio/AudioMain.kt b/android/src/main/java/com/reactnativecompressor/Audio/AudioMain.kt -index 446d4fb8b69e7cfdb51b29603aa2d52aac1ab8c8..f02190992dac823b6bbf2d77880a25adc48f16c7 100644 ---- a/android/src/main/java/com/reactnativecompressor/Audio/AudioMain.kt -+++ b/android/src/main/java/com/reactnativecompressor/Audio/AudioMain.kt -@@ -11,7 +11,9 @@ class AudioMain(private val reactContext: ReactApplicationContext) { - promise: Promise) { - try { - -- AudioCompressor.CompressAudio(fileUrl,optionMap,reactContext,promise) -+ // Skip compression on Android to avoid libandroidlame dependency -+ // Return the original file URL without compression -+ promise.resolve(fileUrl) - } catch (ex: Exception) { - promise.reject(ex) - } -diff --git a/android/src/main/java/com/reactnativecompressor/Utils/Utils.kt b/android/src/main/java/com/reactnativecompressor/Utils/Utils.kt -index c14b727e930f4114765bfbe15b742ddcdeaa392f..1198908fcc66eeeea5e537085d7632a0d4b04545 100644 ---- a/android/src/main/java/com/reactnativecompressor/Utils/Utils.kt -+++ b/android/src/main/java/com/reactnativecompressor/Utils/Utils.kt -@@ -7,7 +7,6 @@ import android.provider.OpenableColumns - import android.util.Log - import com.facebook.react.bridge.Promise - import com.facebook.react.bridge.ReactApplicationContext --import com.reactnativecompressor.Audio.AudioCompressor - import com.reactnativecompressor.Video.VideoCompressor.CompressionListener - import com.reactnativecompressor.Video.VideoCompressor.VideoCompressorClass - import java.io.FileNotFoundException -@@ -152,10 +151,6 @@ object Utils { - } - } - -- fun addLog(log: String) { -- Log.d(AudioCompressor.TAG, log) -- } -- - val exifAttributes = arrayOf( - "FNumber", - "ApertureValue", diff --git a/patches/react-native-compressor@1.13.0.patch.md b/patches/react-native-compressor@1.13.0.patch.md deleted file mode 100644 index de59e55244..0000000000 --- a/patches/react-native-compressor@1.13.0.patch.md +++ /dev/null @@ -1,5 +0,0 @@ -# react-native-compressor - -Patch file taken from https://github.com/numandev1/react-native-compressor/pull/355#issuecomment-3180870738 - -This patch removes the audio compression feature on Android from the library. This is because `libandroidlame.so`, the native dependency, does not support 16kb page sizes, and the Play Store has made this mandatory as of 1st Nov 2025. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a763b31309..a959c7d706 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -208,7 +208,6 @@ overrides: '@react-native/normalize-colors': 0.86.0 '@expo/image-utils': 0.8.12 '@types/estree': 1.0.6 - react-native-compressor: 1.13.0 react-native-reanimated: 4.5.3 react-native-worklets: 0.11.3 psl: 1.9.0 @@ -226,7 +225,6 @@ patchedDependencies: expo-notifications@57.0.7: 542d7d2024b364ba601b7f2af041353dccde555db0e6ecf0b5948c90e6b33a75 expo-updates@57.0.10: 04f28cb005b770e9ae8f0065eab96e43cbb1e58107f5f6ad1bdd18f6deb66487 expo@57.0.8: 1722861d12907a2d412d04230545b2fcbfa5547a79f99d763cb3e8de6f3a6f2a - react-native-compressor@1.13.0: 58379dfaace6ced8590cb341c77f2ca8099dfa8f7df6297032ec51de767a9925 react-native-date-picker@5.0.13: 92943fb79d17d7342a29bbb12b0d8ee3cf6f7bca12ed322dc8d124a3e9fb75bd react-native-dotenv@3.4.11: 16b34eb974399935d3cf7c2526534f906991ccd876215176658347059cdd2021 react-native-drawer-layout@4.2.3: 74f2c043cc22ab87054f219e7c7373a509b779b18bfa79d27e7d051d73355130 @@ -632,9 +630,6 @@ importers: react-native: specifier: 0.86.0 version: 0.86.0(patch_hash=dd549527bb84c88acc7b0b1d521c9f4b666fda484c75cd0b282f8e9838524909)(@babel/core@7.29.0(supports-color@8.1.1))(@react-native/jest-preset@0.86.0(@babel/core@7.29.0(supports-color@8.1.1))(react@19.2.3)(supports-color@8.1.1))(@react-native/metro-config@0.86.0(@babel/core@7.29.0(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.18)(react@19.2.3)(supports-color@8.1.1) - react-native-compressor: - specifier: 1.13.0 - version: 1.13.0(patch_hash=58379dfaace6ced8590cb341c77f2ca8099dfa8f7df6297032ec51de767a9925)(react-native@0.86.0(patch_hash=dd549527bb84c88acc7b0b1d521c9f4b666fda484c75cd0b282f8e9838524909)(@babel/core@7.29.0(supports-color@8.1.1))(@react-native/jest-preset@0.86.0(@babel/core@7.29.0(supports-color@8.1.1))(react@19.2.3)(supports-color@8.1.1))(@react-native/metro-config@0.86.0(@babel/core@7.29.0(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.18)(react@19.2.3)(supports-color@8.1.1))(react@19.2.3) react-native-date-picker: specifier: ^5.0.13 version: 5.0.13(patch_hash=92943fb79d17d7342a29bbb12b0d8ee3cf6f7bca12ed322dc8d124a3e9fb75bd)(react-native@0.86.0(patch_hash=dd549527bb84c88acc7b0b1d521c9f4b666fda484c75cd0b282f8e9838524909)(@babel/core@7.29.0(supports-color@8.1.1))(@react-native/jest-preset@0.86.0(@babel/core@7.29.0(supports-color@8.1.1))(react@19.2.3)(supports-color@8.1.1))(@react-native/metro-config@0.86.0(@babel/core@7.29.0(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.18)(react@19.2.3)(supports-color@8.1.1))(react@19.2.3) @@ -8169,13 +8164,6 @@ packages: react: '>=18.0.0' react-is: '>=18.0.0' - react-native-compressor@1.13.0: - resolution: {integrity: sha512-vJYbrcjz2S7kgE3Q7444F71CjEDq5Qg6sGn67N9fJ0I1K6EhB/ZxpULdDe3FxJ4h/ncJc0oxOzNWZhxtGTqcQw==} - engines: {node: '>= 16.0.0'} - peerDependencies: - react: '*' - react-native: '*' - react-native-date-picker@5.0.13: resolution: {integrity: sha512-qCLUODZVsJetO5zuoXjw1D39K527XWqBG8sOfhWdHyPzf13h8RXR1/RSKd1N0fdRDi5GdyizYmB0lPAK12/hbw==} peerDependencies: @@ -18175,11 +18163,6 @@ snapshots: react: 19.2.3 react-is: 19.2.6 - react-native-compressor@1.13.0(patch_hash=58379dfaace6ced8590cb341c77f2ca8099dfa8f7df6297032ec51de767a9925)(react-native@0.86.0(patch_hash=dd549527bb84c88acc7b0b1d521c9f4b666fda484c75cd0b282f8e9838524909)(@babel/core@7.29.0(supports-color@8.1.1))(@react-native/jest-preset@0.86.0(@babel/core@7.29.0(supports-color@8.1.1))(react@19.2.3)(supports-color@8.1.1))(@react-native/metro-config@0.86.0(@babel/core@7.29.0(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.18)(react@19.2.3)(supports-color@8.1.1))(react@19.2.3): - dependencies: - react: 19.2.3 - react-native: 0.86.0(patch_hash=dd549527bb84c88acc7b0b1d521c9f4b666fda484c75cd0b282f8e9838524909)(@babel/core@7.29.0(supports-color@8.1.1))(@react-native/jest-preset@0.86.0(@babel/core@7.29.0(supports-color@8.1.1))(react@19.2.3)(supports-color@8.1.1))(@react-native/metro-config@0.86.0(@babel/core@7.29.0(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.18)(react@19.2.3)(supports-color@8.1.1) - react-native-date-picker@5.0.13(patch_hash=92943fb79d17d7342a29bbb12b0d8ee3cf6f7bca12ed322dc8d124a3e9fb75bd)(react-native@0.86.0(patch_hash=dd549527bb84c88acc7b0b1d521c9f4b666fda484c75cd0b282f8e9838524909)(@babel/core@7.29.0(supports-color@8.1.1))(@react-native/jest-preset@0.86.0(@babel/core@7.29.0(supports-color@8.1.1))(react@19.2.3)(supports-color@8.1.1))(@react-native/metro-config@0.86.0(@babel/core@7.29.0(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.18)(react@19.2.3)(supports-color@8.1.1))(react@19.2.3): dependencies: react: 19.2.3 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index b627e1d3a8..1d06463e71 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -17,7 +17,6 @@ overrides: '@react-native/normalize-colors': '0.86.0' '@expo/image-utils': '0.8.12' '@types/estree': '1.0.6' - 'react-native-compressor': '1.13.0' 'react-native-reanimated': '4.5.3' 'react-native-worklets': '0.11.3' 'psl': '1.9.0' @@ -38,7 +37,6 @@ patchedDependencies: 'expo-notifications@57.0.7': patches/expo-notifications@57.0.7.patch 'expo-updates@57.0.10': patches/expo-updates@57.0.10.patch expo@57.0.8: patches/expo@57.0.8.patch - 'react-native-compressor@1.13.0': patches/react-native-compressor@1.13.0.patch 'react-native-date-picker@5.0.13': patches/react-native-date-picker@5.0.13.patch 'react-native-dotenv@3.4.11': patches/react-native-dotenv@3.4.11.patch 'react-native-drawer-layout@4.2.3': patches/react-native-drawer-layout@4.2.3.patch diff --git a/src/lib/media/video/compress.ts b/src/lib/media/video/compress.ts index d38c1cdecf..47d6e7fb1a 100644 --- a/src/lib/media/video/compress.ts +++ b/src/lib/media/video/compress.ts @@ -44,8 +44,6 @@ export async function compressVideo( } // Pre-check the threshold ourselves so we can label the skip in telemetry. - // rnc would do the same skip internally via minimumFileSizeForCompress, but - // that path is invisible to us. const isAcceptableFormat = SUPPORTED_MIME_TYPES.includes( file.mimeType as SupportedMimeTypes, ) diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx index 4a60efa4f0..465110c45a 100644 --- a/src/view/com/composer/Composer.tsx +++ b/src/view/com/composer/Composer.tsx @@ -408,7 +408,7 @@ export const ComposePost = ({ asset.mimeType !== 'image/gif' ) { try { - const probed = await getVideoMetadata(asset.uri) + const probed = await getVideoMetadata(asset.uri, asset.mimeType) asset = { ...asset, mimeType: probed.mimeType ?? asset.mimeType, @@ -538,8 +538,8 @@ export const ComposePost = ({ let uri = videoInfo.uri if (IS_ANDROID) { // Android: expo-file-system double-encodes filenames with special chars. - // The file exists, but react-native-compressor's MediaMetadataRetriever - // can't handle the double-encoded URI. Copy to a temp file with a simple name. + // The native metadata probe can't handle the double-encoded URI, so + // copy it to a temp file with a simple name. const sourceFile = new FileSystem.File(videoInfo.uri) const tempFileName = `draft-video-${Date.now()}.${mimeToExt(videoInfo.mimeType)}` const tempFile = new FileSystem.File( @@ -553,7 +553,7 @@ export const ComposePost = ({ }) uri = tempFile.uri } - asset = await getVideoMetadata(uri) + asset = await getVideoMetadata(uri, videoInfo.mimeType) } // Start video processing using existing flow diff --git a/src/view/com/composer/videos/VideoTranscodeBackdrop.tsx b/src/view/com/composer/videos/VideoTranscodeBackdrop.tsx index 3daf7954f7..ffa80bc8a6 100644 --- a/src/view/com/composer/videos/VideoTranscodeBackdrop.tsx +++ b/src/view/com/composer/videos/VideoTranscodeBackdrop.tsx @@ -1,6 +1,10 @@ -import {clearCache, createVideoThumbnail} from 'react-native-compressor' import Animated, {FadeIn} from 'react-native-reanimated' +import {File} from 'expo-file-system' import {Image} from 'expo-image' +import { + getThumbnailAsync, + type VideoThumbnailsResult, +} from 'expo-video-thumbnails' import {type QueryClient, useQuery} from '@tanstack/react-query' import {atoms as a} from '#/alf' @@ -8,15 +12,32 @@ import {atoms as a} from '#/alf' export const RQKEY = 'video-thumbnail' export function clearThumbnailCache(queryClient: QueryClient) { - clearCache().catch(() => {}) - void queryClient.resetQueries({queryKey: [RQKEY]}) + for (const [, thumbnail] of queryClient.getQueriesData( + {queryKey: [RQKEY]}, + )) { + if (thumbnail) { + deleteThumbnail(thumbnail) + } + } + queryClient.removeQueries({queryKey: [RQKEY]}) +} + +function deleteThumbnail(thumbnail: VideoThumbnailsResult) { + try { + new File(thumbnail.uri).delete() + } catch {} } export function VideoTranscodeBackdrop({uri}: {uri: string}) { const {data: thumbnail} = useQuery({ queryKey: [RQKEY, uri], - queryFn: async () => { - return await createVideoThumbnail(uri) + queryFn: async ({signal}) => { + const result = await getThumbnailAsync(uri) + if (signal.aborted) { + deleteThumbnail(result) + throw new Error('Thumbnail generation canceled') + } + return result }, }) @@ -25,7 +46,7 @@ export function VideoTranscodeBackdrop({uri}: {uri: string}) { { if (typeof file !== 'string') throw new Error( 'getVideoMetadata was passed a File, when on native it should be a uri', ) - const metadata = await getVideoMetaData(file) + const metadata = await probe(file) return { uri: file, - mimeType: extToMime(metadata.extension), + mimeType: getMimeTypeFromUri(file) ?? fallbackMimeType ?? metadata.mimeType, + fileSize: metadata.fileSize, width: metadata.width, height: metadata.height, /* - * react-native-compressor reports seconds; the rest of the app treats - * `ImagePickerAsset.duration` as milliseconds (matching expo-image-picker). + * The probe reports seconds; `ImagePickerAsset.duration` uses milliseconds. */ duration: metadata.duration * 1000, } } +function getMimeTypeFromUri(uri: string): string | undefined { + const extension = uri.match(/\.([^.?#/]+)(?:[?#]|$)/)?.[1] + if (!extension) return + + try { + return extToMime(extension) + } catch { + return + } +} + export function hasWebCodecs(): boolean { return false } diff --git a/src/view/com/composer/videos/metadata.web.ts b/src/view/com/composer/videos/metadata.web.ts index ed87d157c9..47cf3f9ea1 100644 --- a/src/view/com/composer/videos/metadata.web.ts +++ b/src/view/com/composer/videos/metadata.web.ts @@ -11,6 +11,7 @@ export function hasWebCodecs(): boolean { export async function getVideoMetadata( file: File | string, + _fallbackMimeType?: string, ): Promise { if (typeof file === 'string') throw new Error( From e1f75c2c01ada6effb9dc3bf10bc82fb0c5052c4 Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Fri, 28 Aug 2026 16:01:54 -0500 Subject: [PATCH 14/14] Add city to IP-based geo (#11603) --- src/analytics/index.tsx | 2 ++ src/geolocation/const.ts | 1 + src/geolocation/types.ts | 2 ++ 3 files changed, 5 insertions(+) diff --git a/src/analytics/index.tsx b/src/analytics/index.tsx index 07af41473e..ac1c96e629 100644 --- a/src/analytics/index.tsx +++ b/src/analytics/index.tsx @@ -117,6 +117,7 @@ const Context = createContext({ geolocation: device.get(['geolocationServiceResponse']) || { countryCode: '', regionCode: '', + city: '', }, }, }) @@ -181,6 +182,7 @@ export function AnalyticsContext({ } const deviceId = useDeviceId() ?? 'unknown' const sessionId = useSessionId() + // only IP based, never GPS const geolocation = useGeolocationServiceResponse() const parentContext = useContext(Context) /* diff --git a/src/geolocation/const.ts b/src/geolocation/const.ts index 653e829bad..ea44e19d7e 100644 --- a/src/geolocation/const.ts +++ b/src/geolocation/const.ts @@ -9,4 +9,5 @@ export const GEOLOCATION_SERVICE_URL = `${GEOLOCATION_URL}/geolocation` export const FALLBACK_GEOLOCATION_SERVICE_RESPONSE: Geolocation = { countryCode: undefined, regionCode: undefined, + city: undefined, } diff --git a/src/geolocation/types.ts b/src/geolocation/types.ts index 979b4dd742..b496fe4917 100644 --- a/src/geolocation/types.ts +++ b/src/geolocation/types.ts @@ -1,6 +1,8 @@ export type Geolocation = { countryCode: string | undefined regionCode: string | undefined + /** Only populated by the IP-based geolocation service. */ + city?: string serviceGeolocation?: Geolocation deviceGeolocation?: Geolocation }