Add image uploads in chat messages (Gitea issue #10)

Images live on the app server's local disk (uploads/), served through an
authenticated, room-membership-gated endpoint since rooms can be private.
Uploads are streamed with a byte-count cap, validated as genuine decodable
images with Pillow (not just a spoofed Content-Type), and downscaled to
2000px on the longer side (except GIF, to preserve animation).

Backend: MessageImage model + nullable Message.content/image_id with a
content-or-image CheckConstraint, upload/serve endpoints in rooms.py, WS
message envelope gains image_id, push notification body says "sent an
image" for image-only messages.

Frontend: Composer gets an attach button with upload progress and a
thumbnail chip; MessageList renders images inline with a click-to-zoom
ImageLightbox.
This commit is contained in:
2026-08-14 12:21:42 -06:00
parent 559adf9b7e
commit f2a59f798b
27 changed files with 829 additions and 40 deletions
+37 -1
View File
@@ -1,4 +1,4 @@
import { apiFetch } from './client'
import { apiFetch, ApiError, NetworkError } from './client'
import type { Message, MyRoomItem, Room, RoomListItem, RoomMember, RoomRole } from '../types'
export function listRooms(): Promise<RoomListItem[]> {
@@ -71,3 +71,39 @@ export function transferOwnership(roomId: string, newOwnerUserId: string): Promi
export function getRoomMessages(roomId: string): Promise<Message[]> {
return apiFetch<Message[]>(`/api/rooms/${roomId}/messages`)
}
// Not apiFetch: that wrapper always sets Content-Type: application/json,
// which would stomp the multipart boundary the browser needs to set itself
// for a file upload.
export async function uploadRoomImage(roomId: string, file: File): Promise<{ id: string }> {
const formData = new FormData()
formData.append('file', file)
let response: Response
try {
response = await fetch(`/api/rooms/${roomId}/images`, {
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 { id: string }
}
export function getRoomImageUrl(roomId: string, imageId: string): string {
return `/api/rooms/${roomId}/images/${imageId}`
}
+2 -2
View File
@@ -99,8 +99,8 @@ export function ChatPane({ room, members, isMobile, onBack, onToggleInfo, infoOp
</p>
)}
<MessageList messages={[...history, ...live]} members={members} onEdit={sendEdit} />
<Composer roomName={room.name} disabled={!connected} onSend={send} />
<MessageList roomId={room.id} messages={[...history, ...live]} members={members} onEdit={sendEdit} />
<Composer roomId={room.id} roomName={room.name} disabled={!connected} onSend={send} />
</section>
)
}
+74
View File
@@ -49,8 +49,82 @@
cursor: not-allowed;
}
.composer-file-input {
display: none;
}
.composer-attach {
width: 36px;
height: 36px;
flex: none;
border-radius: var(--radius);
background: transparent;
color: var(--ds-muted);
border: 1px solid var(--ds-border);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
}
.composer-attach:hover:not(:disabled) {
color: var(--ds-text);
border-color: var(--ds-accent);
}
.composer-attach:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.composer-spinner {
animation: composer-spin 0.8s linear infinite;
}
@keyframes composer-spin {
to {
transform: rotate(360deg);
}
}
.composer-attachment {
position: relative;
width: fit-content;
}
.composer-attachment-thumb {
max-height: 72px;
max-width: 140px;
border-radius: var(--radius);
border: 1px solid var(--ds-border);
display: block;
object-fit: cover;
}
.composer-attachment-remove {
position: absolute;
top: -6px;
right: -6px;
width: 18px;
height: 18px;
border-radius: 50%;
background: var(--ds-surface-2);
border: 1px solid var(--ds-border);
color: var(--ds-text);
font-size: 0.7rem;
line-height: 1;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
}
.composer-status {
font-size: 0.76rem;
color: var(--ds-muted);
padding-left: 2px;
}
.composer-error {
color: var(--ds-danger, #e5484d);
}
+84 -6
View File
@@ -1,16 +1,22 @@
import { useRef, useState, type KeyboardEvent } from 'react'
import { useRef, useState, type ChangeEvent, type KeyboardEvent } from 'react'
import { useOnlineStatus } from '../hooks/useOnlineStatus'
import { uploadRoomImage } from '../api/rooms'
import './Composer.css'
interface ComposerProps {
roomId: string
roomName: string
disabled?: boolean
onSend: (content: string) => void
onSend: (content: string, imageId?: string) => void
}
export function Composer({ roomName, disabled, onSend }: ComposerProps) {
export function Composer({ roomId, roomName, disabled, onSend }: ComposerProps) {
const [value, setValue] = useState('')
const [pendingImage, setPendingImage] = useState<{ id: string; previewUrl: string } | null>(null)
const [uploading, setUploading] = useState(false)
const [uploadError, setUploadError] = useState<string | null>(null)
const textareaRef = useRef<HTMLTextAreaElement>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
const online = useOnlineStatus()
function autoGrow() {
@@ -22,9 +28,10 @@ export function Composer({ roomName, disabled, onSend }: ComposerProps) {
function handleSend() {
const trimmed = value.trim()
if (!trimmed) return
onSend(trimmed)
if (!trimmed && !pendingImage) return
onSend(trimmed, pendingImage?.id)
setValue('')
removePendingImage()
requestAnimationFrame(autoGrow)
}
@@ -35,9 +42,80 @@ export function Composer({ roomName, disabled, onSend }: ComposerProps) {
}
}
async function handleFileSelected(e: ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0]
e.target.value = ''
if (!file) return
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) }
})
} catch (err) {
setUploadError(err instanceof Error ? err.message : 'Upload failed')
} finally {
setUploading(false)
}
}
function removePendingImage() {
setPendingImage((prev) => {
if (prev) URL.revokeObjectURL(prev.previewUrl)
return null
})
}
return (
<div className="composer">
{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>
)}
{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}
/>
<button
type="button"
className="composer-attach"
onClick={() => fileInputRef.current?.click()}
disabled={disabled || uploading}
aria-label="Attach an image"
>
{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>
<textarea
ref={textareaRef}
rows={1}
@@ -54,7 +132,7 @@ export function Composer({ roomName, disabled, onSend }: ComposerProps) {
type="button"
className="composer-send"
onClick={handleSend}
disabled={disabled || !value.trim()}
disabled={disabled || (!value.trim() && !pendingImage)}
aria-label="Send message"
>
<svg width="15" height="15" viewBox="0 0 20 20" aria-hidden="true">
+18
View File
@@ -0,0 +1,18 @@
.image-lightbox {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.85);
display: flex;
align-items: center;
justify-content: center;
padding: var(--sp-4);
z-index: 100;
cursor: zoom-out;
}
.image-lightbox-img {
max-width: 100%;
max-height: 100%;
object-fit: contain;
border-radius: var(--radius);
}
+23
View File
@@ -0,0 +1,23 @@
import { useEffect } from 'react'
import './ImageLightbox.css'
interface ImageLightboxProps {
src: string
onClose: () => void
}
export function ImageLightbox({ src, onClose }: ImageLightboxProps) {
useEffect(() => {
function handleKeyDown(e: KeyboardEvent) {
if (e.key === 'Escape') onClose()
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [onClose])
return (
<div className="image-lightbox" onClick={onClose}>
<img src={src} alt="" className="image-lightbox-img" />
</div>
)
}
+11
View File
@@ -51,6 +51,17 @@
font-family: var(--mono);
}
.message-image {
display: block;
max-width: min(320px, 100%);
max-height: 240px;
object-fit: contain;
border-radius: var(--radius);
border: 1px solid var(--ds-border);
cursor: zoom-in;
margin-bottom: 4px;
}
.message-text {
font-size: 0.88rem;
line-height: 1.45;
+23 -6
View File
@@ -1,21 +1,25 @@
import { useEffect, useRef, useState } from 'react'
import { getRoomImageUrl } from '../api/rooms'
import { useAuth } from '../context/AuthContext'
import { senderColorIndex } from '../lib/messageGrouping'
import type { ChatMessageEnvelope, Message, RoomMember } from '../types'
import { ImageLightbox } from './ImageLightbox'
import { UserAvatar } from './UserAvatar'
import './MessageList.css'
interface MessageListProps {
roomId: string
messages: (Message | ChatMessageEnvelope)[]
members: RoomMember[]
onEdit: (messageId: string, content: string) => void
}
export function MessageList({ messages, members, onEdit }: MessageListProps) {
export function MessageList({ roomId, messages, members, onEdit }: MessageListProps) {
const { user } = useAuth()
const bottomRef = useRef<HTMLDivElement>(null)
const [editingId, setEditingId] = useState<string | null>(null)
const [draft, setDraft] = useState('')
const [lightboxSrc, setLightboxSrc] = useState<string | null>(null)
useEffect(() => {
bottomRef.current?.scrollIntoView({ block: 'end' })
@@ -23,7 +27,7 @@ export function MessageList({ messages, members, onEdit }: MessageListProps) {
function startEdit(msg: Message | ChatMessageEnvelope) {
setEditingId(msg.id)
setDraft(msg.content)
setDraft(msg.content ?? '')
}
function commitEdit(messageId: string) {
@@ -73,10 +77,22 @@ export function MessageList({ messages, members, onEdit }: MessageListProps) {
onBlur={() => commitEdit(msg.id)}
/>
) : (
<div className="message-text">
{msg.content}
{msg.edited_at && <span className="message-edited"> (edited)</span>}
</div>
<>
{msg.image_id && (
<img
src={getRoomImageUrl(roomId, msg.image_id)}
alt=""
className="message-image"
onClick={() => setLightboxSrc(getRoomImageUrl(roomId, msg.image_id!))}
/>
)}
{msg.content && (
<div className="message-text">
{msg.content}
{msg.edited_at && <span className="message-edited"> (edited)</span>}
</div>
)}
</>
)}
</div>
{mine && !editing && (
@@ -93,6 +109,7 @@ export function MessageList({ messages, members, onEdit }: MessageListProps) {
)
})}
<div ref={bottomRef} />
{lightboxSrc && <ImageLightbox src={lightboxSrc} onClose={() => setLightboxSrc(null)} />}
</div>
)
}
+4 -2
View File
@@ -56,7 +56,8 @@ export interface Message {
room_id: string
user_id: string
username: string
content: string
content: string | null
image_id: string | null
created_at: string
edited_at: string | null
}
@@ -67,7 +68,8 @@ export interface ChatMessageEnvelope {
room_id: string
user_id: string
username: string
content: string
content: string | null
image_id: string | null
created_at: string
edited_at: string | null
}
+4 -2
View File
@@ -82,10 +82,12 @@ export function useChatSocket({ roomId, onMessage, onUnauthenticated }: UseChatS
}
}, [roomId])
const send = useCallback((content: string) => {
const send = useCallback((content: string, imageId?: string) => {
const ws = socketRef.current
if (!ws || ws.readyState !== WebSocket.OPEN) return
ws.send(JSON.stringify({ type: 'message', room_id: roomId, content }))
ws.send(
JSON.stringify({ type: 'message', room_id: roomId, content: content || null, image_id: imageId ?? null }),
)
}, [roomId])
const sendEdit = useCallback((messageId: string, content: string) => {