- 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>
1349 lines
48 KiB
TypeScript
1349 lines
48 KiB
TypeScript
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 & 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>
|
||
)
|
||
}
|