import React, { useState, useEffect, useMemo, useCallback, useRef } from 'react'; import { Modal } from '../ui/Modal'; import { ConfirmDialog } from '../ui/ConfirmDialog'; import { useUIStore } from '../../stores/uiStore'; import { useSpaceStore, getApiForOrigin } from '../../stores/spaceStore'; import { api } from '../../api/client'; import { PermissionBits, permissionsToString, stringToPermissions, hasPermissionBit } from '../../utils/permissions'; import { Toggle } from '../ui/Toggle'; import type { Role, MemberWithUser } from '@backspace/shared'; interface ChannelOverride { channelId: string; targetType: string; targetId: string; allow: string; deny: string; } // ─── Permission Definitions for Channel Overrides ────────────────────────────── interface PermissionDef { key: keyof typeof PermissionBits; label: string; bit: bigint; } const TEXT_CHANNEL_PERMISSIONS: PermissionDef[] = [ { key: 'VIEW_CHANNEL', label: 'View Channel', bit: PermissionBits.VIEW_CHANNEL }, { key: 'SEND_MESSAGES', label: 'Send Messages', bit: PermissionBits.SEND_MESSAGES }, { key: 'MANAGE_MESSAGES', label: 'Manage Messages', bit: PermissionBits.MANAGE_MESSAGES }, { key: 'ATTACH_FILES', label: 'Attach Files', bit: PermissionBits.ATTACH_FILES }, { key: 'READ_MESSAGE_HISTORY', label: 'Read Message History', bit: PermissionBits.READ_MESSAGE_HISTORY }, { key: 'ADD_REACTIONS', label: 'Add Reactions', bit: PermissionBits.ADD_REACTIONS }, ]; const VOICE_CHANNEL_PERMISSIONS: PermissionDef[] = [ { key: 'VIEW_CHANNEL', label: 'View Channel', bit: PermissionBits.VIEW_CHANNEL }, { key: 'CONNECT', label: 'Connect', bit: PermissionBits.CONNECT }, { key: 'SPEAK', label: 'Speak', bit: PermissionBits.SPEAK }, { key: 'STREAM', label: 'Stream', bit: PermissionBits.STREAM }, { key: 'MUTE_MEMBERS', label: 'Mute Members', bit: PermissionBits.MUTE_MEMBERS }, { key: 'DEAFEN_MEMBERS', label: 'Deafen Members', bit: PermissionBits.DEAFEN_MEMBERS }, { key: 'MOVE_MEMBERS', label: 'Move Members', bit: PermissionBits.MOVE_MEMBERS }, { key: 'DISCONNECT_MEMBERS', label: 'Disconnect Members', bit: PermissionBits.DISCONNECT_MEMBERS }, ]; // ─── Tri-State Toggle ────────────────────────────────────────────────────────── type TriState = 'allow' | 'neutral' | 'deny'; function TriStateToggle({ value, onChange, disabled, }: { value: TriState; onChange: (v: TriState) => void; disabled?: boolean; }) { const btnClass = (v: TriState, active: boolean) => { const base = 'w-6 h-6 flex items-center justify-center rounded-full transition-colors text-xs font-bold'; if (disabled) return `${base} cursor-not-allowed opacity-40`; if (!active) return `${base} cursor-pointer text-txt-muted hover:text-txt-tertiary`; switch (v) { case 'deny': return `${base} cursor-pointer bg-accent-rose/15 text-accent-rose`; case 'neutral': return `${base} cursor-pointer bg-white/[0.06] text-txt-tertiary`; case 'allow': return `${base} cursor-pointer bg-accent-primary/15 text-accent-primary`; } }; return (
); } // ─── Override Entry (expandable row) ──────────────────────────────────────────── function OverrideEntry({ label, color, permDefs, allow, deny, onChange, onRemove, isEveryone, }: { label: string; color?: string; permDefs: PermissionDef[]; allow: bigint; deny: bigint; onChange: (allow: bigint, deny: bigint) => void; onRemove?: () => void; isEveryone?: boolean; }) { const [expanded, setExpanded] = useState(false); const getState = (bit: bigint): TriState => { if ((allow & bit) !== 0n) return 'allow'; if ((deny & bit) !== 0n) return 'deny'; return 'neutral'; }; const setState = (bit: bigint, state: TriState) => { let newAllow = allow & ~bit; let newDeny = deny & ~bit; if (state === 'allow') newAllow |= bit; if (state === 'deny') newDeny |= bit; onChange(newAllow, newDeny); }; // Compact summary of non-neutral permissions const summary = permDefs.filter(p => getState(p.bit) !== 'neutral'); return (
)} {expanded && (
{permDefs.map((perm) => (
{perm.label} setState(perm.bit, v)} />
))}
)}
); } // ─── Permissions Tab ──────────────────────────────────────────────────────────── function PermissionsTab({ channelId, channelType, spaceId, overrides, onOverridesChange, }: { channelId: string; channelType: 'text' | 'voice'; spaceId: string; overrides: ChannelOverride[]; onOverridesChange: () => void; }) { const spaces = useSpaceStore((s) => s.spaces); const space = spaces.find(s => s.id === spaceId); const roles = useSpaceStore((s) => s.roles); const members = useSpaceStore((s) => s.members); // Draft state: keyed by "role:id" or "member:id" const [draftOverrides, setDraftOverrides] = useState>(new Map()); const [pendingRemovals, setPendingRemovals] = useState>(new Set()); const [newOverrides, setNewOverrides] = useState>(new Map()); const [saving, setSaving] = useState(false); const [saveError, setSaveError] = useState(''); // Add role/member dropdown state const [showAddRole, setShowAddRole] = useState(false); const [showAddMember, setShowAddMember] = useState(false); const [memberSearch, setMemberSearch] = useState(''); // Refs + click-outside/Escape for dropdown menus const roleDropdownRef = useRef(null); const memberDropdownRef = useRef(null); useEffect(() => { if (!showAddRole) return; const handleMouseDown = (e: MouseEvent) => { if (roleDropdownRef.current && !roleDropdownRef.current.contains(e.target as Node)) { setShowAddRole(false); } }; const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape') setShowAddRole(false); }; document.addEventListener('mousedown', handleMouseDown); document.addEventListener('keydown', handleKeyDown); return () => { document.removeEventListener('mousedown', handleMouseDown); document.removeEventListener('keydown', handleKeyDown); }; }, [showAddRole]); useEffect(() => { if (!showAddMember) return; const handleMouseDown = (e: MouseEvent) => { if (memberDropdownRef.current && !memberDropdownRef.current.contains(e.target as Node)) { setShowAddMember(false); setMemberSearch(''); } }; const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape') { setShowAddMember(false); setMemberSearch(''); } }; document.addEventListener('mousedown', handleMouseDown); document.addEventListener('keydown', handleKeyDown); return () => { document.removeEventListener('mousedown', handleMouseDown); document.removeEventListener('keydown', handleKeyDown); }; }, [showAddMember]); const permDefs = channelType === 'voice' ? VOICE_CHANNEL_PERMISSIONS : TEXT_CHANNEL_PERMISSIONS; // Build map of existing overrides keyed by "role:id" or "member:id" const existingOverrideMap = useMemo(() => { const map = new Map(); for (const o of overrides) { map.set(`${o.targetType}:${o.targetId}`, o); } return map; }, [overrides]); // Roles that already have overrides const existingRoleIds = useMemo(() => { const set = new Set(); for (const o of overrides) { if (o.targetType === 'role') set.add(o.targetId); } for (const [key] of newOverrides) { if (key.startsWith('role:')) set.add(key.slice(5)); } return set; }, [overrides, newOverrides]); // Members that already have overrides const existingMemberIds = useMemo(() => { const set = new Set(); for (const o of overrides) { if (o.targetType === 'member') set.add(o.targetId); } for (const [key] of newOverrides) { if (key.startsWith('member:')) set.add(key.slice(7)); } return set; }, [overrides, newOverrides]); // Available roles to add (not already in overrides) const availableRoles = useMemo(() => roles.filter(r => !existingRoleIds.has(r.id) && !pendingRemovals.has(`role:${r.id}`)), [roles, existingRoleIds, pendingRemovals]); // Available members to add (not already in overrides), filtered by search const availableMembers = useMemo(() => { const filtered = members.filter(m => !existingMemberIds.has(m.userId) && !pendingRemovals.has(`member:${m.userId}`) ); if (!memberSearch.trim()) return filtered.slice(0, 20); const q = memberSearch.toLowerCase(); return filtered.filter(m => m.user.username.toLowerCase().includes(q) || (m.user.displayName?.toLowerCase().includes(q)) ).slice(0, 20); }, [members, existingMemberIds, pendingRemovals, memberSearch]); // Get effective allow/deny for a key — considers drafts, new overrides, and originals const getEffective = useCallback((key: string): { allow: bigint; deny: bigint } => { if (newOverrides.has(key)) { const n = newOverrides.get(key)!; return { allow: n.allow, deny: n.deny }; } if (draftOverrides.has(key)) return draftOverrides.get(key)!; const orig = existingOverrideMap.get(key); if (orig) return { allow: stringToPermissions(orig.allow), deny: stringToPermissions(orig.deny) }; return { allow: 0n, deny: 0n }; }, [draftOverrides, newOverrides, existingOverrideMap]); // Update handler for an override entry const handleChange = useCallback((key: string, allow: bigint, deny: bigint) => { if (newOverrides.has(key)) { setNewOverrides(prev => { const next = new Map(prev); const entry = next.get(key)!; next.set(key, { ...entry, allow, deny }); return next; }); } else { setDraftOverrides(prev => { const next = new Map(prev); next.set(key, { allow, deny }); return next; }); } }, [newOverrides]); // Remove handler const handleRemove = useCallback((key: string) => { if (newOverrides.has(key)) { setNewOverrides(prev => { const next = new Map(prev); next.delete(key); return next; }); } else { setPendingRemovals(prev => { const next = new Set(prev); next.add(key); return next; }); // Remove from drafts too setDraftOverrides(prev => { const next = new Map(prev); next.delete(key); return next; }); } }, [newOverrides]); // Add role override const handleAddRole = useCallback((roleId: string) => { const key = `role:${roleId}`; setNewOverrides(prev => { const next = new Map(prev); next.set(key, { targetType: 'role', targetId: roleId, allow: 0n, deny: 0n }); return next; }); // If it was pending removal, unmark it setPendingRemovals(prev => { const next = new Set(prev); next.delete(key); return next; }); setShowAddRole(false); }, []); // Add member override const handleAddMember = useCallback((userId: string) => { const key = `member:${userId}`; setNewOverrides(prev => { const next = new Map(prev); next.set(key, { targetType: 'member', targetId: userId, allow: 0n, deny: 0n }); return next; }); setPendingRemovals(prev => { const next = new Set(prev); next.delete(key); return next; }); setShowAddMember(false); setMemberSearch(''); }, []); const hasChanges = draftOverrides.size > 0 || newOverrides.size > 0 || pendingRemovals.size > 0; const handleDiscard = useCallback(() => { setDraftOverrides(new Map()); setNewOverrides(new Map()); setPendingRemovals(new Set()); setSaveError(''); }, []); const handleSave = useCallback(async () => { setSaving(true); setSaveError(''); const channelApi = getApiForOrigin(space?._instanceOrigin ?? ''); try { const promises: Promise[] = []; // Delete removed overrides for (const key of pendingRemovals) { const parts = key.split(':'); promises.push(channelApi.channels.deleteOverride(channelId, parts[0]!, parts[1]!)); } // Update modified existing overrides for (const [key, { allow, deny }] of draftOverrides) { if (pendingRemovals.has(key)) continue; const parts = key.split(':'); promises.push(channelApi.channels.putOverride(channelId, { targetType: parts[0]!, targetId: parts[1]!, allow: permissionsToString(allow), deny: permissionsToString(deny), })); } // Create new overrides for (const [, { targetType, targetId, allow, deny }] of newOverrides) { promises.push(channelApi.channels.putOverride(channelId, { targetType, targetId, allow: permissionsToString(allow), deny: permissionsToString(deny), })); } const results = await Promise.allSettled(promises); const failures = results.filter(r => r.status === 'rejected'); if (failures.length > 0) { const first = failures[0] as PromiseRejectedResult; setSaveError(first.reason?.message || `${failures.length} override(s) failed to save`); } // Reset draft state and re-fetch overrides setDraftOverrides(new Map()); setNewOverrides(new Map()); setPendingRemovals(new Set()); onOverridesChange(); } catch (err) { setSaveError(err instanceof Error ? err.message : 'Failed to save overrides'); } finally { setSaving(false); } }, [channelId, draftOverrides, newOverrides, pendingRemovals, space, onOverridesChange]); // Build ordered lists of role and member overrides const roleOverrides = useMemo(() => { const items: { key: string; role: Role; isNew: boolean }[] = []; // Existing overrides (excluding pending removals) for (const o of overrides) { if (o.targetType !== 'role') continue; const key = `role:${o.targetId}`; if (pendingRemovals.has(key)) continue; const role = roles.find(r => r.id === o.targetId); if (!role) continue; items.push({ key, role, isNew: false }); } // New overrides for (const [key, entry] of newOverrides) { if (!key.startsWith('role:')) continue; const role = roles.find(r => r.id === entry.targetId); if (!role) continue; if (items.some(i => i.key === key)) continue; items.push({ key, role, isNew: true }); } // Sort: @everyone first, then by position items.sort((a, b) => { if (a.role.id === spaceId) return -1; if (b.role.id === spaceId) return 1; return (a.role.position ?? 0) - (b.role.position ?? 0); }); return items; }, [overrides, newOverrides, pendingRemovals, roles, spaceId]); const memberOverrides = useMemo(() => { const items: { key: string; member: MemberWithUser; isNew: boolean }[] = []; for (const o of overrides) { if (o.targetType !== 'member') continue; const key = `member:${o.targetId}`; if (pendingRemovals.has(key)) continue; const member = members.find(m => m.userId === o.targetId); if (!member) continue; items.push({ key, member, isNew: false }); } for (const [key, entry] of newOverrides) { if (!key.startsWith('member:')) continue; const member = members.find(m => m.userId === entry.targetId); if (!member) continue; if (items.some(i => i.key === key)) continue; items.push({ key, member, isNew: true }); } return items; }, [overrides, newOverrides, pendingRemovals, members]); return (
{/* Role Overrides */}
Role Overrides
{roleOverrides.map(({ key, role }) => { const eff = getEffective(key); return ( handleChange(key, a, d)} onRemove={() => handleRemove(key)} isEveryone={role.id === spaceId} /> ); })}
{/* Add Role */}
{!showAddRole ? ( ) : (
{availableRoles.length === 0 ? (
No more roles to add
) : ( availableRoles.map(role => ( )) )}
)}
{/* Member Overrides */}
Member Overrides
{memberOverrides.map(({ key, member }) => { const eff = getEffective(key); return ( handleChange(key, a, d)} onRemove={() => handleRemove(key)} /> ); })}
{/* Add Member */}
{!showAddMember ? ( ) : (
setMemberSearch(e.target.value)} placeholder="Search members..." className="input-search w-full mb-1" autoFocus />
{availableMembers.length === 0 ? (
No members found
) : ( availableMembers.map(member => ( )) )}
)}
{/* Error */} {saveError && (
{saveError}
)} {/* Save/Discard pill */} {hasChanges && (
)}
); } // ─── Overview Tab ─────────────────────────────────────────────────────────────── function OverviewTab({ channelId, channelName, channelType, isPrivate, isFetching, isLoading, error, canManageChannels, onTogglePrivate, onDeleteChannel, }: { channelId: string; channelName: string; channelType: string; isPrivate: boolean; isFetching: boolean; isLoading: boolean; error: string; canManageChannels: boolean; onTogglePrivate: () => void; onDeleteChannel: () => void; }) { return (
{isPrivate ? ( ) : ( )} {channelName}
{error && (
{error}
)}
Private Channel
Only selected members and roles will be able to view this channel.
{isPrivate && !isFetching && (
This channel is hidden from members without explicit access. Users with the Administrator permission or space owners can always see all channels.
)} {canManageChannels && (
)}
); } // ─── Main Modal ───────────────────────────────────────────────────────────────── export function ChannelSettingsModal() { const activeModal = useUIStore((s) => s.activeModal); const modalData = useUIStore((s) => s.modalData); const closeModal = useUIStore((s) => s.closeModal); const currentSpaceId = useSpaceStore((s) => s.currentSpaceId); const channels = useSpaceStore((s) => s.channels); const spaces = useSpaceStore((s) => s.spaces); const spacePermissions = useSpaceStore((s) => s.spacePermissions); const [tab, setTab] = useState<'overview' | 'permissions'>('overview'); const [isPrivate, setIsPrivate] = useState(false); const [isLoading, setIsLoading] = useState(false); const [isFetching, setIsFetching] = useState(true); const [error, setError] = useState(''); const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); const [isDeleting, setIsDeleting] = useState(false); const [overrides, setOverrides] = useState([]); const isOpen = activeModal === 'channelSettings'; const channelId = modalData?.channelId as string | undefined; const channel = channels.find(c => c.id === channelId); const myPerms = currentSpaceId ? spacePermissions.get(currentSpaceId) : undefined; const canManageChannels = myPerms !== undefined && hasPermissionBit(myPerms, PermissionBits.MANAGE_CHANNELS); const canManageRoles = myPerms !== undefined && hasPermissionBit(myPerms, PermissionBits.MANAGE_ROLES); // Reset state when modal closes useEffect(() => { if (!isOpen) { setShowDeleteConfirm(false); setIsDeleting(false); setTab('overview'); setOverrides([]); } }, [isOpen]); // Fetch overrides when modal opens const fetchOverrides = useCallback(() => { if (!channelId || !currentSpaceId) return; setIsFetching(true); setError(''); const space = spaces.find(s => s.id === currentSpaceId); const channelApi = getApiForOrigin(space?._instanceOrigin ?? ''); channelApi.channels.getOverrides(channelId) .then((data: ChannelOverride[]) => { setOverrides(data); // Check if @everyone role (id === spaceId) has VIEW_CHANNEL denied const everyoneOverride = data.find( o => o.targetType === 'role' && o.targetId === currentSpaceId ); if (everyoneOverride) { const denyBits = stringToPermissions(everyoneOverride.deny); setIsPrivate((denyBits & PermissionBits.VIEW_CHANNEL) !== 0n); } else { setIsPrivate(false); } }) .catch((err: Error) => { setError(err.message || 'Failed to load channel overrides'); }) .finally(() => { setIsFetching(false); }); }, [channelId, currentSpaceId, spaces]); useEffect(() => { if (isOpen && channelId && currentSpaceId) { fetchOverrides(); } else { setIsFetching(false); } }, [isOpen, channelId, currentSpaceId, fetchOverrides]); if (!isOpen || !channel || !channelId || !currentSpaceId) return null; const handleToggle = async () => { setError(''); setIsLoading(true); const space = spaces.find(s => s.id === currentSpaceId); const channelApi = getApiForOrigin(space?._instanceOrigin ?? ''); try { if (!isPrivate) { // Make private: deny VIEW_CHANNEL for @everyone role await channelApi.channels.putOverride(channelId, { targetType: 'role', targetId: currentSpaceId, allow: '0', deny: permissionsToString(PermissionBits.VIEW_CHANNEL), }); setIsPrivate(true); } else { // Make public: remove the @everyone VIEW_CHANNEL deny override await channelApi.channels.deleteOverride(channelId, 'role', currentSpaceId); setIsPrivate(false); } // Re-fetch overrides to keep permissions tab in sync fetchOverrides(); } catch (err) { setError(err instanceof Error ? err.message : 'Failed to update channel privacy'); } finally { setIsLoading(false); } }; const handleDeleteChannel = async () => { if (!channelId || !currentSpaceId) return; setIsDeleting(true); try { const space = spaces.find(s => s.id === currentSpaceId); const channelApi = getApiForOrigin(space?._instanceOrigin ?? ''); await channelApi.channels.delete(channelId); closeModal(); } catch (err) { setError(err instanceof Error ? err.message : 'Failed to delete channel'); setIsDeleting(false); } }; const showTabs = canManageRoles; const tabClass = (t: typeof tab) => `w-full text-left px-2.5 py-1.5 rounded text-sm transition-colors ${ tab === t ? 'bg-interactive-selected text-txt-primary' : 'text-txt-tertiary hover:text-txt-secondary hover:bg-interactive-hover' }`; return ( <> {showTabs ? (
{/* Tabs */}
{/* Content */}
{tab === 'overview' && ( setShowDeleteConfirm(true)} /> )} {tab === 'permissions' && ( )}
) : ( setShowDeleteConfirm(true)} /> )}
setShowDeleteConfirm(false)} onConfirm={handleDeleteChannel} title={`Delete #${channel.name}?`} description={<> This will permanently delete #{channel.name} and all of its messages. {channel.type === 'voice' && ' Any users currently in this voice channel will be disconnected.'} {' '}This action cannot be undone. } confirmLabel="Delete Channel" variant="danger" loading={isDeleting} /> ); }