diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index 27cce9c5795ba..8ef88e43be319 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -15,7 +15,7 @@ import { parseThreadKey, threadKey } from "@t3tools/client-runtime/state/entitie import { createThreadVisitTracker } from "@t3tools/client-runtime/state/thread-visits"; import { useChildThreadInputs, useThreadReportedModelSelection } from "../../state/entities"; import { UsageLimitRecoveryCard } from "./UsageLimitRecoveryCard"; -import { useFocusEffect, useNavigation } from "@react-navigation/native"; +import { useFocusEffect, useIsFocused, useNavigation } from "@react-navigation/native"; import { effectiveSnoozed, threadWokeAt, @@ -208,6 +208,8 @@ import type { ThreadContentPresentation } from "./threadContentPresentation"; import { resolveThreadFeedSubmissionAnchor } from "./thread-feed-live-follow"; import { useOpenLinkedThread } from "./useOpenLinkedThread"; import { useGlobalVoiceInput } from "../voice-input/VoiceInputProvider"; +import { useEnvironmentShellReadiness } from "../../state/shell"; +import { useSettledThreadView } from "./useSettledThreadView"; import type { OrchestrationV2ProviderSession } from "@t3tools/contracts"; export interface ThreadDetailScreenProps { @@ -1217,14 +1219,18 @@ function ThreadDetailBody( } }; const showContent = props.showContent ?? true; - // Showing the thread counts as viewing it, before its live shell arrives too. - useAtomMount( - threadVisitTracker( - showContent && !props.draftMode - ? { environmentId: props.environmentId, threadId: props.selectedThread.id } - : null, - ), + // Showing the thread counts as viewing it; after a resume or sync, only once it settles. + const shellSynchronizing = + useEnvironmentShellReadiness(props.environmentId).status === "synchronizing"; + const covered = !useIsFocused(); + const viewedThread = useSettledThreadView( + showContent && !props.draftMode + ? { environmentId: props.environmentId, threadId: props.selectedThread.id } + : null, + shellSynchronizing, + covered, ); + useAtomMount(threadVisitTracker(viewedThread)); const layoutVariant = props.layoutVariant ?? "compact"; const isSplitLayout = layoutVariant !== "compact"; const contentMaxWidth = diff --git a/apps/mobile/src/features/threads/threadViewInterruption.test.ts b/apps/mobile/src/features/threads/threadViewInterruption.test.ts new file mode 100644 index 0000000000000..91d34b1622e27 --- /dev/null +++ b/apps/mobile/src/features/threads/threadViewInterruption.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { nextThreadViewState } from "./threadViewInterruption"; + +describe("nextThreadViewState", () => { + it("counts a thread opened while synced and in the foreground at once", () => { + expect(nextThreadViewState(null, "env:a", true)).toEqual({ key: "env:a", interrupted: false }); + }); + + it("waits for a thread opened while syncing or in the background", () => { + expect(nextThreadViewState(null, "env:a", false)).toEqual({ key: "env:a", interrupted: true }); + }); + + it("keeps waiting after a resume or sync interrupts the view, until another thread opens", () => { + const opened = nextThreadViewState(null, "env:a", true); + const hidden = nextThreadViewState(opened, "env:a", false); + expect(hidden).toEqual({ key: "env:a", interrupted: true }); + expect(nextThreadViewState(hidden, "env:a", true)).toBe(hidden); + expect(nextThreadViewState(hidden, "env:b", true)).toEqual({ + key: "env:b", + interrupted: false, + }); + }); + + it("returns the same state while nothing changes", () => { + const opened = nextThreadViewState(null, "env:a", true); + expect(nextThreadViewState(opened, "env:a", true)).toBe(opened); + expect(nextThreadViewState(opened, null, true)).toBeNull(); + }); +}); diff --git a/apps/mobile/src/features/threads/threadViewInterruption.ts b/apps/mobile/src/features/threads/threadViewInterruption.ts new file mode 100644 index 0000000000000..ef4c8113c478d --- /dev/null +++ b/apps/mobile/src/features/threads/threadViewInterruption.ts @@ -0,0 +1,17 @@ +export interface ThreadViewState { + readonly key: string; + /** The view began, or has since been, hidden or syncing, so reading waits for it to settle. */ + readonly interrupted: boolean; +} + +/** The next view state for the shown thread; returns `previous` itself when nothing changed. */ +export function nextThreadViewState( + previous: ThreadViewState | null, + key: string | null, + visible: boolean, +): ThreadViewState | null { + if (key === null) return null; + if (previous?.key !== key) return { key, interrupted: !visible }; + if (!visible && !previous.interrupted) return { key, interrupted: true }; + return previous; +} diff --git a/apps/mobile/src/features/threads/useSettledThreadView.ts b/apps/mobile/src/features/threads/useSettledThreadView.ts new file mode 100644 index 0000000000000..f1e77bc2e5308 --- /dev/null +++ b/apps/mobile/src/features/threads/useSettledThreadView.ts @@ -0,0 +1,44 @@ +import type { ScopedThreadRef } from "@t3tools/contracts"; +import { useEffect, useMemo, useState } from "react"; + +import { useAppActive } from "../../lib/appState"; +import { scopedThreadKey } from "../../lib/scopedEntities"; +import { nextThreadViewState, type ThreadViewState } from "./threadViewInterruption"; + +/** How long a thread shows, synced and in the foreground, after a resume or sync before it counts as read. */ +const THREAD_READ_DWELL_MS = 2_000; + +/** + * The thread while it counts as viewed. A synced open counts at once; after a resume or sync a + * finished run may land unseen, so the view counts only once the thread has shown, synced, in the + * foreground and uncovered, for a moment. Backing out sooner keeps it unread. + */ +export function useSettledThreadView( + ref: ScopedThreadRef | null, + synchronizing: boolean, + covered: boolean, +): ScopedThreadRef | null { + const active = useAppActive(); + const key = ref === null ? null : scopedThreadKey(ref.environmentId, ref.threadId); + const visible = active && !synchronizing && key !== null; + const [opened, setOpened] = useState(null); + const next = nextThreadViewState(opened, key, visible); + if (next !== opened) setOpened(next); + const interrupted = next?.interrupted ?? false; + // A fresh view each time the thread shows again, so the wait restarts even if no timer ran + // while the app was in the background or the thread was covered. + const view = useMemo( + () => (visible && !covered && key !== null ? { key } : null), + [visible, covered, key], + ); + const [settled, setSettled] = useState<{ readonly key: string } | null>(null); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- times how long a resumed or synced thread has shown in the foreground + useEffect(() => { + if (view === null || !interrupted) return; + const timer = setTimeout(() => setSettled(view), THREAD_READ_DWELL_MS); + return () => clearTimeout(timer); + }, [view, interrupted]); + if (!visible) return null; + if (!interrupted) return ref; + return view !== null && settled === view ? ref : null; +}