cheogram-www
created pr with
cheogram-www/no-insert-when-not-at-end.1
cmds
checkout latest patchset:
ssh pr.pico.sh pull cheogram-www:no-insert-when-not-at-end | git am -3checkout specific patchset revision:
ssh pr.pico.sh pull cheogram-www:no-insert-when-not-at-end [rev] | git am -3add changes to patch request:
git format-patch main --stdout | ssh pr.pico.sh cheogram-www:no-insert-when-not-at-endadd 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
Do not insert live when not at end
Stephen Paul Weber
2026-10-28T02:14:15ZBefore 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 @@ | |
| 1 | 1 | // @vitest-environment jsdom | |
| 2 | 2 | ||
| 3 | + | import * as borogove from "borogove"; | |
| 3 | 4 | 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"; | |
| 5 | 12 | import Quill from "quill"; | |
| 13 | + | import type { ReactNode } from "react"; | |
| 6 | 14 | import { Await, MemoryRouter, Outlet, Route, Routes } from "react-router"; | |
| 7 | 15 | import { afterEach, expect, it, vi } from "vitest"; | |
| 8 | 16 | import { mock } from "vitest-mock-extended"; |
| ... | ... | @@ -12,8 +20,39 @@ vi.mock("./AvatarWithPlaceholder", () => ({ | |
| 12 | 20 | AvatarWithPlaceholder: () => null, | |
| 13 | 21 | })); | |
| 14 | 22 | ||
| 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 | + | ||
| 15 | 53 | afterEach(() => { | |
| 16 | 54 | cleanup(); | |
| 55 | + | document.getElementById("chat-messages-aria-live")?.remove(); | |
| 17 | 56 | vi.restoreAllMocks(); | |
| 18 | 57 | vi.unstubAllGlobals(); | |
| 19 | 58 | }); |
| ... | ... | @@ -84,3 +123,152 @@ it("reports an empty typing string when the composer is cleared", async () => { | |
| 84 | 123 | expect(editor.getText()).toBe("\n"); | |
| 85 | 124 | expect(activeChat.typing).toHaveBeenLastCalledWith(null, ""); | |
| 86 | 125 | }); | |
| 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 = () => { | |
| 216 | 216 | const loading = useRef(false); | |
| 217 | 217 | const firstMessage = useRef(messages[0]); | |
| 218 | 218 | const lastMessage = useRef(paginationCursor(messages)); | |
| 219 | + | const lastChatMessageSeen = useRef(activeChat.lastMessage); | |
| 220 | + | const liveMessageQueue = useRef(Promise.resolve()); | |
| 219 | 221 | const groups: (Group | "readUpToMarker")[] = useMemo( | |
| 220 | 222 | () => | |
| 221 | 223 | messages.reduce( |
| ... | ... | @@ -537,7 +539,15 @@ export const ChatInner = () => { | |
| 537 | 539 | (message: borogove.ChatMessage, eventType: borogove.ChatMessageEvent) => { | |
| 538 | 540 | if (message.chatId() !== activeChat.chatId) return; | |
| 539 | 541 | ||
| 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 | + | ||
| 541 | 551 | const newMembers = await membersForMessages( | |
| 542 | 552 | [message], | |
| 543 | 553 | (ids) => activeChat.getMemberDetails(ids), |
| ... | ... | @@ -561,7 +571,10 @@ export const ChatInner = () => { | |
| 561 | 571 | return result; | |
| 562 | 572 | } | |
| 563 | 573 | ||
| 564 | - | if (eventType !== borogove.ChatMessageEvent.DeliveryEvent) | |
| 574 | + | if ( | |
| 575 | + | eventType !== borogove.ChatMessageEvent.DeliveryEvent || | |
| 576 | + | !wasAtEnd | |
| 577 | + | ) | |
| 565 | 578 | return messages; | |
| 566 | 579 | ||
| 567 | 580 | firstMessage.current = messages[0] ?? message; |
| ... | ... | @@ -583,6 +596,10 @@ export const ChatInner = () => { | |
| 583 | 596 | }; | |
| 584 | 597 | }, [client]); | |
| 585 | 598 | ||
| 599 | + | useEffect(() => { | |
| 600 | + | lastChatMessageSeen.current = activeChat.lastMessage; | |
| 601 | + | }, [messages, activeChat]); | |
| 602 | + | ||
| 586 | 603 | const addCommandSession = ( | |
| 587 | 604 | name: string, | |
| 588 | 605 | session: Promise<borogove.CommandSession>, |