diff --git a/packages/shared/src/assets/icons/role_shield.svg b/packages/shared/src/assets/icons/role_shield.svg new file mode 100644 index 0000000..6a8747b --- /dev/null +++ b/packages/shared/src/assets/icons/role_shield.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/shared/src/components/Sidebar.jsx b/packages/shared/src/components/Sidebar.jsx index 38ec5fa..4440520 100644 --- a/packages/shared/src/components/Sidebar.jsx +++ b/packages/shared/src/components/Sidebar.jsx @@ -1,2036 +1,2664 @@ -import React, { useState, useRef, useEffect, useLayoutEffect, useCallback } from 'react'; -import { useNavigate } from 'react-router-dom'; -import { useConvex, useMutation, useQuery } from 'convex/react'; -import { api } from '../../../../convex/_generated/api'; -import Tooltip from './Tooltip'; -import { useVoice } from '../contexts/VoiceContext'; -import ChannelSettingsModal from './ChannelSettingsModal'; -import ServerSettingsModal from './ServerSettingsModal'; -import ScreenShareModal from './ScreenShareModal'; -import MobileServerDrawer from './MobileServerDrawer'; -import MobileCreateChannelScreen from './MobileCreateChannelScreen'; -import MobileCreateCategoryScreen from './MobileCreateCategoryScreen'; -import MobileChannelDrawer from './MobileChannelDrawer'; -import MobileChannelSettingsScreen from './MobileChannelSettingsScreen'; -import DMList from './DMList'; -import Avatar from './Avatar'; -import UserSettings from './UserSettings'; -import ChangeNicknameModal from './ChangeNicknameModal'; -import { Track } from 'livekit-client'; -import { DndContext, closestCenter, PointerSensor, useSensor, useSensors, DragOverlay, useDraggable } from '@dnd-kit/core'; -import { SortableContext, verticalListSortingStrategy, useSortable } from '@dnd-kit/sortable'; -import { CSS } from '@dnd-kit/utilities'; -import muteIcon from '../assets/icons/mute.svg'; -import mutedIcon from '../assets/icons/muted.svg'; -import defeanIcon from '../assets/icons/defean.svg'; -import defeanedIcon from '../assets/icons/defeaned.svg'; -import settingsIcon from '../assets/icons/settings.svg'; -import voiceIcon from '../assets/icons/voice.svg'; -import disconnectIcon from '../assets/icons/disconnect.svg'; -import cameraIcon from '../assets/icons/camera.svg'; -import screenIcon from '../assets/icons/screen.svg'; -import inviteUserIcon from '../assets/icons/invite_user.svg'; -import personalMuteIcon from '../assets/icons/personal_mute.svg'; -import serverMuteIcon from '../assets/icons/server_mute.svg'; -import categoryCollapsedIcon from '../assets/icons/category_collapsed_icon.svg'; -import PingSound from '../assets/sounds/ping.mp3'; -import screenShareStartSound from '../assets/sounds/screenshare_start.mp3'; -import screenShareStopSound from '../assets/sounds/screenshare_stop.mp3'; -import { getUserPref, setUserPref } from '../utils/userPreferences'; -import { usePlatform } from '../platform'; -import ColoredIcon from './ColoredIcon'; +import React, { useState, useRef, useEffect, useLayoutEffect, useCallback } from "react"; +import { useNavigate } from "react-router-dom"; +import { useConvex, useMutation, useQuery } from "convex/react"; +import { api } from "../../../../convex/_generated/api"; +import Tooltip from "./Tooltip"; +import { useVoice } from "../contexts/VoiceContext"; +import ChannelSettingsModal from "./ChannelSettingsModal"; +import ServerSettingsModal from "./ServerSettingsModal"; +import ScreenShareModal from "./ScreenShareModal"; +import MobileServerDrawer from "./MobileServerDrawer"; +import MobileCreateChannelScreen from "./MobileCreateChannelScreen"; +import MobileCreateCategoryScreen from "./MobileCreateCategoryScreen"; +import MobileChannelDrawer from "./MobileChannelDrawer"; +import MobileChannelSettingsScreen from "./MobileChannelSettingsScreen"; +import DMList from "./DMList"; +import Avatar from "./Avatar"; +import UserSettings from "./UserSettings"; +import ChangeNicknameModal from "./ChangeNicknameModal"; +import { Track } from "livekit-client"; +import { + DndContext, + closestCenter, + PointerSensor, + useSensor, + useSensors, + DragOverlay, + useDraggable, +} from "@dnd-kit/core"; +import { SortableContext, verticalListSortingStrategy, useSortable } from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; +import muteIcon from "../assets/icons/mute.svg"; +import mutedIcon from "../assets/icons/muted.svg"; +import defeanIcon from "../assets/icons/defean.svg"; +import defeanedIcon from "../assets/icons/defeaned.svg"; +import settingsIcon from "../assets/icons/settings.svg"; +import voiceIcon from "../assets/icons/voice.svg"; +import disconnectIcon from "../assets/icons/disconnect.svg"; +import cameraIcon from "../assets/icons/camera.svg"; +import screenIcon from "../assets/icons/screen.svg"; +import inviteUserIcon from "../assets/icons/invite_user.svg"; +import personalMuteIcon from "../assets/icons/personal_mute.svg"; +import serverMuteIcon from "../assets/icons/server_mute.svg"; +import categoryCollapsedIcon from "../assets/icons/category_collapsed_icon.svg"; +import PingSound from "../assets/sounds/ping.mp3"; +import screenShareStartSound from "../assets/sounds/screenshare_start.mp3"; +import screenShareStopSound from "../assets/sounds/screenshare_stop.mp3"; +import { getUserPref, setUserPref } from "../utils/userPreferences"; +import { usePlatform } from "../platform"; +import ColoredIcon from "./ColoredIcon"; -const USER_COLORS = ['#5865F2', '#EBA7CD', '#57F287', '#FEE75C', '#EB459E', '#ED4245']; +const USER_COLORS = ["#5865F2", "#EBA7CD", "#57F287", "#FEE75C", "#EB459E", "#ED4245"]; -const ICON_COLOR_DEFAULT = 'hsl(240, 4.294%, 68.039%)'; -const ICON_COLOR_ACTIVE = 'hsl(357.692, 67.826%, 54.902%)'; -const SERVER_MUTE_RED = 'hsl(1.343, 84.81%, 69.02%)'; +const ICON_COLOR_DEFAULT = "hsl(240, 4.294%, 68.039%)"; +const ICON_COLOR_ACTIVE = "hsl(357.692, 67.826%, 54.902%)"; +const SERVER_MUTE_RED = "hsl(1.343, 84.81%, 69.02%)"; const controlButtonStyle = { - background: 'transparent', - border: 'none', - cursor: 'pointer', - padding: '6px', - borderRadius: '4px', - display: 'flex', - alignItems: 'center', - justifyContent: 'center' + background: "transparent", + border: "none", + cursor: "pointer", + padding: "6px", + borderRadius: "4px", + display: "flex", + alignItems: "center", + justifyContent: "center", }; function getUserColor(name) { - let hash = 0; - for (let i = 0; i < name.length; i++) { - hash = name.charCodeAt(i) + ((hash << 5) - hash); - } - return USER_COLORS[Math.abs(hash) % USER_COLORS.length]; + let hash = 0; + for (let i = 0; i < name.length; i++) { + hash = name.charCodeAt(i) + ((hash << 5) - hash); + } + return USER_COLORS[Math.abs(hash) % USER_COLORS.length]; } function bytesToHex(bytes) { - return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join(''); + return Array.from(bytes) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); } function randomHex(length) { - const bytes = new Uint8Array(length); - crypto.getRandomValues(bytes); - return bytesToHex(bytes); + const bytes = new Uint8Array(length); + crypto.getRandomValues(bytes); + return bytesToHex(bytes); } const VoiceTimer = () => { - const [elapsed, setElapsed] = React.useState(0); - React.useEffect(() => { - const start = Date.now(); - const interval = setInterval(() => setElapsed(Math.floor((Date.now() - start) / 1000)), 1000); - return () => clearInterval(interval); - }, []); - const hours = Math.floor(elapsed / 3600); - const mins = Math.floor((elapsed % 3600) / 60); - const secs = elapsed % 60; - const time = hours > 0 - ? `${hours}:${String(mins).padStart(2, '0')}:${String(secs).padStart(2, '0')}` - : `${mins}:${String(secs).padStart(2, '0')}`; - return {time}; + const [elapsed, setElapsed] = React.useState(0); + React.useEffect(() => { + const start = Date.now(); + const interval = setInterval(() => setElapsed(Math.floor((Date.now() - start) / 1000)), 1000); + return () => clearInterval(interval); + }, []); + const hours = Math.floor(elapsed / 3600); + const mins = Math.floor((elapsed % 3600) / 60); + const secs = elapsed % 60; + const time = + hours > 0 + ? `${hours}:${String(mins).padStart(2, "0")}:${String(secs).padStart(2, "0")}` + : `${mins}:${String(secs).padStart(2, "0")}`; + return {time}; }; const STATUS_OPTIONS = [ - { value: 'online', label: 'Online', color: '#3ba55c' }, - { value: 'idle', label: 'Idle', color: '#faa61a' }, - { value: 'dnd', label: 'Do Not Disturb', color: '#ed4245' }, - { value: 'invisible', label: 'Invisible', color: '#747f8d' }, + { value: "online", label: "Online", color: "#3ba55c" }, + { value: "idle", label: "Idle", color: "#faa61a" }, + { value: "dnd", label: "Do Not Disturb", color: "#ed4245" }, + { value: "invisible", label: "Invisible", color: "#747f8d" }, ]; const UserControlPanel = React.memo(({ username, userId }) => { - const { session, idle, searchDB } = usePlatform(); - const { isMuted, isDeafened, toggleMute, toggleDeafen, connectionState, disconnectVoice } = useVoice(); - const [showStatusMenu, setShowStatusMenu] = useState(false); - const [showUserSettings, setShowUserSettings] = useState(false); - const [currentStatus, setCurrentStatus] = useState('online'); - const updateStatusMutation = useMutation(api.auth.updateStatus); - const navigate = useNavigate(); - const manualStatusRef = useRef(false); - const preIdleStatusRef = useRef('online'); - const hasInitializedRef = useRef(false); - const currentStatusRef = useRef(currentStatus); - currentStatusRef.current = currentStatus; + const { session, idle, searchDB } = usePlatform(); + const { isMuted, isDeafened, toggleMute, toggleDeafen, connectionState, disconnectVoice } = + useVoice(); + const [showStatusMenu, setShowStatusMenu] = useState(false); + const [showUserSettings, setShowUserSettings] = useState(false); + const [currentStatus, setCurrentStatus] = useState("online"); + const updateStatusMutation = useMutation(api.auth.updateStatus); + const navigate = useNavigate(); + const manualStatusRef = useRef(false); + const preIdleStatusRef = useRef("online"); + const hasInitializedRef = useRef(false); + const currentStatusRef = useRef(currentStatus); + currentStatusRef.current = currentStatus; - // Fetch stored status preference from server and sync local state - const allUsers = useQuery(api.auth.getPublicKeys) || []; - const myUser = allUsers.find(u => u.id === userId); - React.useEffect(() => { - if (myUser) { - const isInitial = !hasInitializedRef.current; - if (isInitial) hasInitializedRef.current = true; + // Fetch stored status preference from server and sync local state + const allUsers = useQuery(api.auth.getPublicKeys) || []; + const myUser = allUsers.find((u) => u.id === userId); + React.useEffect(() => { + if (myUser) { + const isInitial = !hasInitializedRef.current; + if (isInitial) hasInitializedRef.current = true; - // 'idle' is auto-set by the idle detector, not a user preference — - // on a fresh app launch, reset it to 'online' just like 'offline' - const shouldReset = !myUser.status || myUser.status === 'offline' - || (isInitial && myUser.status === 'idle'); + // 'idle' is auto-set by the idle detector, not a user preference — + // on a fresh app launch, reset it to 'online' just like 'offline' + const shouldReset = + !myUser.status || myUser.status === "offline" || (isInitial && myUser.status === "idle"); - if (shouldReset) { - setCurrentStatus('online'); - manualStatusRef.current = false; - if (userId) { - updateStatusMutation({ userId, status: 'online' }).catch(() => {}); - } - } else if (myUser.status) { - setCurrentStatus(myUser.status); - manualStatusRef.current = (myUser.status === 'dnd' || myUser.status === 'invisible'); - } - } - }, [myUser?.status]); - - const handleLogout = async () => { - // Disconnect voice if connected - if (connectionState === 'connected') { - try { disconnectVoice(); } catch {} - } - // Save and close search DB - if (searchDB?.isOpen()) { - try { await searchDB.save(); searchDB.close(); } catch {} - } - // Clear persisted session - if (session) { - try { await session.clear(); } catch {} - } - // Clear storage (preserve theme and user preferences) - const theme = localStorage.getItem('theme'); - const savedPrefs = {}; - for (let i = 0; i < localStorage.length; i++) { - const key = localStorage.key(i); - if (key.startsWith('userPrefs_')) { - savedPrefs[key] = localStorage.getItem(key); - } - } - localStorage.clear(); - if (theme) localStorage.setItem('theme', theme); - for (const [key, value] of Object.entries(savedPrefs)) { - localStorage.setItem(key, value); - } - sessionStorage.clear(); - navigate('/'); - }; - - const effectiveMute = isMuted || isDeafened; - const statusColor = STATUS_OPTIONS.find(s => s.value === currentStatus)?.color || '#3ba55c'; - - const handleStatusChange = async (status) => { - manualStatusRef.current = (status !== 'online'); - setCurrentStatus(status); - setShowStatusMenu(false); + if (shouldReset) { + setCurrentStatus("online"); + manualStatusRef.current = false; if (userId) { - try { - await updateStatusMutation({ userId, status }); - } catch (e) { - console.error('Failed to update status:', e); - } + updateStatusMutation({ userId, status: "online" }).catch(() => {}); } + } else if (myUser.status) { + setCurrentStatus(myUser.status); + manualStatusRef.current = myUser.status === "dnd" || myUser.status === "invisible"; + } + } + }, [myUser?.status]); + + const handleLogout = async () => { + // Disconnect voice if connected + if (connectionState === "connected") { + try { + disconnectVoice(); + } catch {} + } + // Save and close search DB + if (searchDB?.isOpen()) { + try { + await searchDB.save(); + searchDB.close(); + } catch {} + } + // Clear persisted session + if (session) { + try { + await session.clear(); + } catch {} + } + // Clear storage (preserve theme and user preferences) + const theme = localStorage.getItem("theme"); + const savedPrefs = {}; + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i); + if (key.startsWith("userPrefs_")) { + savedPrefs[key] = localStorage.getItem(key); + } + } + localStorage.clear(); + if (theme) localStorage.setItem("theme", theme); + for (const [key, value] of Object.entries(savedPrefs)) { + localStorage.setItem(key, value); + } + sessionStorage.clear(); + navigate("/"); + }; + + const effectiveMute = isMuted || isDeafened; + const statusColor = STATUS_OPTIONS.find((s) => s.value === currentStatus)?.color || "#3ba55c"; + + const handleStatusChange = async (status) => { + manualStatusRef.current = status !== "online"; + setCurrentStatus(status); + setShowStatusMenu(false); + if (userId) { + try { + await updateStatusMutation({ userId, status }); + } catch (e) { + console.error("Failed to update status:", e); + } + } + }; + + // Auto-idle detection via platform idle API + // On Capacitor (Android), skip this entirely — presence disconnect handles + // offline when not in voice, and VoiceContext AFK polling handles idle + // after 5 min of not talking when in voice. + useEffect(() => { + if (!idle || !userId) return; + if (window.Capacitor?.isNativePlatform?.()) return; + + const handleIdleChange = (data) => { + if (manualStatusRef.current) return; + if (data.isIdle) { + preIdleStatusRef.current = currentStatusRef.current; + setCurrentStatus("idle"); + updateStatusMutation({ userId, status: "idle" }).catch(() => {}); + } else { + const restoreTo = preIdleStatusRef.current || "online"; + setCurrentStatus(restoreTo); + updateStatusMutation({ userId, status: restoreTo }).catch(() => {}); + } }; + idle.onIdleStateChanged(handleIdleChange); + return () => idle.removeIdleStateListener(); + }, [userId]); - // Auto-idle detection via platform idle API - // On Capacitor (Android), skip this entirely — presence disconnect handles - // offline when not in voice, and VoiceContext AFK polling handles idle - // after 5 min of not talking when in voice. - useEffect(() => { - if (!idle || !userId) return; - if (window.Capacitor?.isNativePlatform?.()) return; - - const handleIdleChange = (data) => { - if (manualStatusRef.current) return; - if (data.isIdle) { - preIdleStatusRef.current = currentStatusRef.current; - setCurrentStatus('idle'); - updateStatusMutation({ userId, status: 'idle' }).catch(() => {}); - } else { - const restoreTo = preIdleStatusRef.current || 'online'; - setCurrentStatus(restoreTo); - updateStatusMutation({ userId, status: restoreTo }).catch(() => {}); - } - }; - idle.onIdleStateChanged(handleIdleChange); - return () => idle.removeIdleStateListener(); - }, [userId]); - - return ( -
- {showStatusMenu && ( -
- {STATUS_OPTIONS.map(opt => ( -
handleStatusChange(opt.value)} - > -
- {opt.label} -
- ))} -
- )} -
setShowStatusMenu(!showStatusMenu)}> -
- -
-
-
-
- {username || 'Unknown'} -
-
- {STATUS_OPTIONS.find(s => s.value === currentStatus)?.label || 'Online'} -
-
+ return ( +
+ {showStatusMenu && ( +
+ {STATUS_OPTIONS.map((opt) => ( +
handleStatusChange(opt.value)} + > +
+ {opt.label}
- -
- - - - - - - - - -
- {showUserSettings && ( - setShowUserSettings(false)} - userId={userId} - username={username} - onLogout={handleLogout} - /> - )} + ))}
- ); + )} +
setShowStatusMenu(!showStatusMenu)}> +
+ +
+
+
+
+ {username || "Unknown"} +
+
+ {STATUS_OPTIONS.find((s) => s.value === currentStatus)?.label || "Online"} +
+
+
+ +
+ + + + + + + + + +
+ {showUserSettings && ( + setShowUserSettings(false)} + userId={userId} + username={username} + onLogout={handleLogout} + /> + )} +
+ ); }); - - const headerButtonStyle = { - background: 'transparent', - border: 'none', - color: 'var(--header-secondary)', - cursor: 'pointer', - fontSize: '18px', - padding: '0 4px' + background: "transparent", + border: "none", + color: "var(--header-secondary)", + cursor: "pointer", + fontSize: "18px", + padding: "0 4px", }; const voicePanelButtonStyle = { - flex: 1, - alignItems: 'center', - minHeight: '32px', - background: 'hsla(240, 4%, 60.784%, 0.078)', - border: 'hsla(0, 0%, 100%, 0.078)', - borderColor: 'hsla(240, 4%, 60.784%, 0.039)', - borderRadius: '8px', - cursor: 'pointer', - padding: '4px', - display: 'flex', - justifyContent: 'center' + flex: 1, + alignItems: "center", + minHeight: "32px", + background: "hsla(240, 4%, 60.784%, 0.078)", + border: "hsla(0, 0%, 100%, 0.078)", + borderColor: "hsla(240, 4%, 60.784%, 0.039)", + borderRadius: "8px", + cursor: "pointer", + padding: "4px", + display: "flex", + justifyContent: "center", }; const liveBadgeStyle = { - backgroundColor: '#ed4245', - borderRadius: '8px', - padding: '0 6px', - textOverflow: 'ellipsis', - whiteSpace: 'nowrap', - overflow: 'hidden', - textAlign: 'center', - height: '16px', - minHeight: '16px', - minWidth: '16px', - color: 'hsl(0, 0%, 100%)', - fontSize: '12px', - fontWeight: '700', - letterSpacing: '.02em', - lineHeight: '1.3333333333333333', - textTransform: 'uppercase', - display: 'flex', - alignItems: 'center', - marginRight: '4px' + backgroundColor: "#ed4245", + borderRadius: "8px", + padding: "0 6px", + textOverflow: "ellipsis", + whiteSpace: "nowrap", + overflow: "hidden", + textAlign: "center", + height: "16px", + minHeight: "16px", + minWidth: "16px", + color: "hsl(0, 0%, 100%)", + fontSize: "12px", + fontWeight: "700", + letterSpacing: ".02em", + lineHeight: "1.3333333333333333", + textTransform: "uppercase", + display: "flex", + alignItems: "center", + marginRight: "4px", }; -const ACTIVE_SPEAKER_SHADOW = 'rgb(67, 162, 90) 0px 0px 0px 2px, rgb(67, 162, 90) 0px 0px 0px 20px inset, rgb(26, 26, 30) 0px 0px 0px 20px inset'; -const VOICE_ACTIVE_COLOR = 'hsl(132.809, 34.902%, 50%)'; +const ACTIVE_SPEAKER_SHADOW = + "rgb(67, 162, 90) 0px 0px 0px 2px, rgb(67, 162, 90) 0px 0px 0px 20px inset, rgb(26, 26, 30) 0px 0px 0px 20px inset"; +const VOICE_ACTIVE_COLOR = "hsl(132.809, 34.902%, 50%)"; async function encryptKeyForUsers(convex, channelId, keyHex, crypto) { - const users = await convex.query(api.auth.getPublicKeys, {}); - const batchKeys = []; + const users = await convex.query(api.auth.getPublicKeys, {}); + const batchKeys = []; - for (const u of users) { - if (!u.public_identity_key) continue; - try { - const payload = JSON.stringify({ [channelId]: keyHex }); - const encryptedKeyHex = await crypto.publicEncrypt(u.public_identity_key, payload); - batchKeys.push({ - channelId, - userId: u.id, - encryptedKeyBundle: encryptedKeyHex, - keyVersion: 1 - }); - } catch (e) { - console.error("Failed to encrypt for user", u.id, e); - } + for (const u of users) { + if (!u.public_identity_key) continue; + try { + const payload = JSON.stringify({ [channelId]: keyHex }); + const encryptedKeyHex = await crypto.publicEncrypt(u.public_identity_key, payload); + batchKeys.push({ + channelId, + userId: u.id, + encryptedKeyBundle: encryptedKeyHex, + keyVersion: 1, + }); + } catch (e) { + console.error("Failed to encrypt for user", u.id, e); } + } - await convex.mutation(api.channelKeys.uploadKeys, { keys: batchKeys }); + await convex.mutation(api.channelKeys.uploadKeys, { keys: batchKeys }); } function getScreenCaptureConstraints(selection) { - if (selection.type === 'device') { - return { video: { deviceId: { exact: selection.deviceId } }, audio: false }; - } - return { - audio: selection.shareAudio ? { - mandatory: { - chromeMediaSource: 'desktop', - chromeMediaSourceId: selection.sourceId - } - } : false, - video: { - mandatory: { - chromeMediaSource: 'desktop', - chromeMediaSourceId: selection.sourceId - } + if (selection.type === "device") { + return { video: { deviceId: { exact: selection.deviceId } }, audio: false }; + } + return { + audio: selection.shareAudio + ? { + mandatory: { + chromeMediaSource: "desktop", + chromeMediaSourceId: selection.sourceId, + }, } - }; + : false, + video: { + mandatory: { + chromeMediaSource: "desktop", + chromeMediaSourceId: selection.sourceId, + }, + }, + }; } -const VoiceUserContextMenu = ({ x, y, onClose, user, onMute, isMuted, onServerMute, isServerMuted, hasPermission, onDisconnect, hasDisconnectPermission, onMessage, isSelf, userVolume, onVolumeChange, onChangeNickname, showNicknameOption, onStartCall }) => { - const menuRef = useRef(null); - const [pos, setPos] = useState({ top: y, left: x }); +const VoiceUserContextMenu = ({ + x, + y, + onClose, + user, + onMute, + isMuted, + onServerMute, + isServerMuted, + hasPermission, + onDisconnect, + hasDisconnectPermission, + onMessage, + isSelf, + userVolume, + onVolumeChange, + onChangeNickname, + showNicknameOption, + onStartCall, +}) => { + const menuRef = useRef(null); + const [pos, setPos] = useState({ top: y, left: x }); - useEffect(() => { - const h = () => onClose(); - window.addEventListener('click', h); - window.addEventListener('close-context-menus', h); - return () => { window.removeEventListener('click', h); window.removeEventListener('close-context-menus', h); }; - }, [onClose]); + useEffect(() => { + const h = () => onClose(); + window.addEventListener("click", h); + window.addEventListener("close-context-menus", h); + return () => { + window.removeEventListener("click", h); + window.removeEventListener("close-context-menus", h); + }; + }, [onClose]); - useLayoutEffect(() => { - if (!menuRef.current) return; - const rect = menuRef.current.getBoundingClientRect(); - let newTop = y, newLeft = x; - if (x + rect.width > window.innerWidth) newLeft = x - rect.width; - if (y + rect.height > window.innerHeight) newTop = y - rect.height; - if (newLeft < 0) newLeft = 10; - if (newTop < 0) newTop = 10; - setPos({ top: newTop, left: newLeft }); - }, [x, y]); + useLayoutEffect(() => { + if (!menuRef.current) return; + const rect = menuRef.current.getBoundingClientRect(); + let newTop = y, + newLeft = x; + if (x + rect.width > window.innerWidth) newLeft = x - rect.width; + if (y + rect.height > window.innerHeight) newTop = y - rect.height; + if (newLeft < 0) newLeft = 10; + if (newTop < 0) newTop = 10; + setPos({ top: newTop, left: newLeft }); + }, [x, y]); - const sliderPercent = (userVolume / 200) * 100; + const sliderPercent = (userVolume / 200) * 100; - return ( -
e.stopPropagation()}> - {!isSelf && ( - <> -
e.stopPropagation()} onClick={(e) => e.stopPropagation()}> -
- User Volume - {userVolume}% -
- onVolumeChange(Number(e.target.value))} - className="context-menu-volume-slider" - style={{ background: `linear-gradient(to right, hsl(235, 86%, 65%) ${sliderPercent}%, var(--bg-tertiary) ${sliderPercent}%)` }} - /> -
-
- - )} -
{ e.stopPropagation(); onMute(); }} - > - Mute -
-
- {isMuted ? ( - - - - ) : ( - - - )} -
-
+ return ( +
e.stopPropagation()} + > + {!isSelf && ( + <> +
e.stopPropagation()} + onClick={(e) => e.stopPropagation()} + > +
+ User Volume + {userVolume}%
- {hasPermission && ( -
{ e.stopPropagation(); onServerMute(); }} - > - Server Mute -
-
- {isServerMuted ? ( - - - - ) : ( - - - )} -
-
-
- )} - {!isSelf && hasDisconnectPermission && ( -
{ e.stopPropagation(); onDisconnect(); onClose(); }} - > - Disconnect -
- )} -
- {showNicknameOption && ( -
{ e.stopPropagation(); onChangeNickname(); onClose(); }}> - Change Nickname -
- )} - {!isSelf && ( -
{ e.stopPropagation(); onMessage(); onClose(); }}> - Message -
- )} - {!isSelf && ( -
{ e.stopPropagation(); onStartCall(); onClose(); }}> - Start a Call -
+ onVolumeChange(Number(e.target.value))} + className="context-menu-volume-slider" + style={{ + background: `linear-gradient(to right, hsl(235, 86%, 65%) ${sliderPercent}%, var(--bg-tertiary) ${sliderPercent}%)`, + }} + /> +
+
+ + )} +
{ + e.stopPropagation(); + onMute(); + }} + > + Mute +
+
+ {isMuted ? ( + + + + ) : ( + )} +
- ); +
+ {hasPermission && ( +
{ + e.stopPropagation(); + onServerMute(); + }} + > + Server Mute +
+
+ {isServerMuted ? ( + + + + ) : ( + + )} +
+
+
+ )} + {!isSelf && hasDisconnectPermission && ( +
{ + e.stopPropagation(); + onDisconnect(); + onClose(); + }} + > + Disconnect +
+ )} +
+ {showNicknameOption && ( +
{ + e.stopPropagation(); + onChangeNickname(); + onClose(); + }} + > + Change Nickname +
+ )} + {!isSelf && ( +
{ + e.stopPropagation(); + onMessage(); + onClose(); + }} + > + Message +
+ )} + {!isSelf && ( +
{ + e.stopPropagation(); + onStartCall(); + onClose(); + }} + > + Start a Call +
+ )} +
+ ); }; const ChannelListContextMenu = ({ x, y, onClose, onCreateChannel, onCreateCategory }) => { - const menuRef = useRef(null); - const [pos, setPos] = useState({ top: y, left: x }); + const menuRef = useRef(null); + const [pos, setPos] = useState({ top: y, left: x }); - useEffect(() => { - const h = () => onClose(); - window.addEventListener('click', h); - window.addEventListener('close-context-menus', h); - return () => { window.removeEventListener('click', h); window.removeEventListener('close-context-menus', h); }; - }, [onClose]); + useEffect(() => { + const h = () => onClose(); + window.addEventListener("click", h); + window.addEventListener("close-context-menus", h); + return () => { + window.removeEventListener("click", h); + window.removeEventListener("close-context-menus", h); + }; + }, [onClose]); - useLayoutEffect(() => { - if (!menuRef.current) return; - const rect = menuRef.current.getBoundingClientRect(); - let newTop = y, newLeft = x; - if (x + rect.width > window.innerWidth) newLeft = x - rect.width; - if (y + rect.height > window.innerHeight) newTop = y - rect.height; - if (newLeft < 0) newLeft = 10; - if (newTop < 0) newTop = 10; - setPos({ top: newTop, left: newLeft }); - }, [x, y]); + useLayoutEffect(() => { + if (!menuRef.current) return; + const rect = menuRef.current.getBoundingClientRect(); + let newTop = y, + newLeft = x; + if (x + rect.width > window.innerWidth) newLeft = x - rect.width; + if (y + rect.height > window.innerHeight) newTop = y - rect.height; + if (newLeft < 0) newLeft = 10; + if (newTop < 0) newTop = 10; + setPos({ top: newTop, left: newLeft }); + }, [x, y]); - return ( -
e.stopPropagation()}> -
{ e.stopPropagation(); onCreateChannel(); onClose(); }}> - Create Channel -
-
{ e.stopPropagation(); onCreateCategory(); onClose(); }}> - Create Category -
-
- ); + return ( +
e.stopPropagation()} + > +
{ + e.stopPropagation(); + onCreateChannel(); + onClose(); + }} + > + Create Channel +
+
{ + e.stopPropagation(); + onCreateCategory(); + onClose(); + }} + > + Create Category +
+
+ ); }; const CategoryContextMenu = ({ x, y, onClose, categoryName, onEdit, onDelete }) => { - const menuRef = useRef(null); - const [pos, setPos] = useState({ top: y, left: x }); + const menuRef = useRef(null); + const [pos, setPos] = useState({ top: y, left: x }); - useEffect(() => { - const h = () => onClose(); - window.addEventListener('click', h); - window.addEventListener('close-context-menus', h); - return () => { window.removeEventListener('click', h); window.removeEventListener('close-context-menus', h); }; - }, [onClose]); + useEffect(() => { + const h = () => onClose(); + window.addEventListener("click", h); + window.addEventListener("close-context-menus", h); + return () => { + window.removeEventListener("click", h); + window.removeEventListener("close-context-menus", h); + }; + }, [onClose]); - useLayoutEffect(() => { - if (!menuRef.current) return; - const rect = menuRef.current.getBoundingClientRect(); - let newTop = y, newLeft = x; - if (x + rect.width > window.innerWidth) newLeft = x - rect.width; - if (y + rect.height > window.innerHeight) newTop = y - rect.height; - if (newLeft < 0) newLeft = 10; - if (newTop < 0) newTop = 10; - setPos({ top: newTop, left: newLeft }); - }, [x, y]); + useLayoutEffect(() => { + if (!menuRef.current) return; + const rect = menuRef.current.getBoundingClientRect(); + let newTop = y, + newLeft = x; + if (x + rect.width > window.innerWidth) newLeft = x - rect.width; + if (y + rect.height > window.innerHeight) newTop = y - rect.height; + if (newLeft < 0) newLeft = 10; + if (newTop < 0) newTop = 10; + setPos({ top: newTop, left: newLeft }); + }, [x, y]); - return ( -
e.stopPropagation()}> -
{ e.stopPropagation(); onEdit(); }}> - Edit Category -
-
-
{ e.stopPropagation(); onDelete(); }}> - Delete Category -
-
- ); + return ( +
e.stopPropagation()} + > +
{ + e.stopPropagation(); + onEdit(); + }} + > + Edit Category +
+
+
{ + e.stopPropagation(); + onDelete(); + }} + > + Delete Category +
+
+ ); }; const CreateChannelModal = ({ onClose, onSubmit, categoryId }) => { - const [channelType, setChannelType] = useState('text'); - const [channelName, setChannelName] = useState(''); + const [channelType, setChannelType] = useState("text"); + const [channelName, setChannelName] = useState(""); - const handleSubmit = () => { - if (!channelName.trim()) return; - onSubmit(channelName.trim(), channelType, categoryId); - onClose(); - }; + const handleSubmit = () => { + if (!channelName.trim()) return; + onSubmit(channelName.trim(), channelType, categoryId); + onClose(); + }; - return ( -
-
e.stopPropagation()}> -
-
-

Create Channel

-

in Text Channels

-
- -
- -
-
- - -
setChannelType('text')} - > -
- # -
-
Text
-
Send messages, images, GIFs, emoji, opinions, and puns
-
-
-
- {channelType === 'text' &&
} -
-
- -
setChannelType('voice')} - > -
- - - -
-
Voice
-
Hang out together with voice, video, and screen share
-
-
-
- {channelType === 'voice' &&
} -
-
-
- -
- -
- - {channelType === 'text' ? '#' : '🔊'} - - setChannelName(e.target.value.toLowerCase().replace(/\s+/g, '-'))} - onKeyDown={(e) => { if (e.key === 'Enter') handleSubmit(); }} - className="create-channel-name-input" - /> -
-
-
- -
- - -
-
+ return ( +
+
e.stopPropagation()}> +
+
+

