Files
Fabric.Frontend/src/pages/ChatPage.tsx
hzhang b1c270a6ce 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>
2026-05-15 20:17:10 +01:00

1349 lines
48 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import axios from 'axios'
import { io, type Socket } from 'socket.io-client'
import { useEffect, useMemo, useState, type MouseEvent as ReactMouseEvent } from 'react'
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
content: string
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 {
const t = (s || '?').trim()
return t.slice(0, 2).toUpperCase()
}
function timeOf(iso?: string): string {
if (!iso) return ''
const d = new Date(iso)
if (Number.isNaN(d.getTime())) return ''
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
}
const X_TYPES = ['general', 'work', 'report', 'discuss', 'triage', 'custom'] as const
type XType = (typeof X_TYPES)[number]
type GuildChannel = { id: string; name: string; guildId?: string; xType?: XType; isMember?: boolean; isPublic?: boolean; closed?: boolean }
type MemberItem = { userId: string; email: string; name: string; status: string }
type MenuItem =
| { kind: 'sep' }
| { kind: 'item'; label: string; onClick: () => void; danger?: boolean; disabled?: boolean }
type CtxMenu = { x: number; y: number; title: string; items: MenuItem[] }
export default function ChatPage() {
const { session, logout, ensureFreshToken, refreshGuilds, updateName } = useAuth()
const [selectedGuildId, setSelectedGuildId] = useState('')
const [selectedChannelId, setSelectedChannelId] = useState('')
const [channelMemberIds, setChannelMemberIds] = useState<string[]>([])
const [bypassMemberIds, setBypassMemberIds] = useState<string[]>([])
const [channels, setChannels] = useState<GuildChannel[]>([])
const [guildDbId, setGuildDbId] = useState('')
const [members, setMembers] = useState<MemberItem[]>([])
const [messages, setMessages] = useState<MessageItem[]>([])
const [content, setContent] = useState('')
const [newChannelName, setNewChannelName] = useState('')
const [joinGuildNodeId, setJoinGuildNodeId] = useState('')
const [selectedMemberIds, setSelectedMemberIds] = useState<string[]>([])
const [newChannelPublic, setNewChannelPublic] = useState(false)
const [newChannelXType, setNewChannelXType] = useState<XType>('general')
const [newChannelOnDuty, setNewChannelOnDuty] = useState('')
const [newChannelListeners, setNewChannelListeners] = useState<string[]>([])
const [newChannelBypass, setNewChannelBypass] = useState<string[]>([])
const [showCreateChannelModal, setShowCreateChannelModal] = useState(false)
const [showSettingsModal, setShowSettingsModal] = useState(false)
const [showAddGuildModal, setShowAddGuildModal] = useState(false)
const [settingsName, setSettingsName] = useState('')
const [showMembers, setShowMembers] = useState(true)
const [devMode, setDevMode] = useState(() => localStorage.getItem(DEV_KEY) === '1')
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()
e.stopPropagation()
if (!items.length) return
setCtxMenu({ x: e.clientX, y: e.clientY, title, items })
}
function copyText(text: string) {
navigator.clipboard?.writeText(text).catch(() => {})
}
function insertMention(id: string) {
setContent((c) => `${c.trim() ? c.replace(/\s*$/, '') + ' ' : ''}<@${id}> `)
}
useEffect(() => {
if (!ctxMenu) return
const close = () => setCtxMenu(null)
const onKey = (ev: KeyboardEvent) => {
if (ev.key === 'Escape') close()
}
window.addEventListener('click', close)
window.addEventListener('resize', close)
window.addEventListener('scroll', close, true)
window.addEventListener('keydown', onKey)
return () => {
window.removeEventListener('click', close)
window.removeEventListener('resize', close)
window.removeEventListener('scroll', close, true)
window.removeEventListener('keydown', onKey)
}
}, [ctxMenu])
function toggleDevMode(on: boolean) {
setDevMode(on)
if (on) localStorage.setItem(DEV_KEY, '1')
else localStorage.removeItem(DEV_KEY)
}
const guilds = session?.guilds ?? []
useEffect(() => {
if (!selectedGuildId && guilds.length) setSelectedGuildId(guilds[0].nodeId)
}, [guilds, selectedGuildId])
// Guild access tokens have a short TTL and are persisted in localStorage.
// On (re)load they may be expired, so re-issue fresh ones from Center
// before any guild API call. refreshGuilds also refreshes the center token.
useEffect(() => {
refreshGuilds().catch(() => {})
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const guild = useMemo(() => guilds.find((g) => g.nodeId === selectedGuildId) ?? null, [guilds, selectedGuildId])
const guildToken = useMemo(
() => (session?.guildAccessTokens ?? []).find((x) => x.guildNodeId === selectedGuildId)?.token ?? '',
[session, selectedGuildId],
)
function guildApi() {
if (!guild || !guildToken) throw new Error('guild or token missing')
return axios.create({
baseURL: `${guild.endpoint}/api`,
timeout: 10000,
headers: { Authorization: `Bearer ${guildToken}` },
})
}
const socket = useMemo<Socket | null>(() => {
if (!guild || !guildToken) return null
return io(`${guild.endpoint}/realtime`, {
transports: ['websocket'],
auth: { token: guildToken },
autoConnect: false,
})
}, [guild, guildToken])
async function loadChannels() {
if (!guild || !guildToken) return
setError('')
try {
const res = await guildApi().get('/channels', { params: guildDbId ? { guildId: guildDbId } : undefined })
const list = Array.isArray(res.data) ? (res.data as GuildChannel[]) : []
setChannels(list)
if (!guildDbId && list[0]?.guildId) setGuildDbId(list[0].guildId)
if (!selectedChannelId && list.length) setSelectedChannelId(list[0].id)
} catch {
setError('Failed to load channels')
setChannels([])
}
}
async function loadMembers() {
if (!session || !selectedGuildId) return
setError('')
try {
const token = await ensureFreshToken()
if (!token) return
const list = await guildMembersCenter(session.centerApiBase, token, selectedGuildId)
setMembers(list)
} catch {
setError('Failed to load guild members')
setMembers([])
}
}
async function loadChannelMembers() {
if (!guild || !guildToken || !selectedChannelId) {
setChannelMemberIds([])
setBypassMemberIds([])
return
}
try {
const res = await guildApi().get(`/channels/${selectedChannelId}/members`)
const list = Array.isArray(res.data)
? (res.data as Array<{ userId: string; bypass?: boolean }>)
: []
setChannelMemberIds(list.map((x) => x.userId))
setBypassMemberIds(list.filter((x) => x.bypass).map((x) => x.userId))
} catch {
setChannelMemberIds([])
setBypassMemberIds([])
}
}
async function pullMessages() {
if (!selectedChannelId || !guild || !guildToken) return
setLoading(true)
setError('')
try {
const res = await guildApi().get(`/channels/${selectedChannelId}/messages`, {
params: { seq_from: 1, seq_to: 999999, limit: 100 },
})
setMessages(res.data.items ?? [])
} catch {
setError('Failed to load messages')
} finally {
setLoading(false)
}
}
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 || !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.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')
}
}
async function addGuild() {
if (!session || !joinGuildNodeId.trim()) return
setError('')
try {
const token = await ensureFreshToken()
if (!token) return
await joinGuildCenter(session.centerApiBase, token, joinGuildNodeId.trim())
await refreshGuilds()
setJoinGuildNodeId('')
setShowAddGuildModal(false)
} catch {
setError('Failed to add guild')
}
}
function toggleMember(userId: string) {
setSelectedMemberIds((prev) => (prev.includes(userId) ? prev.filter((x) => x !== userId) : [...prev, userId]))
}
function toggleListener(userId: string) {
setNewChannelListeners((prev) => (prev.includes(userId) ? prev.filter((x) => x !== userId) : [...prev, userId]))
}
function toggleBypass(userId: string) {
setNewChannelBypass((prev) => (prev.includes(userId) ? prev.filter((x) => x !== userId) : [...prev, userId]))
}
function openCreateChannel() {
setNewChannelName('')
setSelectedMemberIds([])
setNewChannelPublic(false)
setNewChannelXType('general')
setNewChannelOnDuty(session?.user.id ?? '')
setNewChannelListeners([])
setNewChannelBypass([])
setShowCreateChannelModal(true)
}
async function createChannel() {
if (!guild || !guildToken || !newChannelName.trim()) return
const effectiveGuildId = guildDbId || selectedGuildId
if (!effectiveGuildId) {
setError('Cannot create channel: no guild selected')
return
}
if (newChannelXType === 'triage' && !newChannelOnDuty) {
setError('Triage channels require an on-duty user')
return
}
setError('')
try {
const payload = {
name: newChannelName.trim(),
guildId: effectiveGuildId,
xType: newChannelXType,
isPublic: newChannelPublic,
memberUserIds: selectedMemberIds,
onDuty: newChannelXType === 'triage' ? newChannelOnDuty : undefined,
listeners: newChannelXType === 'custom' ? newChannelListeners : [],
bypassUserIds:
newChannelXType === 'discuss' || newChannelXType === 'work'
? newChannelBypass
: [],
}
const res = await guildApi().post('/channels', payload)
const createdId = res.data?.id as string | undefined
setNewChannelName('')
setSelectedMemberIds([])
setNewChannelPublic(false)
setNewChannelXType('general')
setNewChannelOnDuty(session?.user.id ?? '')
setNewChannelListeners([])
setNewChannelBypass([])
setShowCreateChannelModal(false)
await loadChannels()
if (createdId) setSelectedChannelId(createdId)
} catch {
setError('Failed to create channel')
}
}
async function joinChannel(c: GuildChannel) {
if (!guild || !guildToken) return
try {
await guildApi().post(`/channels/${c.id}/join`)
await loadChannels()
await loadMembers()
await loadChannelMembers()
} catch {
setError('Failed to join channel')
}
}
async function moveToBypass(userId: string) {
if (!guild || !guildToken || !selectedChannelId) return
try {
await guildApi().post(`/channels/${selectedChannelId}/bypass`, { userId })
await loadChannelMembers()
} catch {
setError('Failed to move member to bypass')
}
}
async function closeChannel(c: GuildChannel) {
if (!guild || !guildToken) return
try {
await guildApi().post(`/channels/${c.id}/close`)
await loadChannels()
} catch {
setError('Failed to close channel')
}
}
async function deleteMessage(m: MessageItem) {
if (!guild || !guildToken || !selectedChannelId) return
try {
await guildApi().delete(`/channels/${selectedChannelId}/messages/${m.messageId}`)
// socket message.deleted will reconcile; optimistic fallback:
setMessages((prev) =>
prev.map((x) =>
x.messageId === m.messageId ? { ...x, isDeleted: true, content: '[deleted]' } : x,
),
)
} catch {
setError('Failed to delete message')
}
}
async function leaveChannel(c: GuildChannel) {
if (!guild || !guildToken) return
try {
await guildApi().post(`/channels/${c.id}/leave`)
if (selectedChannelId === c.id) {
setSelectedChannelId('')
setMessages([])
}
await loadChannels()
await loadMembers()
await loadChannelMembers()
} catch {
setError('Failed to leave channel')
}
}
async function saveName() {
const trimmed = settingsName.trim()
if (!trimmed) {
setError('Name must not be empty')
return
}
try {
await updateName(trimmed)
setShowSettingsModal(false)
await loadMembers()
} catch {
setError('Failed to update name')
}
}
useEffect(() => {
void loadMembers()
setSelectedMemberIds([])
}, [selectedGuildId])
useEffect(() => {
void loadChannels()
setMessages([])
setSelectedChannelId('')
}, [selectedGuildId, guildDbId, guildToken])
useEffect(() => {
void pullMessages()
void loadChannelMembers()
void loadCanvas()
setPendingFiles([])
setCanvasCollapsed(false)
}, [selectedChannelId])
useEffect(() => {
if (!socket || !selectedChannelId) return
socket.on('message.created', (m: MessageItem) => setMessages((prev) => [...prev, m]))
socket.on('message.updated', (m: MessageItem) =>
setMessages((prev) => prev.map((x) => (x.messageId === m.messageId ? m : x))),
)
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 })
return () => {
socket.emit('leave_channel', { channelId: selectedChannelId })
socket.removeAllListeners()
socket.disconnect()
}
}, [socket, selectedChannelId])
const currentChannel = channels.find((c) => c.id === selectedChannelId) ?? null
const nameById = new Map(members.map((m) => [m.userId, m.name || m.email]))
const authorLabel = (uid?: string) =>
uid ? (uid === session?.user.id ? session?.user.name || 'You' : nameById.get(uid) || uid.slice(0, 8)) : 'unknown'
const mentionName = (id: string) =>
id === session?.user.id
? 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' },
)
const rotationChannel =
currentChannel?.xType === 'discuss' || currentChannel?.xType === 'work'
const renderMember = (
m: { userId: string; name?: string; email?: string },
inChannel = false,
) => {
const label = m.name || m.email || m.userId.slice(0, 8)
const isBypass = bypassMemberIds.includes(m.userId)
const showBypassUi = inChannel && rotationChannel
return (
<div
key={m.userId}
className="member-row"
onContextMenu={(e) => openCtx(e, label, userMenu(m, inChannel))}
>
<div className="avatar dot">{initials(label)}</div>
<div style={{ minWidth: 0, flex: 1 }}>
<span className="nm">{label}</span>
{m.userId === session?.user.id ? <span className="you">you</span> : null}
{showBypassUi && isBypass ? <span className="chan-tag">bypass</span> : null}
</div>
{showBypassUi && !isBypass ? (
<button
className="btn btn-secondary btn-xs"
title="Exclude from speaking rotation (only woken when @-mentioned)"
onClick={() => moveToBypass(m.userId)}
>
bypass
</button>
) : null}
</div>
)
}
// ---- right-click context menus (per resource) ----
function guildMenu(g: { nodeId: string; name: string }): MenuItem[] {
return [
{ kind: 'item', label: 'Open', onClick: () => setSelectedGuildId(g.nodeId) },
{ kind: 'sep' },
{ kind: 'item', label: 'Copy node ID', onClick: () => copyText(g.nodeId) },
{ kind: 'item', label: 'Copy name', onClick: () => copyText(g.name) },
{ kind: 'sep' },
{ kind: 'item', label: 'Add guild…', onClick: () => setShowAddGuildModal(true) },
]
}
function channelMenu(c: GuildChannel): MenuItem[] {
const items: MenuItem[] = [
{ kind: 'item', label: 'Open', onClick: () => setSelectedChannelId(c.id) },
]
if (c.isMember)
items.push({ kind: 'item', label: 'Leave channel', danger: true, onClick: () => leaveChannel(c) })
else if (c.isPublic)
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) },
{ kind: 'item', label: 'Copy name', onClick: () => copyText(c.name) },
{ kind: 'sep' },
{ kind: 'item', label: 'Create channel…', onClick: openCreateChannel },
)
return items
}
function messageMenu(m: MessageItem): MenuItem[] {
const isGuild = m.authorUserId === 'guild'
const items: MenuItem[] = [
{ kind: 'item', label: 'Copy text', onClick: () => copyText(m.content) },
{ kind: 'item', label: 'Copy message ID', onClick: () => copyText(m.messageId) },
]
if (m.authorUserId && !isGuild)
items.push({
kind: 'item',
label: 'Mention author',
onClick: () => insertMention(m.authorUserId as string),
})
items.push(
{ kind: 'sep' },
{
kind: 'item',
label: 'Delete message',
danger: true,
disabled: !!m.isDeleted || isGuild,
onClick: () => deleteMessage(m),
},
)
return items
}
function userMenu(
m: { userId: string; name?: string; email?: string },
inChannel: boolean,
): MenuItem[] {
const isSelf = m.userId === session?.user.id
const label = m.name || m.email || m.userId.slice(0, 8)
const items: MenuItem[] = [
{ kind: 'item', label: 'Mention', onClick: () => insertMention(m.userId) },
{ kind: 'item', label: 'Copy user ID', onClick: () => copyText(m.userId) },
{ kind: 'item', label: 'Copy name', onClick: () => copyText(label) },
]
if (inChannel && rotationChannel && !bypassMemberIds.includes(m.userId))
items.push(
{ kind: 'sep' },
{ kind: 'item', label: 'Move to bypass', onClick: () => moveToBypass(m.userId) },
)
if (isSelf)
items.push(
{ kind: 'sep' },
{
kind: 'item',
label: 'Settings…',
onClick: () => {
setSettingsName(session?.user.name ?? '')
setShowSettingsModal(true)
},
},
)
return items
}
function blankMenu(scope: 'guilds' | 'channels' | 'messages' | 'members' | 'root'): MenuItem[] {
if (scope === 'guilds')
return [{ kind: 'item', label: 'Add guild…', onClick: () => setShowAddGuildModal(true) }]
if (scope === 'channels')
return [
{ kind: 'item', label: 'Create channel…', disabled: !guild, onClick: openCreateChannel },
{ kind: 'item', label: 'Refresh channels', onClick: () => void loadChannels() },
]
if (scope === 'messages')
return [
{
kind: 'item',
label: 'Refresh messages',
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 [
{
kind: 'item',
label: 'Refresh members',
onClick: () => {
void loadMembers()
void loadChannelMembers()
},
},
]
return [
{
kind: 'item',
label: 'Refresh',
onClick: () => {
void loadChannels()
void loadMembers()
},
},
]
}
function renderCtxMenu() {
if (!ctxMenu) return null
const MENU_W = 220
const estH =
36 +
ctxMenu.items.reduce((a, it) => a + (it.kind === 'sep' ? 9 : 32), 0)
const x = Math.max(6, Math.min(ctxMenu.x, window.innerWidth - MENU_W - 8))
const y = Math.max(6, Math.min(ctxMenu.y, window.innerHeight - estH - 8))
return (
<div
className="ctx-menu"
style={{ left: x, top: y, width: MENU_W }}
onContextMenu={(e) => e.preventDefault()}
onClick={(e) => e.stopPropagation()}
>
<div className="ctx-title">{ctxMenu.title}</div>
{ctxMenu.items.map((it, i) =>
it.kind === 'sep' ? (
<div key={i} className="ctx-sep" />
) : (
<button
key={i}
className={`ctx-item ${it.danger ? 'danger' : ''}`}
disabled={it.disabled}
onClick={() => {
if (it.disabled) return
setCtxMenu(null)
it.onClick()
}}
>
{it.label}
</button>
),
)}
</div>
)
}
return (
<div className="dc-shell" onContextMenu={(e) => openCtx(e, 'Fabric', blankMenu('root'))}>
<nav className="dc-rail" onContextMenu={(e) => openCtx(e, 'Guilds', blankMenu('guilds'))}>
{guilds.map((g) => (
<button
key={g.nodeId}
className={`rail-btn ${selectedGuildId === g.nodeId ? 'active' : ''}`}
title={g.name}
onClick={() => setSelectedGuildId(g.nodeId)}
onContextMenu={(e) => openCtx(e, g.name, guildMenu(g))}
>
{initials(g.name)}
</button>
))}
{guilds.length ? <div className="rail-sep" /> : null}
<button className="rail-btn rail-add" title="Add guild" onClick={() => setShowAddGuildModal(true)}>
+
</button>
</nav>
<aside className="dc-sidebar">
<div className="dc-sidebar-head">{guild?.name ?? 'No guild selected'}</div>
<div
className="dc-sidebar-scroll"
onContextMenu={(e) => openCtx(e, 'Channels', blankMenu('channels'))}
>
<div className="dc-section-label">
<span>Channels</span>
<button title="Create channel" onClick={openCreateChannel}>
+
</button>
</div>
{channels.length ? (
channels.map((c) => (
<button
key={c.id}
className={`chan-btn xt-${c.xType ?? 'general'} ${selectedChannelId === c.id ? 'active' : ''}`}
onClick={() => setSelectedChannelId(c.id)}
onContextMenu={(e) => openCtx(e, `#${c.name}`, channelMenu(c))}
>
<span className="hash">#</span>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{c.name}</span>
{c.xType ? <span className="chan-tag">{c.xType}</span> : null}
</button>
))
) : (
<div className="dc-empty">{guild ? 'No channels yet' : 'Pick a guild from the left'}</div>
)}
</div>
<div className="dc-userbar">
<div className="avatar">{initials(session?.user.name || session?.user.email || '?')}</div>
<div className="who">
<div className="nm">{session?.user.name || session?.user.email}</div>
<div className="sub">{session?.user.email}</div>
</div>
<button
className="icon-btn"
title="Settings"
onClick={() => {
setSettingsName(session?.user.name ?? '')
setShowSettingsModal(true)
}}
>
</button>
<button className="icon-btn" title="Logout" onClick={() => void logout()}>
</button>
</div>
</aside>
<main className="dc-main">
<header className="dc-topbar">
{currentChannel ? (
<span className="title">
<span className="hash">#</span>
{currentChannel.name}
</span>
) : (
<span className="title muted">Select a channel</span>
)}
<span className="spacer" />
{currentChannel ? (
currentChannel.isMember ? (
<button className="btn btn-secondary" onClick={() => leaveChannel(currentChannel)}>
Leave
</button>
) : (
<button className="btn" onClick={() => joinChannel(currentChannel)}>
Join
</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'}
onClick={() => setShowMembers((v) => !v)}
>
</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'))}
>
{loading ? <p className="muted" style={{ padding: 8 }}>Loading</p> : null}
{!loading && !messages.length ? (
<div className="dc-empty-center">
{currentChannel ? `This is the start of #${currentChannel.name}` : 'No channel selected'}
</div>
) : null}
{messages
.filter((m) => devMode || m.authorUserId !== 'guild')
.map((m) => (
<div
key={m.messageId}
className={`msg ${m.isDeleted ? 'deleted' : ''}`}
onContextMenu={(e) => openCtx(e, authorLabel(m.authorUserId), messageMenu(m))}
>
<div className="avatar">{initials(authorLabel(m.authorUserId))}</div>
<div className="body">
<div className="meta">
<span className="author">{authorLabel(m.authorUserId)}</span>
<span className="time">{timeOf(m.createdAt)} · #{m.seq}</span>
{devMode && m.wakeup !== undefined ? (
<span className={`meta-badge ${m.wakeup ? 'on' : ''}`}>wakeup={String(m.wakeup)}</span>
) : null}
</div>
<div
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(
{
messageId: m.messageId,
seq: m.seq,
authorUserId: m.authorUserId,
createdAt: m.createdAt,
isDeleted: m.isDeleted ?? false,
wakeup: m.wakeup,
},
null,
0,
)}
</pre>
) : null}
</div>
</div>
))}
</div>
{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) => {
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>
{showMembers ? (
<aside
className="dc-members"
onContextMenu={(e) => openCtx(e, 'Members', blankMenu('members'))}
>
{currentChannel && !currentChannel.isPublic ? (
<>
<div className="dc-section-label">
<span>In channel {channelMembers.length}</span>
</div>
{channelMembers.map((m) => renderMember(m, true))}
<div className="dc-section-label" style={{ marginTop: 14 }}>
<span>Guild {members.length}</span>
</div>
{members.map((m) => renderMember(m))}
</>
) : (
<>
<div className="dc-section-label">
<span>Members {members.length}</span>
</div>
{members.map((m) => renderMember(m))}
</>
)}
</aside>
) : null}
{showAddGuildModal ? (
<div className="modal-backdrop" onClick={() => setShowAddGuildModal(false)}>
<div className="modal-card" onClick={(e) => e.stopPropagation()}>
<h3>Add guild</h3>
<p className="muted" style={{ marginBottom: 10 }}>Enter the guild node id to join.</p>
<input
className="input"
value={joinGuildNodeId}
onChange={(e) => setJoinGuildNodeId(e.target.value)}
placeholder="e.g. test-guild1"
/>
<div className="modal-actions">
<button className="btn" onClick={addGuild}>Join</button>
<button className="btn btn-secondary" onClick={() => setShowAddGuildModal(false)}>Cancel</button>
</div>
</div>
</div>
) : null}
{showCreateChannelModal ? (
<div className="modal-backdrop" onClick={() => setShowCreateChannelModal(false)}>
<div className="modal-card" onClick={(e) => e.stopPropagation()}>
<h3>Create channel</h3>
<input
className="input"
value={newChannelName}
onChange={(e) => setNewChannelName(e.target.value)}
placeholder="Channel name"
/>
<div className="field" style={{ marginTop: 10 }}>
<label>Type</label>
<select
className="input"
value={newChannelXType}
onChange={(e) => setNewChannelXType(e.target.value as XType)}
>
{X_TYPES.map((t) => (
<option key={t} value={t}>
{t}
</option>
))}
</select>
</div>
{newChannelXType === 'triage' ? (
<div className="field" style={{ marginTop: 10 }}>
<label>On-duty (required)</label>
<select
className="input"
value={newChannelOnDuty}
onChange={(e) => setNewChannelOnDuty(e.target.value)}
>
{members.map((m) => (
<option key={m.userId} value={m.userId}>
{(m.name || m.email) + (m.userId === session?.user.id ? ' (you)' : '')}
</option>
))}
</select>
<p className="muted" style={{ fontSize: 12 }}>
Added to the channel automatically and put into wake_mapping.
</p>
</div>
) : null}
{newChannelXType === 'custom' ? (
<div className="field" style={{ marginTop: 10 }}>
<label>Listeners (optional)</label>
<div className="modal-list">
{members.map((m) => (
<label key={m.userId} className="check-row">
<input
type="checkbox"
checked={newChannelListeners.includes(m.userId)}
onChange={() => toggleListener(m.userId)}
/>
<span>
{m.name || m.email}
{m.userId === session?.user.id ? ' (you)' : ''}
</span>
</label>
))}
{members.length === 0 ? <div className="dc-empty">No members in this guild.</div> : null}
</div>
<p className="muted" style={{ fontSize: 12 }}>
Each selected user gets a wake_mapping record for this channel.
</p>
</div>
) : null}
{newChannelXType === 'discuss' || newChannelXType === 'work' ? (
<div className="field" style={{ marginTop: 10 }}>
<label>Bypass list (optional)</label>
<div className="modal-list">
{members.map((m) => (
<label key={m.userId} className="check-row">
<input
type="checkbox"
checked={newChannelBypass.includes(m.userId)}
onChange={() => toggleBypass(m.userId)}
/>
<span>
{m.name || m.email}
{m.userId === session?.user.id ? ' (you)' : ''}
</span>
</label>
))}
{members.length === 0 ? <div className="dc-empty">No members in this guild.</div> : null}
</div>
<p className="muted" style={{ fontSize: 12 }}>
Bypass members are excluded from the speaking rotation and are
only woken when @-mentioned. Order and bypass partition the
channel members.
</p>
</div>
) : null}
<label className="check-row" style={{ marginTop: 10 }}>
<input type="checkbox" checked={newChannelPublic} onChange={(e) => setNewChannelPublic(e.target.checked)} />
<span>Public visible to all guild members</span>
</label>
<p className="muted" style={{ marginTop: 8, fontSize: 13 }}>
{newChannelPublic
? "You're added automatically; every guild member can see it."
: "You're added automatically. Pick who else to include:"}
</p>
<div className="modal-list">
{members
.filter((m) => m.userId !== session?.user.id)
.map((m) => (
<label key={m.userId} className="check-row">
<input
type="checkbox"
checked={selectedMemberIds.includes(m.userId)}
disabled={newChannelPublic}
onChange={() => toggleMember(m.userId)}
/>
<span>{m.name || m.email}</span>
</label>
))}
{members.filter((m) => m.userId !== session?.user.id).length === 0 ? (
<div className="dc-empty">No other members in this guild.</div>
) : null}
</div>
<div className="modal-actions">
<button className="btn" onClick={createChannel}>Create</button>
<button className="btn btn-secondary" onClick={() => setShowCreateChannelModal(false)}>Cancel</button>
</div>
</div>
</div>
) : null}
{showSettingsModal ? (
<div className="modal-backdrop" onClick={() => setShowSettingsModal(false)}>
<div className="modal-card" onClick={(e) => e.stopPropagation()}>
<h3>Settings</h3>
<p className="muted" style={{ marginBottom: 12 }}>Signed in as {session?.user.email}</p>
<div className="field">
<label>Display name</label>
<input
className="input"
value={settingsName}
onChange={(e) => setSettingsName(e.target.value)}
placeholder="Your name"
/>
</div>
<label className="check-row" style={{ marginTop: 4 }}>
<input
type="checkbox"
checked={devMode}
onChange={(e) => toggleDevMode(e.target.checked)}
/>
<span>Developer mode show guild /ack &amp; message metadata</span>
</label>
<div className="modal-actions">
<button className="btn" onClick={saveName}>Save</button>
<button className="btn btn-secondary" onClick={() => setShowSettingsModal(false)}>Cancel</button>
</div>
</div>
</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()}>
<h3>Something went wrong</h3>
<p className="error-text">{error}</p>
<div className="modal-actions">
<button className="btn" onClick={() => setError('')}>OK</button>
</div>
</div>
</div>
) : null}
{renderCtxMenu()}
</div>
)
}