+ );
};
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
-
-
-
-
-
-
-
-
-
-
Category Name
-
- 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.
-
-
-
-
- Cancel
-
- Create Category
-
-
-
+ return (
+
+
e.stopPropagation()}>
+
+
+ Create Category
+
+
+
+
+
+
- );
+
+
+
+
+ Category Name
+
+
+ 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.
+
+
+
+
+
+ Cancel
+
+
+ Create Category
+
+
+
+
+ );
};
// --- 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 && (
-
-
-
-
-
- Search
-
-
-
-
-
- )}
-
-
{
- 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 && (
-
-
-
- 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 && (
-
{
- e.stopPropagation();
- setEditingChannel(channel);
- }}
- style={{
- background: 'transparent',
- border: 'none',
- cursor: 'pointer',
- padding: '2px 4px',
- display: 'flex', alignItems: 'center',
- }}
- >
-
-
- )}
-
}
- {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.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' && (
- <>
-
-
- room?.localParticipant.setCameraEnabled(!room.localParticipant.isCameraEnabled)} title="Turn On Camera" style={voicePanelButtonStyle}>
-
-
-
-
-
-
- >
- )}
-
- )}
-
-
-
- {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 && (
+
+
+
+
+
+ Search
+
+
+
+
+
+ )}
+
+
{
+ 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 && (
+
+
+
+ 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 && (
+
{
+ e.stopPropagation();
+ setEditingChannel(channel);
+ }}
+ style={{
+ background: "transparent",
+ border: "none",
+ cursor: "pointer",
+ padding: "2px 4px",
+ display: "flex",
+ alignItems: "center",
+ }}
+ >
+
+
+ )}
+
+ )}
+ {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.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" && (
+ <>
+
+
+
+
+
+ room?.localParticipant.setCameraEnabled(!room.localParticipant.isCameraEnabled)
+ }
+ title="Turn On Camera"
+ style={voicePanelButtonStyle}
+ >
+
+
+
+
+
+
+ >
+ )}
+
+ )}
+
+
+
+ {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}
- )}
-
-
-
-
{ e.stopPropagation(); onAddChannel(groupId); }} title="Create Channel">
- +
-
+
!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}
+ )}
+
+
+
{
+ e.stopPropagation();
+ onAddChannel(groupId);
+ }}
+ title="Create Channel"
+ >
+ +
+
+
);
-});
+ },
+);
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