feat(chat): file upload + attachments + pinned canvas

- composer 📎 attach (multi-file) with removable chips; uploads to
  /files then sends a message with attachments; image preview /
  download chips via ?access_token.
- pinned per-channel canvas panel below the topbar (fixed; independent
  of message scroll): md→renderMarkdown, text→<pre>, html→sandboxed
  iframe; collapse/expand.
- share/edit canvas modal (sharer-only Edit/Remove); live via
  canvas.updated/canvas.removed sockets; channel & messages context
  menus get canvas actions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
h z
2026-05-15 20:17:10 +01:00
parent f063807089
commit b1c270a6ce
2 changed files with 459 additions and 20 deletions

View File

@@ -864,3 +864,134 @@ button {
cursor: not-allowed;
background: none;
}
/* ---- canvas (pinned document) ---- */
.dc-canvas {
border-bottom: 1px solid var(--border);
background: var(--elevated);
display: flex;
flex-direction: column;
max-height: 46vh;
}
.dc-canvas-head {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 14px;
border-bottom: 1px solid var(--border);
}
.dc-canvas-head .cv-title {
font-weight: 600;
color: var(--text-h);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 40%;
}
.dc-canvas-head .cv-meta {
font-size: 12px;
color: var(--text-faint);
white-space: nowrap;
}
.dc-canvas-head .spacer {
flex: 1;
}
.dc-canvas-body {
overflow: auto;
padding: 14px 16px;
}
.dc-canvas-body .cv-frame {
width: 100%;
min-height: 320px;
border: 0;
background: #fff;
border-radius: var(--radius-sm);
}
.dc-canvas-body .cv-text {
margin: 0;
white-space: pre-wrap;
word-break: break-word;
font-family: var(--mono);
font-size: 13px;
color: var(--text);
}
.dc-canvas-body .cv-md {
font-size: 14px;
}
/* ---- message attachments ---- */
.attachments {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 6px;
}
.att-img {
max-width: 320px;
max-height: 240px;
border-radius: var(--radius-sm);
display: block;
}
.att-chip {
display: inline-flex;
align-items: center;
gap: 6px;
background: var(--input);
border: 1px solid var(--border);
color: var(--text);
font-size: 13px;
padding: 6px 10px;
border-radius: var(--radius-sm);
text-decoration: none;
}
.att-chip:hover {
border-color: var(--accent);
}
.pf-x {
background: none;
border: 0;
color: var(--text-faint);
cursor: pointer;
font-size: 14px;
line-height: 1;
padding: 0 2px;
}
.pf-x:hover {
color: var(--danger);
}
/* ---- composer with attachments ---- */
.dc-composer-wrap {
display: flex;
flex-direction: column;
border-top: 1px solid var(--border);
}
.pending-files {
display: flex;
flex-wrap: wrap;
gap: 6px;
padding: 8px 14px 0;
}
.dc-composer-wrap .dc-composer {
border-top: 0;
}
.attach-btn {
cursor: pointer;
display: inline-flex;
align-items: center;
}
.attach-btn[aria-disabled='true'] {
opacity: 0.4;
pointer-events: none;
}
textarea.canvas-src {
width: 100%;
resize: vertical;
font-family: var(--mono);
font-size: 13px;
line-height: 1.5;
}
.modal-card.modal-wide {
max-width: 720px;
width: 92vw;
}

View File

