cheogram-www
created pr with
cheogram-www/click-nickname.1
added cheogram-www/click-nickname.2
1: 09a926a ! 1: b380266 Click on nickname to mention
commented
lgtm
cmds
checkout latest patchset:
ssh pr.pico.sh pull cheogram-www:click-nickname | git am -3checkout specific patchset revision:
ssh pr.pico.sh pull cheogram-www:click-nickname [rev] | git am -3add changes to patch request:
git format-patch main --stdout | ssh pr.pico.sh cheogram-www:click-nicknameadd comment to patch request:
ssh pr.pico.sh comment cheogram-www:click-nickname "lgtm!"
Patchset
cheogram-www/click-nickname.2
Click on nickname to mention
Stephen Paul Weber
2026-11-07T22:42:53ZSemantic diff summary
0 added,
6 modified,
0 signature changed,
0 removed
across 3 analyzed files
(1 file skipped: unsupported file type)
+8
-0
src/App.scss
#
| ... | ... | @@ -785,6 +785,14 @@ div.cs-message__content { | |
| 785 | 785 | border: 0.125em solid lightcoral; | |
| 786 | 786 | } | |
| 787 | 787 | ||
| 788 | + | .clickable { | |
| 789 | + | cursor: pointer; | |
| 790 | + | ||
| 791 | + | &:hover { | |
| 792 | + | text-decoration: underline; | |
| 793 | + | } | |
| 794 | + | } | |
| 795 | + | ||
| 788 | 796 | .cs-conversation-header__back { | |
| 789 | 797 | button { | |
| 790 | 798 | font-size: inherit; |
+1
-0
src/components/Chat.tsx
#
+81
-0
src/components/MessageBubble.test.tsx
#
| ... | ... | @@ -0,0 +1,81 @@ | |
| 1 | + | // @vitest-environment jsdom | |
| 2 | + | ||
| 3 | + | import * as borogove from "borogove"; | |
| 4 | + | import { cleanup, fireEvent, render, screen } from "@testing-library/react"; | |
| 5 | + | import { MemoryRouter } from "react-router"; | |
| 6 | + | import { afterEach, describe, expect, it, vi } from "vitest"; | |
| 7 | + | import { mock } from "vitest-mock-extended"; | |
| 8 | + | import { MessageBubble } from "./MessageBubble"; | |
| 9 | + | ||
| 10 | + | afterEach(cleanup); | |
| 11 | + | ||
| 12 | + | const html = { | |
| 13 | + | reduce: () => [0], | |
| 14 | + | asDOM: () => document.createDocumentFragment(), | |
| 15 | + | toPlainText: () => "", | |
| 16 | + | } as unknown as borogove.Html; | |
| 17 | + | ||
| 18 | + | const message = (senderId: string) => | |
| 19 | + | ({ | |
| 20 | + | senderId, | |
| 21 | + | isIncoming: () => true, | |
| 22 | + | moderationReason: () => "", | |
| 23 | + | body: () => html, | |
| 24 | + | subject: () => "", | |
| 25 | + | attachments: [], | |
| 26 | + | linkMetadata: [], | |
| 27 | + | versions: [], | |
| 28 | + | reactions: new Map(), | |
| 29 | + | timeSent: new Date().toISOString(), | |
| 30 | + | serverId: "message-1", | |
| 31 | + | threadId: "thread-1", | |
| 32 | + | threadIcon: () => null, | |
| 33 | + | }) as unknown as borogove.ChatMessage; | |
| 34 | + | ||
| 35 | + | const sender = mock<borogove.Member>({ | |
| 36 | + | id: "alice@example.com", | |
| 37 | + | displayName: "Alice", | |
| 38 | + | }); | |
| 39 | + | ||
| 40 | + | const setup = (onMention?: (member: borogove.Member) => void) => { | |
| 41 | + | const setSelectedThread = vi.fn(); | |
| 42 | + | const view = render( | |
| 43 | + | <MemoryRouter> | |
| 44 | + | <MessageBubble | |
| 45 | + | chat={mock<borogove.Chat>({ chatId: "room@example.com" })} | |
| 46 | + | client={mock<borogove.Client>({ displayName: () => "Me" })} | |
| 47 | + | members={ | |
| 48 | + | new Map([ | |
| 49 | + | ["room@example.com", mock<borogove.Member>()], | |
| 50 | + | [sender.id, sender], | |
| 51 | + | ]) | |
| 52 | + | } | |
| 53 | + | m={message(sender.id)} | |
| 54 | + | position="single" | |
| 55 | + | setSelectedThread={setSelectedThread} | |
| 56 | + | onMention={onMention} | |
| 57 | + | > | |
| 58 | + | {null} | |
| 59 | + | </MessageBubble> | |
| 60 | + | </MemoryRouter>, | |
| 61 | + | ); | |
| 62 | + | return { ...view, setSelectedThread }; | |
| 63 | + | }; | |
| 64 | + | ||
| 65 | + | describe("MessageBubble nickname", () => { | |
| 66 | + | it("inserts a mention and selects the thread when the nickname is clicked", () => { | |
| 67 | + | const onMention = vi.fn(); | |
| 68 | + | const { setSelectedThread } = setup(onMention); | |
| 69 | + | ||
| 70 | + | fireEvent.click(screen.getByText("Alice")); | |
| 71 | + | ||
| 72 | + | expect(onMention).toHaveBeenCalledWith(sender); | |
| 73 | + | expect(setSelectedThread).toHaveBeenCalledWith("thread-1"); | |
| 74 | + | }); | |
| 75 | + | ||
| 76 | + | it("does not make the nickname clickable without a mention handler", () => { | |
| 77 | + | setup(); | |
| 78 | + | ||
| 79 | + | expect(screen.getByText("Alice").className).not.toContain("clickable"); | |
| 80 | + | }); | |
| 81 | + | }); |
+12
-6
src/components/MessageBubble.tsx
#
| ... | ... | @@ -265,6 +267,12 @@ export const MessageBubble = ({ | |
| 265 | 267 | .replace(/\s*\b[A-Z]{2,4}$/, ""); // remove trailing TZ abbrev | |
| 266 | 268 | ||
| 267 | 269 | const sender = members.get(m.senderId); | |
| 270 | + | const showSender = | |
| 271 | + | m.senderId !== chat.chatId && | |
| 272 | + | ["last", "single"].includes(position) && | |
| 273 | + | !m.moderationReason() && | |
| 274 | + | m.isIncoming(); | |
| 275 | + | const mention = sender && onMention ? () => onMention(sender) : undefined; | |
| 268 | 276 | ||
| 269 | 277 | return ( | |
| 270 | 278 | <Message |
| ... | ... | @@ -327,13 +335,11 @@ export const MessageBubble = ({ | |
| 327 | 335 | <div className="meta"> | |
| 328 | 336 | <div | |
| 329 | 337 | className={ | |
| 330 | - | (m.senderId !== chat.chatId && | |
| 331 | - | ["last", "single"].includes(position) && | |
| 332 | - | !m.moderationReason() && | |
| 333 | - | m.isIncoming() | |
| 334 | - | ? "" | |
| 335 | - | : "sr-only") + " h-card sender" | |
| 338 | + | (showSender ? "" : "sr-only") + | |
| 339 | + | " h-card sender " + | |
| 340 | + | (mention ? "clickable" : "") | |
| 336 | 341 | } | |
| 342 | + | onClick={mention} | |
| 337 | 343 | > | |
| 338 | 344 | {sender?.displayName} | |
| 339 | 345 | </div> |