From b422765aed649f3381bea305c3c8e29bf50e089c Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Fri, 14 Nov 2025 16:21:37 +0200 Subject: [PATCH 01/20] [Threadgate] Tweak threadgate buttons (#9173) * tweak in-post threadgate button * tweak composer threadgate button * reduce date length slightly * pressed styles * make date length depend on breakpoint * add chevron to label btn * add tiny chevron, special-case button icon width * [Threadgate] Add hint (#9350) * get tooltip working on web * add compatibility layer for working in iOS sheets * add timeout to profile tooltip now that it appears instantly * rm debug code * Update ThreadgateBtn.tsx Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com> * remeasure when keyboard changes --------- Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com> * fix types * [Threadgate] Refresh dialog (#9342) * wip new ui * update threadgate dialog with new designs * restore nobody option * relayout android sheet when ratio changes * fix ratio changing case in bottom sheet * timebox reached, use setTimeout * update panel styles * missing imports * extract out Panel * tweak layout animation * fix icon color * use same color mechamism for icon as text * restore the header * refreshed toggle styles (#9343) * [Threadgate] Persist settings (#9341) * add persist toggle to threadgate dialog * move state back down * sort out spacing * wire up query * @surfdude29 tweaks * use tiny chevron in WhoCanReply * wait for prefetch before opening * move Panel into the Toggle namespace * default -> pref * use medium date length * rm hover state from web selects, fix border radius * fix key issue in Selects --------- Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com> --- ...yChevronBottom_stroke2_corner0_rounded.svg | 1 + .../modules/bottomsheet/BottomSheetView.kt | 7 +- modules/bottom-sheet/index.ts | 4 +- .../src/BottomSheetNativeComponent.tsx | 18 +- src/components/Button.tsx | 3 +- src/components/Select/index.web.tsx | 26 +- src/components/Tooltip/index.tsx | 74 +- src/components/Tooltip/index.web.tsx | 22 +- src/components/WhoCanReply.tsx | 59 +- .../SubscribeProfileButton.tsx | 20 +- .../dialogs/PostInteractionSettingsDialog.tsx | 691 ++++++++++-------- src/components/forms/Toggle/Panel.tsx | 120 +++ .../forms/{Toggle.tsx => Toggle/index.tsx} | 231 ++++-- src/components/icons/Chevron.tsx | 7 + src/components/icons/common.tsx | 1 + .../verification/VerificationsDialog.tsx | 2 +- src/lib/strings/time.ts | 8 +- .../components/ThreadItemAnchor.tsx | 2 +- .../Settings/components/SettingsList.tsx | 1 + src/state/global-gesture-events/index.tsx | 8 +- .../queries/post-interaction-settings.ts | 10 +- src/storage/hooks/threadgate-nudged.ts | 9 + src/storage/schema.ts | 1 + src/view/com/composer/labels/LabelsBtn.tsx | 13 +- .../com/composer/threadgate/ThreadgateBtn.tsx | 151 +++- src/view/screens/Storybook/Forms.tsx | 9 + src/view/shell/Composer.ios.tsx | 27 +- 27 files changed, 1057 insertions(+), 468 deletions(-) create mode 100644 assets/icons/tinyChevronBottom_stroke2_corner0_rounded.svg create mode 100644 src/components/forms/Toggle/Panel.tsx rename src/components/forms/{Toggle.tsx => Toggle/index.tsx} (68%) create mode 100644 src/storage/hooks/threadgate-nudged.ts diff --git a/assets/icons/tinyChevronBottom_stroke2_corner0_rounded.svg b/assets/icons/tinyChevronBottom_stroke2_corner0_rounded.svg new file mode 100644 index 0000000000..c8d9d51d0e --- /dev/null +++ b/assets/icons/tinyChevronBottom_stroke2_corner0_rounded.svg @@ -0,0 +1 @@ + diff --git a/modules/bottom-sheet/android/src/main/java/expo/modules/bottomsheet/BottomSheetView.kt b/modules/bottom-sheet/android/src/main/java/expo/modules/bottomsheet/BottomSheetView.kt index 6db6e35fac..fa42e37d5a 100644 --- a/modules/bottom-sheet/android/src/main/java/expo/modules/bottomsheet/BottomSheetView.kt +++ b/modules/bottom-sheet/android/src/main/java/expo/modules/bottomsheet/BottomSheetView.kt @@ -243,13 +243,18 @@ class BottomSheetView( val bottomSheet = dialog.findViewById(com.google.android.material.R.id.design_bottom_sheet) bottomSheet?.let { val behavior = BottomSheetBehavior.from(it) + val currentState = behavior.state - behavior.halfExpandedRatio = getHalfExpandedRatio(contentHeight) + val oldRatio = behavior.halfExpandedRatio + var newRatio = getHalfExpandedRatio(contentHeight) + behavior.halfExpandedRatio = newRatio if (contentHeight > this.safeScreenHeight && behavior.state != BottomSheetBehavior.STATE_EXPANDED) { behavior.state = BottomSheetBehavior.STATE_EXPANDED } else if (contentHeight < this.safeScreenHeight && behavior.state != BottomSheetBehavior.STATE_HALF_EXPANDED) { behavior.state = BottomSheetBehavior.STATE_HALF_EXPANDED + } else if (currentState == BottomSheetBehavior.STATE_HALF_EXPANDED && oldRatio != newRatio) { + behavior.state = BottomSheetBehavior.STATE_HALF_EXPANDED } } } diff --git a/modules/bottom-sheet/index.ts b/modules/bottom-sheet/index.ts index 4009f2ab28..a52b4201ac 100644 --- a/modules/bottom-sheet/index.ts +++ b/modules/bottom-sheet/index.ts @@ -1,8 +1,8 @@ import {BottomSheet} from './src/BottomSheet' import { BottomSheetSnapPoint, - BottomSheetState, - BottomSheetViewProps, + type BottomSheetState, + type BottomSheetViewProps, } from './src/BottomSheet.types' import {BottomSheetNativeComponent} from './src/BottomSheetNativeComponent' import { diff --git a/modules/bottom-sheet/src/BottomSheetNativeComponent.tsx b/modules/bottom-sheet/src/BottomSheetNativeComponent.tsx index d367ac300c..aa69cfd599 100644 --- a/modules/bottom-sheet/src/BottomSheetNativeComponent.tsx +++ b/modules/bottom-sheet/src/BottomSheetNativeComponent.tsx @@ -112,9 +112,21 @@ export class BottomSheetNativeComponent extends React.Component< onStateChange={this.onStateChange} extraStyles={extraStyles} onLayout={e => { - const {height} = e.nativeEvent.layout - this.setState({viewHeight: height}) - this.updateLayout() + if (isIOS15) { + const {height} = e.nativeEvent.layout + this.setState({viewHeight: height}) + } + if (Platform.OS === 'android') { + // TEMP HACKFIX: I had to timebox this, but this is Bad. + // On Android, if you run updateLayout() immediately, + // it will take ages to actually run on the native side. + // However, adding literally any delay will fix this, including + // a console.log() - just sending the log to the CLI is enough. + // TODO: Get to the bottom of this and fix it properly! -sfn + setTimeout(() => this.updateLayout()) + } else { + this.updateLayout() + } }} /> diff --git a/src/components/Button.tsx b/src/components/Button.tsx index 2fdcd64914..efac8468d0 100644 --- a/src/components/Button.tsx +++ b/src/components/Button.tsx @@ -798,6 +798,7 @@ export function ButtonIcon({ * also so that we can calculate transforms. */ const iconSize = { + '2xs': 8, xs: 12, sm: 16, md: 18, @@ -842,7 +843,7 @@ export function ButtonIcon({ style={[ a.z_20, { - width: iconContainerSize, + width: size === '2xs' ? 10 : iconContainerSize, height: iconContainerSize, marginLeft: iconNegativeMargin, marginRight: iconNegativeMargin, diff --git a/src/components/Select/index.web.tsx b/src/components/Select/index.web.tsx index f53749ef0d..995f9d412e 100644 --- a/src/components/Select/index.web.tsx +++ b/src/components/Select/index.web.tsx @@ -1,4 +1,4 @@ -import {createContext, forwardRef, useContext, useMemo} from 'react' +import {createContext, forwardRef, Fragment, useContext, useMemo} from 'react' import {View} from 'react-native' import {Select as RadixSelect} from 'radix-ui' @@ -96,8 +96,7 @@ export function Trigger({children, label}: TriggerProps) { style={flatten([ a.flex, a.relative, - t.atoms.bg_contrast_25, - a.rounded_sm, + t.atoms.bg_contrast_50, a.w_full, a.align_center, a.gap_sm, @@ -106,15 +105,14 @@ export function Trigger({children, label}: TriggerProps) { a.px_md, a.pointer, { + borderRadius: 10, maxWidth: 400, outline: 0, borderWidth: 2, borderStyle: 'solid', borderColor: focused ? t.palette.primary_500 - : hovered - ? t.palette.contrast_100 - : t.palette.contrast_25, + : t.palette.contrast_50, }, ])}> {children} @@ -140,7 +138,11 @@ export function Icon({style}: IconProps) { ) } -export function Content({items, renderItem}: ContentProps) { +export function Content({ + items, + renderItem, + valueExtractor = defaultItemValueExtractor, +}: ContentProps) { const t = useTheme() const selectedValue = useContext(SelectedValueContext) @@ -198,7 +200,11 @@ export function Content({items, renderItem}: ContentProps) { - {items.map((item, index) => renderItem(item, index, selectedValue))} + {items.map((item, index) => ( + + {renderItem(item, index, selectedValue)} + + ))} @@ -209,6 +215,10 @@ export function Content({items, renderItem}: ContentProps) { ) } +function defaultItemValueExtractor(item: any) { + return item.value +} + const ItemContext = createContext<{ hovered: boolean focused: boolean diff --git a/src/components/Tooltip/index.tsx b/src/components/Tooltip/index.tsx index a7d1510205..e916ee0ed3 100644 --- a/src/components/Tooltip/index.tsx +++ b/src/components/Tooltip/index.tsx @@ -12,9 +12,11 @@ import {useWindowDimensions, View} from 'react-native' import Animated, {Easing, ZoomIn} from 'react-native-reanimated' import {useSafeAreaInsets} from 'react-native-safe-area-context' +import {useIsKeyboardVisible} from '#/lib/hooks/useIsKeyboardVisible' +import {GlobalGestureEventsProvider} from '#/state/global-gesture-events' import {atoms as a, select, useTheme} from '#/alf' import {useOnGesture} from '#/components/hooks/useOnGesture' -import {Portal} from '#/components/Portal' +import {createPortalGroup, Portal as RootPortal} from '#/components/Portal' import { ARROW_HALF_SIZE, ARROW_SIZE, @@ -23,6 +25,33 @@ import { } from '#/components/Tooltip/const' import {Text} from '#/components/Typography' +const TooltipPortal = createPortalGroup() +const TooltipProviderContext = + createContext | null>(null) + +/** + * Provider for Tooltip component. Only needed when you need to position the tooltip relative to a container, + * such as in the composer sheet. + * + * Only really necessary on iOS but can work on Android. + */ +export function SheetCompatProvider({children}: {children: React.ReactNode}) { + const ref = useRef(null) + return ( + + + + + {children} + + + + + + ) +} +SheetCompatProvider.displayName = 'TooltipSheetCompatProvider' + /** * These are native specific values, not shared with web */ @@ -120,22 +149,46 @@ export function Outer({ export function Target({children}: {children: React.ReactNode}) { const {shouldMeasure, setTargetMeasurements} = useContext(TargetContext) + const [hasLayedOut, setHasLayedOut] = useState(false) const targetRef = useRef(null) + const containerRef = useContext(TooltipProviderContext) + const keyboardIsOpen = useIsKeyboardVisible() useEffect(() => { - if (!shouldMeasure) return + if (!shouldMeasure || !hasLayedOut) return /* * Once opened, measure the dimensions and position of the target */ - targetRef.current?.measure((_x, _y, width, height, pageX, pageY) => { - if (pageX !== undefined && pageY !== undefined && width && height) { - setTargetMeasurements({x: pageX, y: pageY, width, height}) - } - }) - }, [shouldMeasure, setTargetMeasurements]) + + if (containerRef?.current) { + targetRef.current?.measureLayout( + containerRef.current, + (x, y, width, height) => { + if (x !== undefined && y !== undefined && width && height) { + setTargetMeasurements({x, y, width, height}) + } + }, + ) + } else { + targetRef.current?.measure((_x, _y, width, height, x, y) => { + if (x !== undefined && y !== undefined && width && height) { + setTargetMeasurements({x, y, width, height}) + } + }) + } + }, [ + shouldMeasure, + setTargetMeasurements, + hasLayedOut, + containerRef, + keyboardIsOpen, + ]) return ( - + setHasLayedOut(true)}> {children} ) @@ -150,12 +203,15 @@ export function Content({ }) { const {position, visible, onVisibleChange} = useContext(TooltipContext) const {targetMeasurements} = useContext(TargetContext) + const isWithinProvider = !!useContext(TooltipProviderContext) const requestClose = useCallback(() => { onVisibleChange(false) }, [onVisibleChange]) if (!visible || !targetMeasurements) return null + const Portal = isWithinProvider ? TooltipPortal.Portal : RootPortal + return ( {children} +} +Provider.displayName = 'TooltipProvider' + type TooltipContextType = { position: 'top' | 'bottom' onVisibleChange: (open: boolean) => void } -const TooltipContext = createContext({ +const TooltipContext = createContext>({ position: 'bottom', - onVisibleChange: () => {}, }) TooltipContext.displayName = 'TooltipContext' @@ -33,10 +38,7 @@ export function Outer({ visible: boolean onVisibleChange: (visible: boolean) => void }) { - const ctx = useMemo( - () => ({position, onVisibleChange}), - [position, onVisibleChange], - ) + const ctx = useMemo(() => ({position}), [position]) return ( {children} @@ -60,7 +62,7 @@ export function Content({ label: string }) { const t = useTheme() - const {position, onVisibleChange} = useContext(TooltipContext) + const {position} = useContext(TooltipContext) return ( onVisibleChange(false)} + onInteractOutside={evt => { + if (evt.type === 'dismissableLayer.focusOutside') { + evt.preventDefault() + } + }} style={flatten([ a.rounded_sm, select(t.name, { diff --git a/src/components/WhoCanReply.tsx b/src/components/WhoCanReply.tsx index a10508f2e4..ae1c68ab82 100644 --- a/src/components/WhoCanReply.tsx +++ b/src/components/WhoCanReply.tsx @@ -1,4 +1,4 @@ -import {Fragment, useMemo} from 'react' +import {Fragment, useMemo, useRef} from 'react' import { Keyboard, Platform, @@ -22,7 +22,7 @@ import { type ThreadgateAllowUISetting, threadgateViewToAllowUISetting, } from '#/state/queries/threadgate' -import {atoms as a, useTheme, web} from '#/alf' +import {atoms as a, native, useTheme, web} from '#/alf' import {Button, ButtonText} from '#/components/Button' import * as Dialog from '#/components/Dialog' import {useDialogControl} from '#/components/Dialog' @@ -30,13 +30,13 @@ import { PostInteractionSettingsDialog, usePrefetchPostInteractionSettings, } from '#/components/dialogs/PostInteractionSettingsDialog' -import {CircleBanSign_Stroke2_Corner0_Rounded as CircleBanSign} from '#/components/icons/CircleBanSign' -import {Earth_Stroke2_Corner0_Rounded as Earth} from '#/components/icons/Globe' -import {Group3_Stroke2_Corner0_Rounded as Group} from '#/components/icons/Group' +import {TinyChevronBottom_Stroke2_Corner0_Rounded as TinyChevronDownIcon} from '#/components/icons/Chevron' +import {CircleBanSign_Stroke2_Corner0_Rounded as CircleBanSignIcon} from '#/components/icons/CircleBanSign' +import {Earth_Stroke2_Corner0_Rounded as EarthIcon} from '#/components/icons/Globe' +import {Group3_Stroke2_Corner0_Rounded as GroupIcon} from '#/components/icons/Group' import {InlineLinkText} from '#/components/Link' import {Text} from '#/components/Typography' import * as bsky from '#/types/bsky' -import {PencilLine_Stroke2_Corner0_Rounded as PencilLine} from './icons/Pencil' interface WhoCanReplyProps { post: AppBskyFeedDefs.PostView @@ -69,6 +69,11 @@ export function WhoCanReply({post, isThreadAuthor, style}: WhoCanReplyProps) { postUri: post.uri, rootPostUri: rootUri, }) + const prefetchPromise = useRef>(Promise.resolve()) + + const prefetch = () => { + prefetchPromise.current = prefetchPostInteractionSettings() + } const anyoneCanReply = settings.length === 1 && settings[0].type === 'everybody' @@ -84,7 +89,14 @@ export function WhoCanReply({post, isThreadAuthor, style}: WhoCanReplyProps) { Keyboard.dismiss() } if (isThreadAuthor) { - editDialogControl.open() + // wait on prefetch if it manages to resolve in under 200ms + // otherwise, proceed immediately and show the spinner -sfn + Promise.race([ + prefetchPromise.current, + new Promise(res => setTimeout(res, 200)), + ]).finally(() => { + editDialogControl.open() + }) } else { infoDialogControl.open() } @@ -100,18 +112,27 @@ export function WhoCanReply({post, isThreadAuthor, style}: WhoCanReplyProps) { {...(isThreadAuthor ? Platform.select({ web: { - onHoverIn: prefetchPostInteractionSettings, + onHoverIn: prefetch, }, native: { - onPressIn: prefetchPostInteractionSettings, + onPressIn: prefetch, }, }) : {})} hitSlop={HITSLOP_10}> - {({hovered}) => ( - + {({hovered, focused, pressed}) => ( + @@ -119,14 +140,16 @@ export function WhoCanReply({post, isThreadAuthor, style}: WhoCanReplyProps) { style={[ a.text_sm, a.leading_tight, - t.atoms.text_contrast_medium, - hovered && a.underline, + isThreadAuthor + ? {color: t.palette.primary_500} + : t.atoms.text_contrast_medium, + (hovered || focused || pressed) && web(a.underline), ]}> {description} {isThreadAuthor && ( - + )} )} @@ -164,7 +187,11 @@ function Icon({ settings.length === 0 || settings.every(setting => setting.type === 'everybody') const isNobody = !!settings.find(gate => gate.type === 'nobody') - const IconComponent = isEverybody ? Earth : isNobody ? CircleBanSign : Group + const IconComponent = isEverybody + ? EarthIcon + : isNobody + ? CircleBanSignIcon + : GroupIcon return } diff --git a/src/components/activity-notifications/SubscribeProfileButton.tsx b/src/components/activity-notifications/SubscribeProfileButton.tsx index 71253dca9b..84d8c80518 100644 --- a/src/components/activity-notifications/SubscribeProfileButton.tsx +++ b/src/components/activity-notifications/SubscribeProfileButton.tsx @@ -1,4 +1,4 @@ -import {useCallback} from 'react' +import {useCallback, useEffect, useState} from 'react' import {type ModerationOpts} from '@atproto/api' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' @@ -27,8 +27,21 @@ export function SubscribeProfileButton({ const subscribeDialogControl = useDialogControl() const [activitySubscriptionsNudged, setActivitySubscriptionsNudged] = useActivitySubscriptionsNudged() + const [showTooltip, setShowTooltip] = useState(false) - const onDismissTooltip = () => { + useEffect(() => { + if (!activitySubscriptionsNudged) { + const timeout = setTimeout(() => { + setShowTooltip(true) + }, 500) + return () => clearTimeout(timeout) + } + }, [activitySubscriptionsNudged]) + + const onDismissTooltip = (visible: boolean) => { + if (visible) return + + setShowTooltip(false) setActivitySubscriptionsNudged(true) } @@ -56,7 +69,7 @@ export function SubscribeProfileButton({ return ( <> @@ -65,7 +78,6 @@ export function SubscribeProfileButton({ testID="dmBtn" size="small" color="secondary" - variant="solid" shape="round" label={_(msg`Get notified when ${name} posts`)} onPress={wrappedOnPress}> diff --git a/src/components/dialogs/PostInteractionSettingsDialog.tsx b/src/components/dialogs/PostInteractionSettingsDialog.tsx index 5b9fc262dc..b499b40751 100644 --- a/src/components/dialogs/PostInteractionSettingsDialog.tsx +++ b/src/components/dialogs/PostInteractionSettingsDialog.tsx @@ -1,16 +1,17 @@ -import React from 'react' -import {type StyleProp, View, type ViewStyle} from 'react-native' +import {useCallback, useMemo, useState} from 'react' +import {LayoutAnimation, Text as NestedText, View} from 'react-native' import { type AppBskyFeedDefs, type AppBskyFeedPostgate, AtUri, } from '@atproto/api' -import {msg, Trans} from '@lingui/macro' +import {msg, Plural, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' import {useQueryClient} from '@tanstack/react-query' -import isEqual from 'lodash.isequal' +import {useHaptics} from '#/lib/haptics' import {logger} from '#/logger' +import {isIOS} from '#/platform/detection' import {STALE} from '#/state/queries' import {useMyListsQuery} from '#/state/queries/my-lists' import {useGetPost} from '#/state/queries/post' @@ -37,13 +38,17 @@ import { } from '#/state/queries/usePostThread' import {useAgent, useSession} from '#/state/session' import * as Toast from '#/view/com/util/Toast' -import {atoms as a, useTheme} from '#/alf' +import {UserAvatar} from '#/view/com/util/UserAvatar' +import {atoms as a, useTheme, web} from '#/alf' import {Button, ButtonIcon, ButtonText} from '#/components/Button' import * as Dialog from '#/components/Dialog' -import {Divider} from '#/components/Divider' import * as Toggle from '#/components/forms/Toggle' -import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check' +import { + ChevronBottom_Stroke2_Corner0_Rounded as ChevronDownIcon, + ChevronTop_Stroke2_Corner0_Rounded as ChevronUpIcon, +} from '#/components/icons/Chevron' import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo' +import {CloseQuote_Stroke2_Corner1_Rounded as QuoteIcon} from '#/components/icons/Quote' import {Loader} from '#/components/Loader' import {Text} from '#/components/Typography' @@ -52,6 +57,10 @@ export type PostInteractionSettingsFormProps = { onSave: () => void isSaving?: boolean + isDirty?: boolean + persist?: boolean + onChangePersist?: (v: boolean) => void + postgate: AppBskyFeedPostgate.Record onChangePostgate: (v: AppBskyFeedPostgate.Record) => void @@ -61,57 +70,39 @@ export type PostInteractionSettingsFormProps = { replySettingsDisabled?: boolean } +/** + * Threadgate settings dialog. Used in the composer. + */ export function PostInteractionSettingsControlledDialog({ control, ...rest }: PostInteractionSettingsFormProps & { control: Dialog.DialogControlProps }) { - const t = useTheme() - const {_} = useLingui() - return ( - + - - -
- - - - You can set default interaction settings in{' '} - - Settings → Moderation → Interaction settings - - . - - - - - + ) } -export function Header() { +function DialogInner(props: Omit) { + const {_} = useLingui() + return ( - - - Post interaction settings - - - Customize who can interact with this post. - - - + +
+ + + ) } @@ -134,12 +125,17 @@ export type PostInteractionSettingsDialogProps = { initialThreadgateView?: AppBskyFeedDefs.ThreadgateView } +/** + * Threadgate settings dialog. Used in the thread. + */ export function PostInteractionSettingsDialog( props: PostInteractionSettingsDialogProps, ) { const postThreadContext = usePostThreadContext() return ( - + @@ -153,7 +149,7 @@ export function PostInteractionSettingsDialogControlledInner( ) { const {_} = useLingui() const {currentAccount} = useSession() - const [isSaving, setIsSaving] = React.useState(false) + const [isSaving, setIsSaving] = useState(false) const {data: threadgateViewLoaded, isLoading: isLoadingThreadgate} = useThreadgateViewQuery({postUri: props.rootPostUri}) @@ -165,28 +161,28 @@ export function PostInteractionSettingsDialogControlledInner( const {mutateAsync: setThreadgateAllow} = useSetThreadgateAllowMutation() const [editedPostgate, setEditedPostgate] = - React.useState() + useState() const [editedAllowUISettings, setEditedAllowUISettings] = - React.useState() + useState() const isLoading = isLoadingThreadgate || isLoadingPostgate const threadgateView = threadgateViewLoaded || props.initialThreadgateView - const isThreadgateOwnedByViewer = React.useMemo(() => { + const isThreadgateOwnedByViewer = useMemo(() => { return currentAccount?.did === new AtUri(props.rootPostUri).host }, [props.rootPostUri, currentAccount?.did]) - const postgateValue = React.useMemo(() => { + const postgateValue = useMemo(() => { return ( editedPostgate || postgate || createPostgateRecord({post: props.postUri}) ) }, [postgate, editedPostgate, props.postUri]) - const allowUIValue = React.useMemo(() => { + const allowUIValue = useMemo(() => { return ( editedAllowUISettings || threadgateViewToAllowUISetting(threadgateView) ) }, [threadgateView, editedAllowUISettings]) - const onSave = React.useCallback(async () => { + const onSave = useCallback(async () => { if (!editedPostgate && !editedAllowUISettings) { props.control.close() return @@ -248,15 +244,24 @@ export function PostInteractionSettingsDialogControlledInner( return ( - -
- - {isLoading ? ( - - - - ) : ( + style={[web({maxWidth: 400}), a.w_full]}> + {isLoading ? ( + + + + Loading post interaction settings... + + + ) : ( + <> +
- )} - + + )} + ) } @@ -281,11 +287,20 @@ export function PostInteractionSettingsForm({ threadgateAllowUISettings, onChangeThreadgateAllowUISettings, replySettingsDisabled, + isDirty, + persist, + onChangePersist, }: PostInteractionSettingsFormProps) { const t = useTheme() const {_} = useLingui() - const {data: lists} = useMyListsQuery('curate') - const [quotesEnabled, setQuotesEnabled] = React.useState( + const playHaptic = useHaptics() + const [showLists, setShowLists] = useState(false) + const { + data: lists, + isPending: isListsPending, + isError: isListsError, + } = useMyListsQuery('curate') + const [quotesEnabled, setQuotesEnabled] = useState( !( postgate.embeddingRules && postgate.embeddingRules.find( @@ -294,27 +309,7 @@ export function PostInteractionSettingsForm({ ), ) - const onPressAudience = (setting: ThreadgateAllowUISetting) => { - // remove boolean values - let newSelected: ThreadgateAllowUISetting[] = - threadgateAllowUISettings.filter( - v => v.type !== 'nobody' && v.type !== 'everybody', - ) - // toggle - const i = newSelected.findIndex(v => isEqual(v, setting)) - if (i === -1) { - newSelected.push(setting) - } else { - newSelected.splice(i, 1) - } - if (newSelected.length === 0) { - newSelected.push({type: 'everybody'}) - } - - onChangeThreadgateAllowUISettings(newSelected) - } - - const onChangeQuotesEnabled = React.useCallback( + const onChangeQuotesEnabled = useCallback( (enabled: boolean) => { setQuotesEnabled(enabled) onChangePostgate( @@ -330,229 +325,347 @@ export function PostInteractionSettingsForm({ const noOneCanReply = !!threadgateAllowUISettings.find( v => v.type === 'nobody', ) + const everyoneCanReply = !!threadgateAllowUISettings.find( + v => v.type === 'everybody', + ) + const numberOfListsSelected = threadgateAllowUISettings.filter( + v => v.type === 'list', + ).length + + const toggleGroupValues = useMemo(() => { + const values: string[] = [] + for (const setting of threadgateAllowUISettings) { + switch (setting.type) { + case 'everybody': + case 'nobody': + // no granularity, early return with nothing + return [] + case 'followers': + values.push('followers') + break + case 'following': + values.push('following') + break + case 'mention': + values.push('mention') + break + case 'list': + values.push(`list:${setting.list}`) + break + default: + break + } + } + return values + }, [threadgateAllowUISettings]) + + const toggleGroupOnChange = (values: string[]) => { + const settings: ThreadgateAllowUISetting[] = [] + + if (values.length === 0) { + settings.push({type: 'everybody'}) + } else { + for (const value of values) { + if (value.startsWith('list:')) { + const listId = value.slice('list:'.length) + settings.push({type: 'list', list: listId}) + } else { + settings.push({type: value as 'followers' | 'following' | 'mention'}) + } + } + } + + onChangeThreadgateAllowUISettings(settings) + } return ( - - - - - - Quote settings - - - - - Allow quote posts - - - - - - - - {replySettingsDisabled && ( - - - - - Reply settings are chosen by the author of the thread - - - - )} - + + + {replySettingsDisabled && ( - - Reply settings + + + + Reply settings are chosen by the author of the thread + - - - Allow replies from: - - - - v.type === 'everybody') - } - onPress={() => - onChangeThreadgateAllowUISettings([{type: 'everybody'}]) - } - style={{flex: 1}} - disabled={replySettingsDisabled} - /> - - onChangeThreadgateAllowUISettings([{type: 'nobody'}]) - } - style={{flex: 1}} - disabled={replySettingsDisabled} - /> - - - {!noOneCanReply && ( - <> - - Or combine these options: - - - - v.type === 'mention', - ) - } - onPress={() => onPressAudience({type: 'mention'})} - disabled={replySettingsDisabled} - /> - v.type === 'following', - ) - } - onPress={() => onPressAudience({type: 'following'})} - disabled={replySettingsDisabled} - /> - v.type === 'followers', - ) - } - onPress={() => onPressAudience({type: 'followers'})} - disabled={replySettingsDisabled} - /> - {lists && lists.length > 0 - ? lists.map(list => ( - v.type === 'list' && v.list === list.uri, - ) - } - onPress={() => - onPressAudience({type: 'list', list: list.uri}) - } - disabled={replySettingsDisabled} - /> - )) - : // No loading states to avoid jumps for the common case (no lists) - null} - - - )} + )} + + + + Who can reply + + + { + if (val.includes('everyone')) { + onChangeThreadgateAllowUISettings([{type: 'everybody'}]) + } else if (val.includes('nobody')) { + onChangeThreadgateAllowUISettings([{type: 'nobody'}]) + } else { + onChangeThreadgateAllowUISettings([{type: 'mention'}]) + } + }}> + + + {({selected}) => ( + + + + Anyone + + + )} + + + {({selected}) => ( + + + + Nobody + + + )} + + + + + + + + {({selected}) => ( + + + + Your followers + + + )} + + + {({selected}) => ( + + + + People you follow + + + )} + + + {({selected}) => ( + + + + People you mention + + + )} + + + + {showLists && + (isListsPending ? ( + + + Loading lists... + + + ) : isListsError ? ( + + + + An error occurred while loading your lists :/ + + + + ) : lists.length === 0 ? ( + + + You don't have any lists yet. + + + ) : ( + lists.map((list, i) => ( + + {({selected}) => ( + + + + {list.name} + + )} + + )) + ))} + + + + {({selected}) => ( + + + Allow quote posts + + + + )} + + + {typeof persist !== 'undefined' && ( + + {isDirty ? ( + onChangePersist?.(!persist)}> + + + Save these options for next time + + + ) : ( + + These are your default settings + + )} + + )} + ) } -function Selectable({ - label, - isSelected, - onPress, - style, - disabled, -}: { - label: string - isSelected: boolean - onPress: () => void - style?: StyleProp - disabled?: boolean -}) { - const t = useTheme() +function Header() { return ( - + + + Post interaction settings + + ) } @@ -567,7 +680,7 @@ export function usePrefetchPostInteractionSettings({ const agent = useAgent() const getPost = useGetPost() - return React.useCallback(async () => { + return useCallback(async () => { try { await Promise.all([ queryClient.prefetchQuery({ diff --git a/src/components/forms/Toggle/Panel.tsx b/src/components/forms/Toggle/Panel.tsx new file mode 100644 index 0000000000..d874750db6 --- /dev/null +++ b/src/components/forms/Toggle/Panel.tsx @@ -0,0 +1,120 @@ +import {createContext, useContext} from 'react' +import {View, type ViewStyle} from 'react-native' + +import {atoms as a, tokens, useTheme} from '#/alf' +import {type Props as SVGIconProps} from '#/components/icons/common' +import {Text} from '#/components/Typography' + +const PanelContext = createContext<{active: boolean}>({active: false}) + +/** + * A nice container for Toggles. See the Threadgate dialog for an example. + */ +export function Panel({ + children, + active = false, + adjacent, +}: { + children: React.ReactNode + active?: boolean + adjacent?: 'leading' | 'trailing' | 'both' +}) { + const t = useTheme() + + const leading = adjacent === 'leading' || adjacent === 'both' + const trailing = adjacent === 'trailing' || adjacent === 'both' + const rounding = { + borderTopLeftRadius: leading + ? tokens.borderRadius.xs + : tokens.borderRadius.md, + borderTopRightRadius: leading + ? tokens.borderRadius.xs + : tokens.borderRadius.md, + borderBottomLeftRadius: trailing + ? tokens.borderRadius.xs + : tokens.borderRadius.md, + borderBottomRightRadius: trailing + ? tokens.borderRadius.xs + : tokens.borderRadius.md, + } satisfies ViewStyle + + return ( + + {children} + + ) +} + +export function PanelText({ + children, + icon, +}: { + children: React.ReactNode + icon?: React.ComponentType +}) { + const t = useTheme() + const ctx = useContext(PanelContext) + + const text = ( + + {children} + + ) + + if (icon) { + // eslint-disable-next-line bsky-internal/avoid-unwrapped-text + return ( + + + {text} + + ) + } + + return text +} + +export function PanelIcon({ + icon: Icon, +}: { + icon: React.ComponentType +}) { + const t = useTheme() + const ctx = useContext(PanelContext) + return ( + + ) +} + +/** + * A group of panels. TODO: auto-leading/trailing + */ +export function PanelGroup({children}: {children: React.ReactNode}) { + return {children} +} diff --git a/src/components/forms/Toggle.tsx b/src/components/forms/Toggle/index.tsx similarity index 68% rename from src/components/forms/Toggle.tsx rename to src/components/forms/Toggle/index.tsx index 849e014fac..60fa50478a 100644 --- a/src/components/forms/Toggle.tsx +++ b/src/components/forms/Toggle/index.tsx @@ -1,12 +1,20 @@ -import React from 'react' -import {Pressable, type StyleProp, View, type ViewStyle} from 'react-native' -import Animated, {LinearTransition} from 'react-native-reanimated' +import {createContext, useCallback, useContext, useMemo} from 'react' +import { + Pressable, + type PressableProps, + type StyleProp, + View, + type ViewStyle, +} from 'react-native' +import Animated, {Easing, LinearTransition} from 'react-native-reanimated' import {HITSLOP_10} from '#/lib/constants' +import {useHaptics} from '#/lib/haptics' import {isNative} from '#/platform/detection' import { atoms as a, native, + platform, type TextStyleProp, useTheme, type ViewStyleProp, @@ -15,6 +23,8 @@ import {useInteractionState} from '#/components/hooks/useInteractionState' import {CheckThick_Stroke2_Corner0_Rounded as Checkmark} from '#/components/icons/Check' import {Text} from '#/components/Typography' +export * from './Panel' + export type ItemState = { name: string selected: boolean @@ -25,7 +35,7 @@ export type ItemState = { focused: boolean } -const ItemContext = React.createContext({ +const ItemContext = createContext({ name: '', selected: false, disabled: false, @@ -36,7 +46,7 @@ const ItemContext = React.createContext({ }) ItemContext.displayName = 'ToggleItemContext' -const GroupContext = React.createContext<{ +const GroupContext = createContext<{ values: string[] disabled: boolean type: 'radio' | 'checkbox' @@ -70,10 +80,11 @@ export type ItemProps = ViewStyleProp & { onChange?: (selected: boolean) => void isInvalid?: boolean children: ((props: ItemState) => React.ReactNode) | React.ReactNode + hitSlop?: PressableProps['hitSlop'] } export function useItemContext() { - return React.useContext(ItemContext) + return useContext(ItemContext) } export function Group({ @@ -88,9 +99,8 @@ export function Group({ }: GroupProps) { const groupRole = type === 'radio' ? 'radiogroup' : undefined const values = type === 'radio' ? providedValues.slice(0, 1) : providedValues - const [maxReached, setMaxReached] = React.useState(false) - const setFieldValue = React.useCallback< + const setFieldValue = useCallback< (props: {name: string; value: boolean}) => void >( ({name, value}) => { @@ -105,25 +115,13 @@ export function Group({ [type, onChange, values], ) - React.useEffect(() => { - if (type === 'checkbox') { - if ( - maxSelections && - values.length >= maxSelections && - maxReached === false - ) { - setMaxReached(true) - } else if ( - maxSelections && - values.length < maxSelections && - maxReached === true - ) { - setMaxReached(false) - } - } - }, [type, values.length, maxSelections, maxReached, setMaxReached]) + const maxReached = !!( + type === 'checkbox' && + maxSelections && + values.length >= maxSelections + ) - const context = React.useMemo( + const context = useMemo( () => ({ values, type, @@ -170,7 +168,7 @@ export function Item({ disabled: groupDisabled, setFieldValue, maxSelectionsReached, - } = React.useContext(GroupContext) + } = useContext(GroupContext) const { state: hovered, onIn: onHoverIn, @@ -182,19 +180,21 @@ export function Item({ onOut: onPressOut, } = useInteractionState() const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState() + const playHaptic = useHaptics() const role = groupType === 'radio' ? 'radio' : type const selected = selectedValues.includes(name) || !!value const disabled = groupDisabled || itemDisabled || (!selected && maxSelectionsReached) - const onPress = React.useCallback(() => { + const onPress = useCallback(() => { + playHaptic('Light') const next = !selected setFieldValue({name, value: next}) onChange?.(next) - }, [name, selected, onChange, setFieldValue]) + }, [playHaptic, name, selected, onChange, setFieldValue]) - const state = React.useMemo( + const state = useMemo( () => ({ name, selected, @@ -250,8 +250,8 @@ export function LabelText({ style={[ a.font_semi_bold, a.leading_tight, + a.user_select_none, { - userSelect: 'none', color: disabled ? t.atoms.text_contrast_low.color : t.atoms.text_contrast_high.color, @@ -287,21 +287,26 @@ export function createSharedToggleStyles({ if (selected) { base.push({ - backgroundColor: t.palette.primary_25, + backgroundColor: t.palette.primary_500, borderColor: t.palette.primary_500, }) if (hovered) { baseHover.push({ - backgroundColor: t.palette.primary_100, - borderColor: t.palette.primary_600, + backgroundColor: t.palette.primary_400, + borderColor: t.palette.primary_400, }) } } else { + base.push({ + backgroundColor: t.palette.contrast_25, + borderColor: t.palette.contrast_100, + }) + if (hovered) { baseHover.push({ backgroundColor: t.palette.contrast_50, - borderColor: t.palette.contrast_500, + borderColor: t.palette.contrast_200, }) } } @@ -318,6 +323,20 @@ export function createSharedToggleStyles({ borderColor: t.palette.negative_600, }) } + + if (selected) { + base.push({ + backgroundColor: t.palette.negative_500, + borderColor: t.palette.negative_500, + }) + + if (hovered) { + baseHover.push({ + backgroundColor: t.palette.negative_400, + borderColor: t.palette.negative_400, + }) + } + } } if (disabled) { @@ -325,6 +344,13 @@ export function createSharedToggleStyles({ backgroundColor: t.palette.contrast_100, borderColor: t.palette.contrast_400, }) + + if (selected) { + base.push({ + backgroundColor: t.palette.primary_100, + borderColor: t.palette.contrast_400, + }) + } } return { @@ -350,66 +376,125 @@ export function Checkbox() { style={[ a.justify_center, a.align_center, - a.rounded_xs, t.atoms.border_contrast_high, + a.transition_color, { borderWidth: 1, height: 24, width: 24, + borderRadius: 6, }, baseStyles, hovered ? baseHoverStyles : {}, ]}> - {selected ? : null} + {selected && } ) } export function Switch() { const t = useTheme() - const {selected, hovered, focused, disabled, isInvalid} = useItemContext() - const {baseStyles, baseHoverStyles, indicatorStyles} = - createSharedToggleStyles({ - theme: t, - hovered, - focused, - selected, - disabled, - isInvalid, - }) + const {selected, hovered, disabled, isInvalid} = useItemContext() + const {baseStyles, baseHoverStyles, indicatorStyles} = useMemo(() => { + const base: ViewStyle[] = [] + const baseHover: ViewStyle[] = [] + const indicator: ViewStyle[] = [] + + if (selected) { + base.push({ + backgroundColor: t.palette.primary_500, + }) + + if (hovered) { + baseHover.push({ + backgroundColor: t.palette.primary_400, + }) + } + } else { + base.push({ + backgroundColor: t.palette.contrast_200, + }) + + if (hovered) { + baseHover.push({ + backgroundColor: t.palette.contrast_100, + }) + } + } + + if (isInvalid) { + base.push({ + backgroundColor: t.palette.negative_200, + }) + + if (hovered) { + baseHover.push({ + backgroundColor: t.palette.negative_100, + }) + } + + if (selected) { + base.push({ + backgroundColor: t.palette.negative_500, + }) + + if (hovered) { + baseHover.push({ + backgroundColor: t.palette.negative_400, + }) + } + } + } + + if (disabled) { + base.push({ + backgroundColor: t.palette.contrast_50, + }) + + if (selected) { + base.push({ + backgroundColor: t.palette.primary_100, + }) + } + } + + return { + baseStyles: base, + baseHoverStyles: disabled ? [] : baseHover, + indicatorStyles: indicator, + } + }, [t, hovered, disabled, selected, isInvalid]) + return ( @@ -420,7 +505,7 @@ export function Switch() { export function Radio() { const t = useTheme() const {selected, hovered, focused, disabled, isInvalid} = - React.useContext(ItemContext) + useContext(ItemContext) const {baseStyles, baseHoverStyles, indicatorStyles} = createSharedToggleStyles({ theme: t, @@ -437,29 +522,27 @@ export function Radio() { a.align_center, a.rounded_full, t.atoms.border_contrast_high, + a.transition_color, { borderWidth: 1, - height: 24, - width: 24, + height: 25, + width: 25, + margin: -1, }, baseStyles, hovered ? baseHoverStyles : {}, ]}> - {selected ? ( + {selected && ( - ) : null} + )} ) } diff --git a/src/components/icons/Chevron.tsx b/src/components/icons/Chevron.tsx index 4d252ee3ca..b033e3c66b 100644 --- a/src/components/icons/Chevron.tsx +++ b/src/components/icons/Chevron.tsx @@ -19,3 +19,10 @@ export const ChevronBottom_Stroke2_Corner0_Rounded = createSinglePathSVG({ export const ChevronTopBottom_Stroke2_Corner0_Rounded = createSinglePathSVG({ path: 'M11.293 4.293a1 1 0 0 1 1.414 0l4 4a1 1 0 0 1-1.414 1.414L12 6.414 8.707 9.707a1 1 0 0 1-1.414-1.414l4-4Zm-4 10a1 1 0 0 1 1.414 0L12 17.586l3.293-3.293a1 1 0 0 1 1.414 1.414l-4 4a1 1 0 0 1-1.414 0l-4-4a1 1 0 0 1 0-1.414Z', }) + +/** + * NOTE: Use with size `2xs` + */ +export const TinyChevronBottom_Stroke2_Corner0_Rounded = createSinglePathSVG({ + path: 'M10.928 18.882c.757.499 1.786.417 2.452-.25l9-9a1.953 1.953 0 0 0-2.76-2.76L12 14.493l-7.62-7.62a1.952 1.952 0 0 0-2.76 2.76l9 9 .308.25Z', +}) diff --git a/src/components/icons/common.tsx b/src/components/icons/common.tsx index bc1e045a48..0f208240f3 100644 --- a/src/components/icons/common.tsx +++ b/src/components/icons/common.tsx @@ -13,6 +13,7 @@ export type Props = { } & Omit export const sizes = { + '2xs': 8, xs: 12, sm: 16, md: 20, diff --git a/src/components/verification/VerificationsDialog.tsx b/src/components/verification/VerificationsDialog.tsx index 7e6b66c816..4091b80531 100644 --- a/src/components/verification/VerificationsDialog.tsx +++ b/src/components/verification/VerificationsDialog.tsx @@ -34,7 +34,7 @@ export function VerificationsDialog({ verificationState: FullVerificationState }) { return ( - + - {niceDate(i18n, post.indexedAt)} + {niceDate(i18n, post.indexedAt, 'medium')} {isRootPost && ( diff --git a/src/screens/Settings/components/SettingsList.tsx b/src/screens/Settings/components/SettingsList.tsx index 14e341de21..5010d42fca 100644 --- a/src/screens/Settings/components/SettingsList.tsx +++ b/src/screens/Settings/components/SettingsList.tsx @@ -194,6 +194,7 @@ export function ItemIcon({ * also so that we can calculate transforms. */ const iconSize = { + '2xs': 8, xs: 12, sm: 16, md: 20, diff --git a/src/state/global-gesture-events/index.tsx b/src/state/global-gesture-events/index.tsx index 8941d9ef46..2f0d652210 100644 --- a/src/state/global-gesture-events/index.tsx +++ b/src/state/global-gesture-events/index.tsx @@ -1,5 +1,5 @@ import {createContext, useContext, useMemo, useRef, useState} from 'react' -import {View} from 'react-native' +import {type StyleProp, View, type ViewStyle} from 'react-native' import { Gesture, GestureDetector, @@ -29,8 +29,10 @@ Context.displayName = 'GlobalGestureEventsContext' export function GlobalGestureEventsProvider({ children, + style, }: { children: React.ReactNode + style?: StyleProp }) { const refCount = useRef(0) const events = useMemo(() => new EventEmitter(), []) @@ -73,7 +75,9 @@ export function GlobalGestureEventsProvider({ return ( - {children} + + {children} + ) diff --git a/src/state/queries/post-interaction-settings.ts b/src/state/queries/post-interaction-settings.ts index 6f2b7d9088..af178d7f8b 100644 --- a/src/state/queries/post-interaction-settings.ts +++ b/src/state/queries/post-interaction-settings.ts @@ -4,7 +4,13 @@ import {useMutation, useQueryClient} from '@tanstack/react-query' import {preferencesQueryKey} from '#/state/queries/preferences' import {useAgent} from '#/state/session' -export function usePostInteractionSettingsMutation() { +export function usePostInteractionSettingsMutation({ + onError, + onSettled, +}: { + onError?: (error: Error) => void + onSettled?: () => void +} = {}) { const qc = useQueryClient() const agent = useAgent() return useMutation({ @@ -16,5 +22,7 @@ export function usePostInteractionSettingsMutation() { queryKey: preferencesQueryKey, }) }, + onError, + onSettled, }) } diff --git a/src/storage/hooks/threadgate-nudged.ts b/src/storage/hooks/threadgate-nudged.ts new file mode 100644 index 0000000000..b1786d35dd --- /dev/null +++ b/src/storage/hooks/threadgate-nudged.ts @@ -0,0 +1,9 @@ +import {device, useStorage} from '#/storage' + +export function useThreadgateNudged() { + const [threadgateNudged = false, setThreadgateNudged] = useStorage(device, [ + 'threadgateNudged', + ]) + + return [threadgateNudged, setThreadgateNudged] as const +} diff --git a/src/storage/schema.ts b/src/storage/schema.ts index d562d9fae4..02923436a5 100644 --- a/src/storage/schema.ts +++ b/src/storage/schema.ts @@ -37,6 +37,7 @@ export type Device = { devMode: boolean demoMode: boolean activitySubscriptionsNudged?: boolean + threadgateNudged?: boolean /** * Policy update overlays. New IDs are required for each new announcement. diff --git a/src/view/com/composer/labels/LabelsBtn.tsx b/src/view/com/composer/labels/LabelsBtn.tsx index 592d954a44..95a93490cd 100644 --- a/src/view/com/composer/labels/LabelsBtn.tsx +++ b/src/view/com/composer/labels/LabelsBtn.tsx @@ -10,11 +10,12 @@ import { type SelfLabel, } from '#/lib/moderation' import {isWeb} from '#/platform/detection' -import {atoms as a, native, useTheme, web} from '#/alf' +import {atoms as a, useTheme, web} from '#/alf' import {Button, ButtonIcon, ButtonText} from '#/components/Button' import * as Dialog from '#/components/Dialog' import * as Toggle from '#/components/forms/Toggle' import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check' +import {TinyChevronBottom_Stroke2_Corner0_Rounded as TinyChevronIcon} from '#/components/icons/Chevron' import {Shield_Stroke2_Corner0_Rounded} from '#/components/icons/Shield' import {Text} from '#/components/Typography' @@ -49,7 +50,6 @@ export function LabelsBtn({ return ( <> diff --git a/src/view/com/composer/threadgate/ThreadgateBtn.tsx b/src/view/com/composer/threadgate/ThreadgateBtn.tsx index 4f46351b27..788e831dc0 100644 --- a/src/view/com/composer/threadgate/ThreadgateBtn.tsx +++ b/src/view/com/composer/threadgate/ThreadgateBtn.tsx @@ -1,17 +1,31 @@ +import {useEffect, useMemo, useState} from 'react' import {Keyboard, type StyleProp, type ViewStyle} from 'react-native' import {type AnimatedStyle} from 'react-native-reanimated' import {type AppBskyFeedPostgate} from '@atproto/api' -import {msg} from '@lingui/macro' +import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' +import deepEqual from 'lodash.isequal' +import {isNetworkError} from '#/lib/strings/errors' +import {logger} from '#/logger' import {isNative} from '#/platform/detection' -import {type ThreadgateAllowUISetting} from '#/state/queries/threadgate' -import {native} from '#/alf' +import {usePostInteractionSettingsMutation} from '#/state/queries/post-interaction-settings' +import {createPostgateRecord} from '#/state/queries/postgate/util' +import {usePreferencesQuery} from '#/state/queries/preferences' +import { + type ThreadgateAllowUISetting, + threadgateAllowUISettingToAllowRecordValue, + threadgateRecordToAllowUISetting, +} from '#/state/queries/threadgate' import {Button, ButtonIcon, ButtonText} from '#/components/Button' import * as Dialog from '#/components/Dialog' import {PostInteractionSettingsControlledDialog} from '#/components/dialogs/PostInteractionSettingsDialog' -import {Earth_Stroke2_Corner0_Rounded as Earth} from '#/components/icons/Globe' -import {Group3_Stroke2_Corner0_Rounded as Group} from '#/components/icons/Group' +import {TinyChevronBottom_Stroke2_Corner0_Rounded as TinyChevronIcon} from '#/components/icons/Chevron' +import {Earth_Stroke2_Corner0_Rounded as EarthIcon} from '#/components/icons/Globe' +import {Group3_Stroke2_Corner0_Rounded as GroupIcon} from '#/components/icons/Group' +import * as Tooltip from '#/components/Tooltip' +import {Text} from '#/components/Typography' +import {useThreadgateNudged} from '#/storage/hooks/threadgate-nudged' export function ThreadgateBtn({ postgate, @@ -29,15 +43,82 @@ export function ThreadgateBtn({ }) { const {_} = useLingui() const control = Dialog.useDialogControl() + const [threadgateNudged, setThreadgateNudged] = useThreadgateNudged() + const [showTooltip, setShowTooltip] = useState(false) + + useEffect(() => { + if (!threadgateNudged) { + const timeout = setTimeout(() => { + setShowTooltip(true) + }, 1000) + return () => clearTimeout(timeout) + } + }, [threadgateNudged]) + + const onDismissTooltip = (visible: boolean) => { + if (visible) return + setThreadgateNudged(true) + setShowTooltip(false) + } + + const {data: preferences} = usePreferencesQuery() + const [persist, setPersist] = useState(false) const onPress = () => { if (isNative && Keyboard.isVisible()) { Keyboard.dismiss() } + setShowTooltip(false) + setThreadgateNudged(true) + control.open() } + const prefThreadgateAllowUISettings = threadgateRecordToAllowUISetting({ + $type: 'app.bsky.feed.threadgate', + post: '', + createdAt: new Date().toISOString(), + allow: preferences?.postInteractionSettings.threadgateAllowRules, + }) + const prefPostgate = createPostgateRecord({ + post: '', + embeddingRules: + preferences?.postInteractionSettings?.postgateEmbeddingRules || [], + }) + + const isDirty = useMemo(() => { + const everybody = [{type: 'everybody'}] + return ( + !deepEqual( + threadgateAllowUISettings, + prefThreadgateAllowUISettings ?? everybody, + ) || + !deepEqual(postgate.embeddingRules, prefPostgate?.embeddingRules ?? []) + ) + }, [ + prefThreadgateAllowUISettings, + prefPostgate, + threadgateAllowUISettings, + postgate, + ]) + + const {mutate: persistChanges, isPending: isSaving} = + usePostInteractionSettingsMutation({ + onError: err => { + if (!isNetworkError(err)) { + logger.error('Failed to persist threadgate settings', { + safeMessage: err, + }) + } + }, + onSettled: () => { + control.close(() => { + setPersist(false) + }) + }, + }) + const anyoneCanReply = threadgateAllowUISettings.length === 1 && threadgateAllowUISettings[0].type === 'everybody' @@ -50,34 +131,54 @@ export function ThreadgateBtn({ return ( <> - + + + + + + + Psst! You can edit who can interact with this post. + + + + { - control.close() + if (persist) { + persistChanges({ + threadgateAllowRules: threadgateAllowUISettingToAllowRecordValue( + threadgateAllowUISettings, + ), + postgateEmbeddingRules: postgate.embeddingRules ?? [], + }) + } else { + control.close() + } }} + isSaving={isSaving} postgate={postgate} onChangePostgate={onChangePostgate} threadgateAllowUISettings={threadgateAllowUISettings} onChangeThreadgateAllowUISettings={onChangeThreadgateAllowUISettings} + isDirty={isDirty} + persist={persist} + onChangePersist={setPersist} /> ) diff --git a/src/view/screens/Storybook/Forms.tsx b/src/view/screens/Storybook/Forms.tsx index 45a1d9aa00..3cf6e47232 100644 --- a/src/view/screens/Storybook/Forms.tsx +++ b/src/view/screens/Storybook/Forms.tsx @@ -155,6 +155,15 @@ export function Forms() { + + + Click me + + + + Click me + + ref.current?.onPressCancel()}> - + + + ) From c92ac4abc1ebeeef7ba79ac47bdb5751f6e8cfe3 Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Fri, 14 Nov 2025 17:46:20 +0200 Subject: [PATCH 02/20] flatten may return undefined (#9387) --- src/alf/typography.tsx | 3 ++- src/components/RichText.tsx | 6 +++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/alf/typography.tsx b/src/alf/typography.tsx index 3c3bb95489..2def71a290 100644 --- a/src/alf/typography.tsx +++ b/src/alf/typography.tsx @@ -25,7 +25,8 @@ export function normalizeTextStyles( fontFamily: Alf['fonts']['family'] } & Pick, ) { - const s = flatten(styles) + const s = flatten(styles) ?? {} + // should always be defined on these components s.fontSize = (s.fontSize || atoms.text_md.fontSize) * fontScale diff --git a/src/components/RichText.tsx b/src/components/RichText.tsx index 40683523c0..9908679c57 100644 --- a/src/components/RichText.tsx +++ b/src/components/RichText.tsx @@ -1,5 +1,5 @@ import React from 'react' -import {type TextStyle} from 'react-native' +import {type StyleProp, type TextStyle} from 'react-native' import {AppBskyRichtextFacet, RichText as RichTextAPI} from '@atproto/api' import {toShortUrl} from '#/lib/strings/url-helpers' @@ -21,7 +21,7 @@ export type RichTextProps = TextStyleProp & enableTags?: boolean authorHandle?: string onLinkPress?: LinkProps['onPress'] - interactiveStyle?: TextStyle + interactiveStyle?: StyleProp emojiMultiplier?: number shouldProxyLinks?: boolean } @@ -55,7 +55,7 @@ export function RichText({ if (!facets?.length) { if (isOnlyEmoji(text)) { - const flattenedStyle = flatten(style) + const flattenedStyle = flatten(style) ?? {} const fontSize = (flattenedStyle.fontSize ?? a.text_sm.fontSize) * emojiMultiplier return ( From 24c96ac881260c8684f8addb81f6e566d538a515 Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Fri, 14 Nov 2025 17:46:39 +0200 Subject: [PATCH 03/20] Image saving - fix extension, increase download timeout (#9388) --- src/lib/media/manip.ts | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/lib/media/manip.ts b/src/lib/media/manip.ts index c955fe3857..7571f2972d 100644 --- a/src/lib/media/manip.ts +++ b/src/lib/media/manip.ts @@ -84,14 +84,14 @@ export async function shareImageModal({uri}: {uri: string}) { return } - // we're currently relying on the fact our CDN only serves pngs + // we're currently relying on the fact our CDN only serves jpegs // -prf - const imageUri = await downloadImage(uri, createPath('png'), 5e3) - const imagePath = await moveToPermanentPath(imageUri, '.png') + const imageUri = await downloadImage(uri, createPath('jpg'), 15e3) + const imagePath = await moveToPermanentPath(imageUri, '.jpg') safeDeleteAsync(imageUri) await Sharing.shareAsync(imagePath, { - mimeType: 'image/png', - UTI: 'image/png', + mimeType: 'image/jpeg', + UTI: 'image/jpeg', }) } @@ -100,11 +100,11 @@ const ALBUM_NAME = 'Bluesky' export async function saveImageToMediaLibrary({uri}: {uri: string}) { // download the file to cache // NOTE - // assuming PNG - // we're currently relying on the fact our CDN only serves pngs + // assuming JPEG + // we're currently relying on the fact our CDN only serves jpegs // -prf - const imageUri = await downloadImage(uri, createPath('png'), 5e3) - const imagePath = await moveToPermanentPath(imageUri, '.png') + const imageUri = await downloadImage(uri, createPath('jpg'), 15e3) + const imagePath = await moveToPermanentPath(imageUri, '.jpg') // save try { From 1caac024ab504c2fc82a3efbf1182493007ee2a5 Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Fri, 14 Nov 2025 20:29:49 +0200 Subject: [PATCH 04/20] Fix video letterboxing issue (#9339) * give video a black background on web * Video crop on web tweaks (#9371) * Remove video embed crop option to reduce confusion * Improve default thumb and border on web --------- Co-authored-by: Eric Bailey --- .../VideoEmbedInner/VideoEmbedInnerWeb.tsx | 2 - .../Post/Embed/VideoEmbed/index.tsx | 38 ++++----------- .../Post/Embed/VideoEmbed/index.web.tsx | 48 ++++++------------- src/components/Post/Embed/index.tsx | 2 +- src/view/com/util/images/AutoSizedImage.tsx | 12 ++--- 5 files changed, 30 insertions(+), 72 deletions(-) diff --git a/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb.tsx b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb.tsx index 52449698c2..add2a7afb4 100644 --- a/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb.tsx +++ b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb.tsx @@ -7,7 +7,6 @@ import type * as HlsTypes from 'hls.js' import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' import {atoms as a} from '#/alf' -import {MediaInsetBorder} from '#/components/MediaInsetBorder' import * as BandwidthEstimate from './bandwidth-estimate' import {Controls} from './web-controls/VideoControls' @@ -102,7 +101,6 @@ export function VideoEmbedInnerWeb({ hasSubtitleTrack={hasSubtitleTrack} /> - ) } diff --git a/src/components/Post/Embed/VideoEmbed/index.tsx b/src/components/Post/Embed/VideoEmbed/index.tsx index 2212be83d3..9de292b4ad 100644 --- a/src/components/Post/Embed/VideoEmbed/index.tsx +++ b/src/components/Post/Embed/VideoEmbed/index.tsx @@ -7,7 +7,7 @@ import {useLingui} from '@lingui/react' import {ErrorBoundary} from '#/view/com/util/ErrorBoundary' import {ConstrainedImage} from '#/view/com/util/images/AutoSizedImage' -import {atoms as a, useTheme} from '#/alf' +import {atoms as a} from '#/alf' import {Button} from '#/components/Button' import {useThrottledValue} from '#/components/hooks/useThrottledValue' import {PlayButtonIcon} from '#/components/video/PlayButtonIcon' @@ -16,11 +16,9 @@ import * as VideoFallback from './VideoEmbedInner/VideoFallback' interface Props { embed: AppBskyEmbedVideo.View - crop?: 'none' | 'square' | 'constrained' } -export function VideoEmbed({embed, crop}: Props) { - const t = useTheme() +export function VideoEmbed({embed}: Props) { const [key, setKey] = useState(0) const renderError = useCallback( @@ -40,13 +38,10 @@ export function VideoEmbed({embed, crop}: Props) { } let constrained: number | undefined - let max: number | undefined if (aspectRatio !== undefined) { const ratio = 1 / 2 // max of 1:2 ratio in feeds constrained = Math.max(aspectRatio, ratio) - max = Math.max(aspectRatio, 0.25) // max of 1:4 in thread } - const cropDisabled = crop === 'none' const contents = ( @@ -56,28 +51,13 @@ export function VideoEmbed({embed, crop}: Props) { return ( - {cropDisabled ? ( - - {contents} - - ) : ( - - {contents} - - )} + + {contents} + ) } diff --git a/src/components/Post/Embed/VideoEmbed/index.web.tsx b/src/components/Post/Embed/VideoEmbed/index.web.tsx index 8965e8b90e..3de5a99687 100644 --- a/src/components/Post/Embed/VideoEmbed/index.web.tsx +++ b/src/components/Post/Embed/VideoEmbed/index.web.tsx @@ -17,6 +17,7 @@ import {ConstrainedImage} from '#/view/com/util/images/AutoSizedImage' import {atoms as a, useTheme} from '#/alf' import {useIsWithinMessage} from '#/components/dms/MessageContext' import {useFullscreen} from '#/components/hooks/useFullscreen' +import {MediaInsetBorder} from '#/components/MediaInsetBorder' import { HLSUnsupportedError, VideoEmbedInnerWeb, @@ -25,13 +26,7 @@ import { import {useActiveVideoWeb} from './ActiveVideoWebContext' import * as VideoFallback from './VideoEmbedInner/VideoFallback' -export function VideoEmbed({ - embed, - crop, -}: { - embed: AppBskyEmbedVideo.View - crop?: 'none' | 'square' | 'constrained' -}) { +export function VideoEmbed({embed}: {embed: AppBskyEmbedVideo.View}) { const t = useTheme() const ref = useRef(null) const {active, setActive, sendPosition, currentActiveView} = @@ -76,13 +71,10 @@ export function VideoEmbed({ } let constrained: number | undefined - let max: number | undefined if (aspectRatio !== undefined) { const ratio = 1 / 2 // max of 1:2 ratio in feeds constrained = Math.max(aspectRatio, ratio) - max = Math.max(aspectRatio, 0.25) // max of 1:4 in thread } - const cropDisabled = crop === 'none' const contents = (
evt.stopPropagation()}> @@ -114,28 +109,15 @@ export function VideoEmbed({ - {cropDisabled ? ( - - {contents} - - ) : ( - - {contents} - - )} + + {contents} + + ) diff --git a/src/components/Post/Embed/index.tsx b/src/components/Post/Embed/index.tsx index 1462ef542f..6382412b56 100644 --- a/src/components/Post/Embed/index.tsx +++ b/src/components/Post/Embed/index.tsx @@ -112,7 +112,7 @@ function MediaEmbed({ - + ) } diff --git a/src/view/com/util/images/AutoSizedImage.tsx b/src/view/com/util/images/AutoSizedImage.tsx index e248ea5414..92210e55e7 100644 --- a/src/view/com/util/images/AutoSizedImage.tsx +++ b/src/view/com/util/images/AutoSizedImage.tsx @@ -13,7 +13,7 @@ import {useLingui} from '@lingui/react' import {type Dimensions} from '#/lib/media/types' import {isNative} from '#/platform/detection' import {useLargeAltBadgeEnabled} from '#/state/preferences/large-alt-badge' -import {atoms as a, useBreakpoints, useTheme} from '#/alf' +import {atoms as a, useTheme} from '#/alf' import {ArrowsDiagonalOut_Stroke2_Corner0_Rounded as Fullscreen} from '#/components/icons/ArrowsDiagonal' import {MediaInsetBorder} from '#/components/MediaInsetBorder' import {Text} from '#/components/Typography' @@ -30,18 +30,16 @@ export function ConstrainedImage({ children: React.ReactNode }) { const t = useTheme() - const {gtMobile} = useBreakpoints() /** * Computed as a % value to apply as `paddingTop`, this basically controls * the height of the image. */ const outerAspectRatio = React.useMemo(() => { - const ratio = - isNative || !gtMobile - ? Math.min(1 / aspectRatio, minMobileAspectRatio ?? 16 / 9) // 9:16 bounding box - : Math.min(1 / aspectRatio, 1) // 1:1 bounding box + const ratio = isNative + ? Math.min(1 / aspectRatio, minMobileAspectRatio ?? 16 / 9) // 9:16 bounding box + : Math.min(1 / aspectRatio, 1) // 1:1 bounding box return `${ratio * 100}%` - }, [aspectRatio, gtMobile, minMobileAspectRatio]) + }, [aspectRatio, minMobileAspectRatio]) return ( From 7a8ab551e321870d8e328753a292b18db7ec82b0 Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Fri, 14 Nov 2025 21:05:31 +0200 Subject: [PATCH 05/20] update crop tool to latest, adjust to API changes (#9389) --- package.json | 2 +- src/lib/media/picker.e2e.tsx | 2 +- src/lib/media/picker.tsx | 5 ++++- yarn.lock | 8 ++++---- 4 files changed, 10 insertions(+), 7 deletions(-) diff --git a/package.json b/package.json index 4ca2b3833f..c11ae7685c 100644 --- a/package.json +++ b/package.json @@ -143,7 +143,7 @@ "expo-font": "~14.0.9", "expo-haptics": "~15.0.7", "expo-image": "~3.0.10", - "expo-image-crop-tool": "^0.1.8", + "expo-image-crop-tool": "^0.4.0", "expo-image-manipulator": "~14.0.7", "expo-image-picker": "~17.0.8", "expo-intent-launcher": "~13.0.7", diff --git a/src/lib/media/picker.e2e.tsx b/src/lib/media/picker.e2e.tsx index 0d3c8ed646..037c906a4c 100644 --- a/src/lib/media/picker.e2e.tsx +++ b/src/lib/media/picker.e2e.tsx @@ -67,7 +67,7 @@ export async function openCropper(opts: OpenCropperOptions) { return { path: item.path, - mime: item.mime, + mime: item.mimeType, size: item.size, width: item.width, height: item.height, diff --git a/src/lib/media/picker.tsx b/src/lib/media/picker.tsx index 2526da3c8b..1f2be03181 100644 --- a/src/lib/media/picker.tsx +++ b/src/lib/media/picker.tsx @@ -1,5 +1,6 @@ import ExpoImageCropTool, {type OpenCropperOptions} from 'expo-image-crop-tool' import {type ImagePickerOptions, launchCameraAsync} from 'expo-image-picker' +import {t} from '@lingui/macro' export { openPicker, @@ -31,13 +32,15 @@ export async function openCamera(customOpts: ImagePickerOptions) { export async function openCropper(opts: OpenCropperOptions) { const item = await ExpoImageCropTool.openCropperAsync({ + doneButtonText: t`Done`, + cancelButtonText: t`Cancel`, ...opts, format: 'jpeg', }) return { path: item.path, - mime: item.mime, + mime: item.mimeType, size: item.size, width: item.width, height: item.height, diff --git a/yarn.lock b/yarn.lock index 57b8930398..345b999698 100644 --- a/yarn.lock +++ b/yarn.lock @@ -11348,10 +11348,10 @@ expo-haptics@~15.0.7: resolved "https://registry.yarnpkg.com/expo-haptics/-/expo-haptics-15.0.7.tgz#384bb873d7eca7b141f85e4f300b75eab68ebfe9" integrity sha512-7flWsYPrwjJxZ8x82RiJtzsnk1Xp9ahnbd9PhCy3NnsemyMApoWIEUr4waPqFr80DtiLZfhD9VMLL1CKa8AImQ== -expo-image-crop-tool@^0.1.8: - version "0.1.8" - resolved "https://registry.yarnpkg.com/expo-image-crop-tool/-/expo-image-crop-tool-0.1.8.tgz#3e9f34825cf5d7dad1ef2786615571b078ece4e7" - integrity sha512-UlS1zV7JewUzuZzVT9aA0vFD1+dt+pU60ILgt3ntQl4G9SeDJ9bB/+ylz9dzn6BjZecUQkGJmbCQ3H7jGZeZMA== +expo-image-crop-tool@^0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/expo-image-crop-tool/-/expo-image-crop-tool-0.4.0.tgz#c376b0695e8b2bf6b38fff5595ce30aaf9cddd64" + integrity sha512-2KZI016tb2i0yb0ZRMdH8h1I4YofD78fG/l6KrQTFzy4DtKaQlmJwU2VSJ8AYV5/nxusbHxgro7RQnr1BQ5lJg== expo-image-loader@~6.0.0: version "6.0.0" From 92926a2417af8fb6f37feb93779f8cf4c4a4b622 Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Fri, 14 Nov 2025 21:43:28 +0200 Subject: [PATCH 06/20] =?UTF-8?q?=E2=9C=A8=20`SegmentedControl`=20componen?= =?UTF-8?q?t=20(#8606)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * new segmented control * fix type error * convert server input, use CSS for web * add segmented control to storybook * use segmented control in embed dialog * add to suggested text wrappers * update change handle dialog * update styles since button changes * fix atom * style updates to segmented control, add size prop * update state in layout effect rather than in render * set type = 'radio' as default * prevent expansion in server dialog on iOS * use non reactive callback in needsUpdate effect --- .eslintrc.js | 1 + src/components/dialogs/Embed.tsx | 35 +-- .../dialogs/ServerInput.tsx} | 108 ++++--- src/components/forms/HostingProvider.tsx | 2 +- src/components/forms/SegmentedControl.tsx | 284 ++++++++++++++++++ src/components/forms/ToggleButton.tsx | 15 +- src/screens/Settings/AppearanceSettings.tsx | 73 ++--- .../components/ChangeHandleDialog.tsx | 27 +- src/view/screens/Storybook/Forms.tsx | 39 ++- 9 files changed, 445 insertions(+), 139 deletions(-) rename src/{view/com/auth/server-input/index.tsx => components/dialogs/ServerInput.tsx} (73%) create mode 100644 src/components/forms/SegmentedControl.tsx diff --git a/.eslintrc.js b/.eslintrc.js index 8dab053c24..37ed895aa4 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -43,6 +43,7 @@ module.exports = { suggestedTextWrappers: { Button: 'ButtonText', 'ToggleButton.Button': 'ToggleButton.ButtonText', + 'SegmentedControl.Item': 'SegmentedControl.ItemText', }, }, ], diff --git a/src/components/dialogs/Embed.tsx b/src/components/dialogs/Embed.tsx index a61004fd2e..048cf208ba 100644 --- a/src/components/dialogs/Embed.tsx +++ b/src/components/dialogs/Embed.tsx @@ -10,8 +10,8 @@ import {toShareUrl} from '#/lib/strings/url-helpers' import {atoms as a, useTheme} from '#/alf' import {Button, ButtonIcon, ButtonText} from '#/components/Button' import * as Dialog from '#/components/Dialog' +import * as SegmentedControl from '#/components/forms/SegmentedControl' import * as TextField from '#/components/forms/TextField' -import * as ToggleButton from '#/components/forms/ToggleButton' import {Check_Stroke2_Corner0_Rounded as CheckIcon} from '#/components/icons/Check' import { ChevronBottom_Stroke2_Corner0_Rounded as ChevronBottomIcon, @@ -150,26 +150,27 @@ function EmbedDialogInner({ Color theme - setColorMode(value as ColorModeValues)}> - - + type="radio" + value={colorMode} + onChange={setColorMode}> + + System - - - - + + + + Light - - - - + + + + Dark - - - + + + )} diff --git a/src/view/com/auth/server-input/index.tsx b/src/components/dialogs/ServerInput.tsx similarity index 73% rename from src/view/com/auth/server-input/index.tsx rename to src/components/dialogs/ServerInput.tsx index c79b8a5794..d7c02bb9f4 100644 --- a/src/view/com/auth/server-input/index.tsx +++ b/src/components/dialogs/ServerInput.tsx @@ -5,18 +5,20 @@ import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' import {BSKY_SERVICE} from '#/lib/constants' -import {logEvent} from '#/lib/statsig/statsig' +import {logger} from '#/logger' import * as persisted from '#/state/persisted' import {useSession} from '#/state/session' -import {atoms as a, useBreakpoints, useTheme} from '#/alf' +import {atoms as a, platform, useBreakpoints, useTheme, web} from '#/alf' import {Admonition} from '#/components/Admonition' import {Button, ButtonText} from '#/components/Button' import * as Dialog from '#/components/Dialog' +import * as SegmentedControl from '#/components/forms/SegmentedControl' import * as TextField from '#/components/forms/TextField' -import * as ToggleButton from '#/components/forms/ToggleButton' import {Globe_Stroke2_Corner0_Rounded as Globe} from '#/components/icons/Globe' import {InlineLinkText} from '#/components/Link' -import {P, Text} from '#/components/Typography' +import {Text} from '#/components/Typography' + +type SegmentedControlOptions = typeof BSKY_SERVICE | 'custom' export function ServerInputDialog({ control, @@ -29,7 +31,8 @@ export function ServerInputDialog({ const formRef = useRef(null) // persist these options between dialog open/close - const [fixedOption, setFixedOption] = useState(BSKY_SERVICE) + const [fixedOption, setFixedOption] = + useState(BSKY_SERVICE) const [previousCustomAddress, setPreviousCustomAddress] = useState('') const onClose = useCallback(() => { @@ -40,7 +43,7 @@ export function ServerInputDialog({ setPreviousCustomAddress(result) } } - logEvent('signin:hostingProviderPressed', { + logger.metric('signin:hostingProviderPressed', { hostingProviderDidChange: fixedOption !== BSKY_SERVICE, }) }, [onSelect, fixedOption]) @@ -49,7 +52,10 @@ export function ServerInputDialog({ + nativeOptions={platform({ + android: {minHeight: height / 2}, + ios: {preventExpansion: true}, + })}> - fixedOption: string - setFixedOption: (opt: string) => void + fixedOption: SegmentedControlOptions + setFixedOption: (opt: SegmentedControlOptions) => void initialCustomAddress: string }) { const control = Dialog.useDialogContext() @@ -124,45 +130,49 @@ function DialogInner({ return ( + accessibilityLabelledBy="dialog-title" + style={web({maxWidth: 500})}> - + Choose your account provider - setFixedOption(values[0])}> - - {_(msg`Bluesky`)} - - + + + {_(msg`Bluesky`)} + + + - {_(msg`Custom`)} - - + + {_(msg`Custom`)} + + + {fixedOption === BSKY_SERVICE && isFirstTimeUser && ( - - - Bluesky is an open network where you can choose your own provider. - If you're new here, we recommend sticking with the default Bluesky - Social option. - - + + + + Bluesky is an open network where you can choose your own + provider. If you're new here, we recommend sticking with the + default Bluesky Social option. + + + )} {fixedOption === 'custom' && ( - + Server address @@ -197,13 +207,8 @@ function DialogInner({ )} -

+ {isFirstTimeUser ? ( If you're a developer, you can host your own server. @@ -219,18 +224,23 @@ function DialogInner({ to="https://atproto.com/guides/self-hosting"> Learn more. -

+
diff --git a/src/components/forms/HostingProvider.tsx b/src/components/forms/HostingProvider.tsx index 1100900176..b7d23ba3ab 100644 --- a/src/components/forms/HostingProvider.tsx +++ b/src/components/forms/HostingProvider.tsx @@ -4,10 +4,10 @@ import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' import {toNiceDomain} from '#/lib/strings/url-helpers' -import {ServerInputDialog} from '#/view/com/auth/server-input' import {atoms as a, tokens, useTheme} from '#/alf' import {Button, ButtonIcon, ButtonText} from '#/components/Button' import {useDialogControl} from '#/components/Dialog' +import {ServerInputDialog} from '#/components/dialogs/ServerInput' import {Globe_Stroke2_Corner0_Rounded as GlobeIcon} from '#/components/icons/Globe' import {PencilLine_Stroke2_Corner0_Rounded as PencilIcon} from '#/components/icons/Pencil' import {Text} from '#/components/Typography' diff --git a/src/components/forms/SegmentedControl.tsx b/src/components/forms/SegmentedControl.tsx new file mode 100644 index 0000000000..71da847e1d --- /dev/null +++ b/src/components/forms/SegmentedControl.tsx @@ -0,0 +1,284 @@ +import { + createContext, + useCallback, + useContext, + useLayoutEffect, + useMemo, + useState, +} from 'react' +import {type StyleProp, View, type ViewStyle} from 'react-native' +import Animated, {Easing, LinearTransition} from 'react-native-reanimated' + +import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' +import {atoms as a, native, platform, useTheme} from '#/alf' +import { + Button, + type ButtonProps, + ButtonText, + type ButtonTextProps, +} from '../Button' + +const InternalContext = createContext<{ + type: 'tabs' | 'radio' + size: 'small' | 'large' + selectedValue: string + selectedPosition: {width: number; x: number} | null + onSelectValue: ( + value: string, + position: {width: number; x: number} | null, + ) => void + updatePosition: (position: {width: number; x: number}) => void +} | null>(null) + +/** + * Segmented control component. + * + * @example + * ```tsx + * + * + * + * One + * + * + * + * + * Two + * + * + * + * ``` + */ +export function Root({ + label, + type = 'radio', + size = 'large', + value, + onChange, + children, + style, + accessibilityHint, +}: { + label: string + type: 'tabs' | 'radio' + size?: 'small' | 'large' + value: T + onChange: (value: T) => void + children: React.ReactNode + style?: StyleProp + accessibilityHint?: string +}) { + const t = useTheme() + const [selectedPosition, setSelectedPosition] = useState<{ + width: number + x: number + } | null>(null) + + const contextValue = useMemo(() => { + return { + type, + size, + selectedValue: value, + selectedPosition, + onSelectValue: ( + val: string, + position: {width: number; x: number} | null, + ) => { + onChange(val as T) + if (position) setSelectedPosition(position) + }, + updatePosition: (position: {width: number; x: number}) => { + setSelectedPosition(currPos => { + if ( + currPos && + currPos.width === position.width && + currPos.x === position.x + ) { + return currPos + } + return position + }) + }, + } + }, [value, selectedPosition, setSelectedPosition, onChange, type, size]) + + return ( + + {selectedPosition !== null && ( + + )} + + {children} + + + ) +} + +const InternalItemContext = createContext<{ + active: boolean + pressed: boolean + hovered: boolean + focused: boolean +} | null>(null) + +export function Item({ + value, + style, + children, + onPress: onPressProp, + ...props +}: {value: string; children: React.ReactNode} & Omit) { + const [position, setPosition] = useState<{x: number; width: number} | null>( + null, + ) + + const ctx = useContext(InternalContext) + if (!ctx) + throw new Error( + 'SegmentedControl.Item must be used within a SegmentedControl.Root', + ) + + const active = ctx.selectedValue === value + + // update position if change was external, and not due to onPress + const needsUpdate = + active && + position && + (ctx.selectedPosition?.x !== position.x || + ctx.selectedPosition?.width !== position.width) + + // can't wait for `useEffectEvent` + const update = useNonReactiveCallback(() => { + if (position) ctx.updatePosition(position) + }) + + useLayoutEffect(() => { + if (needsUpdate) { + update() + } + }, [needsUpdate, update]) + + const onPress = useCallback( + (evt: any) => { + ctx.onSelectValue(value, position) + onPressProp?.(evt) + }, + [ctx, value, position, onPressProp], + ) + + return ( + { + const measuredPosition = { + x: evt.nativeEvent.layout.x, + width: evt.nativeEvent.layout.width, + } + if (!ctx.selectedPosition && active) { + ctx.onSelectValue(value, measuredPosition) + } + setPosition(measuredPosition) + }}> + + + ) +} + +export function ItemText({style, ...props}: ButtonTextProps) { + const t = useTheme() + const ctx = useContext(InternalItemContext) + if (!ctx) + throw new Error( + 'SegmentedControl.ItemText must be used within a SegmentedControl.Item', + ) + return ( + + ) +} + +function Slider({x, width}: {x: number; width: number}) { + const t = useTheme() + + return ( + + ) +} diff --git a/src/components/forms/ToggleButton.tsx b/src/components/forms/ToggleButton.tsx index 367122585b..77e12b2046 100644 --- a/src/components/forms/ToggleButton.tsx +++ b/src/components/forms/ToggleButton.tsx @@ -1,4 +1,4 @@ -import React from 'react' +import {useMemo} from 'react' import { type AccessibilityProps, type TextStyle, @@ -20,6 +20,9 @@ export type GroupProps = Omit & { multiple?: boolean } +/** + * @deprecated - use SegmentedControl + */ export function Group({children, multiple, ...props}: GroupProps) { const t = useTheme() return ( @@ -39,6 +42,9 @@ export function Group({children, multiple, ...props}: GroupProps) { ) } +/** + * @deprecated - use SegmentedControl + */ export function Button({children, ...props}: ItemProps) { return ( @@ -51,7 +57,7 @@ function ButtonInner({children}: React.PropsWithChildren<{}>) { const t = useTheme() const state = Toggle.useItemContext() - const {baseStyles, hoverStyles, activeStyles} = React.useMemo(() => { + const {baseStyles, hoverStyles, activeStyles} = useMemo(() => { const base: ViewStyle[] = [] const hover: ViewStyle[] = [] const active: ViewStyle[] = [] @@ -112,11 +118,14 @@ function ButtonInner({children}: React.PropsWithChildren<{}>) { ) } +/** + * @deprecated - use SegmentedControl + */ export function ButtonText({children}: {children: React.ReactNode}) { const t = useTheme() const state = Toggle.useItemContext() - const textStyles = React.useMemo(() => { + const textStyles = useMemo(() => { const text: TextStyle[] = [] if (state.selected) { text.push(t.atoms.text_inverted) diff --git a/src/screens/Settings/AppearanceSettings.tsx b/src/screens/Settings/AppearanceSettings.tsx index 5d597ff8e2..658434c03f 100644 --- a/src/screens/Settings/AppearanceSettings.tsx +++ b/src/screens/Settings/AppearanceSettings.tsx @@ -15,8 +15,8 @@ import { import {isNative} from '#/platform/detection' import {useSetThemePrefs, useThemePrefs} from '#/state/shell' import {SettingsListItem as AppIconSettingsListItem} from '#/screens/Settings/AppIconSettings/SettingsListItem' -import {atoms as a, native, useAlf, useTheme} from '#/alf' -import * as ToggleButton from '#/components/forms/ToggleButton' +import {type Alf, atoms as a, native, useAlf, useTheme} from '#/alf' +import * as SegmentedControl from '#/components/forms/SegmentedControl' import {type Props as SVGIconProps} from '#/components/icons/common' import {Moon_Stroke2_Corner0_Rounded as MoonIcon} from '#/components/icons/Moon' import {Phone_Stroke2_Corner0_Rounded as PhoneIcon} from '#/components/icons/Phone' @@ -36,42 +36,29 @@ export function AppearanceSettingsScreen({}: Props) { const {setColorMode, setDarkTheme} = useSetThemePrefs() const onChangeAppearance = useCallback( - (keys: string[]) => { - const appearance = keys.find(key => key !== colorMode) as - | 'system' - | 'light' - | 'dark' - | undefined - if (!appearance) return - setColorMode(appearance) + (value: 'light' | 'system' | 'dark') => { + setColorMode(value) }, - [setColorMode, colorMode], + [setColorMode], ) const onChangeDarkTheme = useCallback( - (keys: string[]) => { - const theme = keys.find(key => key !== darkTheme) as - | 'dim' - | 'dark' - | undefined - if (!theme) return - setDarkTheme(theme) + (value: 'dim' | 'dark') => { + setDarkTheme(value) }, - [setDarkTheme, darkTheme], + [setDarkTheme], ) const onChangeFontFamily = useCallback( - (values: string[]) => { - const next = values[0] === 'system' ? 'system' : 'theme' - fonts.setFontFamily(next) + (value: 'system' | 'theme') => { + fonts.setFontFamily(value) }, [fonts], ) const onChangeFontScale = useCallback( - (values: string[]) => { - const next = values[0] || ('0' as any) - fonts.setFontScale(next) + (value: Alf['fonts']['scale']) => { + fonts.setFontScale(value) }, [fonts], ) @@ -107,7 +94,7 @@ export function AppearanceSettingsScreen({}: Props) { name: 'dark', }, ]} - values={[colorMode]} + value={colorMode} onChange={onChangeAppearance} /> @@ -128,7 +115,7 @@ export function AppearanceSettingsScreen({}: Props) { name: 'dark', }, ]} - values={[darkTheme ?? 'dim']} + value={darkTheme ?? 'dim'} onChange={onChangeDarkTheme} /> @@ -153,7 +140,7 @@ export function AppearanceSettingsScreen({}: Props) { name: 'theme', }, ]} - values={[fonts.family]} + value={fonts.family} onChange={onChangeFontFamily} /> @@ -174,7 +161,7 @@ export function AppearanceSettingsScreen({}: Props) { name: '1', }, ]} - values={[fonts.scale]} + value={fonts.scale} onChange={onChangeFontScale} /> @@ -192,12 +179,12 @@ export function AppearanceSettingsScreen({}: Props) { ) } -export function AppearanceToggleButtonGroup({ +export function AppearanceToggleButtonGroup({ title, description, icon: Icon, items, - values, + value, onChange, }: { title: string @@ -205,10 +192,10 @@ export function AppearanceToggleButtonGroup({ icon: React.ComponentType items: { label: string - name: string + name: T }[] - values: string[] - onChange: (values: string[]) => void + value: T + onChange: (value: T) => void }) { const t = useTheme() return ( @@ -227,16 +214,22 @@ export function AppearanceToggleButtonGroup({ {description} )} - + {items.map(item => ( - - {item.label} - + value={item.name}> + + {item.label} + + ))} - + ) diff --git a/src/screens/Settings/components/ChangeHandleDialog.tsx b/src/screens/Settings/components/ChangeHandleDialog.tsx index 2187c5fcc4..accbf41c86 100644 --- a/src/screens/Settings/components/ChangeHandleDialog.tsx +++ b/src/screens/Settings/components/ChangeHandleDialog.tsx @@ -29,8 +29,8 @@ import {atoms as a, native, useBreakpoints, useTheme} from '#/alf' import {Admonition} from '#/components/Admonition' import {Button, ButtonIcon, ButtonText} from '#/components/Button' import * as Dialog from '#/components/Dialog' +import * as SegmentedControl from '#/components/forms/SegmentedControl' import * as TextField from '#/components/forms/TextField' -import * as ToggleButton from '#/components/forms/ToggleButton' import { ArrowLeft_Stroke2_Corner0_Rounded as ArrowLeftIcon, ArrowRight_Stroke2_Corner0_Rounded as ArrowRightIcon, @@ -395,21 +395,22 @@ function OwnHandlePage({goToServiceHandle}: {goToServiceHandle: () => void}) { /> - setDNSPanel(values[0] === 'dns')}> - - + type="tabs" + value={dnsPanel ? 'dns' : 'file'} + onChange={values => setDNSPanel(values === 'dns')}> + + DNS Panel - - - - + + + + No DNS Panel - - - + + + {dnsPanel ? ( <> diff --git a/src/view/screens/Storybook/Forms.tsx b/src/view/screens/Storybook/Forms.tsx index 3cf6e47232..a29aa70ab7 100644 --- a/src/view/screens/Storybook/Forms.tsx +++ b/src/view/screens/Storybook/Forms.tsx @@ -4,6 +4,7 @@ import {type TextInput, View} from 'react-native' import {atoms as a} from '#/alf' import {Button, ButtonText} from '#/components/Button' import {DateField, LabelText} from '#/components/forms/DateField' +import * as SegmentedControl from '#/components/forms/SegmentedControl' import * as TextField from '#/components/forms/TextField' import * as Toggle from '#/components/forms/Toggle' import * as ToggleButton from '#/components/forms/ToggleButton' @@ -15,6 +16,9 @@ export function Forms() { const [toggleGroupBValues, setToggleGroupBValues] = React.useState(['a', 'b']) const [toggleGroupCValues, setToggleGroupCValues] = React.useState(['a', 'b']) const [toggleGroupDValues, setToggleGroupDValues] = React.useState(['warn']) + const [segmentedControlValue, setSegmentedControlValue] = React.useState< + 'hide' | 'warn' | 'show' + >('warn') const [value, setValue] = React.useState('') const [date, setDate] = React.useState('2001-01-01') @@ -254,23 +258,26 @@ export function Forms() { Show + - - - - Hide - - - Warn - - - Show - - - + +

SegmentedControl

+ + + + Hide + + + Warn + + + Show + +
) From 185fd39092cd4c43db060439b03c6c49be60a34e Mon Sep 17 00:00:00 2001 From: pfrazee <1270099+pfrazee@users.noreply.github.com> Date: Sat, 15 Nov 2025 02:38:20 +0000 Subject: [PATCH 07/20] Nightly source-language update --- src/locale/locales/en/messages.po | 424 +++++++++++++++++------------- 1 file changed, 242 insertions(+), 182 deletions(-) diff --git a/src/locale/locales/en/messages.po b/src/locale/locales/en/messages.po index 17d1a1fa58..0724bade46 100644 --- a/src/locale/locales/en/messages.po +++ b/src/locale/locales/en/messages.po @@ -489,7 +489,7 @@ msgstr "" msgid "<0>{0} is included in your starter pack" msgstr "" -#: src/components/WhoCanReply.tsx:319 +#: src/components/WhoCanReply.tsx:346 msgid "<0>{0} members" msgstr "" @@ -630,6 +630,10 @@ msgstr "" msgid "Account options" msgstr "" +#: src/components/dialogs/ServerInput.tsx:141 +msgid "Account provider" +msgstr "" + #: src/screens/Settings/Settings.tsx:662 msgid "Account removed from quick access" msgstr "" @@ -681,8 +685,8 @@ msgstr "" msgid "Add {displayName} to starter pack" msgstr "" +#: src/view/com/composer/labels/LabelsBtn.tsx:102 #: src/view/com/composer/labels/LabelsBtn.tsx:107 -#: src/view/com/composer/labels/LabelsBtn.tsx:112 msgid "Add a content warning" msgstr "" @@ -786,7 +790,7 @@ msgstr "" msgid "Add the default feed of only people you follow" msgstr "" -#: src/screens/Settings/components/ChangeHandleDialog.tsx:416 +#: src/screens/Settings/components/ChangeHandleDialog.tsx:417 msgid "Add the following DNS record to your domain:" msgstr "" @@ -830,7 +834,7 @@ msgstr "" msgid "Additional details (limit 300 characters)" msgstr "" -#: src/view/com/composer/labels/LabelsBtn.tsx:159 +#: src/view/com/composer/labels/LabelsBtn.tsx:154 msgid "Adult" msgstr "" @@ -841,7 +845,7 @@ msgstr "" #: src/components/moderation/ContentHider.tsx:120 #: src/lib/moderation/useGlobalLabelStrings.ts:34 #: src/lib/moderation/useModerationCauseDescription.ts:149 -#: src/view/com/composer/labels/LabelsBtn.tsx:127 +#: src/view/com/composer/labels/LabelsBtn.tsx:122 msgid "Adult Content" msgstr "" @@ -853,8 +857,8 @@ msgstr "" msgid "Adult content is disabled." msgstr "" -#: src/view/com/composer/labels/LabelsBtn.tsx:138 -#: src/view/com/composer/labels/LabelsBtn.tsx:196 +#: src/view/com/composer/labels/LabelsBtn.tsx:133 +#: src/view/com/composer/labels/LabelsBtn.tsx:191 msgid "Adult Content labels" msgstr "" @@ -914,6 +918,10 @@ msgstr "" msgid "Allow access to your direct messages" msgstr "" +#: src/components/dialogs/PostInteractionSettingsDialog.tsx:431 +msgid "Allow anyone to reply" +msgstr "" + #: src/components/dialogs/DeviceLocationRequestDialog.tsx:146 #: src/components/dialogs/DeviceLocationRequestDialog.tsx:152 msgid "Allow location access" @@ -929,12 +937,24 @@ msgstr "" msgid "Allow others to be notified of your posts" msgstr "" -#: src/components/dialogs/PostInteractionSettingsDialog.tsx:355 +#: src/components/dialogs/PostInteractionSettingsDialog.tsx:484 +msgid "Allow people you follow to reply" +msgstr "" + +#: src/components/dialogs/PostInteractionSettingsDialog.tsx:498 +msgid "Allow people you mention to reply" +msgstr "" + +#: src/components/dialogs/PostInteractionSettingsDialog.tsx:617 msgid "Allow quote posts" msgstr "" -#: src/components/dialogs/PostInteractionSettingsDialog.tsx:400 -msgid "Allow replies from:" +#: src/components/dialogs/PostInteractionSettingsDialog.tsx:579 +msgid "Allow users in {0} to reply" +msgstr "" + +#: src/components/dialogs/PostInteractionSettingsDialog.tsx:470 +msgid "Allow your followers to reply" msgstr "" #: src/screens/Settings/AppPasswords.tsx:199 @@ -1014,14 +1034,18 @@ msgstr "" msgid "An error occurred while generating your starter pack. Want to try again?" msgstr "" -#: src/components/Post/Embed/VideoEmbed/index.tsx:160 +#: src/components/Post/Embed/VideoEmbed/index.tsx:140 msgid "An error occurred while loading the video. Please try again later." msgstr "" -#: src/components/Post/Embed/VideoEmbed/index.web.tsx:244 +#: src/components/Post/Embed/VideoEmbed/index.web.tsx:226 msgid "An error occurred while loading the video. Please try again." msgstr "" +#: src/components/dialogs/PostInteractionSettingsDialog.tsx:562 +msgid "An error occurred while loading your lists :/" +msgstr "" + #: src/components/StarterPack/QrCodeDialog.tsx:75 msgid "An error occurred while saving the QR code!" msgstr "" @@ -1074,7 +1098,7 @@ msgstr "" msgid "an unknown labeler" msgstr "" -#: src/components/WhoCanReply.tsx:340 +#: src/components/WhoCanReply.tsx:367 msgid "and" msgstr "" @@ -1104,10 +1128,14 @@ msgstr "" msgid "Announcing verification on Bluesky" msgstr "" -#: src/view/com/composer/threadgate/ThreadgateBtn.tsx:48 +#: src/view/com/composer/threadgate/ThreadgateBtn.tsx:129 msgid "Anybody can interact" msgstr "" +#: src/components/dialogs/PostInteractionSettingsDialog.tsx:437 +msgid "Anyone" +msgstr "" + #: src/screens/Settings/ActivityPrivacySettings.tsx:111 #: src/screens/Settings/ActivityPrivacySettings.tsx:116 #: src/screens/Settings/PrivacyAndSecuritySettings.tsx:162 @@ -1188,7 +1216,7 @@ msgid "Appeal this decision" msgstr "" #: src/Navigation.tsx:391 -#: src/screens/Settings/AppearanceSettings.tsx:86 +#: src/screens/Settings/AppearanceSettings.tsx:73 #: src/screens/Settings/Settings.tsx:212 #: src/screens/Settings/Settings.tsx:215 msgid "Appearance" @@ -1263,7 +1291,7 @@ msgstr "" msgid "Art" msgstr "" -#: src/view/com/composer/labels/LabelsBtn.tsx:171 +#: src/view/com/composer/labels/LabelsBtn.tsx:166 msgid "Artistic or non-erotic nudity." msgstr "" @@ -1346,7 +1374,7 @@ msgstr "" msgid "Before you can accept this chat request, you must first verify your email." msgstr "" -#: src/components/activity-notifications/SubscribeProfileButton.tsx:43 +#: src/components/activity-notifications/SubscribeProfileButton.tsx:56 msgid "Before you can get notifications for {name}'s posts, you must first verify your email." msgstr "" @@ -1468,8 +1496,8 @@ msgstr "" msgid "Blog" msgstr "" -#: src/view/com/auth/server-input/index.tsx:136 -#: src/view/com/auth/server-input/index.tsx:137 +#: src/components/dialogs/ServerInput.tsx:147 +#: src/components/dialogs/ServerInput.tsx:149 msgid "Bluesky" msgstr "" @@ -1482,11 +1510,11 @@ msgctxt "Name of app icon variant" msgid "Bluesky Classic™" msgstr "" -#: src/view/com/auth/server-input/index.tsx:212 +#: src/components/dialogs/ServerInput.tsx:217 msgid "Bluesky is an open network where you can choose your hosting provider. If you're a developer, you can host your own server." msgstr "" -#: src/view/com/auth/server-input/index.tsx:149 +#: src/components/dialogs/ServerInput.tsx:165 msgid "Bluesky is an open network where you can choose your own provider. If you're new here, we recommend sticking with the default Bluesky Social option." msgstr "" @@ -1638,6 +1666,7 @@ msgstr "" #: src/components/PostControls/RepostButton.tsx:209 #: src/components/Prompt.tsx:144 #: src/components/Prompt.tsx:146 +#: src/lib/media/picker.tsx:36 #: src/screens/Deactivated.tsx:158 #: src/screens/Profile/Header/EditProfileDialog.tsx:218 #: src/screens/Profile/Header/EditProfileDialog.tsx:226 @@ -1868,7 +1897,7 @@ msgstr "" msgid "Choose this color as your avatar" msgstr "" -#: src/view/com/auth/server-input/index.tsx:130 +#: src/components/dialogs/ServerInput.tsx:137 msgid "Choose your account provider" msgstr "" @@ -1917,14 +1946,6 @@ msgstr "" msgid "Click here to update your email" msgstr "" -#: src/components/dialogs/PostInteractionSettingsDialog.tsx:348 -msgid "Click to disable quote posts of this post." -msgstr "" - -#: src/components/dialogs/PostInteractionSettingsDialog.tsx:349 -msgid "Click to enable quote posts of this post." -msgstr "" - #: src/components/RichTextTag.tsx:54 msgid "Click to open tag menu for {tag}" msgstr "" @@ -1969,8 +1990,8 @@ msgstr "" #: src/components/StarterPack/Wizard/WizardEditListDialog.tsx:124 #: src/components/verification/VerificationsDialog.tsx:144 #: src/components/verification/VerifierDialog.tsx:150 -#: src/components/WhoCanReply.tsx:202 -#: src/components/WhoCanReply.tsx:209 +#: src/components/WhoCanReply.tsx:229 +#: src/components/WhoCanReply.tsx:236 #: src/screens/Settings/components/ChangePasswordDialog.tsx:286 #: src/screens/Settings/components/ChangePasswordDialog.tsx:291 #: src/view/com/composer/select-language/PostLanguageSelectDialog.tsx:335 @@ -2064,7 +2085,7 @@ msgid "Collapses list of users for a given notification" msgstr "" #: src/components/dialogs/Embed.tsx:154 -#: src/screens/Settings/AppearanceSettings.tsx:94 +#: src/screens/Settings/AppearanceSettings.tsx:81 msgid "Color mode" msgstr "" @@ -2304,7 +2325,7 @@ msgstr "" msgid "Copied to clipboard" msgstr "" -#: src/components/dialogs/Embed.tsx:201 +#: src/components/dialogs/Embed.tsx:202 #: src/screens/Settings/components/CopyButton.tsx:66 msgid "Copied!" msgstr "" @@ -2331,18 +2352,18 @@ msgstr "" msgid "Copy author DID" msgstr "" -#: src/components/dialogs/Embed.tsx:189 -#: src/components/dialogs/Embed.tsx:206 +#: src/components/dialogs/Embed.tsx:190 +#: src/components/dialogs/Embed.tsx:207 msgid "Copy code" msgstr "" -#: src/screens/Settings/components/ChangeHandleDialog.tsx:501 +#: src/screens/Settings/components/ChangeHandleDialog.tsx:502 #: src/view/com/profile/ProfileMenu.tsx:447 #: src/view/com/profile/ProfileMenu.tsx:450 msgid "Copy DID" msgstr "" -#: src/screens/Settings/components/ChangeHandleDialog.tsx:433 +#: src/screens/Settings/components/ChangeHandleDialog.tsx:434 msgid "Copy host" msgstr "" @@ -2395,7 +2416,7 @@ msgstr "" msgid "Copy QR code" msgstr "" -#: src/screens/Settings/components/ChangeHandleDialog.tsx:454 +#: src/screens/Settings/components/ChangeHandleDialog.tsx:455 msgid "Copy TXT record value" msgstr "" @@ -2545,8 +2566,8 @@ msgstr "" msgid "Culture" msgstr "" -#: src/view/com/auth/server-input/index.tsx:142 -#: src/view/com/auth/server-input/index.tsx:143 +#: src/components/dialogs/ServerInput.tsx:155 +#: src/components/dialogs/ServerInput.tsx:157 msgid "Custom" msgstr "" @@ -2554,10 +2575,6 @@ msgstr "" msgid "Customization options" msgstr "" -#: src/components/dialogs/PostInteractionSettingsDialog.tsx:111 -msgid "Customize who can interact with this post." -msgstr "" - #: src/screens/Onboarding/Layout.tsx:60 msgid "Customizes your Bluesky experience" msgstr "" @@ -2570,10 +2587,10 @@ msgstr "" msgid "Dangerous substances or drug abuse" msgstr "" -#: src/components/dialogs/Embed.tsx:167 -#: src/components/dialogs/Embed.tsx:169 -#: src/screens/Settings/AppearanceSettings.tsx:106 -#: src/screens/Settings/AppearanceSettings.tsx:127 +#: src/components/dialogs/Embed.tsx:168 +#: src/components/dialogs/Embed.tsx:170 +#: src/screens/Settings/AppearanceSettings.tsx:93 +#: src/screens/Settings/AppearanceSettings.tsx:114 msgid "Dark" msgstr "" @@ -2586,7 +2603,7 @@ msgstr "" msgid "Dark mode" msgstr "" -#: src/screens/Settings/AppearanceSettings.tsx:119 +#: src/screens/Settings/AppearanceSettings.tsx:106 msgid "Dark theme" msgstr "" @@ -2612,7 +2629,7 @@ msgstr "" msgid "Deepfake adult content" msgstr "" -#: src/screens/Settings/AppearanceSettings.tsx:169 +#: src/screens/Settings/AppearanceSettings.tsx:156 msgid "Default" msgstr "" @@ -2763,11 +2780,11 @@ msgstr "" msgid "Developer options" msgstr "" -#: src/components/WhoCanReply.tsx:188 +#: src/components/WhoCanReply.tsx:215 msgid "Dialog: adjust who can interact with this post" msgstr "" -#: src/screens/Settings/AppearanceSettings.tsx:123 +#: src/screens/Settings/AppearanceSettings.tsx:110 msgid "Dim" msgstr "" @@ -2789,6 +2806,14 @@ msgstr "" msgid "Disable haptic feedback" msgstr "" +#: src/components/dialogs/PostInteractionSettingsDialog.tsx:609 +msgid "Disable quote posts of this post." +msgstr "" + +#: src/components/dialogs/PostInteractionSettingsDialog.tsx:445 +msgid "Disable replies entirely" +msgstr "" + #: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VideoControls.tsx:388 msgid "Disable subtitles" msgstr "" @@ -2871,8 +2896,8 @@ msgstr "" msgid "Ditch the trolls and clickbait. Find real people and conversations that matter to you." msgstr "" -#: src/screens/Settings/components/ChangeHandleDialog.tsx:402 -#: src/screens/Settings/components/ChangeHandleDialog.tsx:404 +#: src/screens/Settings/components/ChangeHandleDialog.tsx:403 +#: src/screens/Settings/components/ChangeHandleDialog.tsx:405 msgid "DNS Panel" msgstr "" @@ -2884,7 +2909,7 @@ msgstr "" msgid "Does not include nudity." msgstr "" -#: src/screens/Settings/components/ChangeHandleDialog.tsx:522 +#: src/screens/Settings/components/ChangeHandleDialog.tsx:523 msgid "Domain verified!" msgstr "" @@ -2907,19 +2932,20 @@ msgstr "" #: src/components/dialogs/BirthDateSettings.tsx:149 #: src/components/dialogs/BirthDateSettings.tsx:156 +#: src/components/dialogs/ServerInput.tsx:240 +#: src/components/dialogs/ServerInput.tsx:242 #: src/components/dms/AfterReportDialog.tsx:142 #: src/components/forms/DateField/index.tsx:103 #: src/components/forms/DateField/index.tsx:109 #: src/components/Select/index.tsx:185 #: src/components/Select/index.tsx:192 +#: src/lib/media/picker.tsx:35 #: src/screens/Onboarding/StepProfile/index.tsx:333 #: src/screens/Onboarding/StepProfile/index.tsx:336 #: src/screens/Settings/components/AddAppPasswordDialog.tsx:214 #: src/screens/Settings/components/AddAppPasswordDialog.tsx:221 -#: src/view/com/auth/server-input/index.tsx:232 -#: src/view/com/auth/server-input/index.tsx:233 -#: src/view/com/composer/labels/LabelsBtn.tsx:223 -#: src/view/com/composer/labels/LabelsBtn.tsx:230 +#: src/view/com/composer/labels/LabelsBtn.tsx:218 +#: src/view/com/composer/labels/LabelsBtn.tsx:225 #: src/view/com/composer/select-language/PostLanguageSelectDialog.tsx:303 #: src/view/com/composer/videos/SubtitleDialog.tsx:168 #: src/view/com/composer/videos/SubtitleDialog.tsx:178 @@ -3071,8 +3097,8 @@ msgstr "" msgid "Edit People" msgstr "" -#: src/components/dialogs/PostInteractionSettingsDialog.tsx:77 -#: src/components/dialogs/PostInteractionSettingsDialog.tsx:250 +#: src/components/dialogs/PostInteractionSettingsDialog.tsx:100 +#: src/components/dialogs/PostInteractionSettingsDialog.tsx:246 msgid "Edit post interaction settings" msgstr "" @@ -3096,7 +3122,7 @@ msgstr "" msgid "Edit user list" msgstr "" -#: src/components/WhoCanReply.tsx:97 +#: src/components/WhoCanReply.tsx:109 msgid "Edit who can reply" msgstr "" @@ -3147,7 +3173,7 @@ msgstr "" msgid "Email Verified" msgstr "" -#: src/components/dialogs/Embed.tsx:181 +#: src/components/dialogs/Embed.tsx:182 msgid "Embed HTML code" msgstr "" @@ -3162,7 +3188,7 @@ msgstr "" msgid "Embed this post in your website. Simply copy the following snippet and paste it into the HTML code of your website." msgstr "" -#: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb.tsx:58 +#: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb.tsx:57 msgid "Embedded video player" msgstr "" @@ -3203,6 +3229,10 @@ msgstr "" msgid "Enable push notifications" msgstr "" +#: src/components/dialogs/PostInteractionSettingsDialog.tsx:610 +msgid "Enable quote posts of this post." +msgstr "" + #: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VideoControls.tsx:389 msgid "Enable subtitles" msgstr "" @@ -3320,15 +3350,11 @@ msgstr "" msgid "Error: {error}" msgstr "" -#: src/components/dialogs/PostInteractionSettingsDialog.tsx:405 -msgid "Everybody" -msgstr "" - -#: src/components/WhoCanReply.tsx:77 +#: src/components/WhoCanReply.tsx:82 msgid "Everybody can reply" msgstr "" -#: src/components/WhoCanReply.tsx:245 +#: src/components/WhoCanReply.tsx:272 msgid "Everybody can reply to this post." msgstr "" @@ -3477,7 +3503,7 @@ msgstr "" msgid "Failed to add to starter pack" msgstr "" -#: src/screens/Settings/components/ChangeHandleDialog.tsx:602 +#: src/screens/Settings/components/ChangeHandleDialog.tsx:603 msgid "Failed to change handle. Please try again." msgstr "" @@ -3949,11 +3975,11 @@ msgstr "" msgid "Follows You" msgstr "" -#: src/screens/Settings/AppearanceSettings.tsx:141 +#: src/screens/Settings/AppearanceSettings.tsx:128 msgid "Font" msgstr "" -#: src/screens/Settings/AppearanceSettings.tsx:161 +#: src/screens/Settings/AppearanceSettings.tsx:148 msgid "Font size" msgstr "" @@ -3974,7 +4000,7 @@ msgstr "" msgid "For security reasons, you won't be able to view this again. If you lose this app password, you'll need to generate a new one." msgstr "" -#: src/screens/Settings/AppearanceSettings.tsx:143 +#: src/screens/Settings/AppearanceSettings.tsx:130 msgid "For the best experience, we recommend using the theme font." msgstr "" @@ -4061,7 +4087,7 @@ msgstr "" msgid "Get notifications when people repost your posts." msgstr "" -#: src/components/activity-notifications/SubscribeProfileButton.tsx:77 +#: src/components/activity-notifications/SubscribeProfileButton.tsx:89 msgid "Get notified about new posts" msgstr "" @@ -4077,7 +4103,7 @@ msgstr "" msgid "Get notified of this account’s activity" msgstr "" -#: src/components/activity-notifications/SubscribeProfileButton.tsx:70 +#: src/components/activity-notifications/SubscribeProfileButton.tsx:82 msgid "Get notified when {name} posts" msgstr "" @@ -4204,8 +4230,8 @@ msgstr "" #: src/lib/moderation/useGlobalLabelStrings.ts:46 #: src/lib/moderation/useGlobalLabelStrings.ts:50 -#: src/view/com/composer/labels/LabelsBtn.tsx:201 -#: src/view/com/composer/labels/LabelsBtn.tsx:204 +#: src/view/com/composer/labels/LabelsBtn.tsx:196 +#: src/view/com/composer/labels/LabelsBtn.tsx:199 msgid "Graphic Media" msgstr "" @@ -4231,7 +4257,7 @@ msgstr "" msgid "Handle" msgstr "" -#: src/screens/Settings/components/ChangeHandleDialog.tsx:606 +#: src/screens/Settings/components/ChangeHandleDialog.tsx:607 msgid "Handle already taken. Please try a different one." msgstr "" @@ -4240,7 +4266,7 @@ msgstr "" msgid "Handle changed!" msgstr "" -#: src/screens/Settings/components/ChangeHandleDialog.tsx:610 +#: src/screens/Settings/components/ChangeHandleDialog.tsx:611 msgid "Handle too long. Please try a shorter one." msgstr "" @@ -4338,6 +4364,10 @@ msgstr "" msgid "Hide customization options" msgstr "" +#: src/components/dialogs/PostInteractionSettingsDialog.tsx:513 +msgid "Hide lists" +msgstr "" + #: src/components/PostControls/PostMenu/PostMenuItems.tsx:578 #: src/components/PostControls/PostMenu/PostMenuItems.tsx:584 msgid "Hide post for me" @@ -4434,7 +4464,7 @@ msgstr "" msgid "Home" msgstr "" -#: src/screens/Settings/components/ChangeHandleDialog.tsx:427 +#: src/screens/Settings/components/ChangeHandleDialog.tsx:428 msgid "Host:" msgstr "" @@ -4508,7 +4538,7 @@ msgstr "" msgid "If you want to restrict who can receive notifications for your account's activity, you can change this in <0>Settings → Privacy and Security." msgstr "" -#: src/view/com/auth/server-input/index.tsx:208 +#: src/components/dialogs/ServerInput.tsx:213 msgid "If you're a developer, you can host your own server." msgstr "" @@ -4599,7 +4629,7 @@ msgstr "" msgid "Input the code which has been emailed to you" msgstr "" -#: src/view/com/composer/threadgate/ThreadgateBtn.tsx:49 +#: src/view/com/composer/threadgate/ThreadgateBtn.tsx:130 msgid "Interaction limited" msgstr "" @@ -4620,7 +4650,7 @@ msgstr "" msgid "Invalid 2FA confirmation code." msgstr "" -#: src/screens/Settings/components/ChangeHandleDialog.tsx:612 +#: src/screens/Settings/components/ChangeHandleDialog.tsx:613 msgid "Invalid handle. Please try a different one." msgstr "" @@ -4723,12 +4753,12 @@ msgstr "" msgid "Labeled by the author." msgstr "" -#: src/view/com/composer/labels/LabelsBtn.tsx:75 +#: src/view/com/composer/labels/LabelsBtn.tsx:69 #: src/view/screens/Profile.tsx:223 msgid "Labels" msgstr "" -#: src/view/com/composer/labels/LabelsBtn.tsx:73 +#: src/view/com/composer/labels/LabelsBtn.tsx:67 msgid "Labels added" msgstr "" @@ -4754,7 +4784,7 @@ msgstr "" msgid "Languages" msgstr "" -#: src/screens/Settings/AppearanceSettings.tsx:173 +#: src/screens/Settings/AppearanceSettings.tsx:160 msgid "Larger" msgstr "" @@ -4794,7 +4824,7 @@ msgstr "" msgid "Learn more about Bluesky" msgstr "" -#: src/view/com/auth/server-input/index.tsx:218 +#: src/components/dialogs/ServerInput.tsx:223 msgid "Learn more about self hosting your PDS." msgstr "" @@ -4831,8 +4861,8 @@ msgstr "" msgid "Learn more in your <0>account settings." msgstr "" +#: src/components/dialogs/ServerInput.tsx:225 #: src/components/moderation/ContentHider.tsx:247 -#: src/view/com/auth/server-input/index.tsx:220 msgid "Learn more." msgstr "" @@ -4880,9 +4910,9 @@ msgstr "" msgid "Let's go!" msgstr "" -#: src/components/dialogs/Embed.tsx:162 -#: src/components/dialogs/Embed.tsx:164 -#: src/screens/Settings/AppearanceSettings.tsx:102 +#: src/components/dialogs/Embed.tsx:163 +#: src/components/dialogs/Embed.tsx:165 +#: src/screens/Settings/AppearanceSettings.tsx:89 msgid "Light" msgstr "" @@ -5099,6 +5129,14 @@ msgstr "" msgid "Load new posts" msgstr "" +#: src/components/dialogs/PostInteractionSettingsDialog.tsx:556 +msgid "Loading lists..." +msgstr "" + +#: src/components/dialogs/PostInteractionSettingsDialog.tsx:259 +msgid "Loading post interaction settings..." +msgstr "" + #: src/view/com/composer/text-input/mobile/Autocomplete.tsx:61 msgid "Loading..." msgstr "" @@ -5194,7 +5232,7 @@ msgstr "" msgid "Media" msgstr "" -#: src/view/com/composer/labels/LabelsBtn.tsx:210 +#: src/view/com/composer/labels/LabelsBtn.tsx:205 msgid "Media that may be disturbing or inappropriate for some audiences." msgstr "" @@ -5202,14 +5240,10 @@ msgstr "" msgid "Mention notifications" msgstr "" -#: src/components/WhoCanReply.tsx:286 +#: src/components/WhoCanReply.tsx:313 msgid "mentioned users" msgstr "" -#: src/components/dialogs/PostInteractionSettingsDialog.tsx:434 -msgid "Mentioned users" -msgstr "" - #: src/lib/hooks/useNotificationHandler.ts:147 #: src/screens/Settings/NotificationSettings/index.tsx:159 #: src/screens/Settings/NotificationSettings/MentionNotificationSettings.tsx:41 @@ -5513,7 +5547,7 @@ msgstr "" msgid "Never lose access to your followers or data." msgstr "" -#: src/screens/Settings/components/ChangeHandleDialog.tsx:582 +#: src/screens/Settings/components/ChangeHandleDialog.tsx:583 msgid "Nevermind, create a handle for me" msgstr "" @@ -5660,8 +5694,8 @@ msgstr "" msgid "No app passwords yet" msgstr "" -#: src/screens/Settings/components/ChangeHandleDialog.tsx:407 -#: src/screens/Settings/components/ChangeHandleDialog.tsx:409 +#: src/screens/Settings/components/ChangeHandleDialog.tsx:408 +#: src/screens/Settings/components/ChangeHandleDialog.tsx:410 msgid "No DNS Panel" msgstr "" @@ -5711,7 +5745,7 @@ msgstr "" msgid "No one" msgstr "" -#: src/components/WhoCanReply.tsx:269 +#: src/components/WhoCanReply.tsx:296 msgid "No one but the author can quote this post." msgstr "" @@ -5772,7 +5806,7 @@ msgstr "" msgid "No thanks" msgstr "" -#: src/components/dialogs/PostInteractionSettingsDialog.tsx:416 +#: src/components/dialogs/PostInteractionSettingsDialog.tsx:451 msgid "Nobody" msgstr "" @@ -5887,8 +5921,8 @@ msgstr "" msgid "Now" msgstr "" -#: src/view/com/composer/labels/LabelsBtn.tsx:150 -#: src/view/com/composer/labels/LabelsBtn.tsx:153 +#: src/view/com/composer/labels/LabelsBtn.tsx:145 +#: src/view/com/composer/labels/LabelsBtn.tsx:148 msgid "Nudity" msgstr "" @@ -5955,7 +5989,7 @@ msgstr "" msgid "Only .jpg and .png files are supported" msgstr "" -#: src/components/WhoCanReply.tsx:249 +#: src/components/WhoCanReply.tsx:276 msgid "Only {0} can reply." msgstr "" @@ -6071,8 +6105,8 @@ msgstr "" msgid "Opens a dialog to add a content warning to your post" msgstr "" -#: src/view/com/composer/threadgate/ThreadgateBtn.tsx:61 -msgid "Opens a dialog to choose who can reply to this thread" +#: src/view/com/composer/threadgate/ThreadgateBtn.tsx:146 +msgid "Opens a dialog to choose who can interact with this post" msgstr "" #: src/screens/Log.tsx:83 @@ -6151,10 +6185,6 @@ msgstr "" msgid "Options:" msgstr "" -#: src/components/dialogs/PostInteractionSettingsDialog.tsx:429 -msgid "Or combine these options:" -msgstr "" - #: src/screens/Deactivated.tsx:200 msgid "Or, continue with another account." msgstr "" @@ -6181,7 +6211,7 @@ msgstr "" #: src/components/moderation/ReportDialog/utils/useReportOptions.ts:238 #: src/components/moderation/ReportDialog/utils/useReportOptions.ts:242 -#: src/view/com/composer/labels/LabelsBtn.tsx:185 +#: src/view/com/composer/labels/LabelsBtn.tsx:180 msgid "Other" msgstr "" @@ -6293,6 +6323,14 @@ msgstr "" msgid "People I follow" msgstr "" +#: src/components/dialogs/PostInteractionSettingsDialog.tsx:490 +msgid "People you follow" +msgstr "" + +#: src/components/dialogs/PostInteractionSettingsDialog.tsx:504 +msgid "People you mention" +msgstr "" + #: src/lib/media/save-image.ts:59 msgid "Permission to access your photo library was denied. Please enable it in your system settings." msgstr "" @@ -6310,7 +6348,7 @@ msgstr "" msgid "Photography" msgstr "" -#: src/view/com/composer/labels/LabelsBtn.tsx:169 +#: src/view/com/composer/labels/LabelsBtn.tsx:164 msgid "Pictures meant for adults." msgstr "" @@ -6365,7 +6403,7 @@ msgstr "" msgid "Play {0}" msgstr "" -#: src/components/Post/Embed/VideoEmbed/index.tsx:135 +#: src/components/Post/Embed/VideoEmbed/index.tsx:115 #: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VideoControls.tsx:321 msgid "Play video" msgstr "" @@ -6390,7 +6428,7 @@ msgstr "" msgid "Plays the video" msgstr "" -#: src/view/com/composer/labels/LabelsBtn.tsx:115 +#: src/view/com/composer/labels/LabelsBtn.tsx:110 msgid "Please add any content warning labels that are applicable for the media you are posting." msgstr "" @@ -6536,7 +6574,7 @@ msgstr "" msgid "Politics" msgstr "" -#: src/view/com/composer/labels/LabelsBtn.tsx:156 +#: src/view/com/composer/labels/LabelsBtn.tsx:151 msgid "Porn" msgstr "" @@ -6592,7 +6630,7 @@ msgstr "" msgid "Post Hidden by You" msgstr "" -#: src/components/dialogs/PostInteractionSettingsDialog.tsx:108 +#: src/components/dialogs/PostInteractionSettingsDialog.tsx:666 msgid "Post interaction settings" msgstr "" @@ -6744,6 +6782,10 @@ msgstr "" msgid "Promoting or selling prohibited items or services" msgstr "" +#: src/view/com/composer/threadgate/ThreadgateBtn.tsx:155 +msgid "Psst! You can edit who can interact with this post." +msgstr "" + #: src/screens/Onboarding/StepFinished/index.tsx:391 msgid "Public" msgstr "" @@ -6830,10 +6872,6 @@ msgstr "" msgid "Quote posts disabled" msgstr "" -#: src/components/dialogs/PostInteractionSettingsDialog.tsx:340 -msgid "Quote settings" -msgstr "" - #: src/lib/hooks/useNotificationHandler.ts:154 #: src/screens/Post/PostQuotes.tsx:41 #: src/screens/Settings/NotificationSettings/index.tsx:170 @@ -6845,7 +6883,7 @@ msgstr "" msgid "Quotes of this post" msgstr "" -#: src/screens/Settings/components/ChangeHandleDialog.tsx:615 +#: src/screens/Settings/components/ChangeHandleDialog.tsx:616 msgid "Rate limit exceeded – you've tried to change your handle too many times in a short period. Please wait a minute before trying again." msgstr "" @@ -7150,11 +7188,11 @@ msgstr "" msgid "Replies" msgstr "" -#: src/components/WhoCanReply.tsx:79 +#: src/components/WhoCanReply.tsx:84 msgid "Replies disabled" msgstr "" -#: src/components/WhoCanReply.tsx:247 +#: src/components/WhoCanReply.tsx:274 msgid "Replies to this post are disabled." msgstr "" @@ -7182,11 +7220,7 @@ msgstr "" msgid "Reply notifications" msgstr "" -#: src/components/dialogs/PostInteractionSettingsDialog.tsx:396 -msgid "Reply settings" -msgstr "" - -#: src/components/dialogs/PostInteractionSettingsDialog.tsx:381 +#: src/components/dialogs/PostInteractionSettingsDialog.tsx:398 msgid "Reply settings are chosen by the author of the thread" msgstr "" @@ -7453,7 +7487,7 @@ msgstr "" #: src/screens/Profile/ProfileFeed/index.tsx:93 #: src/screens/ProfileList/components/ErrorScreen.tsx:35 -#: src/screens/Settings/components/ChangeHandleDialog.tsx:574 +#: src/screens/Settings/components/ChangeHandleDialog.tsx:575 #: src/screens/VideoFeed/index.tsx:1163 #: src/view/screens/NotFound.tsx:60 msgid "Returns to previous page" @@ -7466,8 +7500,8 @@ msgstr "" #: src/components/dialogs/BirthDateSettings.tsx:156 #: src/components/dialogs/lists/CreateOrEditListDialog.tsx:292 #: src/components/dialogs/lists/CreateOrEditListDialog.tsx:307 -#: src/components/dialogs/PostInteractionSettingsDialog.tsx:490 -#: src/components/dialogs/PostInteractionSettingsDialog.tsx:496 +#: src/components/dialogs/PostInteractionSettingsDialog.tsx:649 +#: src/components/dialogs/PostInteractionSettingsDialog.tsx:654 #: src/components/live/EditLiveDialog.tsx:216 #: src/components/live/EditLiveDialog.tsx:223 #: src/components/StarterPack/QrCodeDialog.tsx:204 @@ -7513,6 +7547,11 @@ msgstr "" msgid "Save QR code" msgstr "" +#: src/components/dialogs/PostInteractionSettingsDialog.tsx:630 +#: src/components/dialogs/PostInteractionSettingsDialog.tsx:636 +msgid "Save these options for next time" +msgstr "" + #: src/screens/Profile/components/ProfileFeedHeader.tsx:321 #: src/screens/Profile/components/ProfileFeedHeader.tsx:327 msgid "Save to my feeds" @@ -7763,6 +7802,14 @@ msgstr "" msgid "Select from an existing account" msgstr "" +#: src/components/dialogs/PostInteractionSettingsDialog.tsx:534 +msgid "Select from your lists" +msgstr "" + +#: src/components/dialogs/PostInteractionSettingsDialog.tsx:536 +msgid "Select from your lists <0>{numberOfListsSelected, plural, other {(# selected)}}" +msgstr "" + #: src/view/com/composer/photos/SelectGifBtn.tsx:35 msgid "Select GIF" msgstr "" @@ -7905,7 +7952,7 @@ msgstr "" msgid "Sends email with confirmation code for account deletion" msgstr "" -#: src/view/com/auth/server-input/index.tsx:167 +#: src/components/dialogs/ServerInput.tsx:177 msgid "Server address" msgstr "" @@ -7921,10 +7968,18 @@ msgstr "" msgid "Set new password" msgstr "" +#: src/components/dialogs/PostInteractionSettingsDialog.tsx:461 +msgid "Set precisely which groups of people can reply to your post" +msgstr "" + #: src/screens/Onboarding/Layout.tsx:49 msgid "Set up your account" msgstr "" +#: src/components/dialogs/PostInteractionSettingsDialog.tsx:411 +msgid "Set who can reply to your post" +msgstr "" + #: src/screens/Login/ForgotPasswordForm.tsx:107 msgid "Sets email for password reset" msgstr "" @@ -7985,7 +8040,7 @@ msgctxt "toast" msgid "Settings saved" msgstr "" -#: src/view/com/composer/labels/LabelsBtn.tsx:173 +#: src/view/com/composer/labels/LabelsBtn.tsx:168 msgid "Sexual activity or erotic nudity." msgstr "" @@ -8114,6 +8169,10 @@ msgstr "" msgid "Show list anyway" msgstr "" +#: src/components/dialogs/PostInteractionSettingsDialog.tsx:514 +msgid "Show lists of users to select from" +msgstr "" + #: src/components/Post/ShowMoreTextButton.tsx:51 msgid "Show More" msgstr "" @@ -8286,7 +8345,7 @@ msgstr "" msgid "Skip to next step" msgstr "" -#: src/screens/Settings/AppearanceSettings.tsx:165 +#: src/screens/Settings/AppearanceSettings.tsx:152 msgid "Smaller" msgstr "" @@ -8311,7 +8370,7 @@ msgstr "" msgid "Some other feeds you might like" msgstr "" -#: src/components/WhoCanReply.tsx:80 +#: src/components/WhoCanReply.tsx:85 msgid "Some people can reply" msgstr "" @@ -8544,8 +8603,8 @@ msgstr "" msgid "Suggested for you" msgstr "" -#: src/view/com/composer/labels/LabelsBtn.tsx:144 -#: src/view/com/composer/labels/LabelsBtn.tsx:147 +#: src/view/com/composer/labels/LabelsBtn.tsx:139 +#: src/view/com/composer/labels/LabelsBtn.tsx:142 msgid "Suggestive" msgstr "" @@ -8585,10 +8644,10 @@ msgstr "" msgid "Switch to {0}" msgstr "" -#: src/components/dialogs/Embed.tsx:157 -#: src/components/dialogs/Embed.tsx:159 -#: src/screens/Settings/AppearanceSettings.tsx:98 -#: src/screens/Settings/AppearanceSettings.tsx:148 +#: src/components/dialogs/Embed.tsx:158 +#: src/components/dialogs/Embed.tsx:160 +#: src/screens/Settings/AppearanceSettings.tsx:85 +#: src/screens/Settings/AppearanceSettings.tsx:135 msgid "System" msgstr "" @@ -8701,7 +8760,7 @@ msgstr "" msgid "Thanks! You're all set." msgstr "" -#: src/screens/Settings/components/ChangeHandleDialog.tsx:497 +#: src/screens/Settings/components/ChangeHandleDialog.tsx:498 msgid "That contains the following:" msgstr "" @@ -8831,7 +8890,7 @@ msgstr "" msgid "The verification code you have provided is invalid. Please make sure that you have used the correct verification link or request a new one." msgstr "" -#: src/screens/Settings/AppearanceSettings.tsx:152 +#: src/screens/Settings/AppearanceSettings.tsx:139 msgid "Theme" msgstr "" @@ -8908,7 +8967,7 @@ msgstr "" msgid "There was an issue! {0}" msgstr "" -#: src/components/dialogs/PostInteractionSettingsDialog.tsx:228 +#: src/components/dialogs/PostInteractionSettingsDialog.tsx:224 #: src/screens/List/ListHiddenScreen.tsx:63 #: src/screens/List/ListHiddenScreen.tsx:77 #: src/screens/List/ListHiddenScreen.tsx:99 @@ -8931,6 +8990,10 @@ msgstr "" 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:641 +msgid "These are your default settings" +msgstr "" + #: src/screens/Settings/FollowingFeedPreferences.tsx:65 msgid "These settings only apply to the Following feed." msgstr "" @@ -9038,7 +9101,7 @@ msgstr "" msgid "This feed is no longer online. We are showing <0>Discover instead." msgstr "" -#: src/screens/Settings/components/ChangeHandleDialog.tsx:608 +#: src/screens/Settings/components/ChangeHandleDialog.tsx:609 msgid "This handle is reserved. Please try a different one." msgstr "" @@ -9087,7 +9150,7 @@ msgstr "" 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:240 +#: src/components/WhoCanReply.tsx:267 msgid "This post has an unknown type of threadgate on it. Your app may be out of date." msgstr "" @@ -9119,7 +9182,7 @@ msgstr "" msgid "This service has not provided terms of service or a privacy policy." msgstr "" -#: src/screens/Settings/components/ChangeHandleDialog.tsx:466 +#: src/screens/Settings/components/ChangeHandleDialog.tsx:467 msgid "This should create a domain record at:" msgstr "" @@ -9224,6 +9287,10 @@ msgstr "" msgid "Today" msgstr "" +#: src/components/dialogs/PostInteractionSettingsDialog.tsx:516 +msgid "Toggle showing lists" +msgstr "" + #: src/screens/Moderation/index.tsx:398 msgid "Toggle to enable or disable adult content" msgstr "" @@ -9296,7 +9363,7 @@ msgstr "" msgid "Type your message here" msgstr "" -#: src/screens/Settings/components/ChangeHandleDialog.tsx:442 +#: src/screens/Settings/components/ChangeHandleDialog.tsx:443 msgid "Type:" msgstr "" @@ -9558,8 +9625,8 @@ msgstr "" msgid "Update email" msgstr "" -#: src/screens/Settings/components/ChangeHandleDialog.tsx:541 -#: src/screens/Settings/components/ChangeHandleDialog.tsx:562 +#: src/screens/Settings/components/ChangeHandleDialog.tsx:542 +#: src/screens/Settings/components/ChangeHandleDialog.tsx:563 msgid "Update to {domain}" msgstr "" @@ -9585,7 +9652,7 @@ msgstr "" msgid "Upload a photo instead" msgstr "" -#: src/screens/Settings/components/ChangeHandleDialog.tsx:482 +#: src/screens/Settings/components/ChangeHandleDialog.tsx:483 msgid "Upload a text file to:" msgstr "" @@ -9625,7 +9692,7 @@ msgstr "" msgid "Use app passwords to sign in to other Bluesky clients without giving full access to your account or password." msgstr "" -#: src/screens/Settings/components/ChangeHandleDialog.tsx:573 +#: src/screens/Settings/components/ChangeHandleDialog.tsx:574 msgid "Use default provider" msgstr "" @@ -9720,11 +9787,11 @@ msgstr "" msgid "Username or email address" msgstr "" -#: src/components/WhoCanReply.tsx:303 +#: src/components/WhoCanReply.tsx:330 msgid "users followed by <0>@{0}" msgstr "" -#: src/components/WhoCanReply.tsx:290 +#: src/components/WhoCanReply.tsx:317 msgid "users following <0>@{0}" msgstr "" @@ -9733,15 +9800,7 @@ msgstr "" msgid "Users I follow" msgstr "" -#: src/components/dialogs/PostInteractionSettingsDialog.tsx:467 -msgid "Users in \"{0}\"" -msgstr "" - -#: src/components/dialogs/PostInteractionSettingsDialog.tsx:444 -msgid "Users you follow" -msgstr "" - -#: src/screens/Settings/components/ChangeHandleDialog.tsx:448 +#: src/screens/Settings/components/ChangeHandleDialog.tsx:449 msgid "Value:" msgstr "" @@ -9785,8 +9844,8 @@ msgctxt "action" msgid "Verify code" msgstr "" -#: src/screens/Settings/components/ChangeHandleDialog.tsx:543 -#: src/screens/Settings/components/ChangeHandleDialog.tsx:564 +#: src/screens/Settings/components/ChangeHandleDialog.tsx:544 +#: src/screens/Settings/components/ChangeHandleDialog.tsx:565 msgid "Verify DNS Record" msgstr "" @@ -9804,8 +9863,8 @@ msgstr "" msgid "Verify now" msgstr "" -#: src/screens/Settings/components/ChangeHandleDialog.tsx:544 -#: src/screens/Settings/components/ChangeHandleDialog.tsx:566 +#: src/screens/Settings/components/ChangeHandleDialog.tsx:545 +#: src/screens/Settings/components/ChangeHandleDialog.tsx:567 msgid "Verify Text File" msgstr "" @@ -9867,7 +9926,7 @@ msgstr "" msgid "Video is playing" msgstr "" -#: src/components/Post/Embed/VideoEmbed/index.web.tsx:237 +#: src/components/Post/Embed/VideoEmbed/index.web.tsx:219 msgid "Video not found." msgstr "" @@ -10006,8 +10065,8 @@ msgstr "" msgid "View your verifications" msgstr "" -#: src/view/com/util/images/AutoSizedImage.tsx:207 -#: src/view/com/util/images/AutoSizedImage.tsx:234 +#: src/view/com/util/images/AutoSizedImage.tsx:205 +#: src/view/com/util/images/AutoSizedImage.tsx:232 msgid "Views full image" msgstr "" @@ -10243,11 +10302,12 @@ msgstr "" msgid "Which languages would you like to see in your algorithmic feeds?" msgstr "" -#: src/components/WhoCanReply.tsx:192 +#: src/components/WhoCanReply.tsx:219 msgid "Who can interact with this post?" msgstr "" -#: src/components/WhoCanReply.tsx:97 +#: src/components/dialogs/PostInteractionSettingsDialog.tsx:407 +#: src/components/WhoCanReply.tsx:109 msgid "Who can reply" msgstr "" @@ -10464,10 +10524,6 @@ msgstr "" msgid "You can select up to {MAX_IMAGES, plural, other {# images}} in total." msgstr "" -#: src/components/dialogs/PostInteractionSettingsDialog.tsx:89 -msgid "You can set default interaction settings in <0>Settings → Moderation → Interaction settings." -msgstr "" - #: src/components/interstitials/Trending.tsx:130 #: src/components/interstitials/TrendingVideos.tsx:137 #: src/view/shell/desktop/SidebarTrendingTopics.tsx:110 @@ -10486,6 +10542,10 @@ msgstr "" msgid "You don't have any chat requests at the moment." msgstr "" +#: src/components/dialogs/PostInteractionSettingsDialog.tsx:570 +msgid "You don't have any lists yet." +msgstr "" + #: src/screens/SavedFeeds.tsx:149 msgid "You don't have any pinned feeds." msgstr "" @@ -10805,7 +10865,7 @@ msgstr "" msgid "Your birth date" msgstr "" -#: src/components/Post/Embed/VideoEmbed/index.web.tsx:241 +#: src/components/Post/Embed/VideoEmbed/index.web.tsx:223 msgid "Your browser does not support the video format. Please try a different browser." msgstr "" @@ -10817,7 +10877,7 @@ msgstr "" msgid "Your choice will be remembered for future links. You can change it at any time in settings." msgstr "" -#: src/screens/Settings/components/ChangeHandleDialog.tsx:528 +#: src/screens/Settings/components/ChangeHandleDialog.tsx:529 msgid "Your current handle <0>{0} will automatically remain reserved for you. You can switch back to it at any time from this account." msgstr "" @@ -10846,7 +10906,7 @@ msgstr "" msgid "Your first like!" msgstr "" -#: src/components/dialogs/PostInteractionSettingsDialog.tsx:454 +#: src/components/dialogs/PostInteractionSettingsDialog.tsx:476 msgid "Your followers" msgstr "" From fb058afa5419bc3cc3172c88df023d491313703e Mon Sep 17 00:00:00 2001 From: Alex Benzer Date: Mon, 17 Nov 2025 03:23:56 -0800 Subject: [PATCH 08/20] Add client event for clicks on desktop feeds (#9386) --- src/logger/metrics.ts | 4 ++++ src/view/shell/desktop/Feeds.tsx | 9 +++++++++ 2 files changed, 13 insertions(+) diff --git a/src/logger/metrics.ts b/src/logger/metrics.ts index 70ce627fa0..c7bac2fecd 100644 --- a/src/logger/metrics.ts +++ b/src/logger/metrics.ts @@ -373,6 +373,10 @@ export type MetricEvents = { } 'feed:interstitial:feedCard:press': {} + 'desktopFeeds:feed:click': { + feedUri: string + feedDescriptor: string + } 'profile:header:suggestedFollowsCard:press': {} 'profile:addToStarterPack': {} diff --git a/src/view/shell/desktop/Feeds.tsx b/src/view/shell/desktop/Feeds.tsx index df89d95cad..641b90f3e3 100644 --- a/src/view/shell/desktop/Feeds.tsx +++ b/src/view/shell/desktop/Feeds.tsx @@ -5,6 +5,7 @@ import {useNavigation, useNavigationState} from '@react-navigation/native' import {getCurrentRoute} from '#/lib/routes/helpers' import {type NavigationProp} from '#/lib/routes/types' +import {logger} from '#/logger' import {emitSoftReset} from '#/state/events' import {usePinnedFeedsInfos} from '#/state/queries/feed' import {useSelectedFeed, useSetSelectedFeed} from '#/state/shell/selected-feed' @@ -75,6 +76,14 @@ export function DesktopFeeds() { key={feedInfo.uri} label={feedInfo.displayName} {...createStaticClick(() => { + logger.metric( + 'desktopFeeds:feed:click', + { + feedUri: feedInfo.uri, + feedDescriptor: feed, + }, + {statsig: false}, + ) setSelectedFeed(feed) navigation.navigate('Home') if (route.name === 'Home' && feed === selectedFeed) { From 4145878819c5d3b484e085952b956032f1e167bc Mon Sep 17 00:00:00 2001 From: surfdude29 <149612116+surfdude29@users.noreply.github.com> Date: Mon, 17 Nov 2025 11:26:39 +0000 Subject: [PATCH 09/20] remove `accessibilityHint` and tweak labels (#9391) --- src/components/dialogs/PostInteractionSettingsDialog.tsx | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/components/dialogs/PostInteractionSettingsDialog.tsx b/src/components/dialogs/PostInteractionSettingsDialog.tsx index b499b40751..cac82bc4ae 100644 --- a/src/components/dialogs/PostInteractionSettingsDialog.tsx +++ b/src/components/dialogs/PostInteractionSettingsDialog.tsx @@ -513,7 +513,6 @@ export function PostInteractionSettingsForm({ ? _(msg`Hide lists`) : _(msg`Show lists of users to select from`) } - accessibilityHint={_(msg`Toggle showing lists`)} accessibilityRole="togglebutton" hitSlop={0} onPress={() => { @@ -606,8 +605,8 @@ export function PostInteractionSettingsForm({ type="checkbox" label={ quotesEnabled - ? _(msg`Disable quote posts of this post.`) - : _(msg`Enable quote posts of this post.`) + ? _(msg`Disable quote posts of this post`) + : _(msg`Enable quote posts of this post`) } value={quotesEnabled} onChange={onChangeQuotesEnabled}> From a35a81a508861f2e651dff646eee36423def0781 Mon Sep 17 00:00:00 2001 From: surfdude29 <149612116+surfdude29@users.noreply.github.com> Date: Mon, 17 Nov 2025 11:27:43 +0000 Subject: [PATCH 10/20] Change text on threadgate button from 'Anybody' to 'Anyone' (#9390) --- src/view/com/composer/threadgate/ThreadgateBtn.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/view/com/composer/threadgate/ThreadgateBtn.tsx b/src/view/com/composer/threadgate/ThreadgateBtn.tsx index 788e831dc0..edfdbe9949 100644 --- a/src/view/com/composer/threadgate/ThreadgateBtn.tsx +++ b/src/view/com/composer/threadgate/ThreadgateBtn.tsx @@ -126,7 +126,7 @@ export function ThreadgateBtn({ !postgate.embeddingRules || postgate.embeddingRules.length === 0 const anyoneCanInteract = anyoneCanReply && anyoneCanQuote const label = anyoneCanInteract - ? _(msg`Anybody can interact`) + ? _(msg`Anyone can interact`) : _(msg`Interaction limited`) return ( From d464cde4121861b80c9835f9c357c104ded03b53 Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Mon, 17 Nov 2025 14:59:56 +0200 Subject: [PATCH 11/20] add transparency prop to tab bar (#9336) --- src/components/Layout/Header/index.tsx | 5 ++++- src/view/com/home/HomeHeader.tsx | 1 + src/view/com/home/HomeHeaderLayoutMobile.tsx | 1 + src/view/com/pager/TabBar.tsx | 4 +++- 4 files changed, 9 insertions(+), 2 deletions(-) diff --git a/src/components/Layout/Header/index.tsx b/src/components/Layout/Header/index.tsx index 3c895e4d8e..762223fc35 100644 --- a/src/components/Layout/Header/index.tsx +++ b/src/components/Layout/Header/index.tsx @@ -163,7 +163,10 @@ export function MenuButton() { shape="square" onPress={onPress} hitSlop={HITSLOP_30} - style={[{marginLeft: -BUTTON_VISUAL_ALIGNMENT_OFFSET}]}> + style={[ + {marginLeft: -BUTTON_VISUAL_ALIGNMENT_OFFSET}, + a.bg_transparent, + ]}> diff --git a/src/view/com/home/HomeHeader.tsx b/src/view/com/home/HomeHeader.tsx index 4ae3445499..4a2cf881f3 100644 --- a/src/view/com/home/HomeHeader.tsx +++ b/src/view/com/home/HomeHeader.tsx @@ -61,6 +61,7 @@ export function HomeHeader( items={items} dragProgress={props.dragProgress} dragState={props.dragState} + transparent /> ) diff --git a/src/view/com/home/HomeHeaderLayoutMobile.tsx b/src/view/com/home/HomeHeaderLayoutMobile.tsx index a0d7b3e785..ef1e938379 100644 --- a/src/view/com/home/HomeHeaderLayoutMobile.tsx +++ b/src/view/com/home/HomeHeaderLayoutMobile.tsx @@ -77,6 +77,7 @@ export function HomeHeaderLayoutMobile({ style={[ a.justify_center, {marginRight: -Layout.BUTTON_VISUAL_ALIGNMENT_OFFSET}, + a.bg_transparent, ]}> diff --git a/src/view/com/pager/TabBar.tsx b/src/view/com/pager/TabBar.tsx index 70358fd102..c07f44ac8b 100644 --- a/src/view/com/pager/TabBar.tsx +++ b/src/view/com/pager/TabBar.tsx @@ -30,6 +30,7 @@ export interface TabBarProps { onPressSelected?: (index: number) => void dragProgress: SharedValue dragState: SharedValue<'idle' | 'dragging' | 'settling'> + transparent?: boolean } const ITEM_PADDING = 10 @@ -46,6 +47,7 @@ export function TabBar({ onPressSelected, dragProgress, dragState, + transparent, }: TabBarProps) { const t = useTheme() const scrollElRef = useAnimatedRef() @@ -313,7 +315,7 @@ export function TabBar({ return ( Date: Mon, 17 Nov 2025 05:00:26 -0800 Subject: [PATCH 12/20] Adds a "follow back" button to follow notifications (#9359) * Adds a "follow back" button to follow notifications * get shadowcache logic working, strip out manual optimistic update * whoops, don't just stick any old profile in there --------- Co-authored-by: Samuel Newman --- src/state/cache/profile-shadow.ts | 2 + src/state/queries/notifications/feed.ts | 8 +- .../notifications/NotificationFeedItem.tsx | 167 +++++++++++++++--- 3 files changed, 148 insertions(+), 29 deletions(-) diff --git a/src/state/cache/profile-shadow.ts b/src/state/cache/profile-shadow.ts index 168661e0d1..e1cff94092 100644 --- a/src/state/cache/profile-shadow.ts +++ b/src/state/cache/profile-shadow.ts @@ -12,6 +12,7 @@ import {findAllProfilesInQueryData as findAllProfilesInListMembersQueryData} fro import {findAllProfilesInQueryData as findAllProfilesInListConvosQueryData} from '#/state/queries/messages/list-conversations' import {findAllProfilesInQueryData as findAllProfilesInMyBlockedAccountsQueryData} from '#/state/queries/my-blocked-accounts' import {findAllProfilesInQueryData as findAllProfilesInMyMutedAccountsQueryData} from '#/state/queries/my-muted-accounts' +import {findAllProfilesInQueryData as findAllProfilesInNotifsQueryData} from '#/state/queries/notifications/feed' import {findAllProfilesInQueryData as findAllProfilesInFeedsQueryData} from '#/state/queries/post-feed' import {findAllProfilesInQueryData as findAllProfilesInPostLikedByQueryData} from '#/state/queries/post-liked-by' import {findAllProfilesInQueryData as findAllProfilesInPostQuotesQueryData} from '#/state/queries/post-quotes' @@ -176,4 +177,5 @@ function* findProfilesInCache( yield* findAllProfilesInKnownFollowersQueryData(queryClient, did) yield* findAllProfilesInExploreFeedPreviewsQueryData(queryClient, did) yield* findAllProfilesInActivitySubscriptionsQueryData(queryClient, did) + yield* findAllProfilesInNotifsQueryData(queryClient, did) } diff --git a/src/state/queries/notifications/feed.ts b/src/state/queries/notifications/feed.ts index 6010f11b40..7959c67a76 100644 --- a/src/state/queries/notifications/feed.ts +++ b/src/state/queries/notifications/feed.ts @@ -18,7 +18,6 @@ import {useCallback, useEffect, useMemo, useRef} from 'react' import { - type AppBskyActorDefs, AppBskyFeedDefs, AppBskyFeedPost, AtUri, @@ -36,6 +35,7 @@ import {useModerationOpts} from '#/state/preferences/moderation-opts' import {STALE} from '#/state/queries' import {useAgent} from '#/state/session' import {useThreadgateHiddenReplyUris} from '#/state/threadgate-hidden-replies' +import type * as bsky from '#/types/bsky' import { didOrHandleUriMatches, embedViewRecordToPostView, @@ -309,7 +309,7 @@ export function* findAllPostsInQueryData( export function* findAllProfilesInQueryData( queryClient: QueryClient, did: string, -): Generator { +): Generator { const queryDatas = queryClient.getQueriesData>({ queryKey: [RQKEY_ROOT], }) @@ -319,7 +319,9 @@ export function* findAllProfilesInQueryData( } for (const page of queryData?.pages) { for (const item of page.items) { - if ( + if (item.type === 'follow' && item.notification.author.did === did) { + yield item.notification.author + } else if ( item.type !== 'starterpack-joined' && item.subject?.author.did === did ) { diff --git a/src/view/com/notifications/NotificationFeedItem.tsx b/src/view/com/notifications/NotificationFeedItem.tsx index 5809e71065..f78b9650e1 100644 --- a/src/view/com/notifications/NotificationFeedItem.tsx +++ b/src/view/com/notifications/NotificationFeedItem.tsx @@ -42,23 +42,28 @@ import {sanitizeHandle} from '#/lib/strings/handles' import {niceDate} from '#/lib/strings/time' import {s} from '#/lib/styles' import {logger} from '#/logger' +import {useProfileShadow} from '#/state/cache/profile-shadow' import {type FeedNotification} from '#/state/queries/notifications/feed' +import {useProfileFollowMutationQueue} from '#/state/queries/profile' import {unstableCacheProfileView} from '#/state/queries/unstable-profile-cache' -import {useAgent} from '#/state/session' +import {useAgent, useSession} from '#/state/session' import {FeedSourceCard} from '#/view/com/feeds/FeedSourceCard' import {Post} from '#/view/com/post/Post' import {formatCount} from '#/view/com/util/numeric/format' import {TimeElapsed} from '#/view/com/util/TimeElapsed' +import * as Toast from '#/view/com/util/Toast' import {PreviewableUserAvatar, UserAvatar} from '#/view/com/util/UserAvatar' import {atoms as a, platform, useTheme} from '#/alf' -import {Button, ButtonText} from '#/components/Button' +import {Button, ButtonIcon, ButtonText} from '#/components/Button' import {BellRinging_Filled_Corner0_Rounded as BellRingingIcon} from '#/components/icons/BellRinging' +import {Check_Stroke2_Corner0_Rounded as CheckIcon} from '#/components/icons/Check' import { ChevronBottom_Stroke2_Corner0_Rounded as ChevronDownIcon, ChevronTop_Stroke2_Corner0_Rounded as ChevronUpIcon, } from '#/components/icons/Chevron' import {Heart2_Filled_Stroke2_Corner0_Rounded as HeartIconFilled} from '#/components/icons/Heart2' import {PersonPlus_Filled_Stroke2_Corner0_Rounded as PersonPlusIcon} from '#/components/icons/Person' +import {PlusLarge_Stroke2_Corner0_Rounded as PlusIcon} from '#/components/icons/Plus' import {Repost_Stroke2_Corner2_Rounded as RepostIcon} from '#/components/icons/Repost' import {StarterPack} from '#/components/icons/StarterPack' import {VerifiedCheck} from '#/components/icons/VerifiedCheck' @@ -180,6 +185,32 @@ let NotificationFeedItem = ({ firstAuthor.profile.displayName || firstAuthor.profile.handle, ) + // Calculate if this is a follow-back notification + const isFollowBack = useMemo(() => { + if (item.type !== 'follow') return false + if ( + item.notification.author.viewer?.following && + bsky.dangerousIsType( + item.notification.record, + AppBskyGraphFollow.isRecord, + ) + ) { + let followingTimestamp + try { + const rkey = new AtUri(item.notification.author.viewer.following).rkey + followingTimestamp = TID.fromStr(rkey).timestamp() + } catch (e) { + return false + } + if (followingTimestamp) { + const followedTimestamp = + new Date(item.notification.record.createdAt).getTime() * 1000 + return followedTimestamp > followingTimestamp + } + } + return false + }, [item]) + if (item.subjectUri && !item.subject && item.type !== 'feedgen-like') { // don't render anything if the target post was deleted or unfindable return @@ -309,30 +340,6 @@ let NotificationFeedItem = ({ ) icon = } else if (item.type === 'follow') { - let isFollowBack = false - - if ( - item.notification.author.viewer?.following && - bsky.dangerousIsType( - item.notification.record, - AppBskyGraphFollow.isRecord, - ) - ) { - let followingTimestamp - try { - const rkey = new AtUri(item.notification.author.viewer.following).rkey - followingTimestamp = TID.fromStr(rkey).timestamp() - } catch (e) { - // For some reason the following URI was invalid. Default to it not being a follow back. - console.error('Invalid following URI') - } - if (followingTimestamp) { - const followedTimestamp = - new Date(item.notification.record.createdAt).getTime() * 1000 - isFollowBack = followedTimestamp > followingTimestamp - } - } - if (isFollowBack && !hasMultipleAuthors) { /* * Follow-backs are ungrouped, grouped follow-backs not supported atm, @@ -663,6 +670,9 @@ let NotificationFeedItem = ({
+ {item.type === 'follow' && !hasMultipleAuthors && !isFollowBack ? ( + + ) : null} {item.type === 'post-like' || item.type === 'repost' || item.type === 'like-via-repost' || @@ -732,6 +742,111 @@ function ExpandListPressable({ } } +function FollowBackButton({profile}: {profile: AppBskyActorDefs.ProfileView}) { + const {_} = useLingui() + const {currentAccount, hasSession} = useSession() + const profileShadow = useProfileShadow(profile) + const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue( + profileShadow, + 'ProfileCard', + ) + + // Don't show button if not logged in or for own profile + if (!hasSession || profile.did === currentAccount?.did) { + return null + } + + const onPressFollow = async (e: GestureResponderEvent) => { + e.preventDefault() + e.stopPropagation() + + try { + await queueFollow() + Toast.show( + _( + msg`Following ${sanitizeDisplayName( + profile.displayName || profile.handle, + )}`, + ), + ) + } catch (err: any) { + if (err?.name !== 'AbortError') { + Toast.show(_(msg`An issue occurred, please try again.`), 'xmark') + } + } + } + + const onPressUnfollow = async (e: GestureResponderEvent) => { + e.preventDefault() + e.stopPropagation() + + try { + await queueUnfollow() + Toast.show( + _( + msg`No longer following ${sanitizeDisplayName( + profile.displayName || profile.handle, + )}`, + ), + ) + } catch (err: any) { + if (err?.name !== 'AbortError') { + Toast.show(_(msg`An issue occurred, please try again.`), 'xmark') + } + } + } + + // Don't show button if viewer data is missing or user is blocked + if (!profileShadow.viewer) { + return null + } + if ( + profileShadow.viewer.blockedBy || + profileShadow.viewer.blocking || + profileShadow.viewer.blockingByList + ) { + return null + } + + const isFollowing = profileShadow.viewer.following + const followingLabel = _( + msg({ + message: 'Following', + comment: 'User is following this account, click to unfollow', + }), + ) + + return ( + + {isFollowing ? ( + + ) : ( + + )} + + ) +} + function SayHelloBtn({profile}: {profile: AppBskyActorDefs.ProfileView}) { const {_} = useLingui() const agent = useAgent() From 084f60c88f5341b657e09a0b9723dc32304be567 Mon Sep 17 00:00:00 2001 From: Alex Benzer Date: Mon, 17 Nov 2025 05:04:11 -0800 Subject: [PATCH 13/20] Profile follow client events (#9385) * Add parameters to profile:follow Track who was followed, whose profile generated the follow, and the position of the person who was followed in the list * Add profileCard:seen event * Don't send "profileCard:seen" event to Statsig * Clean up * prevent overzealous clearing --------- Co-authored-by: Samuel Newman --- src/components/ProfileCard.tsx | 18 ++++++++++ src/logger/metrics.ts | 8 +++++ src/state/queries/profile.ts | 14 +++++++- src/view/com/profile/ProfileCard.tsx | 6 ++++ src/view/com/profile/ProfileFollowers.tsx | 41 ++++++++++++++++++++++- src/view/com/profile/ProfileFollows.tsx | 41 ++++++++++++++++++++++- 6 files changed, 125 insertions(+), 3 deletions(-) diff --git a/src/components/ProfileCard.tsx b/src/components/ProfileCard.tsx index 41626ff399..55bef226b3 100644 --- a/src/components/ProfileCard.tsx +++ b/src/components/ProfileCard.tsx @@ -48,11 +48,15 @@ export function Default({ moderationOpts, logContext = 'ProfileCard', testID, + position, + contextProfileDid, }: { profile: bsky.profile.AnyProfileView moderationOpts: ModerationOpts logContext?: 'ProfileCard' | 'StarterPackProfilesList' testID?: string + position?: number + contextProfileDid?: string }) { return ( @@ -60,6 +64,8 @@ export function Default({ profile={profile} moderationOpts={moderationOpts} logContext={logContext} + position={position} + contextProfileDid={contextProfileDid} /> ) @@ -69,10 +75,14 @@ export function Card({ profile, moderationOpts, logContext = 'ProfileCard', + position, + contextProfileDid, }: { profile: bsky.profile.AnyProfileView moderationOpts: ModerationOpts logContext?: 'ProfileCard' | 'StarterPackProfilesList' + position?: number + contextProfileDid?: string }) { return ( @@ -83,6 +93,8 @@ export function Card({ profile={profile} moderationOpts={moderationOpts} logContext={logContext} + position={position} + contextProfileDid={contextProfileDid} />
@@ -437,6 +449,8 @@ export type FollowButtonProps = { colorInverted?: boolean onFollow?: () => void withIcon?: boolean + position?: number + contextProfileDid?: string } & Partial export function FollowButton(props: FollowButtonProps) { @@ -453,6 +467,8 @@ export function FollowButtonInner({ onFollow, colorInverted, withIcon = true, + position, + contextProfileDid, ...rest }: FollowButtonProps) { const {_} = useLingui() @@ -461,6 +477,8 @@ export function FollowButtonInner({ const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue( profile, logContext, + position, + contextProfileDid, ) const isRound = Boolean(rest.shape && rest.shape === 'round') diff --git a/src/logger/metrics.ts b/src/logger/metrics.ts index c7bac2fecd..37b8e21f74 100644 --- a/src/logger/metrics.ts +++ b/src/logger/metrics.ts @@ -256,9 +256,12 @@ export type MetricEvents = { 'bookmarks:view': {} 'bookmarks:post-clicked': {} 'profile:follow': { + contextProfileDid?: string didBecomeMutual: boolean | undefined followeeClout: number | undefined + followeeDid: string followerClout: number | undefined + position?: number logContext: | 'RecommendedFollowsItem' | 'PostThreadItem' @@ -276,6 +279,11 @@ export type MetricEvents = { | 'ExploreSuggestedAccounts' | 'OnboardingSuggestedAccounts' } + 'profileCard:seen': { + contextProfileDid?: string + profileDid: string + position?: number + } 'suggestedUser:follow': { logContext: | 'Explore' diff --git a/src/state/queries/profile.ts b/src/state/queries/profile.ts index eb65fef7c2..9d30288d40 100644 --- a/src/state/queries/profile.ts +++ b/src/state/queries/profile.ts @@ -242,12 +242,19 @@ export function useProfileFollowMutationQueue( profile: Shadow, logContext: LogEvents['profile:follow']['logContext'] & LogEvents['profile:follow']['logContext'], + position?: number, + contextProfileDid?: string, ) { const agent = useAgent() const queryClient = useQueryClient() const did = profile.did const initialFollowingUri = profile.viewer?.following - const followMutation = useProfileFollowMutation(logContext, profile) + const followMutation = useProfileFollowMutation( + logContext, + profile, + position, + contextProfileDid, + ) const unfollowMutation = useProfileUnfollowMutation(logContext) const queueToggle = useToggleMutationQueue({ @@ -314,6 +321,8 @@ export function useProfileFollowMutationQueue( function useProfileFollowMutation( logContext: LogEvents['profile:follow']['logContext'], profile: Shadow, + position?: number, + contextProfileDid?: string, ) { const {currentAccount} = useSession() const agent = useAgent() @@ -336,7 +345,10 @@ function useProfileFollowMutation( 'followersCount' in profile ? toClout(profile.followersCount) : undefined, + followeeDid: did, followerClout: toClout(ownProfile?.followersCount), + position, + contextProfileDid, }) return await agent.follow(did) }, diff --git a/src/view/com/profile/ProfileCard.tsx b/src/view/com/profile/ProfileCard.tsx index cee9507030..f200a62cb6 100644 --- a/src/view/com/profile/ProfileCard.tsx +++ b/src/view/com/profile/ProfileCard.tsx @@ -9,10 +9,14 @@ export function ProfileCardWithFollowBtn({ profile, noBorder, logContext = 'ProfileCard', + position, + contextProfileDid, }: { profile: AppBskyActorDefs.ProfileView noBorder?: boolean logContext?: 'ProfileCard' | 'StarterPackProfilesList' + position?: number + contextProfileDid?: string }) { const t = useTheme() const moderationOpts = useModerationOpts() @@ -30,6 +34,8 @@ export function ProfileCardWithFollowBtn({ profile={profile} moderationOpts={moderationOpts} logContext={logContext} + position={position} + contextProfileDid={contextProfileDid} /> ) diff --git a/src/view/com/profile/ProfileFollowers.tsx b/src/view/com/profile/ProfileFollowers.tsx index dfb63909e4..b5838f0c19 100644 --- a/src/view/com/profile/ProfileFollowers.tsx +++ b/src/view/com/profile/ProfileFollowers.tsx @@ -16,15 +16,19 @@ import {ProfileCardWithFollowBtn} from './ProfileCard' function renderItem({ item, index, + contextProfileDid, }: { item: ActorDefs.ProfileView index: number + contextProfileDid: string | undefined }) { return ( ) } @@ -83,6 +87,40 @@ export function ProfileFollowers({name}: {name: string}) { } }, [isFetchingNextPage, hasNextPage, error, fetchNextPage]) + const renderItemWithContext = React.useCallback( + ({item, index}: {item: ActorDefs.ProfileView; index: number}) => + renderItem({item, index, contextProfileDid: resolvedDid}), + [resolvedDid], + ) + + // track seen items + const seenItemsRef = React.useRef>(new Set()) + React.useEffect(() => { + seenItemsRef.current.clear() + }, [resolvedDid]) + const onItemSeen = React.useCallback( + (item: ActorDefs.ProfileView) => { + if (seenItemsRef.current.has(item.did)) { + return + } + seenItemsRef.current.add(item.did) + const position = followers.findIndex(p => p.did === item.did) + 1 + if (position === 0) { + return + } + logger.metric( + 'profileCard:seen', + { + profileDid: item.did, + position, + ...(resolvedDid !== undefined && {contextProfileDid: resolvedDid}), + }, + {statsig: false}, + ) + }, + [followers, resolvedDid], + ) + if (followers.length < 1) { return ( ) } @@ -83,6 +87,40 @@ export function ProfileFollows({name}: {name: string}) { } }, [error, fetchNextPage, hasNextPage, isFetchingNextPage]) + const renderItemWithContext = React.useCallback( + ({item, index}: {item: ActorDefs.ProfileView; index: number}) => + renderItem({item, index, contextProfileDid: resolvedDid}), + [resolvedDid], + ) + + // track seen items + const seenItemsRef = React.useRef>(new Set()) + React.useEffect(() => { + seenItemsRef.current.clear() + }, [resolvedDid]) + const onItemSeen = React.useCallback( + (item: ActorDefs.ProfileView) => { + if (seenItemsRef.current.has(item.did)) { + return + } + seenItemsRef.current.add(item.did) + const position = follows.findIndex(p => p.did === item.did) + 1 + if (position === 0) { + return + } + logger.metric( + 'profileCard:seen', + { + profileDid: item.did, + position, + ...(resolvedDid !== undefined && {contextProfileDid: resolvedDid}), + }, + {statsig: false}, + ) + }, + [follows, resolvedDid], + ) + if (follows.length < 1) { return ( Date: Mon, 17 Nov 2025 15:23:28 +0200 Subject: [PATCH 14/20] add haptics to segmented control (#9398) --- src/components/forms/SegmentedControl.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/components/forms/SegmentedControl.tsx b/src/components/forms/SegmentedControl.tsx index 71da847e1d..68f57c83a3 100644 --- a/src/components/forms/SegmentedControl.tsx +++ b/src/components/forms/SegmentedControl.tsx @@ -9,6 +9,7 @@ import { import {type StyleProp, View, type ViewStyle} from 'react-native' import Animated, {Easing, LinearTransition} from 'react-native-reanimated' +import {useHaptics} from '#/lib/haptics' import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' import {atoms as a, native, platform, useTheme} from '#/alf' import { @@ -142,6 +143,7 @@ export function Item({ onPress: onPressProp, ...props }: {value: string; children: React.ReactNode} & Omit) { + const playHaptic = useHaptics() const [position, setPosition] = useState<{x: number; width: number} | null>( null, ) @@ -174,10 +176,11 @@ export function Item({ const onPress = useCallback( (evt: any) => { + playHaptic('Light') ctx.onSelectValue(value, position) onPressProp?.(evt) }, - [ctx, value, position, onPressProp], + [ctx, value, position, onPressProp, playHaptic], ) return ( From ef97a20c23673ca4a6c1da7e08a172044d07b31e Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Mon, 17 Nov 2025 16:27:00 +0200 Subject: [PATCH 15/20] Fix password autofill on iOS (#9397) * fix password autofill on iOS * update autocomplete props, rm textContentType --- src/screens/Login/LoginForm.tsx | 21 +++++++++++++++---- src/screens/Login/SetNewPasswordForm.tsx | 4 ++-- .../components/ChangePasswordDialog.tsx | 1 + 3 files changed, 20 insertions(+), 6 deletions(-) diff --git a/src/screens/Login/LoginForm.tsx b/src/screens/Login/LoginForm.tsx index b6a528e42b..212059986e 100644 --- a/src/screens/Login/LoginForm.tsx +++ b/src/screens/Login/LoginForm.tsx @@ -18,10 +18,11 @@ import {isNetworkError} from '#/lib/strings/errors' import {cleanError} from '#/lib/strings/errors' import {createFullHandle} from '#/lib/strings/handles' import {logger} from '#/logger' +import {isIOS} from '#/platform/detection' import {useSetHasCheckedForStarterPack} from '#/state/preferences/used-starter-packs' import {useSessionApi} from '#/state/session' import {useLoggedOutViewControls} from '#/state/shell/logged-out' -import {atoms as a, useTheme} from '#/alf' +import {atoms as a, ios, useTheme} from '#/alf' import {Button, ButtonIcon, ButtonText} from '#/components/Button' import {FormError} from '#/components/forms/FormError' import {HostingProvider} from '#/components/forms/HostingProvider' @@ -69,7 +70,9 @@ export const LoginForm = ({ const identifierValueRef = useRef(initialHandle || '') const passwordValueRef = useRef('') const authFactorTokenValueRef = useRef('') + const identifierRef = useRef(null) const passwordRef = useRef(null) + const hasFocusedOnce = useRef(false) const {_} = useLingui() const {login} = useSessionApi() const requestNotificationsPermission = useRequestNotificationsPermission() @@ -198,9 +201,10 @@ export const LoginForm = ({ { passwordValueRef.current = v @@ -241,6 +244,16 @@ export const LoginForm = ({ blurOnSubmit={false} // HACK: https://github.com/facebook/react-native/issues/21911#issuecomment-558343069 Keyboard blur behavior is now handled in onSubmitEditing editable={!isProcessing} accessibilityHint={_(msg`Enter your password`)} + onLayout={ios(() => { + if (hasFocusedOnce.current) return + hasFocusedOnce.current = true + // kinda dumb, but if we use `autoFocus` to focus + // the username input, it happens before the password + // input gets rendered. this breaks the password autofill + // on iOS (it only does the username part). delaying + // it until both inputs are rendered fixes the autofill -sfn + identifierRef.current?.focus() + })} />