+ Create Channel +

+

+ in Text Channels +

+
+
- ); + +
+
+ + +
setChannelType("text")} + > +
+ + # + +
+
+ Text +
+
+ Send messages, images, GIFs, emoji, opinions, and puns +
+
+
+
+ {channelType === "text" &&
} +
+
+ +
setChannelType("voice")} + > +
+ + + +
+
+ Voice +
+
+ Hang out together with voice, video, and screen share +
+
+
+
+ {channelType === "voice" &&
} +
+
+
+ +
+ +
+ + {channelType === "text" ? "#" : "🔊"} + + setChannelName(e.target.value.toLowerCase().replace(/\s+/g, "-"))} + onKeyDown={(e) => { + if (e.key === "Enter") handleSubmit(); + }} + className="create-channel-name-input" + /> +
+
+
+ +
+ + +
+
+
+ ); }; const CreateCategoryModal = ({ onClose, onSubmit }) => { - const [categoryName, setCategoryName] = useState(''); + const [categoryName, setCategoryName] = useState(""); - const handleSubmit = () => { - if (!categoryName.trim()) return; - onSubmit(categoryName.trim()); - onClose(); - }; + const handleSubmit = () => { + if (!categoryName.trim()) return; + onSubmit(categoryName.trim()); + onClose(); + }; - return ( -
-
e.stopPropagation()}> -
-

Create Category

- -
- -
-
- -
- setCategoryName(e.target.value)} - onKeyDown={(e) => { if (e.key === 'Enter') handleSubmit(); }} - className="create-channel-name-input" - /> -
-
- -
-
- - - - Private Category -
-
-
-
-
-

