cheogram-www

created pr with cheogram-www/no-insert-when-not-at-end.1 on 2026-10-28T02:16:40Z · by 9e834e0d
cmds
checkout latest patchset:
ssh pr.pico.sh pull cheogram-www:no-insert-when-not-at-end | git am -3
checkout specific patchset revision:
ssh pr.pico.sh pull cheogram-www:no-insert-when-not-at-end [rev] | git am -3
add changes to patch request:
git format-patch main --stdout | ssh pr.pico.sh cheogram-www:no-insert-when-not-at-end
add comment to patch request:
ssh pr.pico.sh comment cheogram-www:no-insert-when-not-at-end "lgtm!"

Patchset cheogram-www/no-insert-when-not-at-end.1 on 2026-10-28T02:16:40Z · commit c22203e

Do not insert live when not at end
Stephen Paul Weber 2026-10-28T02:14:15Z
Before a live message event fires, borovove sets the chat lastMessage to
this new message. So we cannot just check if activeChat.lastMessage and
messages.at(-1) are the same as we do elsewhere. Introduce a new ref to
check what the last seen message was so we can avoid an insert if we are
not loaded to the end.

Processing of live messages is async, so also introduce a promise
"queue" to make sure they happen in order.
Semantic diff summary
3 added, 7 modified, 0 signature changed, 0 removed across 2 analyzed files
+189 -1 src/components/Chat.test.tsx #
......@@ -1,8 +1,16 @@
11 // @vitest-environment jsdom
22
3+import * as borogove from "borogove";
34 import { act, cleanup, render } from "@testing-library/react";
4-import type { Chat, Client, Persistence, persistence } from "borogove";
5+import type {
6+ Chat,
7+ ChatMessage,
8+ Client,
9+ Persistence,
10+ persistence,
11+} from "borogove";
512 import Quill from "quill";
13+import type { ReactNode } from "react";
614 import { Await, MemoryRouter, Outlet, Route, Routes } from "react-router";
715 import { afterEach, expect, it, vi } from "vitest";
816 import { mock } from "vitest-mock-extended";
......@@ -12,8 +20,39 @@ vi.mock("./AvatarWithPlaceholder", () => ({
1220 AvatarWithPlaceholder: () => null,
1321 }));
1422
23+vi.mock("./MessageBubble", () => ({
24+ MessageBubble: ({ m }: { m: ChatMessage }) => (
25+ <div data-testid="message">{m.body().toPlainText()}</div>
26+ ),
27+}));
28+
29+const virtuosoProps = vi.hoisted(() => ({
30+ current: null as null | {
31+ endReached?: () => void;
32+ startReached?: () => void;
33+ },
34+}));
35+
36+vi.mock("react-virtuoso", () => ({
37+ Virtuoso: ({
38+ data,
39+ itemContent,
40+ endReached,
41+ startReached,
42+ }: {
43+ data: unknown[];
44+ itemContent: (index: number, item: unknown) => ReactNode;
45+ endReached?: () => void;
46+ startReached?: () => void;
47+ }) => {
48+ virtuosoProps.current = { endReached, startReached };
49+ return <>{data.map((item, index) => itemContent(index, item))}</>;
50+ },
51+}));
52+
1553 afterEach(() => {
1654 cleanup();
55+ document.getElementById("chat-messages-aria-live")?.remove();
1756 vi.restoreAllMocks();
1857 vi.unstubAllGlobals();
1958 });
......@@ -84,3 +123,152 @@ it("reports an empty typing string when the composer is cleared", async () => {
84123 expect(editor.getText()).toBe("\n");
85124 expect(activeChat.typing).toHaveBeenLastCalledWith(null, "");
86125 });
126+
127+const message = (id: string): ChatMessage => {
128+ const m = mock<ChatMessage>({
129+ serverId: id,
130+ localId: id,
131+ chatId: () => "bob@example.com",
132+ senderId: "alice@example.com",
133+ threadId: null,
134+ type: borogove.MessageType.MessageChat,
135+ isIncoming: () => true,
136+ moderationReason: () => null,
137+ replyToMessage: null,
138+ body: (() => ({ toPlainText: () => id })) as never,
139+ canReplace: (other) => other.serverId === id,
140+ });
141+ (m as unknown as { reactions: unknown }).reactions = { values: () => [] };
142+ return m as unknown as ChatMessage;
143+};
144+
145+const setupLiveMessageTest = (
146+ initialMessages: ChatMessage[],
147+ lastMessage: ChatMessage,
148+) => {
149+ vi.stubGlobal(
150+ "ResizeObserver",
151+ class {
152+ observe() {}
153+ unobserve() {}
154+ disconnect() {}
155+ },
156+ );
157+
158+ const ariaLive = document.createElement("div");
159+ ariaLive.id = "chat-messages-aria-live";
160+ document.body.appendChild(ariaLive);
161+
162+ const activeChat = mock<Chat>({
163+ chatId: "bob@example.com",
164+ getDisplayName: () => "Bob",
165+ isTrusted: () => true,
166+ callStatus: () => 0,
167+ canSend: () => true,
168+ getMemberDetails: async () => [],
169+ getMessagesAfter: async () => [],
170+ });
171+ Object.assign(activeChat, { lastMessage });
172+
173+ const client = mock<Client>();
174+ const context = {
175+ active: true,
176+ activeChat,
177+ client,
178+ getChatExtras: () => undefined,
179+ setChatExtras: vi.fn(),
180+ innerChatRef: { current: { addMention: vi.fn(), moderateRecent: vi.fn() } },
181+ isInvite: false,
182+ persistence: mock<Persistence & persistence.KeyValueStore>({
183+ get: async () => null,
184+ }),
185+ typingMembers: [],
186+ };
187+
188+ const view = render(
189+ <MemoryRouter>
190+ <Routes>
191+ <Route element={<Outlet context={context} />}>
192+ <Route
193+ index
194+ element={
195+ <Await
196+ resolve={{
197+ messages: initialMessages,
198+ members: new Map(),
199+ readUpTo: null,
200+ invitedBy: null,
201+ }}
202+ >
203+ <ChatInner />
204+ </Await>
205+ }
206+ />
207+ </Route>
208+ </Routes>
209+ </MemoryRouter>,
210+ );
211+
212+ const handler = client.addChatMessageListener.mock.calls[0][0];
213+ const deliver = async (m: ChatMessage) => {
214+ Object.assign(activeChat, { lastMessage: m });
215+ await act(async () => {
216+ handler(m, borogove.ChatMessageEvent.DeliveryEvent);
217+ await new Promise((resolve) => setTimeout(resolve, 350));
218+ });
219+ };
220+
221+ return { view, deliver, activeChat };
222+};
223+
224+it("does not append a live message when the loaded window does not reach the chat's last message", async () => {
225+ const old1 = message("old1");
226+ const old2 = message("old2");
227+ const live = message("live");
228+ const { view, deliver } = setupLiveMessageTest([old1, old2], live);
229+
230+ await deliver(live);
231+
232+ expect(view.queryByText("live")).toBeNull();
233+ expect(view.getByText("old1")).toBeTruthy();
234+ expect(view.getByText("old2")).toBeTruthy();
235+});
236+
237+it("appends a live message when the loaded window reaches the chat's last message", async () => {
238+ const old = message("old");
239+ const live = message("live");
240+ const { view, deliver } = setupLiveMessageTest([old], old);
241+
242+ await deliver(live);
243+
244+ expect(view.getByText("live")).toBeTruthy();
245+});
246+
247+it("appends back-to-back live messages when the window is at the end", async () => {
248+ const old = message("old");
249+ const first = message("first");
250+ const second = message("second");
251+ const { view, deliver } = setupLiveMessageTest([old], old);
252+
253+ await deliver(first);
254+ await deliver(second);
255+
256+ expect(view.getByText("first")).toBeTruthy();
257+ expect(view.getByText("second")).toBeTruthy();
258+});
259+it("refreshes the last seen chat message when pagination updates the list", async () => {
260+ const old = message("old");
261+ const synced = message("synced");
262+ const live = message("live");
263+ const { view, deliver, activeChat } = setupLiveMessageTest([old], old);
264+ vi.spyOn(activeChat, "getMessagesAfter").mockResolvedValue([synced]);
265+
266+ Object.assign(activeChat, { lastMessage: synced });
267+ await act(async () => {
268+ virtuosoProps.current?.endReached?.();
269+ await new Promise((resolve) => setTimeout(resolve, 350));
270+ });
271+ await deliver(live);
272+
273+ expect(view.getByText("live")).toBeTruthy();
274+});
+20 -3 src/components/Chat.tsx #
......@@ -216,6 +216,8 @@ export const ChatInner = () => {
216216 const loading = useRef(false);
217217 const firstMessage = useRef(messages[0]);
218218 const lastMessage = useRef(paginationCursor(messages));
219+ const lastChatMessageSeen = useRef(activeChat.lastMessage);
220+ const liveMessageQueue = useRef(Promise.resolve());
219221 const groups: (Group | "readUpToMarker")[] = useMemo(
220222 () =>
221223 messages.reduce(
......@@ -537,7 +539,15 @@ export const ChatInner = () => {
537539 (message: borogove.ChatMessage, eventType: borogove.ChatMessageEvent) => {
538540 if (message.chatId() !== activeChat.chatId) return;
539541
540- (async () => {
542+ const previousChatLast = lastChatMessageSeen.current;
543+ lastChatMessageSeen.current = activeChat.lastMessage;
544+
545+ liveMessageQueue.current = liveMessageQueue.current.then(async () => {
546+ const wasAtEnd =
547+ !lastMessage.current ||
548+ (!!previousChatLast &&
549+ lastMessage.current.canReplace(previousChatLast));
550+
541551 const newMembers = await membersForMessages(
542552 [message],
543553 (ids) => activeChat.getMemberDetails(ids),
......@@ -561,7 +571,10 @@ export const ChatInner = () => {
561571 return result;
562572 }
563573
564- if (eventType !== borogove.ChatMessageEvent.DeliveryEvent)
574+ if (
575+ eventType !== borogove.ChatMessageEvent.DeliveryEvent ||
576+ !wasAtEnd
577+ )
565578 return messages;
566579
567580 firstMessage.current = messages[0] ?? message;
......@@ -569,7 +582,7 @@ export const ChatInner = () => {
569582 lastMessage.current = paginationCursor(result);
570583 return result;
571584 });
572- })();
585+ });
573586 },
574587 );
575588
......@@ -583,6 +596,10 @@ export const ChatInner = () => {
583596 };
584597 }, [client]);
585598
599+ useEffect(() => {
600+ lastChatMessageSeen.current = activeChat.lastMessage;
601+ }, [messages, activeChat]);
602+
586603 const addCommandSession = (
587604 name: string,
588605 session: Promise<borogove.CommandSession>,
Back to top