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:
@@ -341,32 +341,73 @@ button {
|
|||||||
color: var(--text-h);
|
color: var(--text-h);
|
||||||
}
|
}
|
||||||
.chan-btn {
|
.chan-btn {
|
||||||
|
--xt: #9aa0a6;
|
||||||
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
border: none;
|
border: none;
|
||||||
background: transparent;
|
background: color-mix(in srgb, var(--xt) 9%, transparent);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
padding: 8px 10px;
|
padding: 8px 10px 8px 14px;
|
||||||
margin: 1px 0;
|
margin: 2px 0;
|
||||||
color: var(--text-muted);
|
color: color-mix(in srgb, var(--xt) 55%, var(--text));
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
.chan-btn::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 3px;
|
||||||
|
top: 7px;
|
||||||
|
bottom: 7px;
|
||||||
|
width: 3px;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: var(--xt);
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
.chan-btn .hash {
|
.chan-btn .hash {
|
||||||
color: var(--text-faint);
|
color: var(--xt);
|
||||||
font-weight: 600;
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.chan-btn .chan-tag {
|
||||||
|
color: var(--xt);
|
||||||
|
border-color: color-mix(in srgb, var(--xt) 60%, transparent);
|
||||||
|
background: color-mix(in srgb, var(--xt) 14%, transparent);
|
||||||
}
|
}
|
||||||
.chan-btn:hover {
|
.chan-btn:hover {
|
||||||
background: var(--hover);
|
background: color-mix(in srgb, var(--xt) 18%, transparent);
|
||||||
color: var(--text);
|
color: var(--text-h);
|
||||||
}
|
}
|
||||||
.chan-btn.active {
|
.chan-btn.active {
|
||||||
background: var(--active);
|
background: color-mix(in srgb, var(--xt) 26%, transparent);
|
||||||
color: var(--text-h);
|
color: var(--text-h);
|
||||||
}
|
}
|
||||||
|
.chan-btn.active::before {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* per x_type accent */
|
||||||
|
.chan-btn.xt-general {
|
||||||
|
--xt: #9aa0a6;
|
||||||
|
}
|
||||||
|
.chan-btn.xt-work {
|
||||||
|
--xt: #5865f2;
|
||||||
|
}
|
||||||
|
.chan-btn.xt-report {
|
||||||
|
--xt: #faa61a;
|
||||||
|
}
|
||||||
|
.chan-btn.xt-discuss {
|
||||||
|
--xt: #3ba55d;
|
||||||
|
}
|
||||||
|
.chan-btn.xt-triage {
|
||||||
|
--xt: #ed4245;
|
||||||
|
}
|
||||||
|
.chan-btn.xt-custom {
|
||||||
|
--xt: #c084fc;
|
||||||
|
}
|
||||||
.chan-tag {
|
.chan-tag {
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
@@ -515,6 +556,32 @@ button {
|
|||||||
color: var(--text-faint);
|
color: var(--text-faint);
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
}
|
}
|
||||||
|
.meta-badge {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-faint);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 0 4px;
|
||||||
|
}
|
||||||
|
.meta-badge.on {
|
||||||
|
color: #fff;
|
||||||
|
background: var(--online);
|
||||||
|
border-color: var(--online);
|
||||||
|
}
|
||||||
|
.meta-raw {
|
||||||
|
margin: 4px 0 0;
|
||||||
|
padding: 6px 8px;
|
||||||
|
background: var(--elevated);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 11px;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
.dc-empty-center {
|
.dc-empty-center {
|
||||||
margin: auto;
|
margin: auto;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
|||||||
@@ -11,8 +11,13 @@ type MessageItem = {
|
|||||||
isDeleted?: boolean
|
isDeleted?: boolean
|
||||||
authorUserId?: string
|
authorUserId?: string
|
||||||
createdAt?: 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 {
|
function initials(s: string): string {
|
||||||
const t = (s || '?').trim()
|
const t = (s || '?').trim()
|
||||||
return t.slice(0, 2).toUpperCase()
|
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
|
const X_TYPES = ['general', 'work', 'report', 'discuss', 'triage', 'custom'] as const
|
||||||
type XType = (typeof X_TYPES)[number]
|
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 }
|
type MemberItem = { userId: string; email: string; name: string; status: string }
|
||||||
|
|
||||||
export default function ChatPage() {
|
export default function ChatPage() {
|
||||||
@@ -45,14 +50,23 @@ export default function ChatPage() {
|
|||||||
const [selectedMemberIds, setSelectedMemberIds] = useState<string[]>([])
|
const [selectedMemberIds, setSelectedMemberIds] = useState<string[]>([])
|
||||||
const [newChannelPublic, setNewChannelPublic] = useState(false)
|
const [newChannelPublic, setNewChannelPublic] = useState(false)
|
||||||
const [newChannelXType, setNewChannelXType] = useState<XType>('general')
|
const [newChannelXType, setNewChannelXType] = useState<XType>('general')
|
||||||
|
const [newChannelOnDuty, setNewChannelOnDuty] = useState('')
|
||||||
|
const [newChannelListeners, setNewChannelListeners] = useState<string[]>([])
|
||||||
const [showCreateChannelModal, setShowCreateChannelModal] = useState(false)
|
const [showCreateChannelModal, setShowCreateChannelModal] = useState(false)
|
||||||
const [showSettingsModal, setShowSettingsModal] = useState(false)
|
const [showSettingsModal, setShowSettingsModal] = useState(false)
|
||||||
const [showAddGuildModal, setShowAddGuildModal] = useState(false)
|
const [showAddGuildModal, setShowAddGuildModal] = useState(false)
|
||||||
const [settingsName, setSettingsName] = useState('')
|
const [settingsName, setSettingsName] = useState('')
|
||||||
const [showMembers, setShowMembers] = useState(true)
|
const [showMembers, setShowMembers] = useState(true)
|
||||||
|
const [devMode, setDevMode] = useState(() => localStorage.getItem(DEV_KEY) === '1')
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [error, setError] = useState('')
|
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 ?? []
|
const guilds = session?.guilds ?? []
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -170,6 +184,20 @@ export default function ChatPage() {
|
|||||||
setSelectedMemberIds((prev) => (prev.includes(userId) ? prev.filter((x) => x !== userId) : [...prev, userId]))
|
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() {
|
async function createChannel() {
|
||||||
if (!guild || !guildToken || !newChannelName.trim()) return
|
if (!guild || !guildToken || !newChannelName.trim()) return
|
||||||
const effectiveGuildId = guildDbId || selectedGuildId
|
const effectiveGuildId = guildDbId || selectedGuildId
|
||||||
@@ -177,6 +205,10 @@ export default function ChatPage() {
|
|||||||
setError('Cannot create channel: no guild selected')
|
setError('Cannot create channel: no guild selected')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (newChannelXType === 'triage' && !newChannelOnDuty) {
|
||||||
|
setError('Triage channels require an on-duty user')
|
||||||
|
return
|
||||||
|
}
|
||||||
setError('')
|
setError('')
|
||||||
try {
|
try {
|
||||||
const payload = {
|
const payload = {
|
||||||
@@ -185,6 +217,8 @@ export default function ChatPage() {
|
|||||||
xType: newChannelXType,
|
xType: newChannelXType,
|
||||||
isPublic: newChannelPublic,
|
isPublic: newChannelPublic,
|
||||||
memberUserIds: selectedMemberIds,
|
memberUserIds: selectedMemberIds,
|
||||||
|
onDuty: newChannelXType === 'triage' ? newChannelOnDuty : undefined,
|
||||||
|
listeners: newChannelXType === 'custom' ? newChannelListeners : [],
|
||||||
}
|
}
|
||||||
const res = await guildApi().post('/channels', payload)
|
const res = await guildApi().post('/channels', payload)
|
||||||
const createdId = res.data?.id as string | undefined
|
const createdId = res.data?.id as string | undefined
|
||||||
@@ -192,6 +226,8 @@ export default function ChatPage() {
|
|||||||
setSelectedMemberIds([])
|
setSelectedMemberIds([])
|
||||||
setNewChannelPublic(false)
|
setNewChannelPublic(false)
|
||||||
setNewChannelXType('general')
|
setNewChannelXType('general')
|
||||||
|
setNewChannelOnDuty(session?.user.id ?? '')
|
||||||
|
setNewChannelListeners([])
|
||||||
setShowCreateChannelModal(false)
|
setShowCreateChannelModal(false)
|
||||||
await loadChannels()
|
await loadChannels()
|
||||||
if (createdId) setSelectedChannelId(createdId)
|
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() {
|
async function saveName() {
|
||||||
const trimmed = settingsName.trim()
|
const trimmed = settingsName.trim()
|
||||||
if (!trimmed) {
|
if (!trimmed) {
|
||||||
@@ -280,7 +342,7 @@ export default function ChatPage() {
|
|||||||
<div className="dc-sidebar-scroll">
|
<div className="dc-sidebar-scroll">
|
||||||
<div className="dc-section-label">
|
<div className="dc-section-label">
|
||||||
<span>Channels</span>
|
<span>Channels</span>
|
||||||
<button title="Create channel" onClick={() => setShowCreateChannelModal(true)}>
|
<button title="Create channel" onClick={openCreateChannel}>
|
||||||
+
|
+
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -288,7 +350,7 @@ export default function ChatPage() {
|
|||||||
channels.map((c) => (
|
channels.map((c) => (
|
||||||
<button
|
<button
|
||||||
key={c.id}
|
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)}
|
onClick={() => setSelectedChannelId(c.id)}
|
||||||
>
|
>
|
||||||
<span className="hash">#</span>
|
<span className="hash">#</span>
|
||||||
@@ -333,6 +395,17 @@ export default function ChatPage() {
|
|||||||
<span className="title muted">Select a channel</span>
|
<span className="title muted">Select a channel</span>
|
||||||
)}
|
)}
|
||||||
<span className="spacer" />
|
<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
|
<button
|
||||||
className="icon-btn"
|
className="icon-btn"
|
||||||
title={showMembers ? 'Hide members' : 'Show members'}
|
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'}
|
{currentChannel ? `This is the start of #${currentChannel.name}` : 'No channel selected'}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{messages.map((m) => (
|
{messages
|
||||||
|
.filter((m) => devMode || m.authorUserId !== 'guild')
|
||||||
|
.map((m) => (
|
||||||
<div key={m.messageId} className={`msg ${m.isDeleted ? 'deleted' : ''}`}>
|
<div key={m.messageId} className={`msg ${m.isDeleted ? 'deleted' : ''}`}>
|
||||||
<div className="avatar">{initials(authorLabel(m.authorUserId))}</div>
|
<div className="avatar">{initials(authorLabel(m.authorUserId))}</div>
|
||||||
<div className="body">
|
<div className="body">
|
||||||
<div className="meta">
|
<div className="meta">
|
||||||
<span className="author">{authorLabel(m.authorUserId)}</span>
|
<span className="author">{authorLabel(m.authorUserId)}</span>
|
||||||
<span className="time">{timeOf(m.createdAt)} · #{m.seq}</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>
|
||||||
<div className="text">{m.content}</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>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -443,6 +537,52 @@ export default function ChatPage() {
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</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 }}>
|
<label className="check-row" style={{ marginTop: 10 }}>
|
||||||
<input type="checkbox" checked={newChannelPublic} onChange={(e) => setNewChannelPublic(e.target.checked)} />
|
<input type="checkbox" checked={newChannelPublic} onChange={(e) => setNewChannelPublic(e.target.checked)} />
|
||||||
<span>Public — visible to all guild members</span>
|
<span>Public — visible to all guild members</span>
|
||||||
@@ -492,6 +632,14 @@ export default function ChatPage() {
|
|||||||
placeholder="Your name"
|
placeholder="Your name"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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">
|
<div className="modal-actions">
|
||||||
<button className="btn" onClick={saveName}>Save</button>
|
<button className="btn" onClick={saveName}>Save</button>
|
||||||
<button className="btn btn-secondary" onClick={() => setShowSettingsModal(false)}>Cancel</button>
|
<button className="btn btn-secondary" onClick={() => setShowSettingsModal(false)}>Cancel</button>
|
||||||
|
|||||||
Reference in New Issue
Block a user