Files
ds-chat/frontend/src/components/Composer.tsx
T
ksmithandClaude Sonnet 5 f3f59ad822 Add direct messages (#52)
A DM is a Room with a new is_dm flag, not a separate model -- reuses
all the membership/message/WS plumbing Room already has instead of
duplicating it. The room's `name` (still required + globally unique)
is an internal, never-displayed token derived deterministically from
the two participants' sorted user IDs (dm_room_name), which makes
find-or-create a single indexed lookup and gets free race-condition
safety from the existing unique constraint -- a concurrent double-
start from both people just hits the same IntegrityError->retry-as-
lookup path create_room already established.

Both participants get the plain 'member' role (no owner/admin
distinction makes sense for a 1:1 DM), which incidentally reuses
every existing role gate to block add-member, room-settings edits,
and join-via-browse on a DM for free. update_room also gets an
explicit is_dm guard independent of that, since renaming a DM isn't
just a privacy concern -- it would silently corrupt the find-or-create
invariant. DMs are excluded from both Browse Rooms and the admin
portal's room listing (fully private, per scope).

GET /api/rooms/mine precomputes each DM's other participant (name,
avatar, presence) as dm_partner in one batched query, so the sidebar
can render a DM row without a fetch per row. Frontend: a new "Direct
Messages" sidebar section (searchable by partner name, not the
internal room name), clicking someone in the People list starts or
resumes a DM, and the chat header/composer/RoomInfoPanel all render
the partner's identity instead of a room name where it's a DM.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-19 16:09:21 -06:00

