fix(desktop): respect visible unread presence

Co-authored-by: Morgan Martin <morgan@block.xyz>
Signed-off-by: Morgan Martin <morgan@block.xyz>
Co-authored-by: Trace (Engineer) <9d485ff0c62915e08a801162c195c7ef096f6f6143925407255cea2656c4b8a4@buzz.block.builderlab.xyz>
Signed-off-by: Trace (Engineer) <9d485ff0c62915e08a801162c195c7ef096f6f6143925407255cea2656c4b8a4@buzz.block.builderlab.xyz>
This commit is contained in:
Trace (Engineer)
2026-08-22 22:05:28 -07:00
parent e236329413
commit f9bb9cbc99
17 changed files with 452 additions and 21 deletions

View File

@@ -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();
}
});

View File

@@ -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}

View File

@@ -139,11 +139,13 @@ export type ChannelPaneProps = {
} | null,
) => Promise<void>;
onTargetReached?: (messageId: string) => void;
onTimelineAtBottomChange?: (atBottom: boolean) => void;
onToggleReaction?: (
message: TimelineMessage,
emoji: string,
remove: boolean,
) => Promise<void>;
onThreadAtBottomChange?: (atBottom: boolean) => void;
onThreadScrollTargetResolved: () => void;
onThreadPanelResizeStart: (
event: React.PointerEvent<HTMLButtonElement>,

View File

@@ -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}

View File

@@ -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,
]);
}

View File

@@ -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<string>;
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:<id> 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

View File

@@ -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];
}

View File

@@ -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("<!doctype html><html><body></body></html>", {
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"]]);
});

View File

@@ -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]);
}

View File

@@ -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("<!doctype html><html><body></body></html>", {
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],
]);
});

View File

@@ -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]);
}

View File

@@ -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,

View File

@@ -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

View File

@@ -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 }]);
});

View File

@@ -0,0 +1,34 @@
import type { RelayEvent, SendChannelMessageResult } from "@/shared/api/types";
type ConfirmedReply =
| Pick<RelayEvent, "created_at">
| Pick<SendChannelMessageResult, "createdAt">;
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<SendChannelMessageResult, "createdAt" | "rootEventId">,
markThreadRead: (rootId: string, timestamp: number) => void,
): void {
markConfirmedThreadReplyRead(reply.rootEventId, reply, markThreadRead);
}
export async function sendThreadReplyWithReadBackstop<
Reply extends ConfirmedReply,
>(
send: Promise<Reply>,
getRootId: (reply: Reply) => string | null,
markThreadRead: (rootId: string, timestamp: number) => void,
): Promise<Reply> {
const reply = await send;
markConfirmedThreadReplyRead(getRootId(reply), reply, markThreadRead);
return reply;
}

View File

@@ -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,

View File

@@ -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) {