feat(frontend): channel type colors, triage/custom create fields, join/leave, dev mode

- per-x_type channel row colors (general/work/report/discuss/triage/custom)
- create-channel: required On-duty select (triage) / Listeners checklist (custom)
- channel Join/Leave in topbar; list carries isMember
- Developer mode toggle (Settings): show guild /ack + per-message wakeup
  metadata; off by default (metadata stays transparent)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
h z
2026-05-15 14:51:20 +01:00
parent 396b2fd231
commit b04b099754
2 changed files with 228 additions and 13 deletions

View File

@@ -11,8 +11,13 @@ type MessageItem = {
isDeleted?: boolean
authorUserId?: string
createdAt?: string
// per-push metadata (only present on socket-delivered messages); the app is
// normally metadata-agnostic — only surfaced in developer mode
wakeup?: boolean
}
const DEV_KEY = 'fabric.debug'
function initials(s: string): string {
const t = (s || '?').trim()
return t.slice(0, 2).toUpperCase()
@@ -28,7 +33,7 @@ function timeOf(iso?: string): string {
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 }
type GuildChannel = { id: string; name: string; guildId?: string; xType?: XType; isMember?: boolean }
type MemberItem = { userId: string; email: string; name: string; status: string }
export default function ChatPage() {
@@ -45,14 +50,23 @@ export default function ChatPage() {
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 [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('')
function toggleDevMode(on: boolean) {
setDevMode(on)
if (on) localStorage.setItem(DEV_KEY, '1')
else localStorage.removeItem(DEV_KEY)
}
const guilds = session?.guilds ?? []
useEffect(() => {
@@ -170,6 +184,20 @@ export default function ChatPage() {
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 openCreateChannel() {
setNewChannelName('')
setSelectedMemberIds([])
setNewChannelPublic(false)
setNewChannelXType('general')
setNewChannelOnDuty(session?.user.id ?? '')
setNewChannelListeners([])
setShowCreateChannelModal(true)
}
async function createChannel() {
if (!guild || !guildToken || !newChannelName.trim()) return
const effectiveGuildId = guildDbId || selectedGuildId
@@ -177,6 +205,10 @@ export default function ChatPage() {
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 = {
@@ -185,6 +217,8 @@ export default function ChatPage() {
xType: newChannelXType,
isPublic: newChannelPublic,
memberUserIds: selectedMemberIds,
onDuty: newChannelXType === 'triage' ? newChannelOnDuty : undefined,
listeners: newChannelXType === 'custom' ? newChannelListeners : [],
}
const res = await guildApi().post('/channels', payload)
const createdId = res.data?.id as string | undefined
@@ -192,6 +226,8 @@ export default function ChatPage() {
setSelectedMemberIds([])
setNewChannelPublic(false)
setNewChannelXType('general')
setNewChannelOnDuty(session?.user.id ?? '')
setNewChannelListeners([])
setShowCreateChannelModal(false)
await loadChannels()
if (createdId) setSelectedChannelId(createdId)
@@ -200,6 +236,32 @@ export default function ChatPage() {
}
}
async function joinChannel(c: GuildChannel) {
if (!guild || !guildToken) return
try {
await guildApi().post(`/channels/${c.id}/join`)
await loadChannels()
await loadMembers()
} catch {
setError('Failed to join channel')
}
}
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()
} catch {
setError('Failed to leave channel')
}
}
async function saveName() {
const trimmed = settingsName.trim()
if (!trimmed) {
@@ -280,7 +342,7 @@ export default function ChatPage() {
<div className="dc-sidebar-scroll">
<div className="dc-section-label">
<span>Channels</span>
<button title="Create channel" onClick={() => setShowCreateChannelModal(true)}>
<button title="Create channel" onClick={openCreateChannel}>
+
</button>
</div>
@@ -288,7 +350,7 @@ export default function ChatPage() {
channels.map((c) => (
<button
key={c.id}
className={`chan-btn ${selectedChannelId === c.id ? 'active' : ''}`}
className={`chan-btn xt-${c.xType ?? 'general'} ${selectedChannelId === c.id ? 'active' : ''}`}
onClick={() => setSelectedChannelId(c.id)}
>
<span className="hash">#</span>
@@ -333,6 +395,17 @@ export default function ChatPage() {
<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}
<button
className="icon-btn"
title={showMembers ? 'Hide members' : 'Show members'}
@@ -349,15 +422,36 @@ export default function ChatPage() {
{currentChannel ? `This is the start of #${currentChannel.name}` : 'No channel selected'}
</div>
) : null}
{messages.map((m) => (
{messages
.filter((m) => devMode || m.authorUserId !== 'guild')
.map((m) => (
<div key={m.messageId} className={`msg ${m.isDeleted ? 'deleted' : ''}`}>
<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">{m.content}</div>
{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>
))}
@@ -443,6 +537,52 @@ export default function ChatPage() {
))}
</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}
<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>
@@ -492,6 +632,14 @@ export default function ChatPage() {
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>