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