- By making a category private, only selected members and roles will be able to view this category. Synced channels will automatically match this category's permissions. -

-
- -
- - -
-
+ return ( +
+
e.stopPropagation()}> +
+

+ Create Category +

+
- ); + +
+
+ +
+ setCategoryName(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") handleSubmit(); + }} + className="create-channel-name-input" + /> +
+
+ +
+
+ + + + + Private Category + +
+
+
+
+
+

+ By making a category private, only selected members and roles will be able to view this + category. Synced channels will automatically match this category's permissions. +

+
+ +
+ + +
+
+
+ ); }; // --- DnD wrapper components --- const SortableCategory = ({ id, children }) => { - const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ - id, - data: { type: 'category' }, - }); + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ + id, + data: { type: "category" }, + }); - const style = { - transform: CSS.Transform.toString(transform), - transition, - opacity: isDragging ? 0.5 : 1, - }; + const style = { + transform: CSS.Transform.toString(transform), + transition, + opacity: isDragging ? 0.5 : 1, + }; - return ( -
- {React.Children.map(children, (child, i) => { - // First child is the category header — attach drag listeners to it - if (i === 0 && React.isValidElement(child)) { - return React.cloneElement(child, { dragListeners: listeners }); - } - return child; - })} -
- ); + return ( +
+ {React.Children.map(children, (child, i) => { + // First child is the category header — attach drag listeners to it + if (i === 0 && React.isValidElement(child)) { + return React.cloneElement(child, { dragListeners: listeners }); + } + return child; + })} +
+ ); }; const SortableChannel = ({ id, children }) => { - const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ - id, - data: { type: 'channel' }, - }); + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ + id, + data: { type: "channel" }, + }); - const style = { - transform: CSS.Transform.toString(transform), - transition, - opacity: isDragging ? 0.5 : 1, - }; + const style = { + transform: CSS.Transform.toString(transform), + transition, + opacity: isDragging ? 0.5 : 1, + }; - return ( -
- {typeof children === 'function' ? children(listeners) : children} -
- ); + return ( +
+ {typeof children === "function" ? children(listeners) : children} +
+ ); }; const DraggableVoiceUser = ({ userId, channelId, disabled, children }) => { - const { attributes, listeners, setNodeRef, isDragging } = useDraggable({ - id: `voice-user-${userId}`, - data: { type: 'voice-user', userId, channelId }, - disabled, - }); + const { attributes, listeners, setNodeRef, isDragging } = useDraggable({ + id: `voice-user-${userId}`, + data: { type: "voice-user", userId, channelId }, + disabled, + }); - return ( -
- {children} -
- ); + return ( +
+ {children} +
+ ); }; -const Sidebar = ({ channels, categories, activeChannel, onSelectChannel, username, channelKeys, view, onViewChange, onOpenDM, activeDMChannel, setActiveDMChannel, dmChannels, userId, serverName = 'Secure Chat', serverIconUrl, isMobile, onStartCallWithUser, onOpenMobileSearch }) => { - const { crypto, settings } = usePlatform(); - const [isCreating, setIsCreating] = useState(false); - const [isServerSettingsOpen, setIsServerSettingsOpen] = useState(false); - const [newChannelName, setNewChannelName] = useState(''); - const [newChannelType, setNewChannelType] = useState('text'); - const [editingChannel, setEditingChannel] = useState(null); - const [isScreenShareModalOpen, setIsScreenShareModalOpen] = useState(false); - const [collapsedCategories, setCollapsedCategories] = useState(() => { - const effectiveUserId = userId || localStorage.getItem('userId'); - return getUserPref(effectiveUserId, 'collapsedCategories', {}); - }); - useEffect(() => { - if (userId) { - setCollapsedCategories(getUserPref(userId, 'collapsedCategories', {})); - } - }, [userId]); - const [channelListContextMenu, setChannelListContextMenu] = useState(null); - const [voiceUserMenu, setVoiceUserMenu] = useState(null); - const [categoryContextMenu, setCategoryContextMenu] = useState(null); - const [editingCategoryId, setEditingCategoryId] = useState(null); - const [showCreateChannelModal, setShowCreateChannelModal] = useState(false); - const [showCreateCategoryModal, setShowCreateCategoryModal] = useState(false); - const [createChannelCategoryId, setCreateChannelCategoryId] = useState(null); - const [activeDragItem, setActiveDragItem] = useState(null); - const [dragOverChannelId, setDragOverChannelId] = useState(null); - const [voiceNicknameModal, setVoiceNicknameModal] = useState(null); - const [showMobileServerDrawer, setShowMobileServerDrawer] = useState(false); - const [showMobileCreateChannel, setShowMobileCreateChannel] = useState(false); - const [showMobileCreateCategory, setShowMobileCreateCategory] = useState(false); - const [mobileChannelDrawer, setMobileChannelDrawer] = useState(null); - const [showMobileChannelSettings, setShowMobileChannelSettings] = useState(null); +const Sidebar = ({ + channels, + categories, + activeChannel, + onSelectChannel, + username, + channelKeys, + view, + onViewChange, + onOpenDM, + activeDMChannel, + setActiveDMChannel, + dmChannels, + userId, + serverName = "Secure Chat", + serverIconUrl, + isMobile, + onStartCallWithUser, + onOpenMobileSearch, +}) => { + const { crypto, settings } = usePlatform(); + const [isCreating, setIsCreating] = useState(false); + const [isServerSettingsOpen, setIsServerSettingsOpen] = useState(false); + const [newChannelName, setNewChannelName] = useState(""); + const [newChannelType, setNewChannelType] = useState("text"); + const [editingChannel, setEditingChannel] = useState(null); + const [isScreenShareModalOpen, setIsScreenShareModalOpen] = useState(false); + const [collapsedCategories, setCollapsedCategories] = useState(() => { + const effectiveUserId = userId || localStorage.getItem("userId"); + return getUserPref(effectiveUserId, "collapsedCategories", {}); + }); + useEffect(() => { + if (userId) { + setCollapsedCategories(getUserPref(userId, "collapsedCategories", {})); + } + }, [userId]); + const [channelListContextMenu, setChannelListContextMenu] = useState(null); + const [voiceUserMenu, setVoiceUserMenu] = useState(null); + const [categoryContextMenu, setCategoryContextMenu] = useState(null); + const [editingCategoryId, setEditingCategoryId] = useState(null); + const [showCreateChannelModal, setShowCreateChannelModal] = useState(false); + const [showCreateCategoryModal, setShowCreateCategoryModal] = useState(false); + const [createChannelCategoryId, setCreateChannelCategoryId] = useState(null); + const [activeDragItem, setActiveDragItem] = useState(null); + const [dragOverChannelId, setDragOverChannelId] = useState(null); + const [voiceNicknameModal, setVoiceNicknameModal] = useState(null); + const [showMobileServerDrawer, setShowMobileServerDrawer] = useState(false); + const [showMobileCreateChannel, setShowMobileCreateChannel] = useState(false); + const [showMobileCreateCategory, setShowMobileCreateCategory] = useState(false); + const [mobileChannelDrawer, setMobileChannelDrawer] = useState(null); + const [showMobileChannelSettings, setShowMobileChannelSettings] = useState(null); - const convex = useConvex(); + const convex = useConvex(); - // Permissions for move_members gating - const myPermissions = useQuery( - api.roles.getMyPermissions, - userId ? { userId } : "skip" - ) || {}; + // Permissions for move_members gating + const myPermissions = useQuery(api.roles.getMyPermissions, userId ? { userId } : "skip") || {}; - // Member count for mobile server drawer - const allUsersForDrawer = useQuery(api.auth.getPublicKeys) || []; + // Member count for mobile server drawer + const allUsersForDrawer = useQuery(api.auth.getPublicKeys) || []; - // DnD sensors - const sensors = useSensors( - useSensor(PointerSensor, { activationConstraint: { distance: isMobile ? Infinity : 5 } }) + // DnD sensors + const sensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: isMobile ? Infinity : 5 } }), + ); + + // Unread tracking + const channelIds = React.useMemo( + () => [...channels.map((c) => c._id), ...dmChannels.map((dm) => dm.channel_id)], + [channels, dmChannels], + ); + const rawAllReadStates = useQuery(api.readState.getAllReadStates, userId ? { userId } : "skip"); + const rawLatestTimestamps = useQuery( + api.readState.getLatestMessageTimestamps, + channelIds.length > 0 ? { channelIds } : "skip", + ); + const allReadStates = rawAllReadStates || []; + const latestTimestamps = rawLatestTimestamps || []; + const unreadQueriesLoaded = rawAllReadStates !== undefined && rawLatestTimestamps !== undefined; + + const unreadChannels = React.useMemo(() => { + const set = new Set(); + const readMap = new Map(); + for (const rs of allReadStates) { + readMap.set(rs.channelId, rs.lastReadTimestamp); + } + for (const lt of latestTimestamps) { + const lastRead = readMap.get(lt.channelId); + if (lastRead === undefined || lt.latestTimestamp > lastRead) { + set.add(lt.channelId); + } + } + return set; + }, [allReadStates, latestTimestamps]); + + const unreadDMs = React.useMemo( + () => + dmChannels.filter( + (dm) => + unreadChannels.has(dm.channel_id) && + !(view === "me" && activeDMChannel?.channel_id === dm.channel_id), + ), + [dmChannels, unreadChannels, view, activeDMChannel], + ); + + const { + connectToVoice, + activeChannelId: voiceChannelId, + connectionState, + disconnectVoice, + activeChannelName: voiceChannelName, + voiceStates, + room, + activeSpeakers, + setScreenSharing, + isPersonallyMuted, + togglePersonalMute, + isMuted: selfMuted, + toggleMute, + serverMute, + disconnectUser, + isServerMuted, + serverSettings, + getUserVolume, + setUserVolume, + isReceivingScreenShareAudio, + } = useVoice(); + + const prevUnreadDMsRef = useRef(null); + + useEffect(() => { + if (!unreadQueriesLoaded) return; + + const currentIds = new Set( + dmChannels.filter((dm) => unreadChannels.has(dm.channel_id)).map((dm) => dm.channel_id), ); - // Unread tracking - const channelIds = React.useMemo(() => [ - ...channels.map(c => c._id), - ...dmChannels.map(dm => dm.channel_id) - ], [channels, dmChannels]); - const rawAllReadStates = useQuery( - api.readState.getAllReadStates, - userId ? { userId } : "skip" - ); - const rawLatestTimestamps = useQuery( - api.readState.getLatestMessageTimestamps, - channelIds.length > 0 ? { channelIds } : "skip" - ); - const allReadStates = rawAllReadStates || []; - const latestTimestamps = rawLatestTimestamps || []; - const unreadQueriesLoaded = rawAllReadStates !== undefined && rawLatestTimestamps !== undefined; + if (prevUnreadDMsRef.current === null) { + prevUnreadDMsRef.current = currentIds; + return; + } - const unreadChannels = React.useMemo(() => { - const set = new Set(); - const readMap = new Map(); - for (const rs of allReadStates) { - readMap.set(rs.channelId, rs.lastReadTimestamp); + for (const id of currentIds) { + if (!prevUnreadDMsRef.current.has(id)) { + if (!isReceivingScreenShareAudio) { + const audio = new Audio(PingSound); + audio.volume = 0.5; + audio.play().catch(() => {}); } - for (const lt of latestTimestamps) { - const lastRead = readMap.get(lt.channelId); - if (lastRead === undefined || lt.latestTimestamp > lastRead) { - set.add(lt.channelId); - } - } - return set; - }, [allReadStates, latestTimestamps]); + break; + } + } - const unreadDMs = React.useMemo(() => - dmChannels.filter(dm => - unreadChannels.has(dm.channel_id) && - !(view === 'me' && activeDMChannel?.channel_id === dm.channel_id) - ), - [dmChannels, unreadChannels, view, activeDMChannel] - ); + prevUnreadDMsRef.current = currentIds; + }, [dmChannels, unreadChannels, unreadQueriesLoaded, isReceivingScreenShareAudio]); - const { connectToVoice, activeChannelId: voiceChannelId, connectionState, disconnectVoice, activeChannelName: voiceChannelName, voiceStates, room, activeSpeakers, setScreenSharing, isPersonallyMuted, togglePersonalMute, isMuted: selfMuted, toggleMute, serverMute, disconnectUser, isServerMuted, serverSettings, getUserVolume, setUserVolume, isReceivingScreenShareAudio } = useVoice(); + const onRenameChannel = () => {}; - const prevUnreadDMsRef = useRef(null); + const onDeleteChannel = (id) => { + if (activeChannel === id) onSelectChannel(null); + }; - useEffect(() => { - if (!unreadQueriesLoaded) return; + const handleStartCreate = () => { + setIsCreating(true); + setNewChannelName(""); + setNewChannelType("text"); + }; - const currentIds = new Set( - dmChannels.filter(dm => unreadChannels.has(dm.channel_id)).map(dm => dm.channel_id) - ); + const handleSubmitCreate = async (e) => { + if (e) e.preventDefault(); - if (prevUnreadDMsRef.current === null) { - prevUnreadDMsRef.current = currentIds; - return; - } + if (!newChannelName.trim()) { + setIsCreating(false); + return; + } - for (const id of currentIds) { - if (!prevUnreadDMsRef.current.has(id)) { - if (!isReceivingScreenShareAudio) { - const audio = new Audio(PingSound); - audio.volume = 0.5; - audio.play().catch(() => {}); - } - break; - } - } + const name = newChannelName.trim(); + const userId = localStorage.getItem("userId"); - prevUnreadDMsRef.current = currentIds; - }, [dmChannels, unreadChannels, unreadQueriesLoaded, isReceivingScreenShareAudio]); + if (!userId) { + alert("Please login first."); + setIsCreating(false); + return; + } - const onRenameChannel = () => {}; + try { + const { id: channelId } = await convex.mutation(api.channels.create, { + name, + type: newChannelType, + }); + const keyHex = randomHex(32); - const onDeleteChannel = (id) => { - if (activeChannel === id) onSelectChannel(null); - }; + try { + await encryptKeyForUsers(convex, channelId, keyHex, crypto); + } catch (keyErr) { + console.error("Critical: Failed to distribute keys", keyErr); + alert("Channel created but key distribution failed."); + } + } catch (err) { + console.error(err); + alert("Failed to create channel: " + err.message); + } finally { + setIsCreating(false); + } + }; - const handleStartCreate = () => { - setIsCreating(true); - setNewChannelName(''); - setNewChannelType('text'); - }; + const handleCreateInvite = async () => { + const userId = localStorage.getItem("userId"); + if (!userId) { + alert("Error: No User ID found. Please login again."); + return; + } - const handleSubmitCreate = async (e) => { - if (e) e.preventDefault(); + const generalChannel = channels.find((c) => c.name === "general"); + const targetChannelId = generalChannel ? generalChannel._id : activeChannel; - if (!newChannelName.trim()) { - setIsCreating(false); - return; - } + if (!targetChannelId) { + alert("No channel selected."); + return; + } - const name = newChannelName.trim(); - const userId = localStorage.getItem('userId'); + const targetKey = channelKeys?.[targetChannelId]; - if (!userId) { - alert("Please login first."); - setIsCreating(false); - return; - } + if (!targetKey) { + alert("Error: You don't have the key for this channel yet, so you can't invite others."); + return; + } - try { - const { id: channelId } = await convex.mutation(api.channels.create, { name, type: newChannelType }); - const keyHex = randomHex(32); + try { + const inviteCode = globalThis.crypto.randomUUID(); + const inviteSecret = randomHex(32); - try { - await encryptKeyForUsers(convex, channelId, keyHex, crypto); - } catch (keyErr) { - console.error("Critical: Failed to distribute keys", keyErr); - alert("Channel created but key distribution failed."); - } - } catch (err) { - console.error(err); - alert("Failed to create channel: " + err.message); - } finally { - setIsCreating(false); - } - }; + const payload = JSON.stringify({ [targetChannelId]: targetKey }); + const encrypted = await crypto.encryptData(payload, inviteSecret); + const blob = JSON.stringify({ c: encrypted.content, t: encrypted.tag, iv: encrypted.iv }); - const handleCreateInvite = async () => { - const userId = localStorage.getItem('userId'); - if (!userId) { - alert("Error: No User ID found. Please login again."); - return; - } + await convex.mutation(api.invites.create, { + code: inviteCode, + encryptedPayload: blob, + createdBy: userId, + keyVersion: 1, + }); - const generalChannel = channels.find(c => c.name === 'general'); - const targetChannelId = generalChannel ? generalChannel._id : activeChannel; + const baseUrl = import.meta.env.VITE_APP_URL || window.location.origin; + const link = `${baseUrl}/#/register?code=${inviteCode}&key=${inviteSecret}`; + navigator.clipboard.writeText(link); + alert(`Invite Link Copied to Clipboard!\n\n${link}`); + } catch (e) { + console.error("Invite Error:", e); + alert("Failed to create invite. See console."); + } + }; - if (!targetChannelId) { - alert("No channel selected."); - return; - } + const handleScreenShareSelect = async (selection) => { + if (!room) return; - const targetKey = channelKeys?.[targetChannelId]; + try { + if (room.localParticipant.isScreenShareEnabled) { + await room.localParticipant.setScreenShareEnabled(false); + } - if (!targetKey) { - alert("Error: You don't have the key for this channel yet, so you can't invite others."); - return; - } - - try { - const inviteCode = crypto.randomUUID(); - const inviteSecret = randomHex(32); - - const payload = JSON.stringify({ [targetChannelId]: targetKey }); - const encrypted = await crypto.encryptData(payload, inviteSecret); - const blob = JSON.stringify({ c: encrypted.content, t: encrypted.tag, iv: encrypted.iv }); - - await convex.mutation(api.invites.create, { - code: inviteCode, - encryptedPayload: blob, - createdBy: userId, - keyVersion: 1 - }); - - const baseUrl = import.meta.env.VITE_APP_URL || window.location.origin; - const link = `${baseUrl}/#/register?code=${inviteCode}&key=${inviteSecret}`; - navigator.clipboard.writeText(link); - alert(`Invite Link Copied to Clipboard!\n\n${link}`); - } catch (e) { - console.error("Invite Error:", e); - alert("Failed to create invite. See console."); - } - }; - - const handleScreenShareSelect = async (selection) => { - if (!room) return; - - try { - if (room.localParticipant.isScreenShareEnabled) { - await room.localParticipant.setScreenShareEnabled(false); - } - - let stream; - try { - stream = await navigator.mediaDevices.getUserMedia(getScreenCaptureConstraints(selection)); - } catch (audioErr) { - // Audio capture may fail (e.g. macOS/Linux) — retry video-only - if (selection.shareAudio) { - console.warn("Audio capture failed, falling back to video-only:", audioErr.message); - stream = await navigator.mediaDevices.getUserMedia(getScreenCaptureConstraints({ ...selection, shareAudio: false })); - } else { - throw audioErr; - } - } - - const track = stream.getVideoTracks()[0]; - if (!track) return; - - await room.localParticipant.publishTrack(track, { - name: 'screen_share', - source: Track.Source.ScreenShare - }); - - // Publish audio track if present (system audio from desktop capture) - const audioTrack = stream.getAudioTracks()[0]; - if (audioTrack) { - await room.localParticipant.publishTrack(audioTrack, { - name: 'screen_share_audio', - source: Track.Source.ScreenShareAudio - }); - } - - if (!isReceivingScreenShareAudio) new Audio(screenShareStartSound).play(); - setScreenSharing(true); - - track.onended = () => { - // Clean up audio track when video track ends - if (audioTrack) { - audioTrack.stop(); - room.localParticipant.unpublishTrack(audioTrack); - } - setScreenSharing(false); - room.localParticipant.setScreenShareEnabled(false).catch(console.error); - }; - } catch (err) { - console.error("Error sharing screen:", err); - alert("Failed to share screen: " + err.message); - } - }; - - const handleScreenShareClick = () => { - if (room?.localParticipant.isScreenShareEnabled) { - // Clean up any screen share audio tracks before stopping - for (const pub of room.localParticipant.trackPublications.values()) { - const source = pub.source ? pub.source.toString().toLowerCase() : ''; - const name = pub.trackName ? pub.trackName.toLowerCase() : ''; - if (source === 'screen_share_audio' || name === 'screen_share_audio') { - if (pub.track) pub.track.stop(); - room.localParticipant.unpublishTrack(pub.track); - } - } - room.localParticipant.setScreenShareEnabled(false); - if (!isReceivingScreenShareAudio) new Audio(screenShareStopSound).play(); - setScreenSharing(false); + let stream; + try { + stream = await navigator.mediaDevices.getUserMedia(getScreenCaptureConstraints(selection)); + } catch (audioErr) { + // Audio capture may fail (e.g. macOS/Linux) — retry video-only + if (selection.shareAudio) { + console.warn("Audio capture failed, falling back to video-only:", audioErr.message); + stream = await navigator.mediaDevices.getUserMedia( + getScreenCaptureConstraints({ ...selection, shareAudio: false }), + ); } else { - setIsScreenShareModalOpen(true); + throw audioErr; } - }; + } - const handleChannelClick = (channel) => { - if (channel.type === 'voice') { - if (voiceChannelId !== channel._id) { - connectToVoice(channel._id, channel.name, localStorage.getItem('userId')); - } - onSelectChannel(channel._id); - } else { - onSelectChannel(channel._id); - } - }; + const track = stream.getVideoTracks()[0]; + if (!track) return; - // Long-press handler factory for mobile channel items - const createLongPressHandlers = (callback) => { - let timer = null; - let startX = 0; - let startY = 0; - let triggered = false; - return { - onTouchStart: (e) => { - triggered = false; - startX = e.touches[0].clientX; - startY = e.touches[0].clientY; - timer = setTimeout(() => { - triggered = true; - if (navigator.vibrate) navigator.vibrate(50); - callback(); - }, 500); - }, - onTouchMove: (e) => { - if (!timer) return; - const dx = e.touches[0].clientX - startX; - const dy = e.touches[0].clientY - startY; - if (Math.abs(dx) > 10 || Math.abs(dy) > 10) { - clearTimeout(timer); - timer = null; - } - }, - onTouchEnd: (e) => { - if (timer) { clearTimeout(timer); timer = null; } - if (triggered) { e.preventDefault(); triggered = false; } - }, - }; - }; + await room.localParticipant.publishTrack(track, { + name: "screen_share", + source: Track.Source.ScreenShare, + }); - const handleMarkAsRead = async (channelId) => { - if (!userId) return; - try { - await convex.mutation(api.readState.markRead, { - userId, - channelId, - lastReadTimestamp: Date.now(), - }); - } catch (e) { - console.error('Failed to mark as read:', e); - } - }; - - const renderDMView = () => ( -
- setActiveDMChannel(dm === 'friends' ? null : dm)} - onOpenDM={onOpenDM} - voiceStates={voiceStates} - /> -
- ); - - const renderVoiceUsers = (channel) => { - const users = voiceStates[channel._id]; - if (channel.type !== 'voice' || !users?.length) return null; - - return ( -
- {users.map(user => ( - -
{ - e.preventDefault(); - e.stopPropagation(); - window.dispatchEvent(new Event('close-context-menus')); - setVoiceUserMenu({ x: e.clientX, y: e.clientY, user }); - }} - > - - {user.displayName || user.username} -
- {user.isScreenSharing &&
Live
} - {user.isServerMuted ? ( - - ) : isPersonallyMuted(user.userId) ? ( - - ) : (user.isMuted || user.isDeafened) ? ( - - ) : null} - {user.isDeafened && ( - - )} -
-
-
- ))} -
- ); - }; - - const renderCollapsedVoiceUsers = (channel) => { - const users = voiceStates[channel._id]; - if (channel.type !== 'voice' || !users?.length) return null; - - return ( -
handleChannelClick(channel)} - style={{ position: 'relative', display: 'flex', alignItems: 'center', paddingRight: '8px' }} - > -
- -
-
- {users.map(user => ( -
- -
- ))} -
-
- ); - }; - - const toggleCategory = useCallback((cat) => { - setCollapsedCategories(prev => { - const next = { ...prev, [cat]: !prev[cat] }; - setUserPref(userId, 'collapsedCategories', next, settings); - return next; + // Publish audio track if present (system audio from desktop capture) + const audioTrack = stream.getAudioTracks()[0]; + if (audioTrack) { + await room.localParticipant.publishTrack(audioTrack, { + name: "screen_share_audio", + source: Track.Source.ScreenShareAudio, }); - }, [userId, settings]); + } - const handleAddChannelToCategory = useCallback((groupId) => { - setCreateChannelCategoryId(groupId === '__uncategorized__' ? null : groupId); - setShowCreateChannelModal(true); - }, []); + if (!isReceivingScreenShareAudio) new Audio(screenShareStartSound).play(); + setScreenSharing(true); - // Group channels by categoryId - const groupedChannels = React.useMemo(() => { - const groups = []; - const channelsByCategory = new Map(); - - channels.forEach(ch => { - const catId = ch.categoryId || '__uncategorized__'; - if (!channelsByCategory.has(catId)) channelsByCategory.set(catId, []); - channelsByCategory.get(catId).push(ch); - }); - - // Sort channels within each category by position - for (const [, list] of channelsByCategory) { - list.sort((a, b) => (a.position ?? 0) - (b.position ?? 0)); + track.onended = () => { + // Clean up audio track when video track ends + if (audioTrack) { + audioTrack.stop(); + room.localParticipant.unpublishTrack(audioTrack); } + setScreenSharing(false); + room.localParticipant.setScreenShareEnabled(false).catch(console.error); + }; + } catch (err) { + console.error("Error sharing screen:", err); + alert("Failed to share screen: " + err.message); + } + }; - // Add uncategorized at top - const uncategorized = channelsByCategory.get('__uncategorized__'); - if (uncategorized?.length) { - groups.push({ id: '__uncategorized__', name: 'Channels', channels: uncategorized }); + const handleScreenShareClick = () => { + if (room?.localParticipant.isScreenShareEnabled) { + // Clean up any screen share audio tracks before stopping + for (const pub of room.localParticipant.trackPublications.values()) { + const source = pub.source ? pub.source.toString().toLowerCase() : ""; + const name = pub.trackName ? pub.trackName.toLowerCase() : ""; + if (source === "screen_share_audio" || name === "screen_share_audio") { + if (pub.track) pub.track.stop(); + room.localParticipant.unpublishTrack(pub.track); } + } + room.localParticipant.setScreenShareEnabled(false); + if (!isReceivingScreenShareAudio) new Audio(screenShareStopSound).play(); + setScreenSharing(false); + } else { + setIsScreenShareModalOpen(true); + } + }; - // Add categories in position order - for (const cat of (categories || [])) { - groups.push({ id: cat._id, name: cat.name, channels: channelsByCategory.get(cat._id) || [] }); + const handleChannelClick = (channel) => { + if (channel.type === "voice") { + if (voiceChannelId !== channel._id) { + connectToVoice(channel._id, channel.name, localStorage.getItem("userId")); + } + onSelectChannel(channel._id); + } else { + onSelectChannel(channel._id); + } + }; + + // Long-press handler factory for mobile channel items + const createLongPressHandlers = (callback) => { + let timer = null; + let startX = 0; + let startY = 0; + let triggered = false; + return { + onTouchStart: (e) => { + triggered = false; + startX = e.touches[0].clientX; + startY = e.touches[0].clientY; + timer = setTimeout(() => { + triggered = true; + if (navigator.vibrate) navigator.vibrate(50); + callback(); + }, 500); + }, + onTouchMove: (e) => { + if (!timer) return; + const dx = e.touches[0].clientX - startX; + const dy = e.touches[0].clientY - startY; + if (Math.abs(dx) > 10 || Math.abs(dy) > 10) { + clearTimeout(timer); + timer = null; } - - return groups; - }, [channels, categories]); - - // DnD items - const categoryDndIds = React.useMemo(() => groupedChannels.map(g => `category-${g.id}`), [groupedChannels]); - - const handleDragStart = (event) => { - const { active } = event; - const activeType = active.data.current?.type; - if (activeType === 'category') { - const catId = active.id.replace('category-', ''); - const group = groupedChannels.find(g => g.id === catId); - setActiveDragItem({ type: 'category', name: group?.name || '' }); - } else if (activeType === 'channel') { - const chId = active.id.replace('channel-', ''); - const ch = channels.find(c => c._id === chId); - setActiveDragItem({ type: 'channel', channel: ch }); - } else if (activeType === 'voice-user') { - const targetUserId = active.data.current.userId; - const sourceChannelId = active.data.current.channelId; - const users = voiceStates[sourceChannelId]; - const user = users?.find(u => u.userId === targetUserId); - setActiveDragItem({ type: 'voice-user', user, sourceChannelId }); + }, + onTouchEnd: (e) => { + if (timer) { + clearTimeout(timer); + timer = null; } + if (triggered) { + e.preventDefault(); + triggered = false; + } + }, }; + }; - const handleDragOver = (event) => { - const { active, over } = event; - if (!active?.data.current || active.data.current.type !== 'voice-user') { - setDragOverChannelId(null); - return; - } - if (over) { - // Check if hovering over a voice channel (channel item or its DnD wrapper) - const overType = over.data.current?.type; - if (overType === 'channel') { - const chId = over.id.replace('channel-', ''); - const ch = channels.find(c => c._id === chId); - if (ch?.type === 'voice') { - setDragOverChannelId(ch._id); - return; - } - } - } - setDragOverChannelId(null); - }; + const handleMarkAsRead = async (channelId) => { + if (!userId) return; + try { + await convex.mutation(api.readState.markRead, { + userId, + channelId, + lastReadTimestamp: Date.now(), + }); + } catch (e) { + console.error("Failed to mark as read:", e); + } + }; - const handleDragEnd = async (event) => { - setActiveDragItem(null); - setDragOverChannelId(null); - const { active, over } = event; - if (!over || active.id === over.id) return; + const renderDMView = () => ( +
+ setActiveDMChannel(dm === "friends" ? null : dm)} + onOpenDM={onOpenDM} + voiceStates={voiceStates} + /> +
+ ); - const activeType = active.data.current?.type; - const overType = over.data.current?.type; - - // Handle voice-user drag - if (activeType === 'voice-user') { - if (overType !== 'channel') return; - const targetChId = over.id.replace('channel-', ''); - const targetChannel = channels.find(c => c._id === targetChId); - if (!targetChannel || targetChannel.type !== 'voice') return; - const sourceChannelId = active.data.current.channelId; - if (sourceChannelId === targetChId) return; - try { - await convex.mutation(api.voiceState.moveUser, { - actorUserId: userId, - targetUserId: active.data.current.userId, - targetChannelId: targetChId, - }); - } catch (e) { - console.error('Failed to move voice user:', e); - } - return; - } - - if (activeType === 'category' && overType === 'category') { - // Reorder categories - const oldIndex = groupedChannels.findIndex(g => `category-${g.id}` === active.id); - const newIndex = groupedChannels.findIndex(g => `category-${g.id}` === over.id); - if (oldIndex === -1 || newIndex === -1) return; - - // Build reordered array (only real categories, skip uncategorized) - const reordered = [...groupedChannels]; - const [moved] = reordered.splice(oldIndex, 1); - reordered.splice(newIndex, 0, moved); - - const updates = reordered - .filter(g => g.id !== '__uncategorized__') - .map((g, i) => ({ id: g.id, position: i * 1000 })); - - if (updates.length > 0) { - try { - await convex.mutation(api.categories.reorder, { updates }); - } catch (e) { - console.error('Failed to reorder categories:', e); - } - } - } else if (activeType === 'channel') { - const activeChId = active.id.replace('channel-', ''); - - if (overType === 'channel') { - const overChId = over.id.replace('channel-', ''); - const activeChannel = channels.find(c => c._id === activeChId); - const overChannel = channels.find(c => c._id === overChId); - if (!activeChannel || !overChannel) return; - - const targetCategoryId = overChannel.categoryId; - const targetGroup = groupedChannels.find(g => g.id === (targetCategoryId || '__uncategorized__')); - if (!targetGroup) return; - - // Build new order for the target category - const targetChannels = [...targetGroup.channels]; - - // Remove active channel if it's already in this category - const existingIdx = targetChannels.findIndex(c => c._id === activeChId); - if (existingIdx !== -1) targetChannels.splice(existingIdx, 1); - - // Insert at the position of the over channel - const overIdx = targetChannels.findIndex(c => c._id === overChId); - targetChannels.splice(overIdx, 0, activeChannel); - - const updates = targetChannels.map((ch, i) => ({ - id: ch._id, - categoryId: targetCategoryId, - position: i * 1000, - })); - - try { - await convex.mutation(api.channels.reorderChannels, { updates }); - } catch (e) { - console.error('Failed to reorder channels:', e); - } - } else if (overType === 'category') { - // Drop channel onto a category header — move it to end of that category - const targetCatId = over.id.replace('category-', ''); - const targetCategoryId = targetCatId === '__uncategorized__' ? undefined : targetCatId; - const targetGroup = groupedChannels.find(g => g.id === targetCatId); - const maxPos = (targetGroup?.channels || []).reduce((max, c) => Math.max(max, c.position ?? 0), -1000); - - try { - await convex.mutation(api.channels.moveChannel, { - id: activeChId, - categoryId: targetCategoryId, - position: maxPos + 1000, - }); - } catch (e) { - console.error('Failed to move channel:', e); - } - } - } - }; - - const renderServerView = () => ( -
-
- isMobile ? setShowMobileServerDrawer(true) : setIsServerSettingsOpen(true)}> - {serverName} - {isMobile && ( - - - - )} - - {!isMobile && ( - - )} -
- {isMobile && ( -
- - -
- )} - -
{ - if (!e.target.closest('.channel-item') && !e.target.closest('.channel-category-header')) { - e.preventDefault(); - window.dispatchEvent(new Event('close-context-menus')); - setChannelListContextMenu({ x: e.clientX, y: e.clientY }); - } - }}> - {isCreating && ( -
-
-
- - -
- setNewChannelName(e.target.value)} - style={{ - width: '100%', - background: 'var(--bg-tertiary)', - border: '1px solid var(--brand-experiment)', - borderRadius: '4px', - color: 'var(--text-normal)', - padding: '4px 8px', - fontSize: '14px', - outline: 'none' - }} - /> -
-
- Press Enter to Create {newChannelType === 'voice' && '(Voice)'} -
-
- )} - - - - {groupedChannels.map(group => { - const channelDndIds = group.channels.map(ch => `channel-${ch._id}`); - return ( - - { - e.preventDefault(); - e.stopPropagation(); - window.dispatchEvent(new Event('close-context-menus')); - setCategoryContextMenu({ x: e.clientX, y: e.clientY, categoryId: group.id, categoryName: group.name }); - } : undefined} - isEditing={editingCategoryId === group.id} - onRenameSubmit={async (newName) => { - if (newName && newName !== group.name) { - await convex.mutation(api.categories.rename, { id: group.id, name: newName }); - } - setEditingCategoryId(null); - }} - onRenameCancel={() => setEditingCategoryId(null)} - /> - {(() => { - const isCollapsed = collapsedCategories[group.id]; - const visibleChannels = isCollapsed - ? group.channels.filter(ch => - ch._id === activeChannel || - (ch.type === 'voice' && voiceStates[ch._id]?.length > 0) - ) - : group.channels; - if (visibleChannels.length === 0) return null; - const visibleDndIds = visibleChannels.map(ch => `channel-${ch._id}`); - return ( - - {visibleChannels.map(channel => { - const isUnread = activeChannel !== channel._id && unreadChannels.has(channel._id); - return ( - - {(channelDragListeners) => ( - - {!(isCollapsed && channel.type === 'voice' && voiceStates[channel._id]?.length > 0) &&
handleChannelClick(channel)} - {...channelDragListeners} - {...(isMobile ? createLongPressHandlers(() => setMobileChannelDrawer(channel)) : {})} - style={{ - position: 'relative', - display: 'flex', - justifyContent: 'space-between', - alignItems: 'center', - paddingRight: '8px' - }} - > - {isUnread &&
} -
- {channel.type === 'voice' ? ( -
- 0 ? VOICE_ACTIVE_COLOR : "var(--interactive-normal)"} - /> -
- ) : ( - # - )} - - {channel.name}{serverSettings?.afkChannelId === channel._id ? ' (AFK)' : ''} - -
- - {!isMobile && ( - - )} -
} - {isCollapsed - ? renderCollapsedVoiceUsers(channel) - : renderVoiceUsers(channel)} - - )} - - ); - })} - - ); - })()} - - ); - })} - - - - {activeDragItem?.type === 'channel' && activeDragItem.channel && ( -
- {activeDragItem.channel.type === 'voice' ? ( - - ) : ( - # - )} - {activeDragItem.channel.name} -
- )} - {activeDragItem?.type === 'category' && ( -
- {activeDragItem.name} -
- )} - {activeDragItem?.type === 'voice-user' && activeDragItem.user && ( -
- - {activeDragItem.user.username} -
- )} -
- -
-
- ); + const renderVoiceUsers = (channel) => { + const users = voiceStates[channel._id]; + if (channel.type !== "voice" || !users?.length) return null; return ( -
-
-
-
-
- -
onViewChange('me')} - style={{ - backgroundColor: view === 'me' ? 'var(--brand-experiment)' : 'var(--bg-primary)', - color: view === 'me' ? '#fff' : 'var(--text-normal)', - cursor: 'pointer' - }} - > - - - -
-
-
- - {unreadDMs.map(dm => ( -
-
- -
{ - setActiveDMChannel(dm); - onViewChange('me'); - }} - > - -
-
- -
- ))} - -
- -
-
- -
onViewChange('server')} - style={{ cursor: 'pointer' }} - > - {serverIconUrl ? ( - {serverName} - ) : ( - serverName.substring(0, 2) - )} -
-
-
-
- - {view === 'me' ? renderDMView() : renderServerView()} +
+ {users.map((user) => ( + +
{ + e.preventDefault(); + e.stopPropagation(); + window.dispatchEvent(new Event("close-context-menus")); + setVoiceUserMenu({ x: e.clientX, y: e.clientY, user }); + }} + > + + + {user.displayName || user.username} + +
+ {user.isScreenSharing &&
Live
} + {user.isServerMuted ? ( + + ) : isPersonallyMuted(user.userId) ? ( + + ) : user.isMuted || user.isDeafened ? ( + + ) : null} + {user.isDeafened && ( + + )} +
- - {(connectionState === 'connected' || connectionState === 'connecting') && ( -
-
-
- - - - - - -
- {connectionState === 'connected' ? 'Voice Connected' : 'Voice Connecting'} -
-
- -
-
{dmChannels?.some(dm => dm.channel_id === voiceChannelId) ? `Call with ${voiceChannelName}` : `${voiceChannelName} / ${serverName}`}
- {connectionState === 'connected' && ( - <> -
-
- - -
- - )} -
- )} - - - - {editingChannel && !isMobile && ( - setEditingChannel(null)} - onRename={onRenameChannel} - onDelete={onDeleteChannel} - /> - )} - {isServerSettingsOpen && ( - setIsServerSettingsOpen(false)} /> - )} - {showMobileServerDrawer && ( - setIsServerSettingsOpen(true)} - onCreateChannel={() => { setCreateChannelCategoryId(null); setShowMobileCreateChannel(true); }} - onCreateCategory={() => setShowMobileCreateCategory(true)} - onClose={() => setShowMobileServerDrawer(false)} - /> - )} - {isScreenShareModalOpen && ( - setIsScreenShareModalOpen(false)} - onSelectSource={handleScreenShareSelect} - /> - )} - {channelListContextMenu && ( - setChannelListContextMenu(null)} - onCreateChannel={() => { - setCreateChannelCategoryId(null); - setShowCreateChannelModal(true); - }} - onCreateCategory={() => setShowCreateCategoryModal(true)} - /> - )} - {categoryContextMenu && ( - setCategoryContextMenu(null)} - onEdit={() => { - setEditingCategoryId(categoryContextMenu.categoryId); - setCategoryContextMenu(null); - }} - onDelete={async () => { - const categoryId = categoryContextMenu.categoryId; - const categoryName = categoryContextMenu.categoryName; - setCategoryContextMenu(null); - if (window.confirm(`Are you sure you want to delete "${categoryName}"? Channels in this category will become uncategorized.`)) { - await convex.mutation(api.categories.remove, { id: categoryId }); - } - }} - /> - )} - {voiceUserMenu && ( - setVoiceUserMenu(null)} - isSelf={voiceUserMenu.user.userId === userId} - isMuted={voiceUserMenu.user.userId === userId ? selfMuted : isPersonallyMuted(voiceUserMenu.user.userId)} - onMute={() => voiceUserMenu.user.userId === userId ? toggleMute() : togglePersonalMute(voiceUserMenu.user.userId)} - isServerMuted={isServerMuted(voiceUserMenu.user.userId)} - onServerMute={() => serverMute(voiceUserMenu.user.userId, !isServerMuted(voiceUserMenu.user.userId))} - hasPermission={!!myPermissions.mute_members} - onDisconnect={() => disconnectUser(voiceUserMenu.user.userId)} - hasDisconnectPermission={!!myPermissions.move_members} - onMessage={() => { - onOpenDM(voiceUserMenu.user.userId, voiceUserMenu.user.displayName || voiceUserMenu.user.username); - onViewChange('me'); - }} - userVolume={getUserVolume(voiceUserMenu.user.userId)} - onVolumeChange={(vol) => setUserVolume(voiceUserMenu.user.userId, vol)} - showNicknameOption={voiceUserMenu.user.userId === userId || !!myPermissions.manage_nicknames} - onChangeNickname={() => setVoiceNicknameModal(voiceUserMenu.user)} - onStartCall={() => { - if (onStartCallWithUser) onStartCallWithUser(voiceUserMenu.user.userId, voiceUserMenu.user.displayName || voiceUserMenu.user.username); - }} - /> - )} - {voiceNicknameModal && ( - setVoiceNicknameModal(null)} - /> - )} - {showCreateChannelModal && ( - setShowCreateChannelModal(false)} - onSubmit={async (name, type, catId) => { - const userId = localStorage.getItem('userId'); - if (!userId) { alert("Please login first."); return; } - try { - const createArgs = { name, type }; - if (catId) createArgs.categoryId = catId; - const { id: channelId } = await convex.mutation(api.channels.create, createArgs); - const keyHex = randomHex(32); - try { await encryptKeyForUsers(convex, channelId, keyHex, crypto); } - catch (keyErr) { console.error("Critical: Failed to distribute keys", keyErr); alert("Channel created but key distribution failed."); } - } catch (err) { console.error(err); alert("Failed to create channel: " + err.message); } - }} - /> - )} - {showCreateCategoryModal && ( - setShowCreateCategoryModal(false)} - onSubmit={async (name) => { - try { - await convex.mutation(api.categories.create, { name }); - } catch (err) { - console.error(err); - alert("Failed to create category: " + err.message); - } - }} - /> - )} - {showMobileCreateChannel && ( - setShowMobileCreateChannel(false)} - onSubmit={async (name, type, catId) => { - const userId = localStorage.getItem('userId'); - if (!userId) { alert("Please login first."); return; } - try { - const createArgs = { name, type }; - if (catId) createArgs.categoryId = catId; - const { id: channelId } = await convex.mutation(api.channels.create, createArgs); - const keyHex = randomHex(32); - try { await encryptKeyForUsers(convex, channelId, keyHex, crypto); } - catch (keyErr) { console.error("Critical: Failed to distribute keys", keyErr); alert("Channel created but key distribution failed."); } - } catch (err) { console.error(err); alert("Failed to create channel: " + err.message); } - }} - /> - )} - {showMobileCreateCategory && ( - setShowMobileCreateCategory(false)} - onSubmit={async (name) => { - try { - await convex.mutation(api.categories.create, { name }); - } catch (err) { - console.error(err); - alert("Failed to create category: " + err.message); - } - }} - /> - )} - {mobileChannelDrawer && ( - handleMarkAsRead(mobileChannelDrawer._id)} - onEditChannel={() => setShowMobileChannelSettings(mobileChannelDrawer)} - onClose={() => setMobileChannelDrawer(null)} - /> - )} - {showMobileChannelSettings && ( - setShowMobileChannelSettings(null)} - onDelete={onDeleteChannel} - /> - )} -
+ + ))} +
); + }; + + const renderCollapsedVoiceUsers = (channel) => { + const users = voiceStates[channel._id]; + if (channel.type !== "voice" || !users?.length) return null; + + return ( +
handleChannelClick(channel)} + style={{ position: "relative", display: "flex", alignItems: "center", paddingRight: "8px" }} + > +
+ +
+
+ {users.map((user) => ( +
+ +
+ ))} +
+
+ ); + }; + + const toggleCategory = useCallback( + (cat) => { + setCollapsedCategories((prev) => { + const next = { ...prev, [cat]: !prev[cat] }; + setUserPref(userId, "collapsedCategories", next, settings); + return next; + }); + }, + [userId, settings], + ); + + const handleAddChannelToCategory = useCallback((groupId) => { + setCreateChannelCategoryId(groupId === "__uncategorized__" ? null : groupId); + setShowCreateChannelModal(true); + }, []); + + // Group channels by categoryId + const groupedChannels = React.useMemo(() => { + const groups = []; + const channelsByCategory = new Map(); + + channels.forEach((ch) => { + const catId = ch.categoryId || "__uncategorized__"; + if (!channelsByCategory.has(catId)) channelsByCategory.set(catId, []); + channelsByCategory.get(catId).push(ch); + }); + + // Sort channels within each category by position + for (const [, list] of channelsByCategory) { + list.sort((a, b) => (a.position ?? 0) - (b.position ?? 0)); + } + + // Add uncategorized at top + const uncategorized = channelsByCategory.get("__uncategorized__"); + if (uncategorized?.length) { + groups.push({ id: "__uncategorized__", name: "Channels", channels: uncategorized }); + } + + // Add categories in position order + for (const cat of categories || []) { + groups.push({ id: cat._id, name: cat.name, channels: channelsByCategory.get(cat._id) || [] }); + } + + return groups; + }, [channels, categories]); + + // DnD items + const categoryDndIds = React.useMemo( + () => groupedChannels.map((g) => `category-${g.id}`), + [groupedChannels], + ); + + const handleDragStart = (event) => { + const { active } = event; + const activeType = active.data.current?.type; + if (activeType === "category") { + const catId = active.id.replace("category-", ""); + const group = groupedChannels.find((g) => g.id === catId); + setActiveDragItem({ type: "category", name: group?.name || "" }); + } else if (activeType === "channel") { + const chId = active.id.replace("channel-", ""); + const ch = channels.find((c) => c._id === chId); + setActiveDragItem({ type: "channel", channel: ch }); + } else if (activeType === "voice-user") { + const targetUserId = active.data.current.userId; + const sourceChannelId = active.data.current.channelId; + const users = voiceStates[sourceChannelId]; + const user = users?.find((u) => u.userId === targetUserId); + setActiveDragItem({ type: "voice-user", user, sourceChannelId }); + } + }; + + const handleDragOver = (event) => { + const { active, over } = event; + if (!active?.data.current || active.data.current.type !== "voice-user") { + setDragOverChannelId(null); + return; + } + if (over) { + // Check if hovering over a voice channel (channel item or its DnD wrapper) + const overType = over.data.current?.type; + if (overType === "channel") { + const chId = over.id.replace("channel-", ""); + const ch = channels.find((c) => c._id === chId); + if (ch?.type === "voice") { + setDragOverChannelId(ch._id); + return; + } + } + } + setDragOverChannelId(null); + }; + + const handleDragEnd = async (event) => { + setActiveDragItem(null); + setDragOverChannelId(null); + const { active, over } = event; + if (!over || active.id === over.id) return; + + const activeType = active.data.current?.type; + const overType = over.data.current?.type; + + // Handle voice-user drag + if (activeType === "voice-user") { + if (overType !== "channel") return; + const targetChId = over.id.replace("channel-", ""); + const targetChannel = channels.find((c) => c._id === targetChId); + if (!targetChannel || targetChannel.type !== "voice") return; + const sourceChannelId = active.data.current.channelId; + if (sourceChannelId === targetChId) return; + try { + await convex.mutation(api.voiceState.moveUser, { + actorUserId: userId, + targetUserId: active.data.current.userId, + targetChannelId: targetChId, + }); + } catch (e) { + console.error("Failed to move voice user:", e); + } + return; + } + + if (activeType === "category" && overType === "category") { + // Reorder categories + const oldIndex = groupedChannels.findIndex((g) => `category-${g.id}` === active.id); + const newIndex = groupedChannels.findIndex((g) => `category-${g.id}` === over.id); + if (oldIndex === -1 || newIndex === -1) return; + + // Build reordered array (only real categories, skip uncategorized) + const reordered = [...groupedChannels]; + const [moved] = reordered.splice(oldIndex, 1); + reordered.splice(newIndex, 0, moved); + + const updates = reordered + .filter((g) => g.id !== "__uncategorized__") + .map((g, i) => ({ id: g.id, position: i * 1000 })); + + if (updates.length > 0) { + try { + await convex.mutation(api.categories.reorder, { updates }); + } catch (e) { + console.error("Failed to reorder categories:", e); + } + } + } else if (activeType === "channel") { + const activeChId = active.id.replace("channel-", ""); + + if (overType === "channel") { + const overChId = over.id.replace("channel-", ""); + const activeChannel = channels.find((c) => c._id === activeChId); + const overChannel = channels.find((c) => c._id === overChId); + if (!activeChannel || !overChannel) return; + + const targetCategoryId = overChannel.categoryId; + const targetGroup = groupedChannels.find( + (g) => g.id === (targetCategoryId || "__uncategorized__"), + ); + if (!targetGroup) return; + + // Build new order for the target category + const targetChannels = [...targetGroup.channels]; + + // Remove active channel if it's already in this category + const existingIdx = targetChannels.findIndex((c) => c._id === activeChId); + if (existingIdx !== -1) targetChannels.splice(existingIdx, 1); + + // Insert at the position of the over channel + const overIdx = targetChannels.findIndex((c) => c._id === overChId); + targetChannels.splice(overIdx, 0, activeChannel); + + const updates = targetChannels.map((ch, i) => ({ + id: ch._id, + categoryId: targetCategoryId, + position: i * 1000, + })); + + try { + await convex.mutation(api.channels.reorderChannels, { updates }); + } catch (e) { + console.error("Failed to reorder channels:", e); + } + } else if (overType === "category") { + // Drop channel onto a category header — move it to end of that category + const targetCatId = over.id.replace("category-", ""); + const targetCategoryId = targetCatId === "__uncategorized__" ? undefined : targetCatId; + const targetGroup = groupedChannels.find((g) => g.id === targetCatId); + const maxPos = (targetGroup?.channels || []).reduce( + (max, c) => Math.max(max, c.position ?? 0), + -1000, + ); + + try { + await convex.mutation(api.channels.moveChannel, { + id: activeChId, + categoryId: targetCategoryId, + position: maxPos + 1000, + }); + } catch (e) { + console.error("Failed to move channel:", e); + } + } + } + }; + + const renderServerView = () => ( +
+
+ + isMobile ? setShowMobileServerDrawer(true) : setIsServerSettingsOpen(true) + } + > + {serverName} + {isMobile && ( + + + + )} + + {!isMobile && ( + + )} +
+ {isMobile && ( +
+ + +
+ )} + +
{ + if ( + !e.target.closest(".channel-item") && + !e.target.closest(".channel-category-header") + ) { + e.preventDefault(); + window.dispatchEvent(new Event("close-context-menus")); + setChannelListContextMenu({ x: e.clientX, y: e.clientY }); + } + } + } + > + {isCreating && ( +
+
+
+ + +
+ setNewChannelName(e.target.value)} + style={{ + width: "100%", + background: "var(--bg-tertiary)", + border: "1px solid var(--brand-experiment)", + borderRadius: "4px", + color: "var(--text-normal)", + padding: "4px 8px", + fontSize: "14px", + outline: "none", + }} + /> +
+
+ Press Enter to Create {newChannelType === "voice" && "(Voice)"} +
+
+ )} + + + + {groupedChannels.map((group) => { + const channelDndIds = group.channels.map((ch) => `channel-${ch._id}`); + return ( + + { + e.preventDefault(); + e.stopPropagation(); + window.dispatchEvent(new Event("close-context-menus")); + setCategoryContextMenu({ + x: e.clientX, + y: e.clientY, + categoryId: group.id, + categoryName: group.name, + }); + } + : undefined + } + isEditing={editingCategoryId === group.id} + onRenameSubmit={async (newName) => { + if (newName && newName !== group.name) { + await convex.mutation(api.categories.rename, { + id: group.id, + name: newName, + }); + } + setEditingCategoryId(null); + }} + onRenameCancel={() => setEditingCategoryId(null)} + /> + {(() => { + const isCollapsed = collapsedCategories[group.id]; + const visibleChannels = isCollapsed + ? group.channels.filter( + (ch) => + ch._id === activeChannel || + (ch.type === "voice" && voiceStates[ch._id]?.length > 0), + ) + : group.channels; + if (visibleChannels.length === 0) return null; + const visibleDndIds = visibleChannels.map((ch) => `channel-${ch._id}`); + return ( + + {visibleChannels.map((channel) => { + const isUnread = + activeChannel !== channel._id && unreadChannels.has(channel._id); + return ( + + {(channelDragListeners) => ( + + {!( + isCollapsed && + channel.type === "voice" && + voiceStates[channel._id]?.length > 0 + ) && ( +
handleChannelClick(channel)} + {...channelDragListeners} + {...(isMobile + ? createLongPressHandlers(() => + setMobileChannelDrawer(channel), + ) + : {})} + style={{ + position: "relative", + display: "flex", + justifyContent: "space-between", + alignItems: "center", + paddingRight: "8px", + }} + > + {isUnread &&
} +
+ {channel.type === "voice" ? ( +
+ 0 + ? VOICE_ACTIVE_COLOR + : "var(--interactive-normal)" + } + /> +
+ ) : ( + + # + + )} + + {channel.name} + {serverSettings?.afkChannelId === channel._id + ? " (AFK)" + : ""} + +
+ + {!isMobile && ( + + )} +
+ )} + {isCollapsed + ? renderCollapsedVoiceUsers(channel) + : renderVoiceUsers(channel)} + + )} + + ); + })} + + ); + })()} + + ); + })} + + + + {activeDragItem?.type === "channel" && activeDragItem.channel && ( +
+ {activeDragItem.channel.type === "voice" ? ( + + ) : ( + # + )} + {activeDragItem.channel.name} +
+ )} + {activeDragItem?.type === "category" && ( +
{activeDragItem.name}
+ )} + {activeDragItem?.type === "voice-user" && activeDragItem.user && ( +
+ + {activeDragItem.user.username} +
+ )} +
+ +
+
+ ); + + return ( +
+
+
+
+
+ +
onViewChange("me")} + style={{ + backgroundColor: view === "me" ? "var(--brand-experiment)" : "var(--bg-primary)", + color: view === "me" ? "#fff" : "var(--text-normal)", + cursor: "pointer", + }} + > + + + +
+
+
+ + {unreadDMs.map((dm) => ( +
+
+ +
{ + setActiveDMChannel(dm); + onViewChange("me"); + }} + > + +
+
+ +
+ ))} + +
+ +
+
+ +
onViewChange("server")} + style={{ cursor: "pointer" }} + > + {serverIconUrl ? ( + {serverName} + ) : ( + serverName.substring(0, 2) + )} +
+
+
+
+ + {view === "me" ? renderDMView() : renderServerView()} +
+ + {(connectionState === "connected" || connectionState === "connecting") && ( +
+
+
+ + + + + + +
+ {connectionState === "connected" ? "Voice Connected" : "Voice Connecting"} +
+
+ +
+
+ {dmChannels?.some((dm) => dm.channel_id === voiceChannelId) + ? `Call with ${voiceChannelName}` + : `${voiceChannelName} / ${serverName}`} +
+ {connectionState === "connected" && ( + <> +
+ +
+
+ + +
+ + )} +
+ )} + + + + {editingChannel && !isMobile && ( + setEditingChannel(null)} + onRename={onRenameChannel} + onDelete={onDeleteChannel} + /> + )} + {isServerSettingsOpen && ( + setIsServerSettingsOpen(false)} /> + )} + {showMobileServerDrawer && ( + setIsServerSettingsOpen(true)} + onCreateChannel={() => { + setCreateChannelCategoryId(null); + setShowMobileCreateChannel(true); + }} + onCreateCategory={() => setShowMobileCreateCategory(true)} + onClose={() => setShowMobileServerDrawer(false)} + /> + )} + {isScreenShareModalOpen && ( + setIsScreenShareModalOpen(false)} + onSelectSource={handleScreenShareSelect} + /> + )} + {channelListContextMenu && ( + setChannelListContextMenu(null)} + onCreateChannel={() => { + setCreateChannelCategoryId(null); + setShowCreateChannelModal(true); + }} + onCreateCategory={() => setShowCreateCategoryModal(true)} + /> + )} + {categoryContextMenu && ( + setCategoryContextMenu(null)} + onEdit={() => { + setEditingCategoryId(categoryContextMenu.categoryId); + setCategoryContextMenu(null); + }} + onDelete={async () => { + const categoryId = categoryContextMenu.categoryId; + const categoryName = categoryContextMenu.categoryName; + setCategoryContextMenu(null); + if ( + window.confirm( + `Are you sure you want to delete "${categoryName}"? Channels in this category will become uncategorized.`, + ) + ) { + await convex.mutation(api.categories.remove, { id: categoryId }); + } + }} + /> + )} + {voiceUserMenu && ( + setVoiceUserMenu(null)} + isSelf={voiceUserMenu.user.userId === userId} + isMuted={ + voiceUserMenu.user.userId === userId + ? selfMuted + : isPersonallyMuted(voiceUserMenu.user.userId) + } + onMute={() => + voiceUserMenu.user.userId === userId + ? toggleMute() + : togglePersonalMute(voiceUserMenu.user.userId) + } + isServerMuted={isServerMuted(voiceUserMenu.user.userId)} + onServerMute={() => + serverMute(voiceUserMenu.user.userId, !isServerMuted(voiceUserMenu.user.userId)) + } + hasPermission={!!myPermissions.mute_members} + onDisconnect={() => disconnectUser(voiceUserMenu.user.userId)} + hasDisconnectPermission={!!myPermissions.move_members} + onMessage={() => { + onOpenDM( + voiceUserMenu.user.userId, + voiceUserMenu.user.displayName || voiceUserMenu.user.username, + ); + onViewChange("me"); + }} + userVolume={getUserVolume(voiceUserMenu.user.userId)} + onVolumeChange={(vol) => setUserVolume(voiceUserMenu.user.userId, vol)} + showNicknameOption={ + voiceUserMenu.user.userId === userId || !!myPermissions.manage_nicknames + } + onChangeNickname={() => setVoiceNicknameModal(voiceUserMenu.user)} + onStartCall={() => { + if (onStartCallWithUser) + onStartCallWithUser( + voiceUserMenu.user.userId, + voiceUserMenu.user.displayName || voiceUserMenu.user.username, + ); + }} + /> + )} + {voiceNicknameModal && ( + setVoiceNicknameModal(null)} + /> + )} + {showCreateChannelModal && ( + setShowCreateChannelModal(false)} + onSubmit={async (name, type, catId) => { + const userId = localStorage.getItem("userId"); + if (!userId) { + alert("Please login first."); + return; + } + try { + const createArgs = { name, type }; + if (catId) createArgs.categoryId = catId; + const { id: channelId } = await convex.mutation(api.channels.create, createArgs); + const keyHex = randomHex(32); + try { + await encryptKeyForUsers(convex, channelId, keyHex, crypto); + } catch (keyErr) { + console.error("Critical: Failed to distribute keys", keyErr); + alert("Channel created but key distribution failed."); + } + } catch (err) { + console.error(err); + alert("Failed to create channel: " + err.message); + } + }} + /> + )} + {showCreateCategoryModal && ( + setShowCreateCategoryModal(false)} + onSubmit={async (name) => { + try { + await convex.mutation(api.categories.create, { name }); + } catch (err) { + console.error(err); + alert("Failed to create category: " + err.message); + } + }} + /> + )} + {showMobileCreateChannel && ( + setShowMobileCreateChannel(false)} + onSubmit={async (name, type, catId) => { + const userId = localStorage.getItem("userId"); + if (!userId) { + alert("Please login first."); + return; + } + try { + const createArgs = { name, type }; + if (catId) createArgs.categoryId = catId; + const { id: channelId } = await convex.mutation(api.channels.create, createArgs); + const keyHex = randomHex(32); + try { + await encryptKeyForUsers(convex, channelId, keyHex, crypto); + } catch (keyErr) { + console.error("Critical: Failed to distribute keys", keyErr); + alert("Channel created but key distribution failed."); + } + } catch (err) { + console.error(err); + alert("Failed to create channel: " + err.message); + } + }} + /> + )} + {showMobileCreateCategory && ( + setShowMobileCreateCategory(false)} + onSubmit={async (name) => { + try { + await convex.mutation(api.categories.create, { name }); + } catch (err) { + console.error(err); + alert("Failed to create category: " + err.message); + } + }} + /> + )} + {mobileChannelDrawer && ( + handleMarkAsRead(mobileChannelDrawer._id)} + onEditChannel={() => setShowMobileChannelSettings(mobileChannelDrawer)} + onClose={() => setMobileChannelDrawer(null)} + /> + )} + {showMobileChannelSettings && ( + setShowMobileChannelSettings(null)} + onDelete={onDeleteChannel} + /> + )} +
+ ); }; // Category header component (extracted for DnD drag handle) -const CategoryHeader = React.memo(({ group, groupId, collapsed, onToggle, onAddChannel, dragListeners, onContextMenu, isEditing, onRenameSubmit, onRenameCancel }) => { +const CategoryHeader = React.memo( + ({ + group, + groupId, + collapsed, + onToggle, + onAddChannel, + dragListeners, + onContextMenu, + isEditing, + onRenameSubmit, + onRenameCancel, + }) => { const [editName, setEditName] = useState(group.name); const inputRef = useRef(null); useEffect(() => { - if (isEditing && inputRef.current) { - inputRef.current.focus(); - inputRef.current.select(); - } + if (isEditing && inputRef.current) { + inputRef.current.focus(); + inputRef.current.select(); + } }, [isEditing]); useEffect(() => { - setEditName(group.name); + setEditName(group.name); }, [group.name, isEditing]); return ( -
!isEditing && onToggle(groupId)} onContextMenu={onContextMenu} {...(dragListeners || {})}> - {isEditing ? ( - setEditName(e.target.value)} - onKeyDown={(e) => { - if (e.key === 'Enter') { e.preventDefault(); onRenameSubmit(editName.trim()); } - if (e.key === 'Escape') { e.preventDefault(); onRenameCancel(); } - }} - onBlur={() => onRenameCancel()} - onClick={(e) => e.stopPropagation()} - style={{ - background: 'var(--bg-tertiary)', - border: '1px solid var(--brand-experiment)', - borderRadius: '2px', - color: 'var(--text-normal)', - fontSize: '12px', - fontWeight: 600, - textTransform: 'uppercase', - padding: '1px 4px', - outline: 'none', - width: '100%', - letterSpacing: '.02em', - }} - /> - ) : ( - {group.name} - )} -
- -
- +
!isEditing && onToggle(groupId)} + onContextMenu={onContextMenu} + {...(dragListeners || {})} + > + {isEditing ? ( + setEditName(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + onRenameSubmit(editName.trim()); + } + if (e.key === "Escape") { + e.preventDefault(); + onRenameCancel(); + } + }} + onBlur={() => onRenameCancel()} + onClick={(e) => e.stopPropagation()} + style={{ + background: "var(--bg-tertiary)", + border: "1px solid var(--brand-experiment)", + borderRadius: "2px", + color: "var(--text-normal)", + fontSize: "12px", + fontWeight: 600, + textTransform: "uppercase", + padding: "1px 4px", + outline: "none", + width: "100%", + letterSpacing: ".02em", + }} + /> + ) : ( + {group.name} + )} +
+
+ +
); -}); + }, +); export default Sidebar; diff --git a/roles/Screenshot 2026-02-27 004430.png b/roles/Screenshot 2026-02-27 004430.png new file mode 100644 index 0000000..60b903e Binary files /dev/null and b/roles/Screenshot 2026-02-27 004430.png differ diff --git a/roles/Screenshot 2026-02-27 004454.png b/roles/Screenshot 2026-02-27 004454.png new file mode 100644 index 0000000..fdafed3 Binary files /dev/null and b/roles/Screenshot 2026-02-27 004454.png differ