diff --git a/desktop/src/features/channels/readState/readStateManager.test.mjs b/desktop/src/features/channels/readState/readStateManager.test.mjs index c46654f32e..247d8c6716 100644 --- a/desktop/src/features/channels/readState/readStateManager.test.mjs +++ b/desktop/src/features/channels/readState/readStateManager.test.mjs @@ -909,3 +909,38 @@ test("rememberPublishedId_evictsOldestBeyondCap", () => { mgr.destroy(); }); + +test("confirmed thread frontier survives restart and keeps later replies unread", () => { + const storage = makeLocalStorage(); + globalThis.window.localStorage = storage; + const { timers, restore } = withFakeTimers(); + const pubkey = "7".repeat(64); + const confirmedAt = Math.floor(Date.now() / 1_000); + const first = new ReadStateManager(pubkey, makeFakeRelay()); + + try { + first.markContextRead(threadKey, confirmedAt); + timers.runAll(); + first.destroy(); + + const restarted = new ReadStateManager(pubkey, makeFakeRelay()); + restarted.setContextParentResolver(channelResolver); + try { + restarted.hydrateFromLocalStorage(); + assert.equal(restarted.getEffectiveTimestamp(threadKey), confirmedAt); + assert.equal( + confirmedAt > restarted.getEffectiveTimestamp(threadKey), + false, + ); + assert.equal( + confirmedAt + 1 > restarted.getEffectiveTimestamp(threadKey), + true, + ); + } finally { + restarted.destroy(); + } + } finally { + first.destroy(); + restore(); + } +}); diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx index bccc163ed4..e66600e407 100644 --- a/desktop/src/features/channels/ui/ChannelPane.tsx +++ b/desktop/src/features/channels/ui/ChannelPane.tsx @@ -128,9 +128,11 @@ export const ChannelPane = React.memo(function ChannelPane({ onSendToChannel, onSendVideoReviewComment, onSendThreadReply, + onThreadAtBottomChange, onThreadScrollTargetResolved, onThreadPanelResizeStart, onTargetReached, + onTimelineAtBottomChange, onToggleReaction, onUnfollowThread, unfollowThreadById, @@ -627,6 +629,7 @@ export const ChannelPane = React.memo(function ChannelPane({ onSendVideoReviewComment={ activeChannel?.archivedAt ? undefined : onSendVideoReviewComment } + onAtBottomChange={onTimelineAtBottomChange} onTargetReached={onTargetReached} onToggleReaction={onToggleReaction} targetMessageId={targetMessageId} @@ -821,6 +824,7 @@ export const ChannelPane = React.memo(function ChannelPane({ isComposerDisabled ? undefined : onSendToChannel } onScrollTargetResolved={() => resolveScrollTarget()} + onAtBottomChange={onThreadAtBottomChange} onScrollTargetSettled={resolveScrollTarget} onToggleReaction={onToggleReaction} onUnfollowThread={onUnfollowThread} diff --git a/desktop/src/features/channels/ui/ChannelPane.types.ts b/desktop/src/features/channels/ui/ChannelPane.types.ts index 760ef58073..acb770df63 100644 --- a/desktop/src/features/channels/ui/ChannelPane.types.ts +++ b/desktop/src/features/channels/ui/ChannelPane.types.ts @@ -139,11 +139,13 @@ export type ChannelPaneProps = { } | null, ) => Promise; onTargetReached?: (messageId: string) => void; + onTimelineAtBottomChange?: (atBottom: boolean) => void; onToggleReaction?: ( message: TimelineMessage, emoji: string, remove: boolean, ) => Promise; + onThreadAtBottomChange?: (atBottom: boolean) => void; onThreadScrollTargetResolved: () => void; onThreadPanelResizeStart: ( event: React.PointerEvent, diff --git a/desktop/src/features/channels/ui/ChannelScreen.tsx b/desktop/src/features/channels/ui/ChannelScreen.tsx index 6254afd8c7..5d5db71be4 100644 --- a/desktop/src/features/channels/ui/ChannelScreen.tsx +++ b/desktop/src/features/channels/ui/ChannelScreen.tsx @@ -77,6 +77,7 @@ import { useChannelActivityTyping } from "./useChannelActivityTyping"; import { useChannelAgentSessions } from "./useChannelAgentSessions"; import { useMessageProfiles } from "./useMessageProfiles"; import { useChannelPanelHistoryState } from "./useChannelPanelHistoryState"; +import { useScopedAtBottom } from "./useScopedAtBottom"; import { useChannelProfilePanel } from "./useChannelProfilePanel"; import { useChannelRouteTarget } from "./useChannelRouteTarget"; import { useChannelOpenReadState } from "./useChannelOpenReadState"; @@ -101,6 +102,7 @@ export function ChannelScreen({ clearChannelUnreadSource, markChannelRead, markChannelUnread, + markThreadRead, getChannelReadAt, getMessageReadAt, markMessageRead, @@ -163,6 +165,8 @@ export function ChannelScreen({ const mainInsetRef = useMainInsetRef(); const currentPubkey = currentIdentity?.pubkey; const activeChannelId = activeChannel?.id ?? null; + const [timelineIsAtBottom, handleTimelineAtBottomChange] = + useScopedAtBottom(activeChannelId); const isHuddleTranscript = useIsHuddleTranscript(activeChannelId); const relaySelfPubkey = useRelaySelfQuery(activeChannel !== null).data; const effectiveOpenThreadHeadId = useHuddleThreadIsolation({ @@ -171,6 +175,9 @@ export function ChannelScreen({ openThreadHeadId, optimisticOpenThreadHeadId, }); + const [threadIsAtBottom, handleThreadAtBottomChange] = useScopedAtBottom( + effectiveOpenThreadHeadId, + ); const isNotifiedForEffectiveThread = effectiveOpenThreadHeadId != null ? isNotifiedForThread(effectiveOpenThreadHeadId) @@ -214,6 +221,7 @@ export function ChannelScreen({ activeChannelId, activeChannel?.isMember, activeReadAt, + timelineIsAtBottom, ); React.useEffect(() => { if (!activeChannelId) { @@ -449,6 +457,7 @@ export function ChannelScreen({ threadReplyTargetId, expandedThreadReplyIds, openThreadMessages: threadPanelData.visibleReplies, + openThreadIsAtBottom: threadIsAtBottom, clearChannelUnreadSource, getChannelReadAt, getMessageReadAt, @@ -485,6 +494,7 @@ export function ChannelScreen({ getFirstReplyIdForMessage, getReplyDescendantIdsForMessage, markRevealedRepliesRead, + markThreadRead, profiles: messageProfiles, recordThreadInteraction, openThreadHeadId: effectiveOpenThreadHeadId, @@ -922,11 +932,13 @@ export function ChannelScreen({ onSendToChannel={handleSendToChannel} onSendVideoReviewComment={effectiveSendVideoReviewComment} onSendThreadReply={handleSendThreadReply} + onThreadAtBottomChange={handleThreadAtBottomChange} onThreadScrollTargetResolved={ handleThreadScrollTargetResolved } onThreadPanelResizeStart={handleThreadPanelResizeStart} onTargetReached={handleTargetReached} + onTimelineAtBottomChange={handleTimelineAtBottomChange} onToggleReaction={effectiveToggleReaction} openAgentSessionChannelId={openAgentSessionChannelId} openAgentSessionPubkey={openAgentSessionPubkey} diff --git a/desktop/src/features/channels/ui/useChannelOpenReadState.ts b/desktop/src/features/channels/ui/useChannelOpenReadState.ts index ff927c00bb..356b28a1e6 100644 --- a/desktop/src/features/channels/ui/useChannelOpenReadState.ts +++ b/desktop/src/features/channels/ui/useChannelOpenReadState.ts @@ -4,6 +4,8 @@ import { useAppShell } from "@/app/AppShellContext"; import { isThreadReply } from "@/features/messages/lib/threading"; import type { FeedItem } from "@/shared/api/types"; +import { useVisibleChannelReadState } from "./useVisibleChannelReadState"; + /** * Inbox overrides for top-level rows are consumed by opening the channel. * Thread-reply overrides intentionally remain until their thread is read. @@ -21,25 +23,37 @@ export function useChannelOpenReadState( activeChannelId: string | null, isChannelMember: boolean | undefined, activeReadAt: string | null, + isAtBottom: boolean, ) { const { feedItemState, locallyUnreadFeedItems, markChannelRead } = useAppShell(); + const markVisibleChannelRead = React.useCallback( + (channelId: string, readAt: string | null) => + markChannelRead(channelId, readAt, { topLevelOnly: true }), + [markChannelRead], + ); + useVisibleChannelReadState({ + channelId: activeChannelId, + isAtBottom, + isMember: isChannelMember, + markChannelRead: markVisibleChannelRead, + readAt: activeReadAt, + }); + React.useEffect(() => { - if (!activeChannelId || isChannelMember === false) return; + if (!activeChannelId || isChannelMember === false || !isAtBottom) return; for (const itemId of getTopLevelInboxUnreadOverrideIds( locallyUnreadFeedItems, activeChannelId, )) { feedItemState.undoUnread(itemId); } - markChannelRead(activeChannelId, activeReadAt, { topLevelOnly: true }); }, [ activeChannelId, - activeReadAt, feedItemState.undoUnread, + isAtBottom, isChannelMember, locallyUnreadFeedItems, - markChannelRead, ]); } diff --git a/desktop/src/features/channels/ui/useChannelUnreadState.ts b/desktop/src/features/channels/ui/useChannelUnreadState.ts index 884aa02d8e..958338aa06 100644 --- a/desktop/src/features/channels/ui/useChannelUnreadState.ts +++ b/desktop/src/features/channels/ui/useChannelUnreadState.ts @@ -26,6 +26,7 @@ import type { TimelineMessage } from "@/features/messages/types"; import { isConversationalUnreadKind } from "@/shared/constants/kinds"; import { useWelcomeInitialUnreadSuppression } from "./useWelcomeInitialUnreadSuppression"; +import { useVisibleThreadReadState } from "./useVisibleThreadReadState"; type UseChannelUnreadStateOptions = { activeChannelId: string | null; @@ -35,6 +36,7 @@ type UseChannelUnreadStateOptions = { threadReplyTargetId: string | null; expandedThreadReplyIds: ReadonlySet; openThreadMessages?: MainTimelineEntry[]; + openThreadIsAtBottom: boolean; getChannelReadAt: (channelId: string) => number | null; getMessageReadAt: (messageId: string) => number | null; clearChannelUnreadSource: ( @@ -67,6 +69,7 @@ export function useChannelUnreadState({ threadReplyTargetId, expandedThreadReplyIds, openThreadMessages, + openThreadIsAtBottom, getChannelReadAt, getMessageReadAt, clearChannelUnreadSource, @@ -272,13 +275,13 @@ export function useChannelUnreadState({ // deliberate reversal of #1118's whole-subtree-on-open). Each revealed reply // gets its own msg: marker advanced to its createdAt; a NEWER reply // re-raises the badge because the predicate is strictly createdAt > read. - React.useEffect(() => { - if (!openThreadHeadId) return; - if (isThreadMuted(openThreadHeadId)) return; - for (const entry of threadMessages) { - markMessageRead(entry.message.id, entry.message.createdAt); - } - }, [openThreadHeadId, threadMessages, markMessageRead, isThreadMuted]); + useVisibleThreadReadState({ + isAtBottom: openThreadIsAtBottom, + isMuted: openThreadHeadId ? isThreadMuted(openThreadHeadId) : false, + markMessageRead, + messages: threadMessages, + threadRootId: openThreadHeadId, + }); // In-thread "New" divider position. Reads the open-time snapshot (frozen // before the mark-read effect above), so the divider does not collapse the // instant open marks the revealed replies read. A reply absent from the diff --git a/desktop/src/features/channels/ui/useScopedAtBottom.ts b/desktop/src/features/channels/ui/useScopedAtBottom.ts new file mode 100644 index 0000000000..6ba553d6a4 --- /dev/null +++ b/desktop/src/features/channels/ui/useScopedAtBottom.ts @@ -0,0 +1,15 @@ +import * as React from "react"; + +export function useScopedAtBottom( + scopeId: string | null, +): [boolean, (isAtBottom: boolean) => void] { + const [state, setState] = React.useState({ + isAtBottom: false, + scopeId: null as string | null, + }); + const onAtBottomChange = React.useCallback( + (isAtBottom: boolean) => setState({ isAtBottom, scopeId }), + [scopeId], + ); + return [state.scopeId === scopeId && state.isAtBottom, onAtBottomChange]; +} diff --git a/desktop/src/features/channels/ui/useVisibleChannelReadState.test.mjs b/desktop/src/features/channels/ui/useVisibleChannelReadState.test.mjs new file mode 100644 index 0000000000..d8a34a376c --- /dev/null +++ b/desktop/src/features/channels/ui/useVisibleChannelReadState.test.mjs @@ -0,0 +1,75 @@ +import assert from "node:assert/strict"; +import { after, afterEach, before, test } from "node:test"; + +import { JSDOM } from "jsdom"; + +const dom = new JSDOM("", { + url: "http://localhost", +}); + +before(() => { + Object.assign(globalThis, { + document: dom.window.document, + HTMLElement: dom.window.HTMLElement, + IS_REACT_ACT_ENVIRONMENT: true, + window: dom.window, + }); +}); + +afterEach(async () => { + const { cleanup } = await import("@testing-library/react"); + cleanup(); +}); + +after(() => dom.window.close()); + +async function renderReadState(initialProps) { + const { renderHook } = await import("@testing-library/react"); + const { useVisibleChannelReadState } = await import( + "./useVisibleChannelReadState.ts" + ); + return renderHook((props) => useVisibleChannelReadState(props), { + initialProps, + }); +} + +test("live channel arrivals advance read state at bottom", async () => { + const calls = []; + const markChannelRead = (id, readAt) => calls.push([id, readAt]); + const props = { + channelId: "channel-1", + isAtBottom: true, + isMember: true, + markChannelRead, + readAt: "1970-01-01T00:01:40.000Z", + }; + const view = await renderReadState(props); + calls.length = 0; + + view.rerender({ ...props, readAt: "1970-01-01T00:02:00.000Z" }); + + assert.deepEqual(calls, [["channel-1", "1970-01-01T00:02:00.000Z"]]); +}); + +test("scrolled-away channel arrivals remain unread until the reader returns to bottom", async () => { + const calls = []; + const markChannelRead = (id, readAt) => calls.push([id, readAt]); + const props = { + channelId: "channel-1", + isAtBottom: false, + isMember: true, + markChannelRead, + readAt: "1970-01-01T00:01:40.000Z", + }; + const view = await renderReadState(props); + + view.rerender({ ...props, readAt: "1970-01-01T00:02:00.000Z" }); + assert.deepEqual(calls, []); + + view.rerender({ + ...props, + isAtBottom: true, + readAt: "1970-01-01T00:02:00.000Z", + }); + assert.deepEqual(calls, [["channel-1", "1970-01-01T00:02:00.000Z"]]); +}); diff --git a/desktop/src/features/channels/ui/useVisibleChannelReadState.ts b/desktop/src/features/channels/ui/useVisibleChannelReadState.ts new file mode 100644 index 0000000000..2ce5a71d92 --- /dev/null +++ b/desktop/src/features/channels/ui/useVisibleChannelReadState.ts @@ -0,0 +1,20 @@ +import * as React from "react"; + +export function useVisibleChannelReadState({ + channelId, + isAtBottom, + isMember, + markChannelRead, + readAt, +}: { + channelId: string | null; + isAtBottom: boolean; + isMember: boolean | undefined; + markChannelRead: (channelId: string, readAt: string | null) => void; + readAt: string | null; +}): void { + React.useEffect(() => { + if (!channelId || isMember === false || !isAtBottom) return; + markChannelRead(channelId, readAt); + }, [channelId, isAtBottom, isMember, markChannelRead, readAt]); +} diff --git a/desktop/src/features/channels/ui/useVisibleThreadReadState.test.mjs b/desktop/src/features/channels/ui/useVisibleThreadReadState.test.mjs new file mode 100644 index 0000000000..d353dafe4e --- /dev/null +++ b/desktop/src/features/channels/ui/useVisibleThreadReadState.test.mjs @@ -0,0 +1,97 @@ +import assert from "node:assert/strict"; +import { after, afterEach, before, test } from "node:test"; + +import { JSDOM } from "jsdom"; + +const dom = new JSDOM("", { + url: "http://localhost", +}); + +before(() => { + Object.assign(globalThis, { + document: dom.window.document, + HTMLElement: dom.window.HTMLElement, + IS_REACT_ACT_ENVIRONMENT: true, + window: dom.window, + }); +}); + +afterEach(async () => { + const { cleanup } = await import("@testing-library/react"); + cleanup(); +}); + +after(() => dom.window.close()); + +function entry(id, createdAt) { + return { + index: 0, + message: { id, createdAt }, + summary: null, + }; +} + +async function renderReadState(initialProps) { + const { renderHook } = await import("@testing-library/react"); + const { useVisibleThreadReadState } = await import( + "./useVisibleThreadReadState.ts" + ); + return renderHook((props) => useVisibleThreadReadState(props), { + initialProps, + }); +} + +test("live thread arrivals advance read state at bottom", async () => { + const calls = []; + const markMessageRead = (id, timestamp) => calls.push([id, timestamp]); + const view = await renderReadState({ + isAtBottom: true, + isMuted: false, + markMessageRead, + messages: [entry("reply-1", 100)], + threadRootId: "root-1", + }); + calls.length = 0; + + view.rerender({ + isAtBottom: true, + isMuted: false, + markMessageRead, + messages: [entry("reply-1", 100), entry("reply-2", 120)], + threadRootId: "root-1", + }); + + assert.deepEqual(calls, [ + ["reply-1", 100], + ["reply-2", 120], + ]); +}); + +test("scrolled-away thread arrivals remain unread until the reader returns to bottom", async () => { + const calls = []; + const markMessageRead = (id, timestamp) => calls.push([id, timestamp]); + const props = { + isAtBottom: false, + isMuted: false, + markMessageRead, + messages: [entry("reply-1", 100)], + threadRootId: "root-1", + }; + const view = await renderReadState(props); + + view.rerender({ + ...props, + messages: [entry("reply-1", 100), entry("reply-2", 120)], + }); + assert.deepEqual(calls, []); + + view.rerender({ + ...props, + isAtBottom: true, + messages: [entry("reply-1", 100), entry("reply-2", 120)], + }); + assert.deepEqual(calls, [ + ["reply-1", 100], + ["reply-2", 120], + ]); +}); diff --git a/desktop/src/features/channels/ui/useVisibleThreadReadState.ts b/desktop/src/features/channels/ui/useVisibleThreadReadState.ts new file mode 100644 index 0000000000..09ee259d00 --- /dev/null +++ b/desktop/src/features/channels/ui/useVisibleThreadReadState.ts @@ -0,0 +1,24 @@ +import * as React from "react"; + +import type { MainTimelineEntry } from "@/features/messages/lib/threadPanel"; + +export function useVisibleThreadReadState({ + isAtBottom, + isMuted, + markMessageRead, + messages, + threadRootId, +}: { + isAtBottom: boolean; + isMuted: boolean; + markMessageRead: (messageId: string, timestamp: number) => void; + messages: MainTimelineEntry[]; + threadRootId: string | null; +}): void { + React.useEffect(() => { + if (!threadRootId || !isAtBottom || isMuted) return; + for (const entry of messages) { + markMessageRead(entry.message.id, entry.message.createdAt); + } + }, [isAtBottom, isMuted, markMessageRead, messages, threadRootId]); +} diff --git a/desktop/src/features/channels/useChannelPaneHandlers.ts b/desktop/src/features/channels/useChannelPaneHandlers.ts index f9c57f6656..444176c26e 100644 --- a/desktop/src/features/channels/useChannelPaneHandlers.ts +++ b/desktop/src/features/channels/useChannelPaneHandlers.ts @@ -9,6 +9,7 @@ import type { import { resolveThreadReplyTarget } from "@/features/messages/hooks"; import { getSendToChannelSemantics } from "@/features/messages/lib/sendToChannelSemantics"; import { summarizeThreadRoot } from "@/features/messages/lib/sentFromThread"; +import { sendThreadReplyWithReadBackstop } from "@/features/messages/lib/threadReadTransitions"; import type { TimelineMessage } from "@/features/messages/types"; import type { UserProfileLookup } from "@/features/profile/lib/identity"; @@ -28,6 +29,7 @@ export function useChannelPaneHandlers({ getFirstReplyIdForMessage, getReplyDescendantIdsForMessage, markRevealedRepliesRead, + markThreadRead, profiles, recordThreadInteraction, onOptimisticOpenThreadHeadIdChange, @@ -49,6 +51,7 @@ export function useChannelPaneHandlers({ getFirstReplyIdForMessage: (messageId: string) => string | null; getReplyDescendantIdsForMessage: (messageId: string) => string[]; markRevealedRepliesRead: (messageId: string) => void; + markThreadRead: (rootId: string, timestamp: number) => void; profiles: UserProfileLookup | undefined; recordThreadInteraction: (rootId: string) => void; onOptimisticOpenThreadHeadIdChange: React.Dispatch< @@ -360,14 +363,18 @@ export function useChannelPaneHandlers({ }); } - const sentMessage = await sendMutateRef.current({ - content, - mentionPubkeys, - parentEventId, - mediaTags, - channelId: channelId ?? undefined, - forceRest, - }); + const sentMessage = await sendThreadReplyWithReadBackstop( + sendMutateRef.current({ + content, + mentionPubkeys, + parentEventId, + mediaTags, + channelId: channelId ?? undefined, + forceRest, + }), + () => activeThreadHeadId, + markThreadRead, + ); // Only update thread UI state if the user is still viewing the same // thread. If they navigated away during the async send, don't disrupt @@ -380,6 +387,7 @@ export function useChannelPaneHandlers({ } }, [ + markThreadRead, setExpandedThreadReplyIds, setThreadReplyTargetId, setThreadScrollTargetId, diff --git a/desktop/src/features/home/ui/HomeView.tsx b/desktop/src/features/home/ui/HomeView.tsx index 893b3c309c..cc10fe4001 100644 --- a/desktop/src/features/home/ui/HomeView.tsx +++ b/desktop/src/features/home/ui/HomeView.tsx @@ -62,6 +62,7 @@ import { resolveUserLabel } from "@/features/profile/lib/identity"; import { useRemindLater } from "@/features/reminders/ui/RemindMeLaterProvider"; import { deleteMessage, sendChannelMessage } from "@/shared/api/tauri"; import type { Channel, HomeFeedResponse } from "@/shared/api/types"; +import { markConfirmedInboxReplyRead } from "@/features/messages/lib/threadReadTransitions"; import { KIND_REACTION } from "@/shared/constants/kinds"; import { topChromeInset } from "@/shared/layout/chromeLayout"; import { cn } from "@/shared/lib/cn"; @@ -73,7 +74,6 @@ import { useHistorySearchState } from "@/shared/hooks/useHistorySearchState"; import { ProfilePanelProvider } from "@/shared/context/ProfilePanelContext"; import { Button } from "@/shared/ui/button"; import { HomeMembersSidebarOverlay } from "./HomeMembersSidebarOverlay"; - const INBOX_SEARCH_KEYS = [ "item", "profile", @@ -263,7 +263,6 @@ export function HomeView({ selectedEventId, availableChannelIds, }); - const threadContextFeedItem = activeLatchedItem; // Derive the default composer parent from the active anchor's own tags so // that InboxDetailPane can recover the original reply target even when the @@ -859,6 +858,7 @@ export function HomeView({ emojiTags, mentionTags, ); + markConfirmedInboxReplyRead(result, markThreadRead); const authorPubkey = currentPubkey ?? itemToReply.item.pubkey; const reply: InboxReply = { authorLabel: currentPubkey diff --git a/desktop/src/features/messages/lib/threadReadTransitions.test.mjs b/desktop/src/features/messages/lib/threadReadTransitions.test.mjs new file mode 100644 index 0000000000..f3f7b5100c --- /dev/null +++ b/desktop/src/features/messages/lib/threadReadTransitions.test.mjs @@ -0,0 +1,78 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + markConfirmedThreadReplyRead, + sendThreadReplyWithReadBackstop, +} from "./threadReadTransitions.ts"; + +test("confirmed thread reply advances the thread frontier to the reply timestamp", () => { + const calls = []; + markConfirmedThreadReplyRead( + "root-1", + { created_at: 120 }, + (rootId, timestamp) => calls.push({ rootId, timestamp }), + ); + assert.deepEqual(calls, [{ rootId: "root-1", timestamp: 120 }]); +}); + +test("REST reply result uses the confirmed server timestamp", () => { + const calls = []; + markConfirmedThreadReplyRead( + "root-1", + { createdAt: 120 }, + (rootId, timestamp) => calls.push({ rootId, timestamp }), + ); + assert.deepEqual(calls, [{ rootId: "root-1", timestamp: 120 }]); +}); + +test("missing thread root does not advance a frontier", () => { + let called = false; + markConfirmedThreadReplyRead(null, { createdAt: 120 }, () => { + called = true; + }); + assert.equal(called, false); +}); + +test("confirmed send advances only through the self reply so a later reply stays unread", async () => { + let readAt = 100; + const reply = await sendThreadReplyWithReadBackstop( + Promise.resolve({ created_at: 120 }), + () => "root-1", + (_rootId, timestamp) => { + readAt = Math.max(readAt, timestamp); + }, + ); + + assert.equal(reply.created_at, 120); + assert.equal(readAt, 120); + assert.equal(121 > readAt, true); +}); + +test("failed send leaves the thread frontier unchanged", async () => { + let readAt = 100; + + await assert.rejects( + sendThreadReplyWithReadBackstop( + Promise.reject(new Error("send failed")), + () => "root-1", + (_rootId, timestamp) => { + readAt = timestamp; + }, + ), + /send failed/, + ); + + assert.equal(readAt, 100); +}); + +test("Inbox reply uses the root and timestamp returned by the server", async () => { + const calls = []; + await sendThreadReplyWithReadBackstop( + Promise.resolve({ createdAt: 140, rootEventId: "canonical-root" }), + (reply) => reply.rootEventId, + (rootId, timestamp) => calls.push({ rootId, timestamp }), + ); + + assert.deepEqual(calls, [{ rootId: "canonical-root", timestamp: 140 }]); +}); diff --git a/desktop/src/features/messages/lib/threadReadTransitions.ts b/desktop/src/features/messages/lib/threadReadTransitions.ts new file mode 100644 index 0000000000..b46f478dbb --- /dev/null +++ b/desktop/src/features/messages/lib/threadReadTransitions.ts @@ -0,0 +1,34 @@ +import type { RelayEvent, SendChannelMessageResult } from "@/shared/api/types"; + +type ConfirmedReply = + | Pick + | Pick; + +export function markConfirmedThreadReplyRead( + rootId: string | null, + reply: ConfirmedReply, + markThreadRead: (rootId: string, timestamp: number) => void, +): void { + if (!rootId) return; + const timestamp = "created_at" in reply ? reply.created_at : reply.createdAt; + markThreadRead(rootId, timestamp); +} + +export function markConfirmedInboxReplyRead( + reply: Pick, + markThreadRead: (rootId: string, timestamp: number) => void, +): void { + markConfirmedThreadReplyRead(reply.rootEventId, reply, markThreadRead); +} + +export async function sendThreadReplyWithReadBackstop< + Reply extends ConfirmedReply, +>( + send: Promise, + getRootId: (reply: Reply) => string | null, + markThreadRead: (rootId: string, timestamp: number) => void, +): Promise { + const reply = await send; + markConfirmedThreadReplyRead(getRootId(reply), reply, markThreadRead); + return reply; +} diff --git a/desktop/src/features/messages/ui/MessageThreadPanel.tsx b/desktop/src/features/messages/ui/MessageThreadPanel.tsx index d2650c84ac..bd30f073c3 100644 --- a/desktop/src/features/messages/ui/MessageThreadPanel.tsx +++ b/desktop/src/features/messages/ui/MessageThreadPanel.tsx @@ -61,6 +61,7 @@ type MessageThreadPanelProps = ThreadPanelLayoutProps & { isHuddleTranscript?: boolean; editTarget?: MessageComposerEditTarget | null; isSending: boolean; + onAtBottomChange?: (atBottom: boolean) => void; onCancelEdit?: () => void; onCancelReply: () => void; onClose: () => void; @@ -204,6 +205,7 @@ export function MessageThreadPanel({ isSinglePanelView = false, isFollowingThread, isMessageUnreadById, + onAtBottomChange, onCancelEdit, onCancelReply, onClose, @@ -530,6 +532,9 @@ export function MessageThreadPanel({ "padding", settleAtBottomAfterLayout, ); + React.useEffect(() => { + onAtBottomChange?.(isAtBottom); + }, [isAtBottom, onAtBottomChange]); const stableSendToChannel = useStableSendToChannel( channelId, threadHead, diff --git a/desktop/src/features/messages/ui/MessageTimeline.tsx b/desktop/src/features/messages/ui/MessageTimeline.tsx index df9af09e67..56cc7c8069 100644 --- a/desktop/src/features/messages/ui/MessageTimeline.tsx +++ b/desktop/src/features/messages/ui/MessageTimeline.tsx @@ -115,6 +115,7 @@ type MessageTimelineProps = { /** The current find-in-channel query string. */ searchQuery?: string; targetMessageId?: string | null; + onAtBottomChange?: (atBottom: boolean) => void; onTargetReached?: (messageId: string) => void; splitThreadPanelOpen?: boolean; /** Event id of the oldest unread top-level message at channel open, or null. */ @@ -204,6 +205,7 @@ const MessageTimelineBase = React.forwardRef< searchMatchingMessageIds, searchQuery, targetMessageId = null, + onAtBottomChange, onTargetReached, splitThreadPanelOpen = false, firstUnreadMessageId = null, @@ -369,6 +371,9 @@ const MessageTimelineBase = React.forwardRef< virtualizerRenderVersion, }); + React.useEffect(() => { + onAtBottomChange?.(isAtBottom); + }, [isAtBottom, onAtBottomChange]); const hasConfirmedVirtualizerBottomRef = React.useRef(false); const bottomConfirmationChannelRef = React.useRef(channelId); if (bottomConfirmationChannelRef.current !== channelId) {