@@ -5,6 +5,7 @@ import { useAuth } from '../auth/auth-context'
import { renderMarkdown } from '../lib/markdown'
import { guildMembersCenter, joinGuildCenter } from '../lib/center-auth-client'
type Attachment = { url: string; name?: string; mimeType?: string }
type MessageItem = {
messageId: string
seq: number
@@ -12,11 +13,23 @@ type MessageItem = {
isDeleted?: boolean
authorUserId?: string
createdAt?: string
attachments?: Attachment[]
// per-push metadata (only present on socket-delivered messages); the app is
// normally metadata-agnostic — only surfaced in developer mode
wakeup?: boolean
}
type CanvasFormat = 'md' | 'html' | 'text'
type CanvasDoc = {
channelId: string
sharerUserId: string
title: string
format: CanvasFormat
source: string
version: number
updatedAt: string
}
const DEV_KEY = 'fabric.debug'
function initials(s: string): string {
@@ -70,6 +83,15 @@ export default function ChatPage() {
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [ctxMenu, setCtxMenu] = useState<CtxMenu | null>(null)
const [pendingFiles, setPendingFiles] = useState<File[]>([])
const [uploading, setUploading] = useState(false)
const [canvas, setCanvas] = useState<CanvasDoc | null>(null)
const [canvasCollapsed, setCanvasCollapsed] = useState(false)
const [showCanvasModal, setShowCanvasModal] = useState(false)
const [canvasTitle, setCanvasTitle] = useState('')
const [canvasFormat, setCanvasFormat] = useState<CanvasFormat>('md')
const [canvasSource, setCanvasSource] = useState('')
const [canvasEditMode, setCanvasEditMode] = useState(false)
function openCtx(e: ReactMouseEvent, title: string, items: MenuItem[]) {
e.preventDefault()
@@ -212,18 +234,93 @@ export default function ChatPage() {
}
}
async function uploadFile(file: File): Promise<Attachment> {
const form = new FormData()
form.append('file', file)
const res = await guildApi().post(
`/files?channelId=${encodeURIComponent(selectedChannelId)}`,
form,
)
return { url: res.data.url as string, name: res.data.name as string, mimeType: res.data.mimeType as string }
}
async function sendMessage() {
if (!selectedChannelId || !content.trim() || !guild || !guildToken) return
if (!selectedChannelId || !guild || !guildToken) return
if (!content.trim() && pendingFiles.length === 0) return
setError('')
setUploading(true)
try {
const attachments: Attachment[] = []
for (const f of pendingFiles) attachments.push(await uploadFile(f))
await guildApi().post(`/channels/${selectedChannelId}/messages`, {
content,
content: content.trim() || (attachments.length ? `[${attachments.length} file(s)]` : ''),
authorUserId: session?.user.id ?? 'unknown',
attachments,
})
setContent('')
setPendingFiles([])
await pullMessages()
} catch {
setError('Failed to send message')
} finally {
setUploading(false)
}
}
// ---- canvas (single pinned document per channel) ----
async function loadCanvas() {
if (!guild || !guildToken || !selectedChannelId) {
setCanvas(null)
return
}
try {
const res = await guildApi().get(`/channels/${selectedChannelId}/canvas`)
setCanvas(res.data && res.data.channelId ? (res.data as CanvasDoc) : null)
} catch {
setCanvas(null)
}
}
function openCanvasShare() {
setCanvasEditMode(false)
setCanvasTitle('')
setCanvasFormat('md')
setCanvasSource('')
setShowCanvasModal(true)
}
function openCanvasEdit() {
if (!canvas) return
setCanvasEditMode(true)
setCanvasTitle(canvas.title)
setCanvasFormat(canvas.format)
setCanvasSource(canvas.source)
setShowCanvasModal(true)
}
async function submitCanvas() {
if (!guild || !guildToken || !selectedChannelId) return
const body = { title: canvasTitle, format: canvasFormat, source: canvasSource }
try {
if (canvasEditMode) {
await guildApi().patch(`/channels/${selectedChannelId}/canvas`, body)
} else {
await guildApi().put(`/channels/${selectedChannelId}/canvas`, body)
}
setShowCanvasModal(false)
await loadCanvas()
} catch {
setError(canvasEditMode ? 'Failed to update canvas' : 'Failed to share canvas')
}
}
async function removeCanvas() {
if (!guild || !guildToken || !selectedChannelId) return
try {
await guildApi().delete(`/channels/${selectedChannelId}/canvas`)
setCanvas(null)
} catch {
setError('Failed to remove canvas')
}
}
@@ -400,6 +497,9 @@ export default function ChatPage() {
useEffect(() => {
void pullMessages()
void loadChannelMembers()
void loadCanvas()
setPendingFiles([])
setCanvasCollapsed(false)
}, [selectedChannelId])
useEffect(() => {
@@ -412,6 +512,12 @@ export default function ChatPage() {
socket.on('message.deleted', (m: MessageItem) =>
setMessages((prev) => prev.map((x) => (x.messageId === m.messageId ? { ...x, isDeleted: true, content: '[deleted]' } : x))),
)
socket.on('canvas.updated', (c: CanvasDoc) => {
if (c.channelId === selectedChannelId) setCanvas(c)
})
socket.on('canvas.removed', (p: { channelId: string }) => {
if (p.channelId === selectedChannelId) setCanvas(null)
})
socket.connect()
socket.emit('join_channel', { channelId: selectedChannelId })
@@ -432,6 +538,12 @@ export default function ChatPage() {
? session?.user.name || session?.user.email || 'you'
: nameById.get(id) || id.slice(0, 8)
// Browser <img>/<a> can't send Authorization; pass the guild token in the
// query (the guild ApiKeyGuard accepts ?access_token=).
const fileUrl = (u: string) =>
`${guild?.endpoint ?? ''}${u}${u.includes('?') ? '&' : '?'}access_token=${encodeURIComponent(guildToken)}`
const isCanvasSharer = !!canvas && canvas.sharerUserId === session?.user.id
const guildById = new Map(members.map((m) => [m.userId, m]))
const channelMembers = channelMemberIds.map(
(id) => guildById.get(id) ?? { userId: id, email: '', name: '', status: 'active' },
@@ -492,6 +604,16 @@ export default function ChatPage() {
items.push({ kind: 'item', label: 'Join channel', onClick: () => joinChannel(c) })
if (!c.closed)
items.push({ kind: 'item', label: 'Close channel', danger: true, onClick: () => closeChannel(c) })
if (c.id === selectedChannelId) {
items.push({ kind: 'sep' })
if (!canvas)
items.push({ kind: 'item', label: 'Share canvas…', onClick: openCanvasShare })
else if (isCanvasSharer)
items.push(
{ kind: 'item', label: 'Edit canvas…', onClick: openCanvasEdit },
{ kind: 'item', label: 'Remove canvas', danger: true, onClick: () => void removeCanvas() },
)
}
items.push(
{ kind: 'sep' },
{ kind: 'item', label: 'Copy channel ID', onClick: () => copyText(c.id) },
@@ -574,6 +696,16 @@ export default function ChatPage() {
disabled: !selectedChannelId,
onClick: () => void pullMessages(),
},
{
kind: 'item',
label: canvas ? (isCanvasSharer ? 'Edit canvas…' : 'View canvas') : 'Share canvas…',
disabled: !selectedChannelId,
onClick: canvas
? isCanvasSharer
? openCanvasEdit
: () => setCanvasCollapsed(false)
: openCanvasShare,
},
]
if (scope === 'members')
return [
@@ -729,6 +861,15 @@ export default function ChatPage() {
</button>
)
) : null}
{currentChannel ? (
<button
className="btn btn-secondary"
title="Share a pinned document in this channel"
onClick={canvas ? (isCanvasSharer ? openCanvasEdit : () => setCanvasCollapsed(false)) : openCanvasShare}
>
{canvas ? (isCanvasSharer ? 'Edit canvas' : 'Canvas') : 'Share canvas'}
</button>
) : null}
<button
className="icon-btn"
title={showMembers ? 'Hide members' : 'Show members'}
@@ -738,6 +879,57 @@ export default function ChatPage() {
</button>
</header>
{canvas ? (
<section className="dc-canvas">
<div className="dc-canvas-head">
<span className="cv-title">🗎 {canvas.title}</span>
<span className="cv-meta">
shared by {authorLabel(canvas.sharerUserId)} · v{canvas.version} ·{' '}
{timeOf(canvas.updatedAt)}
</span>
<span className="spacer" />
{isCanvasSharer ? (
<>
<button className="icon-btn" title="Edit canvas" onClick={openCanvasEdit}>
</button>
<button className="icon-btn" title="Remove canvas" onClick={removeCanvas}>
🗑
</button>
</>
) : null}
<button
className="icon-btn"
title={canvasCollapsed ? 'Expand' : 'Collapse'}
onClick={() => setCanvasCollapsed((v) => !v)}
>
{canvasCollapsed ? '▸' : '▾'}
</button>
</div>
{canvasCollapsed ? null : (
<div className="dc-canvas-body">
{canvas.format === 'html' ? (
<iframe
className="cv-frame"
title={canvas.title}
sandbox=""
srcDoc={canvas.source}
/>
) : canvas.format === 'text' ? (
<pre className="cv-text">{canvas.source}</pre>
) : (
<div
className="text md cv-md"
dangerouslySetInnerHTML={{
__html: renderMarkdown(canvas.source, { resolveMention: mentionName }),
}}
/>
)}
</div>
)}
</section>
) : null}
<div
className="dc-messages"
onContextMenu={(e) => openCtx(e, 'Messages', blankMenu('messages'))}
@@ -769,6 +961,29 @@ export default function ChatPage() {
className="text md"
dangerouslySetInnerHTML={{ __html: renderMarkdown(m.content, { resolveMention: mentionName }) }}
/>
{!m.isDeleted && m.attachments && m.attachments.length ? (
<div className="attachments">
{m.attachments.map((a, ai) => {
const isImg = (a.mimeType ?? '').startsWith('image/')
const href = fileUrl(a.url)
return isImg ? (
<a key={ai} href={href} target="_blank" rel="noopener noreferrer">
<img className="att-img" src={href} alt={a.name ?? 'image'} />
</a>
) : (
<a
key={ai}
className="att-chip"
href={href}
target="_blank"
rel="noopener noreferrer"
>
📎 {a.name ?? 'file'}
</a>
)
})}
</div>
) : null}
{devMode ? (
<pre className="meta-raw">
{JSON.stringify(
@@ -793,6 +1008,24 @@ export default function ChatPage() {
{currentChannel?.closed ? (
<div className="dc-closed-banner">This channel is closed history is read-only.</div>
) : (
<div className="dc-composer-wrap">
{pendingFiles.length ? (
<div className="pending-files">
{pendingFiles.map((f, fi) => (
<span key={fi} className="att-chip">
📎 {f.name}
<button
type="button"
className="pf-x"
title="Remove"
onClick={() => setPendingFiles((prev) => prev.filter((_, j) => j !== fi))}
>
×
</button>
</span>
))}
</div>
) : null}
<form
className="dc-composer"
onSubmit={(e) => {
@@ -800,6 +1033,24 @@ export default function ChatPage() {
void sendMessage()
}}
>
<label
className="icon-btn attach-btn"
title="Attach files"
aria-disabled={!currentChannel}
>
📎
<input
type="file"
multiple
hidden
disabled={!currentChannel}
onChange={(e) => {
const files = Array.from(e.target.files ?? [])
if (files.length) setPendingFiles((prev) => [...prev, ...files])
e.target.value = ''
}}
/>
</label>
<input
className="input"
value={content}
@@ -807,10 +1058,19 @@ export default function ChatPage() {
placeholder={currentChannel ? `Message #${currentChannel.name}` : 'Select a channel first'}
disabled={!currentChannel}
/>
<button className="btn" type="submit" disabled={!currentChannel || !content.trim()}>
Send
<button
className="btn"
type="submit"
disabled={
!currentChannel ||
uploading ||
(!content.trim() && pendingFiles.length === 0)
}
>
{uploading ? 'Sending…' : 'Send'}
</button>
</form>
</div>
)}
</main>
@@ -1022,6 +1282,54 @@ export default function ChatPage() {
</div>
) : null}
{showCanvasModal ? (
<div className="modal-backdrop" onClick={() => setShowCanvasModal(false)}>
<div className="modal-card modal-wide" onClick={(e) => e.stopPropagation()}>
<h3>{canvasEditMode ? 'Edit canvas' : 'Share canvas'}</h3>
<p className="muted" style={{ marginBottom: 10, fontSize: 13 }}>
A single pinned document per channel. Only you (the sharer) can
update it. HTML is rendered in a sandboxed frame.
</p>
<input
className="input"
value={canvasTitle}
onChange={(e) => setCanvasTitle(e.target.value)}
placeholder="Document title"
/>
<div className="field" style={{ marginTop: 10 }}>
<label>Format</label>
<select
className="input"
value={canvasFormat}
onChange={(e) => setCanvasFormat(e.target.value as CanvasFormat)}
>
<option value="md">Markdown</option>
<option value="html">HTML</option>
<option value="text">Plain text</option>
</select>
</div>
<div className="field" style={{ marginTop: 10 }}>
<label>Content</label>
<textarea
className="input canvas-src"
value={canvasSource}
onChange={(e) => setCanvasSource(e.target.value)}
placeholder="Document source…"
rows={14}
/>
</div>
<div className="modal-actions">
<button className="btn" onClick={submitCanvas}>
{canvasEditMode ? 'Save' : 'Share'}
</button>
<button className="btn btn-secondary" onClick={() => setShowCanvasModal(false)}>
Cancel
</button>
</div>
</div>
</div>
) : null}
{error ? (
<div className="modal-backdrop" onClick={() => setError('')}>
<div className="modal-card" onClick={(e) => e.stopPropagation()}>