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([]) const [bypassMemberIds, setBypassMemberIds] = useState([]) const [channels, setChannels] = useState([]) const [guildDbId, setGuildDbId] = useState('') const [members, setMembers] = useState([]) const [messages, setMessages] = useState([]) const [content, setContent] = useState('') const [newChannelName, setNewChannelName] = useState('') const [joinGuildNodeId, setJoinGuildNodeId] = useState('') const [selectedMemberIds, setSelectedMemberIds] = useState([]) const [newChannelPublic, setNewChannelPublic] = useState(false) const [newChannelXType, setNewChannelXType] = useState('general') const [newChannelOnDuty, setNewChannelOnDuty] = useState('') const [newChannelListeners, setNewChannelListeners] = useState([]) const [newChannelBypass, setNewChannelBypass] = useState([]) 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(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() 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(() => { 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 { 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 / 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 (
openCtx(e, label, userMenu(m, inChannel))} >
{initials(label)}
{label} {m.userId === session?.user.id ? you : null} {showBypassUi && isBypass ? bypass : null}
{showBypassUi && !isBypass ? ( ) : null}
) } // ---- 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 (
e.preventDefault()} onClick={(e) => e.stopPropagation()} >
{ctxMenu.title}
{ctxMenu.items.map((it, i) => it.kind === 'sep' ? (
) : ( ), )}
) } return (
openCtx(e, 'Fabric', blankMenu('root'))}>
{currentChannel ? ( # {currentChannel.name} ) : ( Select a channel )} {currentChannel ? ( currentChannel.isMember ? ( ) : ( ) ) : null} {currentChannel ? ( ) : null}
{canvas ? (
🗎 {canvas.title} shared by {authorLabel(canvas.sharerUserId)} · v{canvas.version} ·{' '} {timeOf(canvas.updatedAt)} {isCanvasSharer ? ( <> ) : null}
{canvasCollapsed ? null : (
{canvas.format === 'html' ? (