536 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
useEffect,
useMemo,
useRef,
useState,
type ChangeEvent,
type DragEvent,
type FormEvent,
type KeyboardEvent,
} from 'react'
import { useEscapeKey } from '../hooks/useEscapeKey'
import { useOnlineStatus } from '../hooks/useOnlineStatus'
import { uploadRoomFile, uploadRoomImage } from '../api/rooms'
import { getUploadLimit } from '../api/uploads'
import { formatFileSize } from '../lib/fileSize'
import type { MyRoomItem, RoomMember } from '../types'
import { EmojiPicker } from './EmojiPicker'
import { MentionAutocomplete } from './MentionAutocomplete'
import { RoomReferenceAutocomplete } from './RoomReferenceAutocomplete'
import './Composer.css'
interface ComposerProps {
roomId: string
roomName: string
isDm?: boolean
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
onSend: (content: string, imageId?: string, fileId?: string) => void
}
interface TriggerQuery {
start: number
end: number
text: string
}
// Scans left from the cursor for an active "<trigger>partial" token -- the
// trigger char not preceded by a word character (so "foo@bar" mid-email
// doesn't trigger a mention query, and a literal '#' inside a word doesn't
// trigger a room-reference one) with only identifier-safe characters
// 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
while (i >= 0 && /[a-zA-Z0-9_.-]/.test(text[i])) i--
if (i < 0 || text[i] !== trigger) 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) }
}
function detectMentionQuery(text: string, cursor: number): TriggerQuery | null {
return detectTriggerQuery(text, cursor, '@')
}
function detectRoomReferenceQuery(text: string, cursor: number): TriggerQuery | null {
return detectTriggerQuery(text, cursor, '#')
}
interface AttachMenuProps {
onPickPhoto: () => void
onPickFile: () => void
onClose: () => void
}
// #29: splits into two explicit choices rather than one unrestricted file
// input -- see photoInputRef's comment on the Composer below for why a
// single input can't reliably offer both "any file type" and a mobile
// gallery shortcut at once.
function AttachMenu({ onPickPhoto, onPickFile, onClose }: AttachMenuProps) {
useEscapeKey(onClose)
return (
<>
<div className="composer-attach-menu-scrim" onClick={onClose} />
<div className="composer-attach-menu" role="menu">
<button type="button" role="menuitem" onClick={onPickPhoto}>
Photo or video
</button>
<button type="button" role="menuitem" onClick={onPickFile}>
File
</button>
</div>
</>
)
}
export function Composer({ roomId, roomName, isDm, members, rooms, 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>(
null,
)
const [uploading, setUploading] = useState(false)
const [uploadError, setUploadError] = useState<string | null>(null)
const [emojiPickerOpen, setEmojiPickerOpen] = useState(false)
const [maxUploadBytes, setMaxUploadBytes] = useState<number | null>(null)
const [mentionQuery, setMentionQuery] = useState<TriggerQuery | null>(null)
const [mentionActiveIndex, setMentionActiveIndex] = useState(0)
const [roomQuery, setRoomQuery] = useState<TriggerQuery | null>(null)
const [roomActiveIndex, setRoomActiveIndex] = useState(0)
const [dragActive, setDragActive] = useState(false)
const [attachMenuOpen, setAttachMenuOpen] = useState(false)
const textareaRef = useRef<HTMLTextAreaElement>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
// #29: a separate input with an image/video accept hint, so mobile
// browsers offer their media picker (with a direct Photos/Gallery
// shortcut) instead of the generic chooser a fully-unrestricted `accept`
// falls back to (Camera / Camera Video / Files, no gallery). The
// unrestricted `fileInputRef` above still exists for the "File" choice --
// Android can't reliably offer both a gallery shortcut and "any file
// type" from a single input, so the attach button now opens a small menu
// to pick which one you want first.
const photoInputRef = useRef<HTMLInputElement>(null)
// Counts nested dragenter/dragleave pairs (the overlay, the composer box,
// the textarea are all separate elements a drag passes over) so the
// highlight doesn't flicker off every time the pointer crosses a child
// element boundary -- only actually leaving the whole composer zeroes it.
const dragCounterRef = useRef(0)
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])
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(() => {
getUploadLimit()
.then((limit) => setMaxUploadBytes(limit.max_upload_bytes))
.catch(() => {
// Non-critical -- if this fails, oversized uploads just get caught
// by the server's 413 instead of client-side, no functional loss.
})
}, [])
function autoGrow() {
const el = textareaRef.current
if (!el) return
el.style.height = 'auto'
el.style.height = `${Math.min(el.scrollHeight, 120)}px`
}
function handleSend() {
const trimmed = value.trim()
if (!trimmed && !pendingImage && !pendingFile) return
onSend(trimmed, pendingImage?.id, pendingFile?.id)
setValue('')
setMentionQuery(null)
setRoomQuery(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 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>) {
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 (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) {
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/reference (without deleting
// it) still correctly closes the dropdown.
function handleSelectionChange(e: FormEvent<HTMLTextAreaElement>) {
const el = e.currentTarget
const cursor = el.selectionStart ?? 0
setMentionQuery(detectMentionQuery(el.value, cursor))
setMentionActiveIndex(0)
setRoomQuery(detectRoomReferenceQuery(el.value, cursor))
setRoomActiveIndex(0)
}
async function handleFile(file: File) {
setUploadError(null)
if (maxUploadBytes !== null && file.size > maxUploadBytes) {
setUploadError(`File exceeds ${formatFileSize(maxUploadBytes)} limit`)
return
}
setUploading(true)
try {
if (file.type.startsWith('image/')) {
const { id } = await uploadRoomImage(roomId, file)
setPendingImage((prev) => {
if (prev) URL.revokeObjectURL(prev.previewUrl)
return { id, previewUrl: URL.createObjectURL(file) }
})
} else {
const uploaded = await uploadRoomFile(roomId, file)
setPendingFile({ id: uploaded.id, filename: uploaded.filename, size: uploaded.size_bytes })
}
} catch (err) {
setUploadError(err instanceof Error ? err.message : 'Upload failed')
} finally {
setUploading(false)
}
}
function handleFileSelected(e: ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0]
e.target.value = ''
if (file) handleFile(file)
}
function handleDragEnter(e: DragEvent<HTMLDivElement>) {
e.preventDefault()
if (disabled) return
dragCounterRef.current++
setDragActive(true)
}
function handleDragLeave(e: DragEvent<HTMLDivElement>) {
e.preventDefault()
dragCounterRef.current = Math.max(0, dragCounterRef.current - 1)
if (dragCounterRef.current === 0) setDragActive(false)
}
function handleDragOver(e: DragEvent<HTMLDivElement>) {
// Required even though it does nothing else -- without preventDefault()
// here, the browser rejects the element as a drop target entirely and
// handleDrop never fires (it just navigates to/opens the dropped file).
e.preventDefault()
}
function handleDrop(e: DragEvent<HTMLDivElement>) {
e.preventDefault()
dragCounterRef.current = 0
setDragActive(false)
if (disabled) return
// Only the first dropped file, matching the existing single-attachment-
// per-message limit (the button-triggered file input isn't `multiple`
// either).
const file = e.dataTransfer.files?.[0]
if (file) handleFile(file)
}
function removePendingImage() {
setPendingImage((prev) => {
if (prev) URL.revokeObjectURL(prev.previewUrl)
return null
})
}
function insertEmoji(emoji: string) {
const el = textareaRef.current
setEmojiPickerOpen(false)
if (!el) {
setValue((v) => v + emoji)
return
}
const start = el.selectionStart ?? value.length
const end = el.selectionEnd ?? value.length
setValue(value.slice(0, start) + emoji + value.slice(end))
requestAnimationFrame(() => {
el.focus()
const cursor = start + emoji.length
el.setSelectionRange(cursor, cursor)
autoGrow()
})
}
return (
<div
className="composer"
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDrop={handleDrop}
>
{dragActive && (
<div className="composer-drop-overlay">
<span>Drop to attach</span>
</div>
)}
{pendingImage && (
<div className="composer-attachment">
<img src={pendingImage.previewUrl} alt="" className="composer-attachment-thumb" />
<button
type="button"
className="composer-attachment-remove"
onClick={removePendingImage}
aria-label="Remove attached image"
>
×
</button>
</div>
)}
{pendingFile && (
<div className="composer-attachment composer-attachment-file">
<svg width="16" height="16" viewBox="0 0 20 20" fill="none" aria-hidden="true">
<path
d="M6 2.5h6l4 4V16a1.5 1.5 0 0 1-1.5 1.5h-8A1.5 1.5 0 0 1 5 16V4A1.5 1.5 0 0 1 6 2.5Z"
stroke="currentColor"
strokeWidth="1.4"
strokeLinejoin="round"
/>
<path d="M12 2.5V6a1 1 0 0 0 1 1h3.5" stroke="currentColor" strokeWidth="1.4" strokeLinejoin="round" />
</svg>
<span className="composer-attachment-filename">{pendingFile.filename}</span>
<span className="composer-attachment-size">{formatFileSize(pendingFile.size)}</span>
<button
type="button"
className="composer-attachment-remove"
onClick={() => setPendingFile(null)}
aria-label="Remove attached file"
>
×
</button>
</div>
)}
{uploadError && <div className="composer-status composer-error">{uploadError}</div>}
<div className="composer-box">
<input
ref={photoInputRef}
type="file"
accept="image/*,video/*"
className="composer-file-input"
onChange={handleFileSelected}
/>
<input
ref={fileInputRef}
type="file"
className="composer-file-input"
onChange={handleFileSelected}
/>
<div className="composer-attach-wrap">
<button
type="button"
className="composer-attach"
onClick={() => setAttachMenuOpen((v) => !v)}
disabled={disabled || uploading}
aria-label="Attach a photo or file"
aria-expanded={attachMenuOpen}
>
{uploading ? (
<svg className="composer-spinner" width="15" height="15" viewBox="0 0 20 20" aria-hidden="true">
<circle cx="10" cy="10" r="7" stroke="currentColor" strokeWidth="2.4" fill="none" strokeDasharray="30 14" />
</svg>
) : (
<svg width="15" height="15" viewBox="0 0 20 20" fill="none" aria-hidden="true">
<path
d="M13.5 6.5 8 12a2.1 2.1 0 0 0 3 3l5.5-5.5a4 4 0 0 0-5.7-5.7L4.8 9.8a5.7 5.7 0 0 0 8 8"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)}
</button>
{attachMenuOpen && (
<AttachMenu
onPickPhoto={() => {
setAttachMenuOpen(false)
photoInputRef.current?.click()
}}
onPickFile={() => {
setAttachMenuOpen(false)
fileInputRef.current?.click()
}}
onClose={() => setAttachMenuOpen(false)}
/>
)}
</div>
<div className="composer-emoji-wrap">
<button
type="button"
className="composer-emoji-trigger"
onClick={() => setEmojiPickerOpen((v) => !v)}
disabled={disabled}
aria-label="Insert an emoji"
>
🙂
</button>
{emojiPickerOpen && (
<EmojiPicker
onPick={insertEmoji}
onClose={() => setEmojiPickerOpen(false)}
placement="above"
align="left"
/>
)}
</div>
<div className="composer-textarea-wrap">
<textarea
ref={textareaRef}
rows={1}
value={value}
disabled={disabled}
onChange={(e) => {
setValue(e.target.value)
autoGrow()
const cursor = e.target.selectionStart ?? 0
setMentionQuery(detectMentionQuery(e.target.value, cursor))
setMentionActiveIndex(0)
setRoomQuery(detectRoomReferenceQuery(e.target.value, cursor))
setRoomActiveIndex(0)
}}
onSelect={handleSelectionChange}
onKeyDown={handleKeyDown}
placeholder={
disabled
? online
? 'Connecting…'
: "You're offline"
: `Message ${isDm ? roomName : `#${roomName}`}`
}
spellCheck
/>
{mentionQuery && mentionMatches.length > 0 && (
<MentionAutocomplete
matches={mentionMatches}
activeIndex={mentionActiveIndex}
onPick={selectMention}
onHover={setMentionActiveIndex}
/>
)}
{roomQuery && roomMatches.length > 0 && (
<RoomReferenceAutocomplete
matches={roomMatches}
activeIndex={roomActiveIndex}
onPick={selectRoomReference}
onHover={setRoomActiveIndex}
/>
)}
</div>
<button
type="button"
className="composer-send"
onClick={handleSend}
disabled={disabled || (!value.trim() && !pendingImage && !pendingFile)}
aria-label="Send message"
>
<svg width="15" height="15" viewBox="0 0 20 20" aria-hidden="true">
<polygon points="2,2 18,10 2,18 6,10" fill="currentColor" />
</svg>
</button>
</div>
{disabled && (
<div className="composer-status">{online ? 'Connecting…' : "You're offline — messages can't be sent right now"}</div>
)}
</div>
)
}