Private
Public Access
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>
536 lines
18 KiB
TypeScript
536 lines
18 KiB
TypeScript
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>
|
||
)
|
||
}
|