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