feat(chat): bypass-list UI for discuss/work channels

- create-channel modal: optional bypass multi-select (discuss/work only),
  sent as bypassUserIds; reset on open/close.
- members panel (in-channel, discuss/work): bypass members tagged
  'bypass'; others get a '→ bypass' action -> POST /channels/:id/bypass.
- loadChannelMembers consumes the new {userId,bypass} member shape.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
h z
2026-05-15 19:26:24 +01:00
parent 44c308bd06
commit 372805c9fa
2 changed files with 84 additions and 6 deletions

View File

@@ -145,6 +145,13 @@ button {
.btn-secondary:hover {
background: #44474f;
}
.btn-xs {
padding: 2px 8px;
font-size: 11px;
line-height: 1.4;
white-space: nowrap;
flex: 0 0 auto;
}
.btn-ghost {
background: transparent;
color: var(--text-muted);

View File

@@ -42,6 +42,7 @@ export default function ChatPage() {
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[]>([])
@@ -54,6 +55,7 @@ export default function ChatPage() {
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)
@@ -139,14 +141,19 @@ export default function ChatPage() {
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 }>) : []
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([])
}
}
@@ -204,6 +211,10 @@ export default function ChatPage() {
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([])
@@ -211,6 +222,7 @@ export default function ChatPage() {
setNewChannelXType('general')
setNewChannelOnDuty(session?.user.id ?? '')
setNewChannelListeners([])
setNewChannelBypass([])
setShowCreateChannelModal(true)
}
@@ -235,6 +247,10 @@ export default function ChatPage() {
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
@@ -244,6 +260,7 @@ export default function ChatPage() {
setNewChannelXType('general')
setNewChannelOnDuty(session?.user.id ?? '')
setNewChannelListeners([])
setNewChannelBypass([])
setShowCreateChannelModal(false)
await loadChannels()
if (createdId) setSelectedChannelId(createdId)
@@ -264,6 +281,16 @@ export default function ChatPage() {
}
}
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 leaveChannel(c: GuildChannel) {
if (!guild || !guildToken) return
try {
@@ -345,15 +372,32 @@ export default function ChatPage() {
const channelMembers = channelMemberIds.map(
(id) => guildById.get(id) ?? { userId: id, email: '', name: '', status: 'active' },
)
const renderMember = (m: { userId: string; name?: string; email?: string }) => {
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">
<div className="avatar dot">{initials(label)}</div>
<div style={{ minWidth: 0 }}>
<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>
)
}
@@ -531,18 +575,18 @@ export default function ChatPage() {
<div className="dc-section-label">
<span>In channel {channelMembers.length}</span>
</div>
{channelMembers.map(renderMember)}
{channelMembers.map((m) => renderMember(m, true))}
<div className="dc-section-label" style={{ marginTop: 14 }}>
<span>Guild {members.length}</span>
</div>
{members.map(renderMember)}
{members.map((m) => renderMember(m))}
</>
) : (
<>
<div className="dc-section-label">
<span>Members {members.length}</span>
</div>
{members.map(renderMember)}
{members.map((m) => renderMember(m))}
</>
)}
</aside>
@@ -637,6 +681,33 @@ export default function ChatPage() {
</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>