diff --git a/apps/mobile/src/components/ControlPill.tsx b/apps/mobile/src/components/ControlPill.tsx index 587abcc06f5..abcfc7f7b80 100644 --- a/apps/mobile/src/components/ControlPill.tsx +++ b/apps/mobile/src/components/ControlPill.tsx @@ -6,6 +6,7 @@ import { type ComponentProps, type ReactElement, type ReactNode, + useRef, } from "react"; import { Platform, Pressable, useColorScheme, View } from "react-native"; import { useThemeColor } from "../lib/useThemeColor"; @@ -21,10 +22,31 @@ export function ControlPill(props: { readonly label?: string; readonly accessibilityLabel?: string; readonly onPress?: () => void; + readonly activateOnPressIn?: boolean; readonly variant?: "circle" | "pill" | "primary" | "danger"; readonly disabled?: boolean; + readonly className?: string; }) { const variant = props.variant ?? "circle"; + const activatedOnPressInRef = useRef(false); + + const handlePressIn = () => { + activatedOnPressInRef.current = true; + props.onPress?.(); + }; + const handlePressOut = () => { + // Pressability invokes onPressOut immediately before onPress on release. + // Defer the reset so onPress can identify the same physical gesture. + setTimeout(() => { + activatedOnPressInRef.current = false; + }, 0); + }; + const handlePress = () => { + if (activatedOnPressInRef.current) { + return; + } + props.onPress?.(); + }; const iconColor = useThemeColor("--color-icon"); const iconSubtle = useThemeColor("--color-icon-subtle"); @@ -54,6 +76,7 @@ export function ControlPill(props: { : variant === "danger" ? "bg-danger" : "bg-subtle", + props.className, ); const labelClassName = cn( "text-center text-xs font-t3-bold", @@ -68,7 +91,9 @@ export function ControlPill(props: { diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index 3d83c837500..c0675b84592 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -21,6 +21,7 @@ import { KeyboardController, KeyboardStickyView } from "react-native-keyboard-co import Animated, { FadeInDown, FadeOut } from "react-native-reanimated"; import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { ControlPill } from "../../components/ControlPill"; import type { ComposerEditorHandle } from "../../components/ComposerEditor"; import type { StatusTone } from "../../components/StatusPill"; import type { DraftComposerImageAttachment } from "../../lib/composerImages"; @@ -183,6 +184,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread const lastScrolledAnchorMessageIdRef = useRef(null); const [composerExpanded, setComposerExpanded] = useState(false); const [anchorMessageId, setAnchorMessageId] = useState(null); + const [endFollowEnabled, setEndFollowEnabled] = useState(true); const composerBottomInset = composerExpanded ? 0 : Math.max(insets.bottom, 12); const contentPresentationKind = props.contentPresentation.kind; // The raw sync status enters "synchronizing" on every full fetch, cached or @@ -241,6 +243,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread useEffect(() => { setAnchorMessageId(null); lastScrolledAnchorMessageIdRef.current = null; + setEndFollowEnabled(true); freeze.set(false); }, [freeze, selectedThreadKey]); @@ -312,6 +315,15 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread composerEditorRef.current?.blur(); }, []); + const handleScrollToEnd = useCallback(() => { + void Haptics.selectionAsync(); + void scrollMessageToEnd({ animated: true, closeKeyboard: false }).catch(() => { + freeze.set(false); + }); + }, [freeze, scrollMessageToEnd]); + + const showScrollToEndButton = contentPresentationKind === "ready" && !endFollowEnabled; + const handleFeedTouchStart = useCallback((event: GestureResponderEvent) => { feedTouchStartRef.current = { pageX: event.nativeEvent.pageX, @@ -372,6 +384,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread layoutVariant={layoutVariant} usesAutomaticContentInsets={props.usesAutomaticContentInsets} onHeaderMaterialVisibilityChange={props.onHeaderMaterialVisibilityChange} + onEndFollowEnabledChange={setEndFollowEnabled} skills={selectedProviderSkills} loadEarlier={props.loadEarlier ?? null} /> @@ -390,6 +403,22 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread list's bottom inset, so any padding above the pill/composer pushes the resting content floor up by the same amount. */} + {showScrollToEndButton ? ( + + + + ) : null} {props.activePendingApproval || props.activePendingUserInput ? ( void; + readonly onEndFollowEnabledChange?: (enabled: boolean) => void; readonly skills?: ReadonlyArray; /** Non-null when older turns exist beyond the loaded window. */ readonly loadEarlier?: { @@ -1331,13 +1332,17 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { // momentum; only motion inside a session can break follow, so MVCP // compensations and programmatic scrolls never strand a follower. const userScrollSessionRef = useRef(false); - const setEndFollow = useCallback((enabled: boolean) => { - if (endFollowEnabledRef.current === enabled) { - return; - } - endFollowEnabledRef.current = enabled; - setEndFollowEnabled(enabled); - }, []); + const setEndFollow = useCallback( + (enabled: boolean) => { + if (endFollowEnabledRef.current === enabled) { + return; + } + endFollowEnabledRef.current = enabled; + setEndFollowEnabled(enabled); + props.onEndFollowEnabledChange?.(enabled); + }, + [props.onEndFollowEnabledChange], + ); const [interactionState, setInteractionState] = useState<{ readonly copiedRowId: string | null; readonly expandedWorkGroups: Record; diff --git a/docs/user/threads.md b/docs/user/threads.md new file mode 100644 index 00000000000..5d71264b5ed --- /dev/null +++ b/docs/user/threads.md @@ -0,0 +1,5 @@ +# Reading threads + +On mobile, scrolling away from the latest activity reveals a down-arrow button above the message +composer. Tap it to return to the end of the thread. The button disappears when the latest activity +is visible again.