Private
Public Access
Add generic file attachments to chat messages
Messages can now carry an arbitrary file (MessageFile), parallel to the existing MessageImage feature rather than a refactor of it. Files serve with Content-Disposition: attachment to force a download and prevent an uploaded HTML/SVG from executing same-origin. No content-type allowlist, same 8MB cap as images for now (a separate size-limit redesign is tracked as its own issue). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,13 @@
|
||||
import { apiFetch, ApiError, NetworkError } from './client'
|
||||
import type { Message, MyRoomItem, Room, RoomListItem, RoomMember, RoomRole } from '../types'
|
||||
import type {
|
||||
Message,
|
||||
MessageFileInfo,
|
||||
MyRoomItem,
|
||||
Room,
|
||||
RoomListItem,
|
||||
RoomMember,
|
||||
RoomRole,
|
||||
} from '../types'
|
||||
|
||||
export function listRooms(): Promise<RoomListItem[]> {
|
||||
return apiFetch<RoomListItem[]>('/api/rooms')
|
||||
@@ -114,3 +122,37 @@ export async function uploadRoomImage(roomId: string, file: File): Promise<{ id:
|
||||
export function getRoomImageUrl(roomId: string, imageId: string): string {
|
||||
return `/api/rooms/${roomId}/images/${imageId}`
|
||||
}
|
||||
|
||||
// Not apiFetch, same multipart-boundary reason as uploadRoomImage.
|
||||
export async function uploadRoomFile(roomId: string, file: File): Promise<MessageFileInfo> {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
|
||||
let response: Response
|
||||
try {
|
||||
response = await fetch(`/api/rooms/${roomId}/files`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
body: formData,
|
||||
})
|
||||
} catch {
|
||||
throw new NetworkError()
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
let detail = response.statusText
|
||||
try {
|
||||
const body = await response.json()
|
||||
detail = body.detail ?? detail
|
||||
} catch {
|
||||
// response had no JSON body
|
||||
}
|
||||
throw new ApiError(response.status, detail)
|
||||
}
|
||||
|
||||
return (await response.json()) as MessageFileInfo
|
||||
}
|
||||
|
||||
export function getRoomFileUrl(roomId: string, fileId: string): string {
|
||||
return `/api/rooms/${roomId}/files/${fileId}`
|
||||
}
|
||||
|
||||
@@ -130,6 +130,38 @@
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.composer-attachment-file {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
background: var(--ds-surface-2);
|
||||
border: 1px solid var(--ds-border);
|
||||
border-radius: var(--radius);
|
||||
padding: 6px 28px 6px 10px;
|
||||
color: var(--ds-text);
|
||||
max-width: 260px;
|
||||
}
|
||||
|
||||
.composer-attachment-file svg {
|
||||
flex: none;
|
||||
color: var(--ds-muted);
|
||||
}
|
||||
|
||||
.composer-attachment-filename {
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.composer-attachment-size {
|
||||
font-size: 0.72rem;
|
||||
color: var(--ds-muted);
|
||||
font-family: var(--mono);
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.composer-attachment-remove {
|
||||
position: absolute;
|
||||
top: -6px;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useRef, useState, type ChangeEvent, type KeyboardEvent } from 'react'
|
||||
import { useOnlineStatus } from '../hooks/useOnlineStatus'
|
||||
import { uploadRoomImage } from '../api/rooms'
|
||||
import { uploadRoomFile, uploadRoomImage } from '../api/rooms'
|
||||
import { EmojiPicker } from './EmojiPicker'
|
||||
import './Composer.css'
|
||||
|
||||
@@ -8,12 +8,21 @@ interface ComposerProps {
|
||||
roomId: string
|
||||
roomName: string
|
||||
disabled?: boolean
|
||||
onSend: (content: string, imageId?: string) => void
|
||||
onSend: (content: string, imageId?: string, fileId?: string) => void
|
||||
}
|
||||
|
||||
function formatFileSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
||||
}
|
||||
|
||||
export function Composer({ roomId, roomName, 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)
|
||||
@@ -30,10 +39,11 @@ export function Composer({ roomId, roomName, disabled, onSend }: ComposerProps)
|
||||
|
||||
function handleSend() {
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed && !pendingImage) return
|
||||
onSend(trimmed, pendingImage?.id)
|
||||
if (!trimmed && !pendingImage && !pendingFile) return
|
||||
onSend(trimmed, pendingImage?.id, pendingFile?.id)
|
||||
setValue('')
|
||||
removePendingImage()
|
||||
setPendingFile(null)
|
||||
requestAnimationFrame(autoGrow)
|
||||
}
|
||||
|
||||
@@ -52,11 +62,16 @@ export function Composer({ roomId, roomName, disabled, onSend }: ComposerProps)
|
||||
setUploadError(null)
|
||||
setUploading(true)
|
||||
try {
|
||||
const { id } = await uploadRoomImage(roomId, file)
|
||||
setPendingImage((prev) => {
|
||||
if (prev) URL.revokeObjectURL(prev.previewUrl)
|
||||
return { id, previewUrl: URL.createObjectURL(file) }
|
||||
})
|
||||
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 {
|
||||
@@ -104,12 +119,34 @@ export function Composer({ roomId, roomName, disabled, onSend }: ComposerProps)
|
||||
</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={fileInputRef}
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/gif,image/webp"
|
||||
className="composer-file-input"
|
||||
onChange={handleFileSelected}
|
||||
/>
|
||||
@@ -118,7 +155,7 @@ export function Composer({ roomId, roomName, disabled, onSend }: ComposerProps)
|
||||
className="composer-attach"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={disabled || uploading}
|
||||
aria-label="Attach an image"
|
||||
aria-label="Attach a file"
|
||||
>
|
||||
{uploading ? (
|
||||
<svg className="composer-spinner" width="15" height="15" viewBox="0 0 20 20" aria-hidden="true">
|
||||
@@ -171,7 +208,7 @@ export function Composer({ roomId, roomName, disabled, onSend }: ComposerProps)
|
||||
type="button"
|
||||
className="composer-send"
|
||||
onClick={handleSend}
|
||||
disabled={disabled || (!value.trim() && !pendingImage)}
|
||||
disabled={disabled || (!value.trim() && !pendingImage && !pendingFile)}
|
||||
aria-label="Send message"
|
||||
>
|
||||
<svg width="15" height="15" viewBox="0 0 20 20" aria-hidden="true">
|
||||
|
||||
@@ -62,6 +62,49 @@
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.message-file-attachment {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: var(--ds-surface-2);
|
||||
border: 1px solid var(--ds-border);
|
||||
border-radius: var(--radius);
|
||||
padding: 8px 12px;
|
||||
margin-bottom: 4px;
|
||||
color: var(--ds-text);
|
||||
text-decoration: none;
|
||||
max-width: min(320px, 100%);
|
||||
}
|
||||
|
||||
.message-file-attachment:hover {
|
||||
border-color: var(--ds-accent);
|
||||
}
|
||||
|
||||
.message-file-attachment svg {
|
||||
flex: none;
|
||||
color: var(--ds-muted);
|
||||
}
|
||||
|
||||
.message-file-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.message-file-name {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.message-file-size {
|
||||
font-size: 0.72rem;
|
||||
color: var(--ds-muted);
|
||||
font-family: var(--mono);
|
||||
}
|
||||
|
||||
.message-text {
|
||||
font-size: 0.88rem;
|
||||
line-height: 1.45;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { getRoomImageUrl } from '../api/rooms'
|
||||
import { getRoomFileUrl, getRoomImageUrl } from '../api/rooms'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import { avatarUrlFor, displayNameFor, senderColorIndex } from '../lib/messageGrouping'
|
||||
import type { ChatMessageEnvelope, Message, RoomMember } from '../types'
|
||||
@@ -9,6 +9,12 @@ import { MessageContent } from './MessageContent'
|
||||
import { UserAvatar } from './UserAvatar'
|
||||
import './MessageList.css'
|
||||
|
||||
function formatFileSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
||||
}
|
||||
|
||||
interface MessageListProps {
|
||||
roomId: string
|
||||
messages: (Message | ChatMessageEnvelope)[]
|
||||
@@ -103,6 +109,27 @@ export function MessageList({ roomId, messages, members, onEdit, onReact }: Mess
|
||||
onClick={() => setLightboxSrc(getRoomImageUrl(roomId, msg.image_id!))}
|
||||
/>
|
||||
)}
|
||||
{msg.file && (
|
||||
<a
|
||||
href={getRoomFileUrl(roomId, msg.file.id)}
|
||||
download={msg.file.filename}
|
||||
className="message-file-attachment"
|
||||
>
|
||||
<svg width="20" height="20" 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="message-file-info">
|
||||
<span className="message-file-name">{msg.file.filename}</span>
|
||||
<span className="message-file-size">{formatFileSize(msg.file.size_bytes)}</span>
|
||||
</span>
|
||||
</a>
|
||||
)}
|
||||
{msg.content && (
|
||||
<div className="message-text">
|
||||
<MessageContent content={msg.content} />
|
||||
|
||||
@@ -52,6 +52,13 @@ export interface ReactionSummary {
|
||||
user_ids: string[]
|
||||
}
|
||||
|
||||
export interface MessageFileInfo {
|
||||
id: string
|
||||
filename: string
|
||||
size_bytes: number
|
||||
content_type: string
|
||||
}
|
||||
|
||||
export interface Message {
|
||||
id: string
|
||||
room_id: string
|
||||
@@ -59,6 +66,7 @@ export interface Message {
|
||||
username: string
|
||||
content: string | null
|
||||
image_id: string | null
|
||||
file: MessageFileInfo | null
|
||||
reactions: ReactionSummary[]
|
||||
created_at: string
|
||||
edited_at: string | null
|
||||
@@ -72,6 +80,7 @@ export interface ChatMessageEnvelope {
|
||||
username: string
|
||||
content: string | null
|
||||
image_id: string | null
|
||||
file: MessageFileInfo | null
|
||||
reactions: ReactionSummary[]
|
||||
created_at: string
|
||||
edited_at: string | null
|
||||
|
||||
@@ -82,11 +82,17 @@ export function useChatSocket({ roomId, onMessage, onUnauthenticated }: UseChatS
|
||||
}
|
||||
}, [roomId])
|
||||
|
||||
const send = useCallback((content: string, imageId?: string) => {
|
||||
const send = useCallback((content: string, imageId?: string, fileId?: string) => {
|
||||
const ws = socketRef.current
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) return
|
||||
ws.send(
|
||||
JSON.stringify({ type: 'message', room_id: roomId, content: content || null, image_id: imageId ?? null }),
|
||||
JSON.stringify({
|
||||
type: 'message',
|
||||
room_id: roomId,
|
||||
content: content || null,
|
||||
image_id: imageId ?? null,
|
||||
file_id: fileId ?? null,
|
||||
}),
|
||||
)
|
||||
}, [roomId])
|
||||
|
||||
|
||||
Reference in New Issue
Block a user