Add @-mention highlighting, sidebar badge, and push customization (#39)

@username tokens in a sent message are parsed against the room's actual
members (skipping fenced/inline code, so pasted code isn't misread) and
recorded as MessageMention rows, reusing #38's read-tracking and
offline-member broadcast infrastructure rather than building a parallel
notification path:

- Sidebar: a mentioned-and-unread room shows a distinct highlight-
  colored badge instead of (not alongside) the plain unread dot --
  computed the same way as has_unread, just scoped to messages that
  mention the caller, and cleared by the same last_read_at mark-read
  flow.
- Push notifications: a mentioned offline recipient gets "X mentioned
  you: ..." instead of the generic "X: ...", still per-recipient since
  the same message can page some room members and not others.
- Message rendering: a validated @username is highlighted inline,
  implemented by turning it into a `[@username](mention:username)` link
  before markdown parsing and overriding link rendering to style
  `mention:`-scheme links as a span instead of an anchor -- reuses
  markdown-to-jsx's existing parser rather than hand-rolling text-node
  splitting.
- Composer: typing @ opens an autocomplete dropdown of matching room
  members (arrow keys to navigate, Enter/Tab/click to insert, Escape or
  moving the cursor away to dismiss).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-17 08:56:40 -06:00
co-authored by Claude Sonnet 5
parent bd3e621e9f
commit 4bac502b2c
23 changed files with 702 additions and 49 deletions
+7 -1
View File
@@ -184,7 +184,13 @@ export function ChatPane({
onEdit={sendEdit}
onReact={sendReaction}
/>
<Composer roomId={room.id} roomName={room.name} disabled={!connected} onSend={send} />
<Composer
roomId={room.id}
roomName={room.name}
members={members}
disabled={!connected}
onSend={send}
/>
</section>
)
}
+6 -1
View File
@@ -13,9 +13,14 @@
align-items: flex-end;
}
.composer-box textarea {
.composer-textarea-wrap {
position: relative;
flex: 1;
min-width: 0;
}
.composer-box textarea {
width: 100%;
resize: none;
background: var(--ds-surface-2);
border: 1px solid var(--ds-border);
+115 -15
View File
@@ -1,20 +1,49 @@
import { useEffect, useRef, useState, type ChangeEvent, type KeyboardEvent } from 'react'
import {
useEffect,
useMemo,
useRef,
useState,
type ChangeEvent,
type FormEvent,
type KeyboardEvent,
} from 'react'
import { useOnlineStatus } from '../hooks/useOnlineStatus'
import { uploadRoomFile, uploadRoomImage } from '../api/rooms'
import { getUploadLimit } from '../api/uploads'
import { formatFileSize } from '../lib/fileSize'
import type { RoomMember } from '../types'
import { EmojiPicker } from './EmojiPicker'
import { MentionAutocomplete } from './MentionAutocomplete'
import './Composer.css'
interface ComposerProps {
roomId: string
roomName: string
members: RoomMember[]
disabled?: boolean
onSend: (content: string, imageId?: string, fileId?: string) => void
}
interface MentionQuery {
start: number
end: number
text: string
}
export function Composer({ roomId, roomName, disabled, onSend }: ComposerProps) {
// Scans left from the cursor for an active "@partial" token -- an '@' not
// preceded by a word character (so "foo@bar" mid-email doesn't trigger)
// with only mention-safe characters between it and the cursor (a space
// breaks out of the query entirely, closing the dropdown).
function detectMentionQuery(text: string, cursor: number): MentionQuery | null {
let i = cursor - 1
while (i >= 0 && /[a-zA-Z0-9_.-]/.test(text[i])) i--
if (i < 0 || text[i] !== '@') return null
const prevChar = text[i - 1]
if (prevChar && /\w/.test(prevChar)) return null
return { start: i, end: cursor, text: text.slice(i + 1, cursor) }
}
export function Composer({ roomId, roomName, members, disabled, onSend }: ComposerProps) {
const [value, setValue] = useState('')
const [pendingImage, setPendingImage] = useState<{ id: string; previewUrl: string } | null>(null)
const [pendingFile, setPendingFile] = useState<{ id: string; filename: string; size: number } | null>(
@@ -24,10 +53,18 @@ export function Composer({ roomId, roomName, disabled, onSend }: ComposerProps)
const [uploadError, setUploadError] = useState<string | null>(null)
const [emojiPickerOpen, setEmojiPickerOpen] = useState(false)
const [maxUploadBytes, setMaxUploadBytes] = useState<number | null>(null)
const [mentionQuery, setMentionQuery] = useState<MentionQuery | null>(null)
const [mentionActiveIndex, setMentionActiveIndex] = useState(0)
const textareaRef = useRef<HTMLTextAreaElement>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
const online = useOnlineStatus()
const mentionMatches = useMemo(() => {
if (!mentionQuery) return []
const q = mentionQuery.text.toLowerCase()
return members.filter((m) => m.username.toLowerCase().startsWith(q)).slice(0, 8)
}, [mentionQuery, members])
useEffect(() => {
getUploadLimit()
.then((limit) => setMaxUploadBytes(limit.max_upload_bytes))
@@ -49,18 +86,68 @@ export function Composer({ roomId, roomName, disabled, onSend }: ComposerProps)
if (!trimmed && !pendingImage && !pendingFile) return
onSend(trimmed, pendingImage?.id, pendingFile?.id)
setValue('')
setMentionQuery(null)
removePendingImage()
setPendingFile(null)
requestAnimationFrame(autoGrow)
}
function selectMention(username: string) {
const query = mentionQuery
if (!query) return
const el = textareaRef.current
const next = value.slice(0, query.start) + '@' + username + ' ' + value.slice(query.end)
setValue(next)
setMentionQuery(null)
requestAnimationFrame(() => {
if (!el) return
el.focus()
const cursor = query.start + username.length + 2 // '@' + username + trailing space
el.setSelectionRange(cursor, cursor)
autoGrow()
})
}
function handleKeyDown(e: KeyboardEvent<HTMLTextAreaElement>) {
if (mentionQuery && mentionMatches.length > 0) {
if (e.key === 'ArrowDown') {
e.preventDefault()
setMentionActiveIndex((i) => (i + 1) % mentionMatches.length)
return
}
if (e.key === 'ArrowUp') {
e.preventDefault()
setMentionActiveIndex((i) => (i - 1 + mentionMatches.length) % mentionMatches.length)
return
}
if (e.key === 'Enter' || e.key === 'Tab') {
e.preventDefault()
selectMention(mentionMatches[mentionActiveIndex].username)
return
}
if (e.key === 'Escape') {
e.preventDefault()
setMentionQuery(null)
return
}
}
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSend()
}
}
// Re-detects the active @query on every cursor move, not just typing --
// React's onSelect fires for clicks and arrow-key navigation too, so
// moving the cursor out of a partial mention (without deleting it) still
// correctly closes the dropdown.
function handleSelectionChange(e: FormEvent<HTMLTextAreaElement>) {
const el = e.currentTarget
const query = detectMentionQuery(el.value, el.selectionStart ?? 0)
setMentionQuery(query)
setMentionActiveIndex(0)
}
async function handleFileSelected(e: ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0]
e.target.value = ''
@@ -205,19 +292,32 @@ export function Composer({ roomId, roomName, disabled, onSend }: ComposerProps)
/>
)}
</div>
<textarea
ref={textareaRef}
rows={1}
value={value}
disabled={disabled}
onChange={(e) => {
setValue(e.target.value)
autoGrow()
}}
onKeyDown={handleKeyDown}
placeholder={disabled ? (online ? 'Connecting…' : "You're offline") : `Message #${roomName}`}
spellCheck
/>
<div className="composer-textarea-wrap">
<textarea
ref={textareaRef}
rows={1}
value={value}
disabled={disabled}
onChange={(e) => {
setValue(e.target.value)
autoGrow()
setMentionQuery(detectMentionQuery(e.target.value, e.target.selectionStart ?? 0))
setMentionActiveIndex(0)
}}
onSelect={handleSelectionChange}
onKeyDown={handleKeyDown}
placeholder={disabled ? (online ? 'Connecting…' : "You're offline") : `Message #${roomName}`}
spellCheck
/>
{mentionQuery && mentionMatches.length > 0 && (
<MentionAutocomplete
matches={mentionMatches}
activeIndex={mentionActiveIndex}
onPick={selectMention}
onHover={setMentionActiveIndex}
/>
)}
</div>
<button
type="button"
className="composer-send"
@@ -0,0 +1,49 @@
.mention-autocomplete {
position: absolute;
bottom: calc(100% + 8px);
left: 0;
z-index: 31;
width: 240px;
max-height: 220px;
overflow-y: auto;
background: var(--ds-surface);
border: 1px solid var(--ds-border);
border-radius: var(--radius);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
padding: 4px;
display: flex;
flex-direction: column;
gap: 2px;
}
.mention-autocomplete-item {
display: flex;
align-items: baseline;
gap: 6px;
background: transparent;
border: none;
border-radius: 6px;
padding: 6px 8px;
text-align: left;
cursor: pointer;
color: var(--ds-text);
}
.mention-autocomplete-item-active,
.mention-autocomplete-item:hover {
background: var(--ds-surface-2);
}
.mention-autocomplete-username {
font-size: 0.84rem;
font-weight: 700;
color: var(--ds-accent);
}
.mention-autocomplete-display-name {
font-size: 0.76rem;
color: var(--ds-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
@@ -0,0 +1,36 @@
import type { RoomMember } from '../types'
import './MentionAutocomplete.css'
interface MentionAutocompleteProps {
matches: RoomMember[]
activeIndex: number
onPick: (username: string) => void
onHover: (index: number) => void
}
export function MentionAutocomplete({ matches, activeIndex, onPick, onHover }: MentionAutocompleteProps) {
return (
<div className="mention-autocomplete" role="listbox">
{matches.map((member, i) => (
<button
key={member.user_id}
type="button"
role="option"
aria-selected={i === activeIndex}
className={`mention-autocomplete-item${i === activeIndex ? ' mention-autocomplete-item-active' : ''}`}
// Selecting must survive the textarea's blur (which would
// otherwise fire first and could dismiss the dropdown) --
// onMouseDown fires before blur, onClick fires after.
onMouseDown={(e) => e.preventDefault()}
onClick={() => onPick(member.username)}
onMouseEnter={() => onHover(i)}
>
<span className="mention-autocomplete-username">@{member.username}</span>
{member.display_name && (
<span className="mention-autocomplete-display-name">{member.display_name}</span>
)}
</button>
))}
</div>
)
}
+66 -3
View File
@@ -1,8 +1,14 @@
import Markdown from 'markdown-to-jsx'
import type { ReactNode } from 'react'
import { EMOJI_SHORTCODES } from '../lib/emojiShortcodes'
interface MessageContentProps {
content: string
// Validated against actual room members so a bare '@' in prose can't
// false-positive -- optional since FilePreviewModal reuses this same
// component for markdown file previews, where "@mentioning a person"
// doesn't apply.
memberUsernames?: Set<string>
}
interface MarkdownImageLinkProps {
@@ -24,6 +30,27 @@ function MarkdownImageLink({ src, alt, title }: MarkdownImageLinkProps) {
)
}
interface MarkdownLinkProps {
href?: string
children?: ReactNode
}
// highlightMentions (below) turns a validated @username into a
// `[@username](mention:username)` link so markdown-to-jsx parses it as a
// normal link node -- this override is what turns that back into a styled
// span instead of an actual anchor. Everything else renders as a real link,
// same as before mentions existed.
function MarkdownLink({ href, children }: MarkdownLinkProps) {
if (href?.startsWith('mention:')) {
return <span className="message-mention">{children}</span>
}
return (
<a href={href} target="_blank" rel="noopener noreferrer">
{children}
</a>
)
}
const SHORTCODE_PATTERN = /:([a-z0-9_+-]+):/g
// Converts a complete `:name:` shortcode to its emoji, skipping fenced code
@@ -56,6 +83,41 @@ function convertShortcodes(text: string): string {
.join('\n')
}
const MENTION_PATTERN = /@([a-zA-Z0-9_.-]+)/g
// Turns a validated @username into `[@username](mention:username)` --
// markdown-to-jsx parses that as an ordinary link node, which the `a`
// override above then renders as a styled span instead of an anchor. Skips
// fenced code blocks and inline code spans, same convention (and same
// reasoning) as convertShortcodes above -- pasted code containing a bare
// '@' shouldn't light up as if someone were paged. Kept in sync with
// backend/app/services/mention_service.py's equivalent server-side skip
// logic, which decides who actually gets notified.
function highlightMentions(text: string, memberUsernames: Set<string>): string {
if (memberUsernames.size === 0) return text
const lines = text.split('\n')
let inFence = false
return lines
.map((line) => {
if (/^\s*```/.test(line)) {
inFence = !inFence
return line
}
if (inFence) return line
return line
.split(/(`+[^`]*`+)/g)
.map((part, i) =>
i % 2 === 0
? part.replace(MENTION_PATTERN, (match, username) =>
memberUsernames.has(username) ? `[${match}](mention:${username})` : match,
)
: part,
)
.join('')
})
.join('\n')
}
// CommonMark treats a single newline as a soft break (rendered as a space),
// not a visible line break -- only a trailing double-space or blank line
// produces one. The Composer's Shift+Enter has always inserted a plain
@@ -87,11 +149,12 @@ export const MARKDOWN_OPTIONS = {
// and printed literally instead of being parsed into elements.
disableParsingRawHTML: true,
overrides: {
a: { props: { target: '_blank', rel: 'noopener noreferrer' } },
a: { component: MarkdownLink },
img: { component: MarkdownImageLink },
},
}
export function MessageContent({ content }: MessageContentProps) {
return <Markdown options={MARKDOWN_OPTIONS}>{preserveLineBreaks(convertShortcodes(content))}</Markdown>
export function MessageContent({ content, memberUsernames }: MessageContentProps) {
const withMentions = memberUsernames ? highlightMentions(content, memberUsernames) : content
return <Markdown options={MARKDOWN_OPTIONS}>{preserveLineBreaks(convertShortcodes(withMentions))}</Markdown>
}
+8
View File
@@ -120,6 +120,14 @@
margin-bottom: 0;
}
.message-mention {
background: color-mix(in srgb, var(--ds-highlight) 18%, transparent);
color: var(--ds-highlight);
border-radius: 5px;
padding: 0 4px;
font-weight: 700;
}
.message-text code {
background: var(--ds-surface-2);
padding: 1px 5px;
+3 -2
View File
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { getRoomFileUrl, getRoomImageUrl } from '../api/rooms'
import { useAuth } from '../context/AuthContext'
import { formatFileSize } from '../lib/fileSize'
@@ -76,6 +76,7 @@ export function MessageList({ roomId, messages, members, onEdit, onReact }: Mess
const [reactingId, setReactingId] = useState<string | null>(null)
const [reactionPlacement, setReactionPlacement] = useState<'above' | 'below'>('below')
const [previewFile, setPreviewFile] = useState<MessageFileInfo | null>(null)
const memberUsernames = useMemo(() => new Set(members.map((m) => m.username)), [members])
function displayNameForUserId(userId: string): string {
const member = members.find((m) => m.user_id === userId)
@@ -166,7 +167,7 @@ export function MessageList({ roomId, messages, members, onEdit, onReact }: Mess
)}
{msg.content && (
<div className="message-text">
<MessageContent content={msg.content} />
<MessageContent content={msg.content} memberUsernames={memberUsernames} />
{msg.edited_at && <span className="message-edited"> (edited)</span>}
</div>
)}
+11
View File
@@ -58,3 +58,14 @@
border-radius: 50%;
background: var(--ds-accent);
}
/* Distinct from the plain unread dot -- --ds-highlight is already this
app's second brand color (see tokens.css), reused here rather than
introducing a new semantic color just for mentions. */
.room-row-mention-dot {
flex: none;
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--ds-highlight);
}
+6 -1
View File
@@ -32,7 +32,12 @@ export function RoomRow({ room, colorIndex, active }: RoomRowProps) {
</div>
{room.description && <div className="room-row-subtitle">{room.description}</div>}
</div>
{room.has_unread && !active && <span className="room-row-unread-dot" aria-label="Unread messages" />}
{!active && room.has_mention && (
<span className="room-row-mention-dot" aria-label="You were mentioned" />
)}
{!active && !room.has_mention && room.has_unread && (
<span className="room-row-unread-dot" aria-label="Unread messages" />
)}
</Link>
)
}
+13 -5
View File
@@ -59,8 +59,8 @@ export function ChatShellPage() {
const socket = useChatSocketContext()
const setRoomUnread = useCallback((id: string, hasUnread: boolean) => {
setRooms((prev) => prev.map((r) => (r.id === id ? { ...r, has_unread: hasUnread } : r)))
const clearRoomIndicators = useCallback((id: string) => {
setRooms((prev) => prev.map((r) => (r.id === id ? { ...r, has_unread: false, has_mention: false } : r)))
}, [])
useEffect(
@@ -68,9 +68,17 @@ export function ChatShellPage() {
socket.subscribe((envelope) => {
if (envelope.type === 'room_added') refreshRooms()
else if (envelope.type === 'member_updated' && envelope.room_id === roomId) refreshMembers()
else if (envelope.type === 'unread_update') setRoomUnread(envelope.room_id, true)
else if (envelope.type === 'unread_update') {
setRooms((prev) =>
prev.map((r) =>
r.id === envelope.room_id
? { ...r, has_unread: true, has_mention: r.has_mention || envelope.mentioned }
: r,
),
)
}
}),
[socket, refreshRooms, refreshMembers, roomId, setRoomUnread],
[socket, refreshRooms, refreshMembers, roomId],
)
useEffect(() => {
@@ -115,7 +123,7 @@ export function ChatShellPage() {
onToggleInfo={() => setInfoOpen((v) => !v)}
infoOpen={infoOpen}
socket={socket}
onRoomRead={(id) => setRoomUnread(id, false)}
onRoomRead={clearRoomIndicators}
/>
) : (
!isMobile && (
+4
View File
@@ -67,6 +67,9 @@ export interface RoomListItem extends Room {
export interface MyRoomItem extends Room {
role: RoomRole
has_unread: boolean
// Unread and mentions the current user -- takes visual priority over
// has_unread in the sidebar (see RoomRow.tsx), not shown alongside it.
has_mention: boolean
}
export interface RoomMember {
@@ -171,6 +174,7 @@ export interface ChatMemberUpdatedEnvelope {
export interface ChatUnreadUpdateEnvelope {
type: 'unread_update'
room_id: string
mentioned: boolean
}
export type ServerEnvelope =