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:
@@ -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 & 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>
|
||||
|
||||
Reference in New Issue
Block a user