import { useCallback, useEffect, useState } from 'react' import { api } from '../../../api/client.js' import { stateLabel, enableBlockedReason, roleHeadroom, removalCountdown, parseStaffRoles, formatStaffRoles, statusSummary, } from '../../../lib/teamVoice.js' // Team voice channels (TEAMS.md §7.3, phase 9). // // Named for the Team concern and placed under Teams beside the notification // bridge, for the reason that panel gives: phase 10 replaces "Discord" with // whatever the capability registry declares, and what should change then is what // fills this panel rather than where an operator goes to find it. // // **The preflight is the first thing on the page, not a diagnostic.** §7.3 // assumed the bot could manage channels and roles; nothing in this project has // ever checked, because the operator invites the bot by hand and no invite URL // with a permission integer exists anywhere in the tree. An operator whose bot // lacks Manage Roles otherwise has a screen full of controls that cannot work, // and finds out one Team at a time from a column of identical errors. const PANEL = { padding: 22, marginBottom: 22, maxWidth: 760 } const HEADING = { margin: '0 0 6px', fontSize: '1.2rem', color: 'var(--head)' } export default function TeamVoice() { const [config, setConfig] = useState(null) const [draft, setDraft] = useState(null) const [error, setError] = useState('') const [notice, setNotice] = useState('') const [busy, setBusy] = useState(false) const load = useCallback(async () => { setError('') try { const cfg = await api.admin.teamVoice() setConfig(cfg) setDraft({ enabled: cfg.settings.enabled, minMembers: cfg.settings.minMembers, graceDays: cfg.settings.graceDays, staffRoles: formatStaffRoles(cfg.settings.staffRoles), }) } catch (err) { // A moderator never reaches this panel — the admin nav does not render it — // so a 403 means the role changed underneath an open tab. setError(err.status === 403 ? 'Only an admin can configure Team voice channels.' : (err.message || 'Could not load the voice configuration.')) } }, []) useEffect(() => { load() }, [load]) if (!config || !draft) { return (

Voice channels

{error &&

{error}

}
) } const blocked = enableBlockedReason(config.preflight) const headroom = roleHeadroom(config.preflight) async function save() { const { roles, invalid } = parseStaffRoles(draft.staffRoles) if (invalid.length > 0) { setError(`Not a role id: ${invalid.join(', ')}. Copy role ids from Discord with Developer Mode on.`) return } setBusy(true) setError('') setNotice('') try { await api.admin.saveTeamVoice({ enabled: draft.enabled, minMembers: Number(draft.minMembers), graceDays: Number(draft.graceDays), staffRoles: roles, }) setNotice('Saved.') await load() } catch (err) { setError(err.message || 'Could not save.') } finally { setBusy(false) } } async function runPass() { setBusy(true) setError('') setNotice('') try { const result = await api.admin.teamVoicePass() // A pass that refused says why, and that is the useful answer far more often // than a count is — "stale projection" and "synced 0" look identical in a // summary and mean completely different things. setNotice(result.ran ? `Synced ${result.synced}, created ${result.created}, scheduled ${result.scheduled}, removed ${result.removed}, failed ${result.failed}.` : `Nothing was done: ${result.reason}`) await load() } catch (err) { setError(err.message || 'Could not run a pass.') } finally { setBusy(false) } } async function remove(row) { setBusy(true) setError('') try { await api.admin.removeTeamVoice(row.teamId) setNotice('Removed.') await load() } catch (err) { setError(err.message || 'Could not remove.') } finally { setBusy(false) } } return (

Voice channels

Give each Team a {config.platform} voice channel of its own. Access is granted with a role per Team, so members of a Team can see and join their channel and nobody else can. Members need a linked {config.platform} account and must be in the guild.

{blocked && (

{blocked} Voice channels cannot be switched on until that is fixed.

)} {headroom && (

{headroom.used} of {headroom.cap} {config.platform} roles used in this guild {headroom.exhausted ? ' — no room for another Team.' : headroom.tight ? ` — room for about ${headroom.free} more Teams.` : '.'}

)} {error &&

{error}

} {notice &&

{notice}

}

{statusSummary(config.settings, config.rows)}

{config.rows.length > 0 && (
{config.rows.map((row) => ( ))}
Team Members Channel State
{row.teamName} {row.memberCount} {row.channelRef || none} {stateLabel(row.state)} {removalCountdown(row) && ( {removalCountdown(row)} )} {row.lastError && ( {row.lastError} )}
)} {config.lastPass && config.lastPass.at && (

Last pass {new Date(config.lastPass.at).toLocaleString()} {config.lastPass.ran ? '' : ` — nothing was done: ${config.lastPass.reason}`}

)}
) }