Skip to content
Open
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
22 changes: 14 additions & 8 deletions apps/mobile/src/features/threads/ThreadDetailScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 }
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
: null,
shellSynchronizing,
covered,
);
useAtomMount(threadVisitTracker(viewedThread));
const layoutVariant = props.layoutVariant ?? "compact";
const isSplitLayout = layoutVariant !== "compact";
const contentMaxWidth =
Expand Down
30 changes: 30 additions & 0 deletions apps/mobile/src/features/threads/threadViewInterruption.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
17 changes: 17 additions & 0 deletions apps/mobile/src/features/threads/threadViewInterruption.ts
Original file line number Diff line number Diff line change
@@ -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;
}
44 changes: 44 additions & 0 deletions apps/mobile/src/features/threads/useSettledThreadView.ts
Original file line number Diff line number Diff line change
@@ -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<ThreadViewState | null>(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;
}
Loading