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
+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"