Add composer autocomplete for #roomname (#47 follow-up)

Mirrors the @mention autocomplete exactly -- same trigger-detection
logic (factored into a shared detectTriggerQuery helper, parameterized
on '@' vs '#'), same arrow-key/Enter/Tab/Escape keyboard handling, same
dropdown. Suggests rooms the user belongs to, filtered by name prefix,
showing the room's description as a subtitle when it has one.

MentionAutocomplete.css is renamed to ComposerAutocomplete.css with
generic class names (composer-autocomplete-primary/-secondary instead of
-username/-display-name), now shared by both the mention and room-
reference dropdowns instead of being mention-specific.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-17 19:12:51 -06:00
co-authored by Claude Sonnet 5
parent 12264b4d18
commit 2a84a9c9bd
5 changed files with 141 additions and 27 deletions
+1
View File
@@ -261,6 +261,7 @@ export function ChatPane({
roomId={room.id} roomId={room.id}
roomName={room.name} roomName={room.name}
members={members} members={members}
rooms={rooms}
disabled={!connected} disabled={!connected}
onSend={send} onSend={send}
/> />
+93 -16
View File
@@ -12,39 +12,56 @@ import { useOnlineStatus } from '../hooks/useOnlineStatus'
import { uploadRoomFile, uploadRoomImage } from '../api/rooms' import { uploadRoomFile, uploadRoomImage } from '../api/rooms'
import { getUploadLimit } from '../api/uploads' import { getUploadLimit } from '../api/uploads'
import { formatFileSize } from '../lib/fileSize' import { formatFileSize } from '../lib/fileSize'
import type { RoomMember } from '../types' import type { MyRoomItem, RoomMember } from '../types'
import { EmojiPicker } from './EmojiPicker' import { EmojiPicker } from './EmojiPicker'
import { MentionAutocomplete } from './MentionAutocomplete' import { MentionAutocomplete } from './MentionAutocomplete'
import { RoomReferenceAutocomplete } from './RoomReferenceAutocomplete'
import './Composer.css' import './Composer.css'
interface ComposerProps { interface ComposerProps {
roomId: string roomId: string
roomName: string roomName: string
members: RoomMember[] members: RoomMember[]
// #47: rooms this user belongs to, for the #roomname autocomplete --
// deliberately the same list ChatPane already resolves message-display
// references against (see its myRooms comment), so what autocompletes
// while typing and what actually renders as a link later agree.
rooms: MyRoomItem[]
disabled?: boolean disabled?: boolean
onSend: (content: string, imageId?: string, fileId?: string) => void onSend: (content: string, imageId?: string, fileId?: string) => void
} }
interface MentionQuery { interface TriggerQuery {
start: number start: number
end: number end: number
text: string text: string
} }
// Scans left from the cursor for an active "@partial" token -- an '@' not // Scans left from the cursor for an active "<trigger>partial" token -- the
// preceded by a word character (so "foo@bar" mid-email doesn't trigger) // trigger char not preceded by a word character (so "foo@bar" mid-email
// with only mention-safe characters between it and the cursor (a space // doesn't trigger a mention query, and a literal '#' inside a word doesn't
// breaks out of the query entirely, closing the dropdown). // trigger a room-reference one) with only identifier-safe characters
function detectMentionQuery(text: string, cursor: number): MentionQuery | null { // between it and the cursor (a space breaks out of the query entirely,
// closing the dropdown). Shared by both @mention and #roomname detection --
// only the trigger character differs.
function detectTriggerQuery(text: string, cursor: number, trigger: string): TriggerQuery | null {
let i = cursor - 1 let i = cursor - 1
while (i >= 0 && /[a-zA-Z0-9_.-]/.test(text[i])) i-- while (i >= 0 && /[a-zA-Z0-9_.-]/.test(text[i])) i--
if (i < 0 || text[i] !== '@') return null if (i < 0 || text[i] !== trigger) return null
const prevChar = text[i - 1] const prevChar = text[i - 1]
if (prevChar && /\w/.test(prevChar)) return null if (prevChar && /\w/.test(prevChar)) return null
return { start: i, end: cursor, text: text.slice(i + 1, cursor) } return { start: i, end: cursor, text: text.slice(i + 1, cursor) }
} }
export function Composer({ roomId, roomName, members, disabled, onSend }: ComposerProps) { function detectMentionQuery(text: string, cursor: number): TriggerQuery | null {
return detectTriggerQuery(text, cursor, '@')
}
function detectRoomReferenceQuery(text: string, cursor: number): TriggerQuery | null {
return detectTriggerQuery(text, cursor, '#')
}
export function Composer({ roomId, roomName, members, rooms, disabled, onSend }: ComposerProps) {
const [value, setValue] = useState('') const [value, setValue] = useState('')
const [pendingImage, setPendingImage] = useState<{ id: string; previewUrl: string } | null>(null) const [pendingImage, setPendingImage] = useState<{ id: string; previewUrl: string } | null>(null)
const [pendingFile, setPendingFile] = useState<{ id: string; filename: string; size: number } | null>( const [pendingFile, setPendingFile] = useState<{ id: string; filename: string; size: number } | null>(
@@ -54,8 +71,10 @@ export function Composer({ roomId, roomName, members, disabled, onSend }: Compos
const [uploadError, setUploadError] = useState<string | null>(null) const [uploadError, setUploadError] = useState<string | null>(null)
const [emojiPickerOpen, setEmojiPickerOpen] = useState(false) const [emojiPickerOpen, setEmojiPickerOpen] = useState(false)
const [maxUploadBytes, setMaxUploadBytes] = useState<number | null>(null) const [maxUploadBytes, setMaxUploadBytes] = useState<number | null>(null)
const [mentionQuery, setMentionQuery] = useState<MentionQuery | null>(null) const [mentionQuery, setMentionQuery] = useState<TriggerQuery | null>(null)
const [mentionActiveIndex, setMentionActiveIndex] = useState(0) const [mentionActiveIndex, setMentionActiveIndex] = useState(0)
const [roomQuery, setRoomQuery] = useState<TriggerQuery | null>(null)
const [roomActiveIndex, setRoomActiveIndex] = useState(0)
const [dragActive, setDragActive] = useState(false) const [dragActive, setDragActive] = useState(false)
const textareaRef = useRef<HTMLTextAreaElement>(null) const textareaRef = useRef<HTMLTextAreaElement>(null)
const fileInputRef = useRef<HTMLInputElement>(null) const fileInputRef = useRef<HTMLInputElement>(null)
@@ -72,6 +91,12 @@ export function Composer({ roomId, roomName, members, disabled, onSend }: Compos
return members.filter((m) => m.username.toLowerCase().startsWith(q)).slice(0, 8) return members.filter((m) => m.username.toLowerCase().startsWith(q)).slice(0, 8)
}, [mentionQuery, members]) }, [mentionQuery, members])
const roomMatches = useMemo(() => {
if (!roomQuery) return []
const q = roomQuery.text.toLowerCase()
return rooms.filter((r) => r.name.toLowerCase().startsWith(q)).slice(0, 8)
}, [roomQuery, rooms])
useEffect(() => { useEffect(() => {
getUploadLimit() getUploadLimit()
.then((limit) => setMaxUploadBytes(limit.max_upload_bytes)) .then((limit) => setMaxUploadBytes(limit.max_upload_bytes))
@@ -94,6 +119,7 @@ export function Composer({ roomId, roomName, members, disabled, onSend }: Compos
onSend(trimmed, pendingImage?.id, pendingFile?.id) onSend(trimmed, pendingImage?.id, pendingFile?.id)
setValue('') setValue('')
setMentionQuery(null) setMentionQuery(null)
setRoomQuery(null)
removePendingImage() removePendingImage()
setPendingFile(null) setPendingFile(null)
requestAnimationFrame(autoGrow) requestAnimationFrame(autoGrow)
@@ -115,6 +141,22 @@ export function Composer({ roomId, roomName, members, disabled, onSend }: Compos
}) })
} }
function selectRoomReference(roomName: string) {
const query = roomQuery
if (!query) return
const el = textareaRef.current
const next = value.slice(0, query.start) + '#' + roomName + ' ' + value.slice(query.end)
setValue(next)
setRoomQuery(null)
requestAnimationFrame(() => {
if (!el) return
el.focus()
const cursor = query.start + roomName.length + 2 // '#' + roomName + trailing space
el.setSelectionRange(cursor, cursor)
autoGrow()
})
}
function handleKeyDown(e: KeyboardEvent<HTMLTextAreaElement>) { function handleKeyDown(e: KeyboardEvent<HTMLTextAreaElement>) {
if (mentionQuery && mentionMatches.length > 0) { if (mentionQuery && mentionMatches.length > 0) {
if (e.key === 'ArrowDown') { if (e.key === 'ArrowDown') {
@@ -138,21 +180,45 @@ export function Composer({ roomId, roomName, members, disabled, onSend }: Compos
return return
} }
} }
if (roomQuery && roomMatches.length > 0) {
if (e.key === 'ArrowDown') {
e.preventDefault()
setRoomActiveIndex((i) => (i + 1) % roomMatches.length)
return
}
if (e.key === 'ArrowUp') {
e.preventDefault()
setRoomActiveIndex((i) => (i - 1 + roomMatches.length) % roomMatches.length)
return
}
if (e.key === 'Enter' || e.key === 'Tab') {
e.preventDefault()
selectRoomReference(roomMatches[roomActiveIndex].name)
return
}
if (e.key === 'Escape') {
e.preventDefault()
setRoomQuery(null)
return
}
}
if (e.key === 'Enter' && !e.shiftKey) { if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault() e.preventDefault()
handleSend() handleSend()
} }
} }
// Re-detects the active @query on every cursor move, not just typing -- // Re-detects the active @/# query on every cursor move, not just typing --
// React's onSelect fires for clicks and arrow-key navigation too, so // React's onSelect fires for clicks and arrow-key navigation too, so
// moving the cursor out of a partial mention (without deleting it) still // moving the cursor out of a partial mention/reference (without deleting
// correctly closes the dropdown. // it) still correctly closes the dropdown.
function handleSelectionChange(e: FormEvent<HTMLTextAreaElement>) { function handleSelectionChange(e: FormEvent<HTMLTextAreaElement>) {
const el = e.currentTarget const el = e.currentTarget
const query = detectMentionQuery(el.value, el.selectionStart ?? 0) const cursor = el.selectionStart ?? 0
setMentionQuery(query) setMentionQuery(detectMentionQuery(el.value, cursor))
setMentionActiveIndex(0) setMentionActiveIndex(0)
setRoomQuery(detectRoomReferenceQuery(el.value, cursor))
setRoomActiveIndex(0)
} }
async function handleFile(file: File) { async function handleFile(file: File) {
@@ -353,8 +419,11 @@ export function Composer({ roomId, roomName, members, disabled, onSend }: Compos
onChange={(e) => { onChange={(e) => {
setValue(e.target.value) setValue(e.target.value)
autoGrow() autoGrow()
setMentionQuery(detectMentionQuery(e.target.value, e.target.selectionStart ?? 0)) const cursor = e.target.selectionStart ?? 0
setMentionQuery(detectMentionQuery(e.target.value, cursor))
setMentionActiveIndex(0) setMentionActiveIndex(0)
setRoomQuery(detectRoomReferenceQuery(e.target.value, cursor))
setRoomActiveIndex(0)
}} }}
onSelect={handleSelectionChange} onSelect={handleSelectionChange}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
@@ -369,6 +438,14 @@ export function Composer({ roomId, roomName, members, disabled, onSend }: Compos
onHover={setMentionActiveIndex} onHover={setMentionActiveIndex}
/> />
)} )}
{roomQuery && roomMatches.length > 0 && (
<RoomReferenceAutocomplete
matches={roomMatches}
activeIndex={roomActiveIndex}
onPick={selectRoomReference}
onHover={setRoomActiveIndex}
/>
)}
</div> </div>
<button <button
type="button" type="button"
@@ -1,4 +1,4 @@
.mention-autocomplete { .composer-autocomplete {
position: absolute; position: absolute;
bottom: calc(100% + 8px); bottom: calc(100% + 8px);
left: 0; left: 0;
@@ -16,7 +16,7 @@
gap: 2px; gap: 2px;
} }
.mention-autocomplete-item { .composer-autocomplete-item {
display: flex; display: flex;
align-items: baseline; align-items: baseline;
gap: 6px; gap: 6px;
@@ -29,18 +29,18 @@
color: var(--ds-text); color: var(--ds-text);
} }
.mention-autocomplete-item-active, .composer-autocomplete-item-active,
.mention-autocomplete-item:hover { .composer-autocomplete-item:hover {
background: var(--ds-surface-2); background: var(--ds-surface-2);
} }
.mention-autocomplete-username { .composer-autocomplete-primary {
font-size: 0.84rem; font-size: 0.84rem;
font-weight: 700; font-weight: 700;
color: var(--ds-accent); color: var(--ds-accent);
} }
.mention-autocomplete-display-name { .composer-autocomplete-secondary {
font-size: 0.76rem; font-size: 0.76rem;
color: var(--ds-muted); color: var(--ds-muted);
overflow: hidden; overflow: hidden;
@@ -1,5 +1,5 @@
import type { RoomMember } from '../types' import type { RoomMember } from '../types'
import './MentionAutocomplete.css' import './ComposerAutocomplete.css'
interface MentionAutocompleteProps { interface MentionAutocompleteProps {
matches: RoomMember[] matches: RoomMember[]
@@ -10,14 +10,14 @@ interface MentionAutocompleteProps {
export function MentionAutocomplete({ matches, activeIndex, onPick, onHover }: MentionAutocompleteProps) { export function MentionAutocomplete({ matches, activeIndex, onPick, onHover }: MentionAutocompleteProps) {
return ( return (
<div className="mention-autocomplete" role="listbox"> <div className="composer-autocomplete" role="listbox">
{matches.map((member, i) => ( {matches.map((member, i) => (
<button <button
key={member.user_id} key={member.user_id}
type="button" type="button"
role="option" role="option"
aria-selected={i === activeIndex} aria-selected={i === activeIndex}
className={`mention-autocomplete-item${i === activeIndex ? ' mention-autocomplete-item-active' : ''}`} className={`composer-autocomplete-item${i === activeIndex ? ' composer-autocomplete-item-active' : ''}`}
// Selecting must survive the textarea's blur (which would // Selecting must survive the textarea's blur (which would
// otherwise fire first and could dismiss the dropdown) -- // otherwise fire first and could dismiss the dropdown) --
// onMouseDown fires before blur, onClick fires after. // onMouseDown fires before blur, onClick fires after.
@@ -25,9 +25,9 @@ export function MentionAutocomplete({ matches, activeIndex, onPick, onHover }: M
onClick={() => onPick(member.username)} onClick={() => onPick(member.username)}
onMouseEnter={() => onHover(i)} onMouseEnter={() => onHover(i)}
> >
<span className="mention-autocomplete-username">@{member.username}</span> <span className="composer-autocomplete-primary">@{member.username}</span>
{member.display_name && ( {member.display_name && (
<span className="mention-autocomplete-display-name">{member.display_name}</span> <span className="composer-autocomplete-secondary">{member.display_name}</span>
)} )}
</button> </button>
))} ))}
@@ -0,0 +1,36 @@
import type { MyRoomItem } from '../types'
import './ComposerAutocomplete.css'
interface RoomReferenceAutocompleteProps {
matches: MyRoomItem[]
activeIndex: number
onPick: (roomName: string) => void
onHover: (index: number) => void
}
export function RoomReferenceAutocomplete({
matches,
activeIndex,
onPick,
onHover,
}: RoomReferenceAutocompleteProps) {
return (
<div className="composer-autocomplete" role="listbox">
{matches.map((room, i) => (
<button
key={room.id}
type="button"
role="option"
aria-selected={i === activeIndex}
className={`composer-autocomplete-item${i === activeIndex ? ' composer-autocomplete-item-active' : ''}`}
onMouseDown={(e) => e.preventDefault()}
onClick={() => onPick(room.name)}
onMouseEnter={() => onHover(i)}
>
<span className="composer-autocomplete-primary">#{room.name}</span>
{room.description && <span className="composer-autocomplete-secondary">{room.description}</span>}
</button>
))}
</div>
)
}