Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 26 additions & 1 deletion apps/mobile/src/components/ControlPill.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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");
Expand Down Expand Up @@ -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",
Expand All @@ -68,7 +91,9 @@ export function ControlPill(props: {
<Pressable
accessibilityLabel={props.accessibilityLabel ?? props.label}
accessibilityRole="button"
onPress={props.onPress}
onPress={props.activateOnPressIn ? handlePress : props.onPress}
onPressIn={props.activateOnPressIn ? handlePressIn : undefined}
onPressOut={props.activateOnPressIn ? handlePressOut : undefined}
disabled={props.disabled}
className={containerClassName}
>
Expand Down
29 changes: 29 additions & 0 deletions apps/mobile/src/features/threads/ThreadDetailScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -183,6 +184,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
const lastScrolledAnchorMessageIdRef = useRef<MessageId | null>(null);
const [composerExpanded, setComposerExpanded] = useState(false);
const [anchorMessageId, setAnchorMessageId] = useState<MessageId | null>(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
Expand Down Expand Up @@ -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]);

Expand Down Expand Up @@ -312,6 +315,15 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
composerEditorRef.current?.blur();
}, []);

const handleScrollToEnd = useCallback(() => {
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
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,
Expand Down Expand Up @@ -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}
/>
Expand All @@ -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. */}
<View ref={composerOverlayRef} onLayout={onComposerLayout} className="w-full">
{showScrollToEndButton ? (
<Animated.View
pointerEvents="box-none"
className="absolute -top-28 left-0 right-0 z-20 items-center"
entering={FadeInDown.duration(160)}
exiting={FadeOut.duration(100)}
>
<ControlPill
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
accessibilityLabel="Scroll to end"
activateOnPressIn
className="border border-border bg-card shadow-md shadow-black/10"
icon={{ ios: "chevron.down", android: "keyboard_arrow_down" }}
onPress={handleScrollToEnd}
/>
</Animated.View>
) : null}
<View className="w-full self-center" style={{ maxWidth: contentMaxWidth }}>
{props.activePendingApproval || props.activePendingUserInput ? (
<Animated.View
Expand Down
19 changes: 12 additions & 7 deletions apps/mobile/src/features/threads/ThreadFeed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,7 @@ export interface ThreadFeedProps {
readonly layoutVariant?: LayoutVariant;
readonly usesAutomaticContentInsets?: boolean;
readonly onHeaderMaterialVisibilityChange?: (visible: boolean) => void;
readonly onEndFollowEnabledChange?: (enabled: boolean) => void;
readonly skills?: ReadonlyArray<SelectableMarkdownSkill>;
/** Non-null when older turns exist beyond the loaded window. */
readonly loadEarlier?: {
Expand Down Expand Up @@ -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<string, boolean>;
Expand Down
5 changes: 5 additions & 0 deletions docs/user/threads.md
Original file line number Diff line number Diff line change
@@ -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.
Loading