From b1c270a6cea81919d889ae3701aa4f61983bd6e3 Mon Sep 17 00:00:00 2001 From: hzhang Date: Fri, 15 May 2026 20:17:10 +0100 Subject: [PATCH] feat(chat): file upload + attachments + pinned canvas MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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→
, 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) 
---
 src/index.css          | 131 ++++++++++++++++
 src/pages/ChatPage.tsx | 348 ++++++++++++++++++++++++++++++++++++++---
 2 files changed, 459 insertions(+), 20 deletions(-)

diff --git a/src/index.css b/src/index.css
index 8a73fb5..fbf5925 100644
--- a/src/index.css
+++ b/src/index.css
@@ -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;
+}
diff --git a/src/pages/ChatPage.tsx b/src/pages/ChatPage.tsx
index ef609e1..de82a15 100644
--- a/src/pages/ChatPage.tsx
+++ b/src/pages/ChatPage.tsx
@@ -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(null)
+  const [pendingFiles, setPendingFiles] = useState([])
+  const [uploading, setUploading] = useState(false)
+  const [canvas, setCanvas] = useState(null)
+  const [canvasCollapsed, setCanvasCollapsed] = useState(false)
+  const [showCanvasModal, setShowCanvasModal] = useState(false)
+  const [canvasTitle, setCanvasTitle] = useState('')
+  const [canvasFormat, setCanvasFormat] = useState('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 {
+    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 / 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() {
               
             )
           ) : null}
+          {currentChannel ? (
+            
+          ) : null}
           
+                  
+                
+              ) : null}
+              
+            
+            {canvasCollapsed ? null : (
+              
+ {canvas.format === 'html' ? ( +