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:
@@ -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,24 +1008,69 @@ export default function ChatPage() {
|
||||
{currentChannel?.closed ? (
|
||||
<div className="dc-closed-banner">This channel is closed — history is read-only.</div>
|
||||
) : (
|
||||
<form
|
||||
className="dc-composer"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
void sendMessage()
|
||||
}}
|
||||
>
|
||||
<input
|
||||
className="input"
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
placeholder={currentChannel ? `Message #${currentChannel.name}` : 'Select a channel first'}
|
||||
disabled={!currentChannel}
|
||||
/>
|
||||
<button className="btn" type="submit" disabled={!currentChannel || !content.trim()}>
|
||||
Send
|
||||
</button>
|
||||
</form>
|
||||
<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) => {
|
||||
e.preventDefault()
|
||||
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}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
placeholder={currentChannel ? `Message #${currentChannel.name}` : 'Select a channel first'}
|
||||
disabled={!currentChannel}
|
||||
/>
|
||||
<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()}>
|
||||
|
||||
Reference in New Issue
Block a user