mirror of
https://github.com/block/buzz.git
synced 2026-10-30 22:11:36 +00:00
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:
@@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
15
desktop/src/features/channels/ui/useScopedAtBottom.ts
Normal file
15
desktop/src/features/channels/ui/useScopedAtBottom.ts
Normal 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];
|
||||
}
|
||||
@@ -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"]]);
|
||||
});
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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],
|
||||
]);
|
||||
});
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }]);
|
||||
});
|
||||
34
desktop/src/features/messages/lib/threadReadTransitions.ts
Normal file
34
desktop/src/features/messages/lib/threadReadTransitions.ts
Normal 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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user