Private
Public Access
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:
@@ -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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
+6
-6
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user