Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
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
56 changes: 56 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 All @@ -40,6 +41,11 @@ import {
ThreadComposer,
} from "./ThreadComposer";
import { ThreadFeed } from "./ThreadFeed";
import {
initialThreadEndFollowState,
reduceThreadEndFollowState,
threadEndFollowEnabled,
} from "./thread-end-follow-state";
import type { ThreadContentPresentation } from "./threadContentPresentation";

export interface ThreadDetailScreenProps {
Expand Down Expand Up @@ -183,6 +189,10 @@ 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 [endFollowState, setEndFollowState] = useState(() =>
initialThreadEndFollowState(selectedThreadKey),
);
const endFollowEnabled = threadEndFollowEnabled(endFollowState, selectedThreadKey);
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 @@ -312,6 +322,34 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
composerEditorRef.current?.blur();
}, []);

const handleEndFollowEnabledChange = useCallback(
(enabled: boolean) => {
setEndFollowState((current) =>
reduceThreadEndFollowState(current, {
type: "observed",
threadKey: selectedThreadKey,
enabled,
}),
);
},
[selectedThreadKey],
);

const handleScrollToEnd = useCallback(() => {
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
setEndFollowState((current) =>
reduceThreadEndFollowState(current, {
type: "scrollToEnd",
threadKey: selectedThreadKey,
}),
);
void Haptics.selectionAsync();
void scrollMessageToEnd({ animated: true, closeKeyboard: false }).catch(() => {
freeze.set(false);
});
}, [freeze, scrollMessageToEnd, selectedThreadKey]);

const showScrollToEndButton = contentPresentationKind === "ready" && !endFollowEnabled;

const handleFeedTouchStart = useCallback((event: GestureResponderEvent) => {
feedTouchStartRef.current = {
pageX: event.nativeEvent.pageX,
Expand Down Expand Up @@ -372,6 +410,8 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
layoutVariant={layoutVariant}
usesAutomaticContentInsets={props.usesAutomaticContentInsets}
onHeaderMaterialVisibilityChange={props.onHeaderMaterialVisibilityChange}
endFollowEnabled={endFollowEnabled}
onEndFollowEnabledChange={handleEndFollowEnabledChange}
skills={selectedProviderSkills}
loadEarlier={props.loadEarlier ?? null}
/>
Expand All @@ -390,6 +430,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
24 changes: 15 additions & 9 deletions apps/mobile/src/features/threads/ThreadFeed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,8 @@ export interface ThreadFeedProps {
readonly layoutVariant?: LayoutVariant;
readonly usesAutomaticContentInsets?: boolean;
readonly onHeaderMaterialVisibilityChange?: (visible: boolean) => void;
readonly endFollowEnabled: boolean;
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 @@ -1325,19 +1327,23 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
// yanked users off history they were reading every time a stream chunk grew
// a row. Follow breaks when the user scrolls up and away, and re-arms only
// when the list actually returns to the end (or on send / thread switch).
const [endFollowEnabled, setEndFollowEnabled] = useState(true);
const endFollowEnabledRef = useRef(true);
const endFollowEnabled = props.endFollowEnabled;
const endFollowEnabledRef = useRef(endFollowEnabled);
endFollowEnabledRef.current = endFollowEnabled;
// A "user scroll session" spans from drag start through the end of its
// 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;
props.onEndFollowEnabledChange(enabled);
},
[props.onEndFollowEnabledChange],
);
const [interactionState, setInteractionState] = useState<{
readonly copiedRowId: string | null;
readonly expandedWorkGroups: Record<string, boolean>;
Expand Down
32 changes: 32 additions & 0 deletions apps/mobile/src/features/threads/thread-end-follow-state.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { describe, expect, it } from "@effect/vitest";

import {
initialThreadEndFollowState,
reduceThreadEndFollowState,
threadEndFollowEnabled,
} from "./thread-end-follow-state";

describe("thread end-follow state", () => {
it("starts a newly selected thread with end-follow enabled", () => {
const previousThreadState = reduceThreadEndFollowState(
initialThreadEndFollowState("thread-a"),
{ type: "observed", threadKey: "thread-a", enabled: false },
);

expect(threadEndFollowEnabled(previousThreadState, "thread-b")).toBe(true);
});

it("re-arms end-follow before scrolling to the end", () => {
const scrolledAwayState = reduceThreadEndFollowState(initialThreadEndFollowState("thread-a"), {
type: "observed",
threadKey: "thread-a",
enabled: false,
});
const scrollToEndState = reduceThreadEndFollowState(scrolledAwayState, {
type: "scrollToEnd",
threadKey: "thread-a",
});

expect(threadEndFollowEnabled(scrollToEndState, "thread-a")).toBe(true);
});
});
30 changes: 30 additions & 0 deletions apps/mobile/src/features/threads/thread-end-follow-state.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
export type ThreadEndFollowState = {
readonly threadKey: string;
readonly enabled: boolean;
};

export type ThreadEndFollowEvent =
| {
readonly type: "observed";
readonly threadKey: string;
readonly enabled: boolean;
}
| { readonly type: "scrollToEnd"; readonly threadKey: string };

export function initialThreadEndFollowState(threadKey: string): ThreadEndFollowState {
return { threadKey, enabled: true };
}

export function threadEndFollowEnabled(state: ThreadEndFollowState, threadKey: string): boolean {
return state.threadKey === threadKey ? state.enabled : true;
}

export function reduceThreadEndFollowState(
state: ThreadEndFollowState,
event: ThreadEndFollowEvent,
): ThreadEndFollowState {
if (event.type === "observed") {
return { threadKey: event.threadKey, enabled: event.enabled };
}
return { threadKey: event.threadKey, enabled: true };
}
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