- ), [handleResultClick, customEmojis, links]);
-
- const renderGroupedResults = useCallback((results) => {
- if (searching) {
- return
Searching...
Search database is loading...
;
- }
- if (results.length === 0) {
- return (
-
- );
- }
- const grouped = groupByChannel(results);
- return (
-
- );
- }, [searching, isReady, groupByChannel, renderSearchResult]);
-
- const renderContent = () => {
- // Search mode
- if (hasQuery) {
- switch (activeTab) {
- case 'Messages': return renderGroupedResults(messageResults);
- case 'Media': return renderGroupedResults(mediaResults);
- case 'Links': return renderGroupedResults(linkResults);
- case 'Files': return renderGroupedResults(fileResults);
- default: return renderGroupedResults(messageResults);
- }
- }
-
- // Browse mode
- switch (activeTab) {
- case 'Recent':
- return (
-
- );
-
- case 'Members':
- return (
-
- );
-
- case 'Channels':
- return (
-
- );
-
- default:
- return (
-
- );
- }
- };
-
- const currentTabs = hasQuery ? SEARCH_TABS : BROWSE_TABS;
- const getTabLabel = (tab) => {
- if (!hasQuery) return tab;
- switch (tab) {
- case 'Messages': return `Messages${!searching ? ` (${messageResults.length})` : ''}`;
- case 'Media': return `Media${!searching ? ` (${mediaResults.length})` : ''}`;
- case 'Links': return `Links${!searching ? ` (${linkResults.length})` : ''}`;
- case 'Files': return `Files${!searching ? ` (${fileResults.length})` : ''}`;
- default: return tab;
- }
- };
-
- return ReactDOM.createPortal(
-
,
- document.body
- );
-};
-
-export default MobileSearchScreen;
diff --git a/packages/shared/src/components/MobileServerDrawer.jsx b/packages/shared/src/components/MobileServerDrawer.jsx
deleted file mode 100644
index b79b0a7..0000000
--- a/packages/shared/src/components/MobileServerDrawer.jsx
+++ /dev/null
@@ -1,124 +0,0 @@
-import React, { useState, useRef, useCallback } from 'react';
-import ReactDOM from 'react-dom';
-import ColoredIcon from './ColoredIcon';
-import inviteUserIcon from '../assets/icons/invite_user.svg';
-import settingsIcon from '../assets/icons/settings.svg';
-import createIcon from '../assets/icons/create.svg';
-import createCategoryIcon from '../assets/icons/create_category.svg';
-
-const ICON_COLOR_DEFAULT = 'hsl(240, 4.294%, 68.039%)';
-
-const MobileServerDrawer = ({ serverName, serverIconUrl, memberCount, onInvite, onSettings, onCreateChannel, onCreateCategory, onClose }) => {
- const [closing, setClosing] = useState(false);
- const drawerRef = useRef(null);
- const dragStartY = useRef(null);
- const dragCurrentY = useRef(null);
- const dragStartTime = useRef(null);
-
- const dismiss = useCallback(() => {
- setClosing(true);
- setTimeout(onClose, 200);
- }, [onClose]);
-
- const handleAction = useCallback((cb) => {
- dismiss();
- setTimeout(cb, 220);
- }, [dismiss]);
-
- // Swipe-to-dismiss
- const handleTouchStart = useCallback((e) => {
- dragStartY.current = e.touches[0].clientY;
- dragCurrentY.current = e.touches[0].clientY;
- dragStartTime.current = Date.now();
- if (drawerRef.current) {
- drawerRef.current.style.transition = 'none';
- }
- }, []);
-
- const handleTouchMove = useCallback((e) => {
- if (dragStartY.current === null) return;
- dragCurrentY.current = e.touches[0].clientY;
- const dy = dragCurrentY.current - dragStartY.current;
- if (dy > 0 && drawerRef.current) {
- drawerRef.current.style.transform = `translateY(${dy}px)`;
- }
- }, []);
-
- const handleTouchEnd = useCallback(() => {
- if (dragStartY.current === null || !drawerRef.current) return;
- const dy = dragCurrentY.current - dragStartY.current;
- const dt = (Date.now() - dragStartTime.current) / 1000;
- const velocity = dt > 0 ? dy / dt : 0;
- const drawerHeight = drawerRef.current.offsetHeight;
- const threshold = drawerHeight * 0.3;
-
- if (dy > threshold || velocity > 500) {
- dismiss();
- } else {
- drawerRef.current.style.transition = 'transform 0.2s ease-out';
- drawerRef.current.style.transform = 'translateY(0)';
- }
- dragStartY.current = null;
- }, [dismiss]);
-
- const initials = serverName ? serverName.split(/\s+/).map(w => w[0]).join('').slice(0, 2).toUpperCase() : '?';
-
- return ReactDOM.createPortal(
- <>
-
- >,
- document.body
- );
-};
-
-export default MobileServerDrawer;
diff --git a/packages/shared/src/components/PinnedMessagesPanel.jsx b/packages/shared/src/components/PinnedMessagesPanel.jsx
deleted file mode 100644
index aea386d..0000000
--- a/packages/shared/src/components/PinnedMessagesPanel.jsx
+++ /dev/null
@@ -1,199 +0,0 @@
-import React, { useState, useEffect } from 'react';
-import { useQuery, useMutation } from 'convex/react';
-import { api } from '../../../../convex/_generated/api';
-import MessageItem from './MessageItem';
-import { usePlatform } from '../platform';
-
-const TAG_LENGTH = 32;
-const EMPTY = [];
-
-const PinnedMessagesPanel = ({
- channelId,
- visible,
- onClose,
- channelKey,
- onJumpToMessage,
- userId,
- username,
- roles,
- Attachment,
- LinkPreview,
- DirectVideo,
- onReactionClick,
- onProfilePopup,
- onImageClick,
-}) => {
- const { crypto } = usePlatform();
- const [decryptedPins, setDecryptedPins] = useState([]);
-
- const pinnedMessages = useQuery(
- api.messages.listPinned,
- channelId ? { channelId, userId: userId || undefined } : "skip"
- ) || EMPTY;
-
- const unpinMutation = useMutation(api.messages.pin);
-
- useEffect(() => {
- if (!pinnedMessages.length || !channelKey) {
- setDecryptedPins(prev => prev.length === 0 ? prev : []);
- return;
- }
-
- let cancelled = false;
-
- const decryptAll = async () => {
- // Build batch arrays for message decryption
- const decryptItems = [];
- const decryptMsgMap = [];
- const replyDecryptItems = [];
- const replyMsgMap = [];
- const verifyItems = [];
- const verifyMsgMap = [];
-
- for (const msg of pinnedMessages) {
- if (msg.ciphertext && msg.ciphertext.length >= TAG_LENGTH) {
- const tag = msg.ciphertext.slice(-TAG_LENGTH);
- const content = msg.ciphertext.slice(0, -TAG_LENGTH);
- decryptItems.push({ ciphertext: content, key: channelKey, iv: msg.nonce, tag });
- decryptMsgMap.push(msg);
- }
-
- if (msg.replyToContent && msg.replyToNonce) {
- const rTag = msg.replyToContent.slice(-TAG_LENGTH);
- const rContent = msg.replyToContent.slice(0, -TAG_LENGTH);
- replyDecryptItems.push({ ciphertext: rContent, key: channelKey, iv: msg.replyToNonce, tag: rTag });
- replyMsgMap.push(msg);
- }
-
- if (msg.signature && msg.public_signing_key) {
- verifyItems.push({ publicKey: msg.public_signing_key, message: msg.ciphertext, signature: msg.signature });
- verifyMsgMap.push(msg);
- }
- }
-
- const [decryptResults, replyResults, verifyResults] = await Promise.all([
- decryptItems.length > 0 ? crypto.decryptBatch(decryptItems) : [],
- replyDecryptItems.length > 0 ? crypto.decryptBatch(replyDecryptItems) : [],
- verifyItems.length > 0 ? crypto.verifyBatch(verifyItems) : [],
- ]);
-
- if (cancelled) return;
-
- const decryptedMap = new Map();
- for (let i = 0; i < decryptResults.length; i++) {
- const msg = decryptMsgMap[i];
- const result = decryptResults[i];
- decryptedMap.set(msg.id, result.success ? result.data : '[Decryption Error]');
- }
-
- const replyMap = new Map();
- for (let i = 0; i < replyResults.length; i++) {
- const msg = replyMsgMap[i];
- const result = replyResults[i];
- if (result.success) {
- let text = result.data;
- if (text.startsWith('{')) text = '[Attachment]';
- else if (text.length > 100) text = text.substring(0, 100) + '...';
- replyMap.set(msg.id, text);
- } else {
- replyMap.set(msg.id, '[Encrypted]');
- }
- }
-
- const verifyMap = new Map();
- for (let i = 0; i < verifyResults.length; i++) {
- const msg = verifyMsgMap[i];
- verifyMap.set(msg.id, verifyResults[i].success && verifyResults[i].verified);
- }
-
- const results = pinnedMessages.map(msg => ({
- ...msg,
- content: decryptedMap.get(msg.id) ?? '[Encrypted Message]',
- isVerified: verifyMap.get(msg.id) ?? false,
- decryptedReply: replyMap.get(msg.id) ?? null,
- }));
-
- if (!cancelled) setDecryptedPins(results);
- };
-
- decryptAll();
- return () => { cancelled = true; };
- }, [pinnedMessages, channelKey]);
-
- if (!visible) return null;
-
- const noop = () => {};
-
- return (
-
- );
-};
-
-export default PinnedMessagesPanel;
diff --git a/packages/shared/src/components/ScreenShareModal.jsx b/packages/shared/src/components/ScreenShareModal.jsx
deleted file mode 100644
index 9a568d5..0000000
--- a/packages/shared/src/components/ScreenShareModal.jsx
+++ /dev/null
@@ -1,283 +0,0 @@
-import React, { useState, useEffect } from 'react';
-import { usePlatform } from '../platform';
-
-const ScreenShareModal = ({ onClose, onSelectSource }) => {
- const { screenCapture } = usePlatform();
- const [activeTab, setActiveTab] = useState('applications'); // applications | screens | devices
- const [sources, setSources] = useState([]);
- const [loading, setLoading] = useState(true);
- const [shareAudio, setShareAudio] = useState(true);
-
- useEffect(() => {
- loadSources();
- }, []);
-
- const [isWebFallback, setIsWebFallback] = useState(false);
-
- const loadSources = async () => {
- setLoading(true);
- try {
- // Get screen/window sources from Electron
- const desktopSources = await screenCapture.getScreenSources();
-
- // If no desktop sources (web platform), use getDisplayMedia fallback
- if (!desktopSources || desktopSources.length === 0) {
- setIsWebFallback(true);
- setLoading(false);
- return;
- }
-
- // Get video input devices (webcams)
- const devices = await navigator.mediaDevices.enumerateDevices();
- const videoDevices = devices.filter(d => d.kind === 'videoinput');
-
- // Categorize
- const apps = desktopSources.filter(s => s.id.startsWith('window'));
- const screens = desktopSources.filter(s => s.id.startsWith('screen'));
-
- const formattedDevices = videoDevices.map(d => ({
- id: d.deviceId,
- name: d.label || `Camera ${d.deviceId.substring(0, 4)}...`,
- isDevice: true,
- thumbnail: null // Devices don't have static thumbnails easily referencable without opening stream
- }));
-
- setSources({
- applications: apps,
- screens: screens,
- devices: formattedDevices
- });
- } catch (err) {
- console.error("Failed to load sources:", err);
- setIsWebFallback(true);
- } finally {
- setLoading(false);
- }
- };
-
- const handleWebFallback = async () => {
- try {
- const stream = await navigator.mediaDevices.getDisplayMedia({
- video: { frameRate: { ideal: 60, max: 60 } },
- audio: shareAudio,
- });
- onSelectSource({ type: 'web_stream', stream, shareAudio });
- onClose();
- } catch (err) {
- if (err.name !== 'NotAllowedError') {
- console.error('getDisplayMedia failed:', err);
- }
- onClose();
- }
- };
-
- // Auto-trigger the browser picker on web
- useEffect(() => {
- if (isWebFallback) {
- handleWebFallback();
- }
- }, [isWebFallback]);
-
- const handleSelect = (source) => {
- // If device, pass constraints differently (webcams don't have loopback audio)
- if (source.isDevice) {
- onSelectSource({ deviceId: source.id, type: 'device', shareAudio: false });
- } else {
- onSelectSource({ sourceId: source.id, type: 'screen', shareAudio });
- }
- onClose();
- };
-
- const renderGrid = (items) => {
- if (!items || items.length === 0) return
No sources found.
- );
-};
-
-export default ScreenShareModal;
diff --git a/packages/shared/src/components/SearchDropdown.jsx b/packages/shared/src/components/SearchDropdown.jsx
deleted file mode 100644
index 8141640..0000000
--- a/packages/shared/src/components/SearchDropdown.jsx
+++ /dev/null
@@ -1,244 +0,0 @@
-import React, { useEffect, useRef, useState, useCallback } from 'react';
-import ReactDOM from 'react-dom';
-import { detectActivePrefix } from '../utils/searchUtils';
-
-const FILTER_SUGGESTIONS = [
- { prefix: 'from:', label: 'from:', description: 'user', icon: 'user' },
- { prefix: 'mentions:', label: 'mentions:', description: 'user', icon: 'at' },
- { prefix: 'has:', label: 'has:', description: 'link, file, image, or video', icon: 'has' },
- { prefix: 'in:', label: 'in:', description: 'channel', icon: 'channel' },
- { prefix: 'before:', label: 'before:', description: 'date', icon: 'date' },
- { prefix: 'after:', label: 'after:', description: 'date', icon: 'date' },
- { prefix: 'pinned:', label: 'pinned:', description: 'true or false', icon: 'pin' },
-];
-
-const HAS_OPTIONS = [
- { value: 'link', label: 'link' },
- { value: 'file', label: 'file' },
- { value: 'image', label: 'image' },
- { value: 'video', label: 'video' },
-];
-
-function getAvatarColor(name) {
- const colors = ['#5865F2', '#57F287', '#FEE75C', '#EB459E', '#ED4245', '#F47B67', '#E67E22', '#3498DB'];
- let hash = 0;
- for (let i = 0; i < (name || '').length; i++) hash = name.charCodeAt(i) + ((hash << 5) - hash);
- return colors[Math.abs(hash) % colors.length];
-}
-
-function FilterIcon({ type }) {
- switch (type) {
- case 'user':
- return (
-
- );
- default:
- return null;
- }
-}
-
-const SearchDropdown = ({
- visible,
- searchText,
- channels,
- members,
- searchHistory,
- onSelectFilter,
- onSelectHistoryItem,
- onClearHistory,
- onClearHistoryItem,
- anchorRef,
- onClose,
-}) => {
- const dropdownRef = useRef(null);
- const [pos, setPos] = useState({ top: 0, left: 0, width: 420 });
-
- // Position dropdown below anchor
- useEffect(() => {
- if (!visible || !anchorRef?.current) return;
- const rect = anchorRef.current.getBoundingClientRect();
- setPos({
- top: rect.bottom + 4,
- left: Math.max(rect.right - 420, 8),
- width: 420,
- });
- }, [visible, anchorRef, searchText]);
-
- // Click outside to close
- useEffect(() => {
- if (!visible) return;
- const handler = (e) => {
- if (
- dropdownRef.current && !dropdownRef.current.contains(e.target) &&
- anchorRef?.current && !anchorRef.current.contains(e.target)
- ) {
- onClose();
- }
- };
- document.addEventListener('mousedown', handler);
- return () => document.removeEventListener('mousedown', handler);
- }, [visible, onClose, anchorRef]);
-
- if (!visible) return null;
-
- const activePrefix = detectActivePrefix(searchText);
-
- let content;
-
- if (activePrefix?.prefix === 'from' || activePrefix?.prefix === 'mentions') {
- const filtered = (members || []).filter(m =>
- m.username.toLowerCase().includes(activePrefix.partial)
- );
- const headerText = activePrefix.prefix === 'from' ? 'FROM USER' : 'MENTIONS USER';
- content = (
-
- );
- } else if (activePrefix?.prefix === 'in') {
- const filtered = (channels || []).filter(c =>
- c.name?.toLowerCase().includes(activePrefix.partial) && c.type === 'text'
- );
- content = (
-
- );
- } else if (activePrefix?.prefix === 'has') {
- const filtered = HAS_OPTIONS.filter(o =>
- o.value.includes(activePrefix.partial)
- );
- content = (
-
- );
- } else {
- // Default: show filter suggestions + search history
- content = (
-
,
- document.body
- );
-};
-
-export default SearchDropdown;
diff --git a/packages/shared/src/components/SearchPanel.jsx b/packages/shared/src/components/SearchPanel.jsx
deleted file mode 100644
index 782fe51..0000000
--- a/packages/shared/src/components/SearchPanel.jsx
+++ /dev/null
@@ -1,235 +0,0 @@
-import React, { useState, useCallback, useEffect } from 'react';
-import { useQuery } from 'convex/react';
-import { api } from '../../../../convex/_generated/api';
-import { useSearch } from '../contexts/SearchContext';
-import { parseFilters } from '../utils/searchUtils';
-import { usePlatform } from '../platform';
-import { LinkPreview } from './ChatArea';
-import { extractUrls } from './MessageItem';
-import {
- formatTime, escapeHtml, linkifyHtml, formatEmojisHtml, getAvatarColor,
- SearchResultImage, SearchResultVideo, SearchResultFile
-} from '../utils/searchRendering';
-
-const SearchPanel = ({ visible, onClose, channels, isDM, dmChannelId, onJumpToMessage, query, sortOrder, onSortChange }) => {
- const { search, isReady } = useSearch() || {};
- const { links } = usePlatform();
- const customEmojis = useQuery(api.customEmojis.list) || [];
- const [results, setResults] = useState([]);
- const [searching, setSearching] = useState(false);
- const [showSortMenu, setShowSortMenu] = useState(false);
-
- // Execute search when query changes
- useEffect(() => {
- if (!visible || !query?.trim() || !search || !isReady) {
- if (!query?.trim()) setResults([]);
- return;
- }
-
- setSearching(true);
- const { textQuery, filters } = parseFilters(query);
-
- let channelId;
- if (isDM) {
- // In DM view — always scope to the DM channel
- channelId = dmChannelId;
- } else {
- channelId = filters.channelName
- ? channels?.find(c => c.name?.toLowerCase() === filters.channelName.toLowerCase())?._id
- : undefined;
- }
-
- const params = {
- query: textQuery || undefined,
- channelId,
- senderName: filters.senderName,
- hasLink: filters.hasLink,
- hasImage: filters.hasImage,
- hasVideo: filters.hasVideo,
- hasFile: filters.hasFile,
- hasMention: filters.hasMention,
- before: filters.before,
- after: filters.after,
- pinned: filters.pinned,
- limit: 25,
- };
-
- const res = search(params);
-
- let filtered;
- if (isDM) {
- // In DM view — results are already scoped to dmChannelId
- filtered = res;
- } else {
- // In server view — filter out DM messages
- const serverChannelIds = new Set(channels?.map(c => c._id) || []);
- filtered = res.filter(r => serverChannelIds.has(r.channel_id));
- }
-
- // Sort results
- let sorted = [...filtered];
- if (sortOrder === 'oldest') {
- sorted.sort((a, b) => a.created_at - b.created_at);
- } else {
- // newest first (default)
- sorted.sort((a, b) => b.created_at - a.created_at);
- }
-
- setResults(sorted);
- setSearching(false);
- }, [visible, query, sortOrder, search, isReady, channels, isDM, dmChannelId]);
-
- const handleResultClick = useCallback((result) => {
- onJumpToMessage(result.channel_id, result.id);
- }, [onJumpToMessage]);
-
- if (!visible) return null;
-
- const channelMap = {};
- if (channels) {
- for (const c of channels) channelMap[c._id] = c.name;
- }
-
- // Group results by channel
- const grouped = {};
- for (const r of results) {
- const chName = channelMap[r.channel_id] || 'Unknown';
- if (!grouped[chName]) grouped[chName] = [];
- grouped[chName].push(r);
- }
-
- const { filters: activeFilters } = query?.trim() ? parseFilters(query) : { filters: {} };
- const filterChips = [];
- if (activeFilters.senderName) filterChips.push({ label: `from: ${activeFilters.senderName}`, key: 'from' });
- if (activeFilters.hasLink) filterChips.push({ label: 'has: link', key: 'hasLink' });
- if (activeFilters.hasImage) filterChips.push({ label: 'has: image', key: 'hasImage' });
- if (activeFilters.hasVideo) filterChips.push({ label: 'has: video', key: 'hasVideo' });
- if (activeFilters.hasFile) filterChips.push({ label: 'has: file', key: 'hasFile' });
- if (activeFilters.hasMention) filterChips.push({ label: 'has: mention', key: 'hasMention' });
- if (activeFilters.before) filterChips.push({ label: `before: ${activeFilters.before}`, key: 'before' });
- if (activeFilters.after) filterChips.push({ label: `after: ${activeFilters.after}`, key: 'after' });
- if (activeFilters.pinned) filterChips.push({ label: 'pinned: true', key: 'pinned' });
- if (activeFilters.channelName) filterChips.push({ label: `in: ${activeFilters.channelName}`, key: 'in' });
-
- const sortLabel = sortOrder === 'oldest' ? 'Oldest' : 'Newest';
-
- return (
-
-
-
-
- {results.length} result{results.length !== 1 ? 's' : ''}
-
-
-
-
-
- {showSortMenu && (
-
-
{ onSortChange('newest'); setShowSortMenu(false); }}
- >
- Newest
-
-
{ onSortChange('oldest'); setShowSortMenu(false); }}
- >
- Oldest
-
-
- )}
-
-
-
-
-
- {filterChips.length > 0 && (
-
- {filterChips.map(chip => (
-
- {chip.label}
-
- ))}
-
- )}
-
-
- {!isReady && (
-
Search database is loading...
- )}
- {isReady && searching &&
Searching...
}
- {isReady && !searching && results.length === 0 && (
-
-
-
No results found
-
- )}
- {Object.entries(grouped).map(([chName, msgs]) => (
-
-
{isDM ? chName : `#${chName}`}
- {msgs.map(r => (
-
handleResultClick(r)}
- >
-
- {r.username?.[0]?.toUpperCase()}
-
-
-
- {r.username}
- {formatTime(r.created_at)}
-
- {!(r.has_attachment && r.attachment_meta) && (
-
{
- if (e.target.tagName === 'A' && e.target.href) {
- e.preventDefault();
- e.stopPropagation();
- links.openExternal(e.target.href);
- }
- }}
- />
- )}
- {r.has_attachment && r.attachment_meta ? (() => {
- try {
- const meta = JSON.parse(r.attachment_meta);
- if (r.attachment_type?.startsWith('image/')) return ;
- if (r.attachment_type?.startsWith('video/')) return ;
- return ;
- } catch { return File; }
- })() : r.has_attachment ? File : null}
- {r.has_link && r.content && (() => {
- const urls = extractUrls(r.content);
- return urls.map((url, i) => );
- })()}
- {r.pinned && Pinned}
-
-
- ))}
-
- ))}
-
-
- );
-};
-
-export default SearchPanel;
diff --git a/packages/shared/src/components/ServerSettingsModal.jsx b/packages/shared/src/components/ServerSettingsModal.jsx
deleted file mode 100644
index 7e29852..0000000
--- a/packages/shared/src/components/ServerSettingsModal.jsx
+++ /dev/null
@@ -1,1345 +0,0 @@
-import React, { useState, useEffect, useRef, useCallback } from 'react';
-import ReactDOM from 'react-dom';
-import { useQuery, useConvex } from 'convex/react';
-import { api } from '../../../../convex/_generated/api';
-import { AllEmojis } from '../assets/emojis';
-import AvatarCropModal from './AvatarCropModal';
-import Cropper from 'react-easy-crop';
-import { useIsMobile } from '../hooks/useIsMobile';
-
-function getCroppedEmojiImg(imageSrc, pixelCrop, rotation, flipH, flipV) {
- return new Promise((resolve, reject) => {
- const image = new Image();
- image.crossOrigin = 'anonymous';
- image.onload = () => {
- const canvas = document.createElement('canvas');
- canvas.width = 128;
- canvas.height = 128;
- const ctx = canvas.getContext('2d');
-
- ctx.translate(64, 64);
- ctx.rotate((rotation * Math.PI) / 180);
- ctx.scale(flipH ? -1 : 1, flipV ? -1 : 1);
- ctx.translate(-64, -64);
-
- ctx.drawImage(
- image,
- pixelCrop.x, pixelCrop.y, pixelCrop.width, pixelCrop.height,
- 0, 0, 128, 128
- );
- canvas.toBlob((blob) => {
- if (!blob) return reject(new Error('Canvas toBlob failed'));
- resolve(blob);
- }, 'image/png');
- };
- image.onerror = reject;
- image.src = imageSrc;
- });
-}
-
-const TIMEOUT_OPTIONS = [
- { value: 60, label: '1 min' },
- { value: 300, label: '5 min' },
- { value: 900, label: '15 min' },
- { value: 1800, label: '30 min' },
- { value: 3600, label: '1 hour' },
-];
-
-const ServerSettingsModal = ({ onClose }) => {
- const [activeTab, setActiveTab] = useState('Overview');
- const [selectedRole, setSelectedRole] = useState(null);
-
- const userId = localStorage.getItem('userId');
- const convex = useConvex();
-
- // Reactive queries from Convex
- const roles = useQuery(api.roles.list) || [];
- const members = useQuery(api.roles.listMembers) || [];
- const myPermissions = useQuery(
- api.roles.getMyPermissions,
- userId ? { userId } : "skip"
- ) || {};
-
- // Custom emojis
- const customEmojis = useQuery(api.customEmojis.list) || [];
- const [showEmojiModal, setShowEmojiModal] = useState(false);
- const [emojiPreviewUrl, setEmojiPreviewUrl] = useState(null);
- const [emojiName, setEmojiName] = useState('');
- const [emojiFile, setEmojiFile] = useState(null);
- const [emojiUploading, setEmojiUploading] = useState(false);
- const [emojiError, setEmojiError] = useState('');
- const emojiFileInputRef = useRef(null);
- const [emojiCrop, setEmojiCrop] = useState({ x: 0, y: 0 });
- const [emojiZoom, setEmojiZoom] = useState(1);
- const [emojiRotation, setEmojiRotation] = useState(0);
- const [emojiFlipH, setEmojiFlipH] = useState(false);
- const [emojiFlipV, setEmojiFlipV] = useState(false);
- const [emojiCroppedAreaPixels, setEmojiCroppedAreaPixels] = useState(null);
-
- const onEmojiCropComplete = useCallback((_croppedArea, croppedPixels) => {
- setEmojiCroppedAreaPixels(croppedPixels);
- }, []);
-
- // Server settings
- const serverSettings = useQuery(api.serverSettings.get);
- const channels = useQuery(api.channels.list) || [];
- const voiceChannels = channels.filter(c => c.type === 'voice');
- const [serverName, setServerName] = useState('Secure Chat');
- const [serverNameDirty, setServerNameDirty] = useState(false);
- const [afkChannelId, setAfkChannelId] = useState('');
- const [afkTimeout, setAfkTimeout] = useState(300);
- const [afkDirty, setAfkDirty] = useState(false);
- const [iconFile, setIconFile] = useState(null);
- const [iconPreview, setIconPreview] = useState(null);
- const [rawIconUrl, setRawIconUrl] = useState(null);
- const [showIconCropModal, setShowIconCropModal] = useState(false);
- const [iconDirty, setIconDirty] = useState(false);
- const [savingIcon, setSavingIcon] = useState(false);
- const iconInputRef = useRef(null);
-
- // Mobile state
- const isMobile = useIsMobile();
- const [mobileScreen, setMobileScreen] = useState('menu');
-
- const mobileGoBack = () => {
- if (mobileScreen === 'role-edit') setMobileScreen('roles');
- else setMobileScreen('menu');
- };
-
- const mobileSelectRole = (role) => {
- setSelectedRole(role);
- setMobileScreen('role-edit');
- };
-
- // Auto-navigate to overview on icon crop (so user sees save button)
- useEffect(() => {
- if (isMobile && iconDirty && mobileScreen === 'menu') {
- setMobileScreen('overview');
- }
- }, [isMobile, iconDirty, mobileScreen]);
-
- useEffect(() => {
- const handleKey = (e) => {
- if (e.key === 'Escape') {
- if (showEmojiModal) {
- handleEmojiModalClose();
- } else if (!showIconCropModal) {
- if (isMobile && mobileScreen !== 'menu') {
- mobileGoBack();
- } else {
- onClose();
- }
- }
- }
- };
- window.addEventListener('keydown', handleKey);
- return () => window.removeEventListener('keydown', handleKey);
- }, [onClose, showEmojiModal, showIconCropModal, isMobile, mobileScreen]);
-
- React.useEffect(() => {
- if (serverSettings) {
- setServerName(serverSettings.serverName || 'Secure Chat');
- setServerNameDirty(false);
- setAfkChannelId(serverSettings.afkChannelId || '');
- setAfkTimeout(serverSettings.afkTimeout || 300);
- setAfkDirty(false);
- }
- }, [serverSettings]);
-
- const handleSaveServerName = async () => {
- if (!userId) return;
- try {
- await convex.mutation(api.serverSettings.updateName, {
- userId,
- serverName,
- });
- setServerNameDirty(false);
- } catch (e) {
- console.error('Failed to update server name:', e);
- alert('Failed to save server name: ' + e.message);
- }
- };
-
- const handleSaveAfkSettings = async () => {
- if (!userId) return;
- try {
- await convex.mutation(api.serverSettings.update, {
- userId,
- afkChannelId: afkChannelId || undefined,
- afkTimeout,
- });
- setAfkDirty(false);
- } catch (e) {
- console.error('Failed to update server settings:', e);
- alert('Failed to save settings: ' + e.message);
- }
- };
-
- const handleIconFileChange = (e) => {
- const file = e.target.files?.[0];
- if (!file) return;
- const url = URL.createObjectURL(file);
- setRawIconUrl(url);
- setShowIconCropModal(true);
- e.target.value = '';
- };
-
- const handleIconCropApply = (blob) => {
- const file = new File([blob], 'server-icon.png', { type: 'image/png' });
- setIconFile(file);
- const previewUrl = URL.createObjectURL(blob);
- setIconPreview(previewUrl);
- if (rawIconUrl) URL.revokeObjectURL(rawIconUrl);
- setRawIconUrl(null);
- setShowIconCropModal(false);
- setIconDirty(true);
- };
-
- const handleIconCropCancel = () => {
- if (rawIconUrl) URL.revokeObjectURL(rawIconUrl);
- setRawIconUrl(null);
- setShowIconCropModal(false);
- };
-
- const handleSaveIcon = async () => {
- if (!userId || savingIcon) return;
- setSavingIcon(true);
- try {
- let iconStorageId;
- if (iconFile) {
- const uploadUrl = await convex.mutation(api.files.generateUploadUrl);
- const res = await fetch(uploadUrl, {
- method: 'POST',
- headers: { 'Content-Type': iconFile.type },
- body: iconFile,
- });
- const { storageId } = await res.json();
- iconStorageId = storageId;
- }
- await convex.mutation(api.serverSettings.updateIcon, {
- userId,
- iconStorageId,
- });
- setIconFile(null);
- setIconDirty(false);
- if (iconPreview) {
- URL.revokeObjectURL(iconPreview);
- setIconPreview(null);
- }
- } catch (e) {
- console.error('Failed to update server icon:', e);
- alert('Failed to save server icon: ' + e.message);
- } finally {
- setSavingIcon(false);
- }
- };
-
- const handleRemoveIcon = async () => {
- if (!userId) return;
- setSavingIcon(true);
- try {
- await convex.mutation(api.serverSettings.updateIcon, {
- userId,
- iconStorageId: undefined,
- });
- setIconFile(null);
- setIconDirty(false);
- if (iconPreview) {
- URL.revokeObjectURL(iconPreview);
- setIconPreview(null);
- }
- } catch (e) {
- console.error('Failed to remove server icon:', e);
- alert('Failed to remove server icon: ' + e.message);
- } finally {
- setSavingIcon(false);
- }
- };
-
- const currentIconUrl = iconPreview || serverSettings?.iconUrl;
-
- const handleEmojiFileSelect = (e) => {
- const file = e.target.files?.[0];
- if (!file) return;
- const name = file.name.replace(/\.[^.]+$/, '').replace(/[^a-zA-Z0-9_]/g, '_').replace(/^_+|_+$/g, '').substring(0, 32);
- setEmojiFile(file);
- setEmojiName(name || 'emoji');
- setEmojiPreviewUrl(URL.createObjectURL(file));
- setEmojiError('');
- setShowEmojiModal(true);
- e.target.value = '';
- };
-
- const handleEmojiModalClose = () => {
- setShowEmojiModal(false);
- if (emojiPreviewUrl) URL.revokeObjectURL(emojiPreviewUrl);
- setEmojiPreviewUrl(null);
- setEmojiFile(null);
- setEmojiName('');
- setEmojiError('');
- setEmojiCrop({ x: 0, y: 0 });
- setEmojiZoom(1);
- setEmojiRotation(0);
- setEmojiFlipH(false);
- setEmojiFlipV(false);
- setEmojiCroppedAreaPixels(null);
- };
-
- const handleEmojiUpload = async () => {
- if (!userId || !emojiFile || !emojiName.trim()) return;
- setEmojiError('');
- const name = emojiName.trim();
-
- if (!/^[a-zA-Z0-9_]+$/.test(name)) {
- setEmojiError('Name can only contain letters, numbers, and underscores');
- return;
- }
- if (name.length < 2 || name.length > 32) {
- setEmojiError('Name must be between 2 and 32 characters');
- return;
- }
- if (AllEmojis.find(e => e.name === name)) {
- setEmojiError(`"${name}" conflicts with a built-in emoji`);
- return;
- }
- if (customEmojis.find(e => e.name === name)) {
- setEmojiError(`"${name}" already exists as a custom emoji`);
- return;
- }
-
- setEmojiUploading(true);
- try {
- let fileToUpload = emojiFile;
- if (emojiCroppedAreaPixels && emojiPreviewUrl) {
- const blob = await getCroppedEmojiImg(emojiPreviewUrl, emojiCroppedAreaPixels, emojiRotation, emojiFlipH, emojiFlipV);
- fileToUpload = new File([blob], 'emoji.png', { type: 'image/png' });
- }
- const uploadUrl = await convex.mutation(api.files.generateUploadUrl);
- const res = await fetch(uploadUrl, {
- method: 'POST',
- headers: { 'Content-Type': fileToUpload.type },
- body: fileToUpload,
- });
- const { storageId } = await res.json();
- await convex.mutation(api.customEmojis.upload, { userId, name, storageId });
- handleEmojiModalClose();
- } catch (e) {
- console.error('Failed to upload emoji:', e);
- setEmojiError(e.message || 'Failed to upload emoji');
- } finally {
- setEmojiUploading(false);
- }
- };
-
- const handleEmojiDelete = async (emojiId) => {
- if (!userId) return;
- try {
- await convex.mutation(api.customEmojis.remove, { userId, emojiId });
- } catch (e) {
- console.error('Failed to delete emoji:', e);
- }
- };
-
- const handleCreateRole = async () => {
- try {
- const newRole = await convex.mutation(api.roles.create, {
- name: 'new role',
- color: '#99aab5'
- });
- setSelectedRole(newRole);
- } catch (e) {
- console.error('Failed to create role:', e);
- }
- };
-
- const handleUpdateRole = async (id, updates) => {
- try {
- const updated = await convex.mutation(api.roles.update, { id, ...updates });
- if (selectedRole && selectedRole._id === id) {
- setSelectedRole(updated);
- }
- } catch (e) {
- console.error('Failed to update role:', e);
- }
- };
-
- const handleDeleteRole = async (id) => {
- if (!confirm('Delete this role?')) return;
- try {
- await convex.mutation(api.roles.remove, { id });
- if (selectedRole && selectedRole._id === id) setSelectedRole(null);
- } catch (e) {
- console.error('Failed to delete role:', e);
- }
- };
-
- const handleAssignRole = async (roleId, targetUserId, isAdding) => {
- const action = isAdding ? api.roles.assign : api.roles.unassign;
- try {
- await convex.mutation(action, { roleId, userId: targetUserId });
- } catch (e) {
- console.error('Failed to assign/unassign role:', e);
- }
- };
-
- // Render Tabs
- const renderSidebar = () => (
-
-
-
- Server Settings
-
- {['Overview', 'Emoji', 'Roles', 'Members'].map(tab => (
-
setActiveTab(tab)}
- style={{
- padding: '6px 10px', borderRadius: '4px',
- backgroundColor: activeTab === tab ? 'var(--background-modifier-selected)' : 'transparent',
- color: activeTab === tab ? 'var(--header-primary)' : 'var(--header-secondary)',
- cursor: 'pointer', marginBottom: '2px', fontSize: '15px'
- }}
- >
- {tab}
-
- ))}
-
-
- );
-
- const canManageRoles = myPermissions.manage_roles;
- const disabledOpacity = canManageRoles ? 1 : 0.5;
- const labelStyle = { display: 'block', color: 'var(--header-secondary)', fontSize: '12px', fontWeight: '700', marginBottom: 8 };
- const editableRoles = roles.filter(r => r.name !== 'Owner');
-
- const renderRolesTab = () => (
-
-
-
-
ROLES
- {canManageRoles && (
-
- )}
-
- {editableRoles.map(r => (
-
setSelectedRole(r)}
- style={{
- padding: '6px',
- backgroundColor: selectedRole?._id === r._id ? 'var(--background-modifier-selected)' : 'transparent',
- borderRadius: '4px', cursor: 'pointer', color: r.color || '#b9bbbe',
- display: 'flex', alignItems: 'center'
- }}
- >
-
- {r.name}
-
- ))}
-
-
- {selectedRole ? (
-
-
Edit Role - {selectedRole.name}
-
-
-
handleUpdateRole(selectedRole._id, { name: e.target.value })}
- disabled={!canManageRoles}
- style={{ width: '100%', padding: 10, background: 'var(--bg-tertiary)', border: 'none', borderRadius: 4, color: 'var(--header-primary)', marginBottom: 20, opacity: disabledOpacity }}
- />
-
-
-
handleUpdateRole(selectedRole._id, { color: e.target.value })}
- disabled={!canManageRoles}
- style={{ width: '100%', height: 40, border: 'none', padding: 0, marginBottom: 20, opacity: disabledOpacity }}
- />
-
-
- {['manage_channels', 'manage_roles', 'manage_nicknames', 'create_invite', 'embed_links', 'attach_files', 'move_members', 'mute_members'].map(perm => (
-
- {perm.replace('_', ' ')}
- {
- handleUpdateRole(selectedRole._id, {
- permissions: { ...selectedRole.permissions, [perm]: e.target.checked }
- });
- }}
- disabled={!canManageRoles}
- style={{ transform: 'scale(1.5)', opacity: disabledOpacity }}
- />
-
- ))}
-
- {canManageRoles && selectedRole.name !== 'Owner' && selectedRole.name !== '@everyone' && (
-
- )}
-
- ) : (
-
Select a role to edit
- )}
-
- );
-
- const isCurrentUserAdmin = members.find(m => m.id === userId)?.roles?.some(r => r.name === 'Owner');
-
- const handleDeleteUser = async (targetUserId, targetUsername) => {
- if (!confirm(`Are you sure you want to delete "${targetUsername}" and ALL their messages? This cannot be undone.`)) return;
- try {
- const result = await convex.mutation(api.auth.deleteUser, {
- requestingUserId: userId,
- targetUserId,
- });
- if (!result.success) {
- alert(result.error || 'Failed to delete user.');
- }
- } catch (e) {
- console.error('Delete user error:', e);
- alert('Failed to delete user. See console.');
- }
- };
-
- const renderMembersTab = () => (
-
-
Members
- {members.map(m => {
- const isOwner = m.roles?.some(r => r.name === 'Owner');
- const isSelf = m.id === userId;
- return (
-
-
- {m.username[0].toUpperCase()}
-
-
-
{m.username}
-
- {m.roles?.map(r => (
-
- {r.name}
-
- ))}
-
-
-
- {canManageRoles && editableRoles.map(r => {
- const hasRole = m.roles?.some(ur => ur._id === r._id);
- return (
-
-
- );
- })}
-
- );
-
- const renderEmojiTab = () => (
-
-
-
- Add custom emoji that anyone can use in this server.
-
- {myPermissions.manage_channels && (
- <>
-
emojiFileInputRef.current?.click()}
- style={{
- backgroundColor: '#5865F2', color: '#fff', border: 'none',
- borderRadius: 3, padding: '8px 16px', cursor: 'pointer',
- fontWeight: 600, fontSize: 14,
- }}
- >
- Upload Emoji
-
-
- >
- )}
-
-
- {/* Emoji table */}
-
- {/* Table header */}
-
- Image
- Name
- Uploaded By
-
-
-
- {customEmojis.length === 0 ? (
-
- No custom emojis yet
-
- ) : (
- customEmojis.map(emoji => (
-
-

-
:{emoji.name}:
-
{emoji.uploadedByUsername}
-
- {myPermissions.manage_channels && (
- handleEmojiDelete(emoji._id)}
- style={{
- background: 'transparent', border: 'none', color: 'var(--header-secondary)',
- cursor: 'pointer', fontSize: 16, padding: '4px 8px',
- borderRadius: 4, opacity: 0.5, transition: 'opacity 0.15s, color 0.15s',
- }}
- onMouseEnter={(e) => { e.currentTarget.style.opacity = '1'; e.currentTarget.style.color = '#ed4245'; }}
- onMouseLeave={(e) => { e.currentTarget.style.opacity = '0.5'; e.currentTarget.style.color = 'var(--header-secondary)'; }}
- title="Delete emoji"
- >
- ✕
-
- )}
-
-
- ))
- )}
-
-
- );
-
- const renderTabContent = () => {
- switch (activeTab) {
- case 'Emoji': return renderEmojiTab();
- case 'Roles': return renderRolesTab();
- case 'Members': return renderMembersTab();
- default: return (
-
-
-
-
myPermissions.manage_channels && iconInputRef.current?.click()}
- style={{ cursor: myPermissions.manage_channels ? 'pointer' : 'default', opacity: myPermissions.manage_channels ? 1 : 0.5 }}
- >
- {currentIconUrl ? (
-

- ) : (
-
- {serverName.substring(0, 2)}
-
- )}
- {myPermissions.manage_channels && (
-
- CHANGE
ICON
-
- )}
-
-
-
- {iconDirty && myPermissions.manage_channels && (
-
- {savingIcon ? 'Saving...' : 'Upload Icon'}
-
- )}
- {currentIconUrl && !iconDirty && myPermissions.manage_channels && (
-
- Remove Icon
-
- )}
-
-
-
-
-
{ setServerName(e.target.value); setServerNameDirty(true); }}
- disabled={!myPermissions.manage_channels}
- maxLength={100}
- style={{
- width: '100%', padding: 10, background: 'var(--bg-tertiary)', border: 'none',
- borderRadius: 4, color: 'var(--header-primary)', marginBottom: 8,
- opacity: myPermissions.manage_channels ? 1 : 0.5,
- }}
- />
- {serverNameDirty && myPermissions.manage_channels && (
-
- Save Changes
-
- )}
- {!serverNameDirty &&
}
-
-
Region: US-East
-
-
-
-
-
-
-
- {afkDirty && myPermissions.manage_channels && (
-
- Save Changes
-
- )}
-
- );
- }
- };
-
- // ─── Mobile render functions ───
-
- const roleMemberCounts = React.useMemo(() => {
- const counts = {};
- for (const m of members) {
- for (const r of (m.roles || [])) {
- counts[r._id] = (counts[r._id] || 0) + 1;
- }
- }
- return counts;
- }, [members]);
-
- const renderMobileHeader = (title, onBack, rightAction) => (
-
-
-
-
-
{title}
- {rightAction ||
}
-
- );
-
- const renderMobileMenu = () => (
-
-
-
- {/* Server icon + name */}
-
-
myPermissions.manage_channels && iconInputRef.current?.click()}>
- {currentIconUrl ? (
-

- ) : (
-
{serverName.substring(0, 2)}
- )}
- {myPermissions.manage_channels && (
-
- )}
-
-
-
{serverName}
-
-
- {/* Settings menu */}
-
Settings
-
- {[
- { key: 'overview', label: 'Overview', icon:
},
- { key: 'emoji', label: 'Emoji', icon:
},
- { key: 'roles', label: 'Roles', icon:
},
- { key: 'members', label: 'Members', icon:
},
- ].map(item => (
-
setMobileScreen(item.key)}>
-
{item.icon}
-
{item.label}
-
-
-
-
- ))}
-
-
-
- );
-
- const renderMobileOverview = () => (
-
- {renderMobileHeader('Overview', mobileGoBack)}
-
- {/* Server icon (small) */}
-
Server Icon
-
-
myPermissions.manage_channels && iconInputRef.current?.click()}>
- {currentIconUrl ? (
-

- ) : (
-
{serverName.substring(0, 2)}
- )}
-
-
-
- {iconDirty && myPermissions.manage_channels && (
-
- {savingIcon ? 'Saving...' : 'Save Icon'}
-
- )}
- {currentIconUrl && !iconDirty && myPermissions.manage_channels && (
-
- Remove
-
- )}
-
-
-
- {/* Server name */}
-
Server Name
-
{ setServerName(e.target.value); setServerNameDirty(true); }}
- disabled={!myPermissions.manage_channels}
- maxLength={100}
- style={{ opacity: myPermissions.manage_channels ? 1 : 0.5 }}
- />
- {serverNameDirty && myPermissions.manage_channels && (
-
- Save Changes
-
- )}
-
- {/* AFK settings */}
-
Inactive Channel
-
-
-
Inactive Timeout
-
-
- {afkDirty && myPermissions.manage_channels && (
-
- Save Changes
-
- )}
-
-
- );
-
- const renderMobileEmoji = () => (
-
- {renderMobileHeader('Emoji', mobileGoBack)}
-
- {myPermissions.manage_channels && (
- <>
-
emojiFileInputRef.current?.click()} style={{ marginBottom: 12 }}>
- Upload Emoji
-
-
- >
- )}
-
Add custom emoji that anyone can use in this server.
-
- {customEmojis.length === 0 ? (
-
No custom emojis yet
- ) : (
-
- {customEmojis.map(emoji => (
-
-

-
- :{emoji.name}:
- {emoji.uploadedByUsername}
-
- {myPermissions.manage_channels && (
-
handleEmojiDelete(emoji._id)}>
-
-
- )}
-
- ))}
-
- )}
-
-
- );
-
- const renderMobileRoles = () => (
-
- {renderMobileHeader('Roles', mobileGoBack, canManageRoles ? (
-
-
-
- ) : null)}
-
-
Roles let you organize members and customize permissions.
-
- {/* @everyone */}
-
-
mobileSelectRole(editableRoles.find(r => r.name === '@everyone') || editableRoles[0])}>
-
-
@everyone
-
{members.length}
-
-
-
-
-
-
- {/* Other roles */}
- {editableRoles.filter(r => r.name !== '@everyone').length > 0 && (
- <>
-
Roles - {editableRoles.filter(r => r.name !== '@everyone').length}
-
- {editableRoles.filter(r => r.name !== '@everyone').map(r => (
-
mobileSelectRole(r)}>
-
-
{r.name}
-
{roleMemberCounts[r._id] || 0}
-
-
-
-
- ))}
-
- >
- )}
-
-
- );
-
- const renderMobileRoleEdit = () => {
- if (!selectedRole) return renderMobileRoles();
- const permList = ['manage_channels', 'manage_roles', 'manage_nicknames', 'create_invite', 'embed_links', 'attach_files', 'move_members', 'mute_members'];
-
- return (
-
- {renderMobileHeader(`Edit Role`, () => setMobileScreen('roles'))}
-
- {/* Role name */}
-
Role Name
-
handleUpdateRole(selectedRole._id, { name: e.target.value })}
- disabled={!canManageRoles}
- style={{ opacity: canManageRoles ? 1 : 0.5 }}
- />
-
- {/* Role color */}
-
Role Color
-
- handleUpdateRole(selectedRole._id, { color: e.target.value })}
- disabled={!canManageRoles}
- />
- {selectedRole.color}
-
-
- {/* Display separately toggle (isHoist) */}
-
Display
-
-
canManageRoles && handleUpdateRole(selectedRole._id, { isHoist: !selectedRole.isHoist })}>
-
Display separately
-
-
-
-
- {/* Permissions */}
-
Permissions
-
- {permList.map(perm => (
-
canManageRoles && handleUpdateRole(selectedRole._id, { permissions: { ...selectedRole.permissions, [perm]: !selectedRole.permissions?.[perm] } })}>
-
{perm.replace(/_/g, ' ')}
-
-
- ))}
-
-
- {/* Delete */}
- {canManageRoles && selectedRole.name !== 'Owner' && selectedRole.name !== '@everyone' && (
-
-
handleDeleteRole(selectedRole._id)}>
- Delete Role
-
-
- )}
-
-
- );
- };
-
- const renderMobileMembers = () => (
-
- {renderMobileHeader('Members', mobileGoBack)}
-
- {members.length === 0 ? (
-
No members found
- ) : (
-
- {members.map(m => (
-
-
- {m.avatarUrl ? (
-

- ) : (
- m.username[0].toUpperCase()
- )}
-
-
-
{m.username}
-
- {m.roles?.map(r => (
-
- {r.name}
-
- ))}
-
-
- {canManageRoles && (
-
- {editableRoles.map(r => {
- const hasRole = m.roles?.some(ur => ur._id === r._id);
- return (
- handleAssignRole(r._id, m.id, !hasRole)}
- style={{
- borderColor: r.color,
- backgroundColor: hasRole ? r.color : 'transparent',
- }}
- title={hasRole ? `Remove ${r.name}` : `Add ${r.name}`}
- />
- );
- })}
-
- )}
-
- ))}
-
- )}
-
-
- );
-
- const renderMobileContent = () => {
- switch (mobileScreen) {
- case 'overview': return renderMobileOverview();
- case 'emoji': return renderMobileEmoji();
- case 'roles': return renderMobileRoles();
- case 'role-edit': return renderMobileRoleEdit();
- case 'members': return renderMobileMembers();
- default: return renderMobileMenu();
- }
- };
-
- // ─── Shared modals ───
- const sharedModals = (
- <>
- {showIconCropModal && rawIconUrl && (
-
- )}
- {showEmojiModal && emojiPreviewUrl && (
-
-
e.stopPropagation()}
- style={{
- backgroundColor: 'var(--bg-secondary)', borderRadius: 8,
- width: 580, maxWidth: '90vw', overflow: 'hidden',
- }}
- >
-
-
Add Emoji
-
- ✕
-
-
-
-
-
-
-
-
-
setEmojiRotation((r) => (r - 90 + 360) % 360)} title="Rotate left" style={{ width: 36, height: 36, borderRadius: 4, background: 'var(--bg-tertiary)', border: 'none', color: 'var(--header-secondary)', cursor: 'pointer', fontSize: 16, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
-
-
-
setEmojiRotation((r) => (r + 90) % 360)} title="Rotate right" style={{ width: 36, height: 36, borderRadius: 4, background: 'var(--bg-tertiary)', border: 'none', color: 'var(--header-secondary)', cursor: 'pointer', fontSize: 16, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
-
-
-
setEmojiFlipH((f) => !f)} title="Flip horizontal" style={{ width: 36, height: 36, borderRadius: 4, background: emojiFlipH ? 'rgba(88, 101, 242, 0.3)' : 'var(--bg-tertiary)', border: emojiFlipH ? '1px solid #5865F2' : 'none', color: 'var(--header-secondary)', cursor: 'pointer', fontSize: 16, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
-
-
-
setEmojiFlipV((f) => !f)} title="Flip vertical" style={{ width: 36, height: 36, borderRadius: 4, background: emojiFlipV ? 'rgba(88, 101, 242, 0.3)' : 'var(--bg-tertiary)', border: emojiFlipV ? '1px solid #5865F2' : 'none', color: 'var(--header-secondary)', cursor: 'pointer', fontSize: 16, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
-
-
-
-
-
-
setEmojiZoom(Number(e.target.value))} className="avatar-crop-slider" />
-
-
-
-
-
-
Preview
-
-

-
1
-
-
-
-
-
- { setEmojiName(e.target.value); setEmojiError(''); }} maxLength={32} style={{ width: '100%', padding: '10px 32px 10px 10px', background: 'var(--bg-tertiary)', border: 'none', borderRadius: 4, color: 'var(--header-primary)', fontSize: 14, boxSizing: 'border-box' }} />
- {emojiName && (
- setEmojiName('')} style={{ position: 'absolute', right: 8, top: '50%', transform: 'translateY(-50%)', background: 'transparent', border: 'none', color: 'var(--header-secondary)', cursor: 'pointer', fontSize: 14, padding: '2px 4px' }}>✕
- )}
-
- {emojiError &&
{emojiError}
}
-
-
-
- {emojiUploading ? 'Uploading...' : 'Finish'}
-
-
-
-
-
- )}
- >
- );
-
- // ─── Main return ───
-
- if (isMobile) {
- return ReactDOM.createPortal(
-
- {renderMobileContent()}
- {sharedModals}
-
,
- document.body
- );
- }
-
- return (
-
- {renderSidebar()}
-
-
-
{activeTab}
- {renderTabContent()}
-
-
-
-
- {sharedModals}
-
- );
-};
-
-export default ServerSettingsModal;
diff --git a/packages/shared/src/components/Sidebar.jsx b/packages/shared/src/components/Sidebar.jsx
deleted file mode 100644
index bafe111..0000000
--- a/packages/shared/src/components/Sidebar.jsx
+++ /dev/null
@@ -1,2663 +0,0 @@
-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 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",
-};
-
-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];
-}
-
-function bytesToHex(bytes) {
- 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 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 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" },
-];
-
-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;
-
- // 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");
-
- 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 (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]);
-
- return (
-
- {showStatusMenu && (
-
- {STATUS_OPTIONS.map((opt) => (
-
handleStatusChange(opt.value)}
- >
-
-
{opt.label}
-
- ))}
-
- )}
-
setShowStatusMenu(!showStatusMenu)}>
-
-
-
- {username || "Unknown"}
-
-
- {STATUS_OPTIONS.find((s) => s.value === currentStatus)?.label || "Online"}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- setShowUserSettings(true)}>
-
-
-
-
- {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",
-};
-
-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",
-};
-
-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",
-};
-
-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 = [];
-
- 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 });
-}
-
-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,
- },
- },
- };
-}
-
-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]);
-
- 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;
-
- 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 ? (
-
- ) : (
-
- )}
-
-
-
- {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 });
-
- 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]);
-
- 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 });
-
- 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]);
-
- 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 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"
- />
-
-
-
-
-
-
- Cancel
-
-
- Create Channel
-
-
-
-
- );
-};
-
-const CreateCategoryModal = ({ onClose, onSubmit }) => {
- const [categoryName, setCategoryName] = useState("");
-
- 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.
-
-
-
-
-
- Cancel
-
-
- Create Category
-
-
-
-
- );
-};
-
-// --- DnD wrapper components ---
-
-const SortableCategory = ({ id, children }) => {
- 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,
- };
-
- 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 style = {
- transform: CSS.Transform.toString(transform),
- transition,
- opacity: isDragging ? 0.5 : 1,
- };
-
- 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,
- });
-
- 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 convex = useConvex();
-
- // 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) || [];
-
- // 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),
- );
-
- if (prevUnreadDMsRef.current === null) {
- prevUnreadDMsRef.current = currentIds;
- 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;
- }
- }
-
- prevUnreadDMsRef.current = currentIds;
- }, [dmChannels, unreadChannels, unreadQueriesLoaded, isReceivingScreenShareAudio]);
-
- const onRenameChannel = () => {};
-
- const onDeleteChannel = (id) => {
- if (activeChannel === id) onSelectChannel(null);
- };
-
- const handleStartCreate = () => {
- setIsCreating(true);
- setNewChannelName("");
- setNewChannelType("text");
- };
-
- const handleSubmitCreate = async (e) => {
- if (e) e.preventDefault();
-
- if (!newChannelName.trim()) {
- setIsCreating(false);
- return;
- }
-
- const name = newChannelName.trim();
- const userId = localStorage.getItem("userId");
-
- if (!userId) {
- alert("Please login first.");
- setIsCreating(false);
- return;
- }
-
- try {
- const { id: channelId } = await convex.mutation(api.channels.create, {
- name,
- type: newChannelType,
- });
- 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);
- } finally {
- setIsCreating(false);
- }
- };
-
- const handleCreateInvite = async () => {
- const userId = localStorage.getItem("userId");
- if (!userId) {
- alert("Error: No User ID found. Please login again.");
- return;
- }
-
- // Bundle all server channel keys (not DM keys) so new users get access to everything
- const serverChannelIds = new Set(channels.map((c) => c._id));
- const allServerKeys = {};
- for (const [chId, key] of Object.entries(channelKeys || {})) {
- if (serverChannelIds.has(chId)) {
- allServerKeys[chId] = key;
- }
- }
-
- if (Object.keys(allServerKeys).length === 0) {
- alert("Error: You don't have any channel keys to share.");
- return;
- }
-
- try {
- const inviteCode = globalThis.crypto.randomUUID();
- const inviteSecret = randomHex(32);
-
- const payload = JSON.stringify(allServerKeys);
- 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);
- } else {
- setIsScreenShareModalOpen(true);
- }
- };
-
- 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;
- }
- },
- onTouchEnd: (e) => {
- if (timer) {
- clearTimeout(timer);
- timer = null;
- }
- if (triggered) {
- e.preventDefault();
- triggered = false;
- }
- },
- };
- };
-
- 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;
- });
- },
- [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 [editName, setEditName] = useState(group.name);
- const inputRef = useRef(null);
-
- useEffect(() => {
- if (isEditing && inputRef.current) {
- inputRef.current.focus();
- inputRef.current.select();
- }
- }, [isEditing]);
-
- useEffect(() => {
- 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"
- >
- +
-
-
- );
- },
-);
-
-export default Sidebar;
diff --git a/packages/shared/src/components/SlashCommandMenu.jsx b/packages/shared/src/components/SlashCommandMenu.jsx
deleted file mode 100644
index b4a5bf8..0000000
--- a/packages/shared/src/components/SlashCommandMenu.jsx
+++ /dev/null
@@ -1,51 +0,0 @@
-import React, { useEffect, useRef } from 'react';
-
-const SlashCommandMenu = ({ commands, selectedIndex, onSelect, onHover }) => {
- const scrollerRef = useRef(null);
-
- useEffect(() => {
- if (!scrollerRef.current) return;
- const selected = scrollerRef.current.querySelector('.slash-command-row.selected');
- if (selected) selected.scrollIntoView({ block: 'nearest' });
- }, [selectedIndex]);
-
- if (!commands || commands.length === 0) return null;
-
- const grouped = {};
- for (const cmd of commands) {
- const cat = cmd.category || 'Built-In';
- if (!grouped[cat]) grouped[cat] = [];
- grouped[cat].push(cmd);
- }
-
- let globalIndex = 0;
-
- return (
-
-
- {Object.entries(grouped).map(([category, cmds]) => (
-
- {category}
- {cmds.map((cmd) => {
- const idx = globalIndex++;
- return (
- e.preventDefault()}
- onClick={() => onSelect(cmd)}
- onMouseEnter={() => onHover(idx)}
- >
- /{cmd.name}
- {cmd.description}
-
- );
- })}
-
- ))}
-
-
- );
-};
-
-export default SlashCommandMenu;
diff --git a/packages/shared/src/components/ThemeSelector.jsx b/packages/shared/src/components/ThemeSelector.jsx
deleted file mode 100644
index cf9228f..0000000
--- a/packages/shared/src/components/ThemeSelector.jsx
+++ /dev/null
@@ -1,56 +0,0 @@
-import React from 'react';
-import { useTheme, THEMES, THEME_LABELS } from '../contexts/ThemeContext';
-
-const THEME_PREVIEWS = {
- [THEMES.LIGHT]: { bg: '#ffffff', sidebar: '#f2f3f5', tertiary: '#e3e5e8', text: '#313338' },
- [THEMES.DARK]: { bg: '#313338', sidebar: '#2b2d31', tertiary: '#1e1f22', text: '#f2f3f5' },
- [THEMES.ASH]: { bg: '#202225', sidebar: '#1a1b1e', tertiary: '#111214', text: '#f0f1f3' },
- [THEMES.ONYX]: { bg: '#0c0c14', sidebar: '#080810', tertiary: '#000000', text: '#e0def0' },
-};
-
-const ThemeSelector = ({ onClose }) => {
- const { theme, setTheme } = useTheme();
-
- return (
-
-
e.stopPropagation()}>
-
-
Appearance
- ✕
-
-
- {Object.values(THEMES).map((themeKey) => {
- const preview = THEME_PREVIEWS[themeKey];
- const isActive = theme === themeKey;
- return (
-
setTheme(themeKey)}
- >
-
-
-
-
{THEME_LABELS[themeKey]}
-
-
- );
- })}
-
-
-
- );
-};
-
-export default ThemeSelector;
diff --git a/packages/shared/src/components/TitleBar.jsx b/packages/shared/src/components/TitleBar.jsx
deleted file mode 100644
index 85982d6..0000000
--- a/packages/shared/src/components/TitleBar.jsx
+++ /dev/null
@@ -1,48 +0,0 @@
-import React from 'react';
-import { usePlatform } from '../platform';
-import { TitleBarUpdateIcon } from './UpdateBanner';
-
-const TitleBar = () => {
- const { windowControls, features } = usePlatform();
-
- if (!features.hasWindowControls) return null;
-
- return (
-
-
-
Brycord
-
-
-
windowControls.minimize()}
- aria-label="Minimize"
- >
-
-
-
windowControls.maximize()}
- aria-label="Maximize"
- >
-
-
-
windowControls.close()}
- aria-label="Close"
- >
-
-
-
-
- );
-};
-
-export default TitleBar;
diff --git a/packages/shared/src/components/Toast.jsx b/packages/shared/src/components/Toast.jsx
deleted file mode 100644
index ea4572b..0000000
--- a/packages/shared/src/components/Toast.jsx
+++ /dev/null
@@ -1,69 +0,0 @@
-import React, { useState, useEffect, useCallback } from 'react';
-import ReactDOM from 'react-dom';
-
-const USER_COLORS = ['#5865F2', '#EBA7CD', '#57F287', '#FEE75C', '#EB459E', '#ED4245'];
-
-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];
-}
-
-const ToastContainer = ({ toasts, removeToast }) => {
- if (toasts.length === 0) return null;
-
- return ReactDOM.createPortal(
-
- {toasts.map(toast => (
- removeToast(toast.id)} />
- ))}
-
,
- document.body
- );
-};
-
-const ToastItem = ({ toast, onDismiss }) => {
- const [exiting, setExiting] = useState(false);
-
- useEffect(() => {
- const timer = setTimeout(() => {
- setExiting(true);
- setTimeout(onDismiss, 300);
- }, 5000);
- return () => clearTimeout(timer);
- }, [onDismiss]);
-
- return (
-
-
- {(toast.username || '?').substring(0, 1).toUpperCase()}
-
-
-
New message from {toast.username}
-
{toast.preview}
-
-
{ setExiting(true); setTimeout(onDismiss, 300); }}>
- ×
-
-
- );
-};
-
-export function useToasts() {
- const [toasts, setToasts] = useState([]);
-
- const addToast = useCallback((toast) => {
- const id = Date.now() + Math.random();
- setToasts(prev => [...prev.slice(-4), { ...toast, id }]);
- }, []);
-
- const removeToast = useCallback((id) => {
- setToasts(prev => prev.filter(t => t.id !== id));
- }, []);
-
- return { toasts, addToast, removeToast, ToastContainer: () =>
};
-}
-
-export default ToastContainer;
diff --git a/packages/shared/src/components/Tooltip.jsx b/packages/shared/src/components/Tooltip.jsx
deleted file mode 100644
index 7aa3ccc..0000000
--- a/packages/shared/src/components/Tooltip.jsx
+++ /dev/null
@@ -1,90 +0,0 @@
-import React, { useState, useRef, useEffect } from 'react';
-import ReactDOM from 'react-dom';
-
-const Tooltip = ({ children, text, position = 'top' }) => {
- const [visible, setVisible] = useState(false);
- const [coords, setCoords] = useState({ top: 0, left: 0 });
- const triggerRef = useRef(null);
- const timeoutRef = useRef(null);
-
- const showTooltip = () => {
- timeoutRef.current = setTimeout(() => {
- if (triggerRef.current) {
- const rect = triggerRef.current.getBoundingClientRect();
- let top, left;
-
- switch (position) {
- case 'bottom':
- top = rect.bottom + 8;
- left = rect.left + rect.width / 2;
- break;
- case 'left':
- top = rect.top + rect.height / 2;
- left = rect.left - 8;
- break;
- case 'right':
- top = rect.top + rect.height / 2;
- left = rect.right + 8;
- break;
- default: // top
- top = rect.top - 8;
- left = rect.left + rect.width / 2;
- break;
- }
- setCoords({ top, left });
- setVisible(true);
- }
- }, 200);
- };
-
- const hideTooltip = () => {
- if (timeoutRef.current) clearTimeout(timeoutRef.current);
- setVisible(false);
- };
-
- useEffect(() => {
- return () => { if (timeoutRef.current) clearTimeout(timeoutRef.current); };
- }, []);
-
- const getTransformStyle = () => {
- switch (position) {
- case 'bottom': return 'translate(-50%, 0)';
- case 'left': return 'translate(-100%, -50%)';
- case 'right': return 'translate(0, -50%)';
- default: return 'translate(-50%, -100%)';
- }
- };
-
- const getArrowClass = () => `tooltip-arrow tooltip-arrow-${position}`;
-
- return (
- <>
-
- {children}
-
- {visible && ReactDOM.createPortal(
-
,
- document.body
- )}
- >
- );
-};
-
-export default Tooltip;
diff --git a/packages/shared/src/components/UpdateBanner.jsx b/packages/shared/src/components/UpdateBanner.jsx
deleted file mode 100644
index 9aaa915..0000000
--- a/packages/shared/src/components/UpdateBanner.jsx
+++ /dev/null
@@ -1,143 +0,0 @@
-import React, { useState, useEffect, createContext, useContext } from 'react';
-import { usePlatform } from '../platform';
-import ColoredIcon from './ColoredIcon';
-import updateIcon from '../assets/icons/update.svg';
-
-const RELEASE_URL = 'https://gitea.moyettes.com/Moyettes/DiscordClone/releases/tag/latest';
-
-const UpdateContext = createContext(null);
-
-export function useUpdateCheck() {
- return useContext(UpdateContext);
-}
-
-export function UpdateProvider({ children }) {
- const { updates, features } = usePlatform();
- const [state, setState] = useState(null);
-
- useEffect(() => {
- if (!features.hasNativeUpdates || !updates) return;
-
- updates.checkUpdate().then((result) => {
- if (!result?.updateAvailable) return;
- setState(result);
- }).catch(() => {});
- }, []);
-
- return (
-
- {children}
- {state && (state.updateType === 'major' || state.updateType === 'minor' || !features.hasWindowControls) && (
-
- )}
-
- );
-}
-
-function ForcedUpdateModal({ updateType, latestVersion }) {
- const { links, updates } = usePlatform();
- const [downloading, setDownloading] = useState(false);
- const [progress, setProgress] = useState(0);
- const [error, setError] = useState(null);
-
- useEffect(() => {
- if (!downloading || !updates?.onDownloadProgress) return;
- updates.onDownloadProgress(({ percent }) => {
- setProgress(percent);
- });
- }, [downloading]);
-
- const handleDownload = async () => {
- if (updates?.installUpdate) {
- setDownloading(true);
- setError(null);
- try {
- await updates.installUpdate();
- } catch (e) {
- setError('Download failed. Please try again.');
- setDownloading(false);
- }
- } else {
- links.openExternal(RELEASE_URL);
- }
- };
-
- const isPatch = updateType === 'patch';
-
- return (
-
-
-
{isPatch ? 'Update Available' : 'Update Required'}
-
- {isPatch
- ? `A new version (v${latestVersion}) is available.`
- : `A new version (v${latestVersion}) is available. This update is required to continue using the app.`
- }
-
- {downloading ? (
-
-
-
Downloading... {progress}%
-
- ) : (
-
- Download Update
-
- )}
- {error &&
{error}
}
-
-
- );
-}
-
-export function TitleBarUpdateIcon() {
- const { links, updates } = usePlatform();
- const update = useUpdateCheck();
- const [downloading, setDownloading] = useState(false);
- const [progress, setProgress] = useState(0);
-
- useEffect(() => {
- if (!downloading || !updates?.onDownloadProgress) return;
- updates.onDownloadProgress(({ percent }) => {
- setProgress(percent);
- });
- }, [downloading]);
-
- if (!update) return null;
-
- const handleClick = async () => {
- if (updates?.installUpdate && !downloading) {
- setDownloading(true);
- try {
- await updates.installUpdate();
- } catch {
- setDownloading(false);
- }
- } else if (!downloading) {
- links.openExternal(RELEASE_URL);
- }
- };
-
- const label = downloading
- ? `Downloading update... ${progress}%`
- : `Update available: v${update.latestVersion}`;
-
- return (
-
-
-
-
-
- );
-}
diff --git a/packages/shared/src/components/UserProfilePopup.jsx b/packages/shared/src/components/UserProfilePopup.jsx
deleted file mode 100644
index 52384c5..0000000
--- a/packages/shared/src/components/UserProfilePopup.jsx
+++ /dev/null
@@ -1,138 +0,0 @@
-import React, { useRef, useEffect, useState } from 'react';
-import ReactDOM from 'react-dom';
-import { useQuery } from 'convex/react';
-import { api } from '../../../../convex/_generated/api';
-import Avatar from './Avatar';
-
-const STATUS_LABELS = {
- online: 'Online',
- idle: 'Idle',
- dnd: 'Do Not Disturb',
- invisible: 'Invisible',
- offline: 'Offline',
-};
-
-const STATUS_COLORS = {
- online: '#3ba55c',
- idle: '#faa61a',
- dnd: '#ed4245',
- invisible: '#747f8d',
- offline: '#747f8d',
-};
-
-const USER_COLORS = ['#5865F2', '#EBA7CD', '#57F287', '#FEE75C', '#EB459E', '#ED4245'];
-
-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];
-}
-
-const UserProfilePopup = ({ userId, username, avatarUrl, status, position, onClose, onSendMessage }) => {
- const popupRef = useRef(null);
- const [note, setNote] = useState('');
-
- // Fetch member data (roles, aboutMe) for this user
- const allUsers = useQuery(api.auth.getPublicKeys) || [];
- const userData = allUsers.find(u => u.id === userId);
-
- useEffect(() => {
- const handleClick = (e) => {
- if (popupRef.current && !popupRef.current.contains(e.target)) {
- onClose();
- }
- };
- document.addEventListener('mousedown', handleClick);
- return () => document.removeEventListener('mousedown', handleClick);
- }, [onClose]);
-
- // Load note from localStorage
- useEffect(() => {
- if (userId) {
- const saved = localStorage.getItem(`note_${userId}`);
- if (saved) setNote(saved);
- }
- }, [userId]);
-
- const handleNoteChange = (e) => {
- const val = e.target.value;
- setNote(val);
- if (userId) {
- localStorage.setItem(`note_${userId}`, val);
- }
- };
-
- const userColor = getUserColor(username || 'Unknown');
- const userStatus = status || 'online';
- const resolvedAvatarUrl = avatarUrl || userData?.avatarUrl;
- const aboutMe = userData?.aboutMe;
-
- const style = {
- position: 'fixed',
- top: Math.min(position.y, window.innerHeight - 420),
- left: Math.min(position.x, window.innerWidth - 320),
- zIndex: 10000,
- };
-
- return ReactDOM.createPortal(
-
-
-
-
-
{userData?.displayName || username}
- {userData?.displayName && (
-
- {username}
-
- )}
-
- {userData?.customStatus || STATUS_LABELS[userStatus] || 'Online'}
-
-
-
-
ABOUT ME
-
- {aboutMe || 'No information set.'}
-
-
-
-
-
NOTE
-
-
- {onSendMessage && (
-
{ onSendMessage(userId, username); onClose(); }}
- style={{ marginTop: '8px' }}
- >
- Send Message
-
- )}
-
-
,
- document.body
- );
-};
-
-export default UserProfilePopup;
diff --git a/packages/shared/src/components/UserSettings.jsx b/packages/shared/src/components/UserSettings.jsx
deleted file mode 100644
index e77e403..0000000
--- a/packages/shared/src/components/UserSettings.jsx
+++ /dev/null
@@ -1,1477 +0,0 @@
-import React, { useState, useEffect, useRef, useCallback } from 'react';
-import ReactDOM from 'react-dom';
-import { useQuery, useConvex } from 'convex/react';
-import { api } from '../../../../convex/_generated/api';
-import Avatar from './Avatar';
-import AvatarCropModal from './AvatarCropModal';
-import { useTheme, THEMES, THEME_LABELS } from '../contexts/ThemeContext';
-import { useVoice } from '../contexts/VoiceContext';
-import { useSearch } from '../contexts/SearchContext';
-import { usePlatform } from '../platform';
-import { useIsMobile } from '../hooks/useIsMobile';
-
-const THEME_PREVIEWS = {
- [THEMES.LIGHT]: { bg: '#ffffff', sidebar: '#f2f3f5', tertiary: '#e3e5e8', text: '#313338' },
- [THEMES.DARK]: { bg: '#313338', sidebar: '#2b2d31', tertiary: '#1e1f22', text: '#f2f3f5' },
- [THEMES.ASH]: { bg: '#202225', sidebar: '#1a1b1e', tertiary: '#111214', text: '#f0f1f3' },
- [THEMES.ONYX]: { bg: '#0c0c14', sidebar: '#080810', tertiary: '#000000', text: '#e0def0' },
-};
-
-const TABS = [
- { id: 'account', label: 'My Account', section: 'USER SETTINGS' },
- { id: 'security', label: 'Security', section: 'USER SETTINGS' },
- { id: 'appearance', label: 'Appearance', section: 'USER SETTINGS' },
- { id: 'voice', label: 'Voice & Video', section: 'APP SETTINGS' },
- { id: 'keybinds', label: 'Keybinds', section: 'APP SETTINGS' },
- { id: 'search', label: 'Search', section: 'APP SETTINGS' },
-];
-
-const UserSettings = ({ onClose, userId, username, onLogout }) => {
- const [activeTab, setActiveTab] = useState('account');
- const isMobile = useIsMobile();
- const [mobileScreen, setMobileScreen] = useState('menu');
-
- useEffect(() => {
- const handleKey = (e) => {
- if (e.key === 'Escape') {
- if (isMobile && mobileScreen !== 'menu') {
- setMobileScreen('menu');
- } else {
- onClose();
- }
- }
- };
- window.addEventListener('keydown', handleKey);
- return () => window.removeEventListener('keydown', handleKey);
- }, [onClose, isMobile, mobileScreen]);
-
- const renderSidebar = () => {
- let lastSection = null;
- const items = [];
-
- TABS.forEach((tab, i) => {
- if (tab.section !== lastSection) {
- if (lastSection !== null) {
- items.push(
);
- }
- items.push(
-
- {tab.section}
-
- );
- lastSection = tab.section;
- }
- items.push(
-
setActiveTab(tab.id)}
- style={{
- padding: '6px 10px', borderRadius: '4px', cursor: 'pointer', marginBottom: '2px', fontSize: '15px',
- backgroundColor: activeTab === tab.id ? 'var(--background-modifier-selected)' : 'transparent',
- color: activeTab === tab.id ? 'var(--header-primary)' : 'var(--header-secondary)',
- }}
- >
- {tab.label}
-
- );
- });
-
- items.push(
);
- items.push(
-
- );
-
- return items;
- };
-
- // ─── Mobile render functions ───
-
- const renderMobileHeader = (title, onBack) => (
-
- );
-
- const renderMobileMenu = () => (
-
-
-
-
User Settings
-
-
setMobileScreen('account')}>
-
-
-
-
Account
-
-
-
-
-
setMobileScreen('security')}>
-
-
-
-
Security
-
-
-
-
-
-
-
App Settings
-
-
setMobileScreen('appearance')}>
-
-
-
-
Appearance
-
-
-
-
-
setMobileScreen('voice')}>
-
-
-
-
Voice & Video
-
-
-
-
-
setMobileScreen('keybinds')}>
-
-
-
-
Keybinds
-
-
-
-
-
setMobileScreen('search')}>
-
-
-
-
Search
-
-
-
-
-
-
- {/* Log Out */}
-
-
-
-
-
- Log Out
-
-
-
-
- );
-
- const renderMobileContent = () => {
- switch (mobileScreen) {
- case 'account':
- return (
-
- {renderMobileHeader('Account', () => setMobileScreen('menu'))}
-
-
-
-
- );
- case 'security':
- return (
-
- {renderMobileHeader('Security', () => setMobileScreen('menu'))}
-
-
-
-
- );
- case 'appearance':
- return (
-
- {renderMobileHeader('Appearance', () => setMobileScreen('menu'))}
-
-
- );
- case 'voice':
- return (
-
- {renderMobileHeader('Voice & Video', () => setMobileScreen('menu'))}
-
-
-
-
- );
- case 'keybinds':
- return (
-
- {renderMobileHeader('Keybinds', () => setMobileScreen('menu'))}
-
-
-
-
- );
- case 'search':
- return (
-
- {renderMobileHeader('Search', () => setMobileScreen('menu'))}
-
-
-
-
- );
- default:
- return renderMobileMenu();
- }
- };
-
- if (isMobile) {
- return ReactDOM.createPortal(
-
{renderMobileContent()}
,
- document.body
- );
- }
-
- return (
-
- {/* Sidebar */}
-
-
- {renderSidebar()}
-
-
-
- {/* Content */}
-
-
- {activeTab === 'account' &&
}
- {activeTab === 'security' &&
}
- {activeTab === 'appearance' &&
}
- {activeTab === 'voice' &&
}
- {activeTab === 'keybinds' &&
}
- {activeTab === 'search' &&
}
-
-
- {/* Right spacer with close button */}
-
-
-
-
- );
-};
-
-/* =========================================
- MY ACCOUNT TAB
- ========================================= */
-const MyAccountTab = ({ userId, username }) => {
- const allUsers = useQuery(api.auth.getPublicKeys);
- const convex = useConvex();
-
- const currentUser = allUsers?.find(u => u.id === userId);
-
- const [displayName, setDisplayName] = useState('');
- const [aboutMe, setAboutMe] = useState('');
- const [customStatus, setCustomStatus] = useState('');
- const [avatarFile, setAvatarFile] = useState(null);
- const [avatarPreview, setAvatarPreview] = useState(null);
- const [saving, setSaving] = useState(false);
- const [hasChanges, setHasChanges] = useState(false);
- const [showCropModal, setShowCropModal] = useState(false);
- const [rawImageUrl, setRawImageUrl] = useState(null);
- const fileInputRef = useRef(null);
- const [joinSoundFile, setJoinSoundFile] = useState(null);
- const [joinSoundPreviewName, setJoinSoundPreviewName] = useState(null);
- const [removeJoinSound, setRemoveJoinSound] = useState(false);
- const joinSoundInputRef = useRef(null);
- const joinSoundAudioRef = useRef(null);
-
- useEffect(() => {
- if (currentUser) {
- setDisplayName(currentUser.displayName || '');
- setAboutMe(currentUser.aboutMe || '');
- setCustomStatus(currentUser.customStatus || '');
- }
- }, [currentUser]);
-
- useEffect(() => {
- if (!currentUser) return;
- const changed =
- displayName !== (currentUser.displayName || '') ||
- aboutMe !== (currentUser.aboutMe || '') ||
- customStatus !== (currentUser.customStatus || '') ||
- avatarFile !== null ||
- joinSoundFile !== null ||
- removeJoinSound;
- setHasChanges(changed);
- }, [displayName, aboutMe, customStatus, avatarFile, joinSoundFile, removeJoinSound, currentUser]);
-
- const handleAvatarChange = (e) => {
- const file = e.target.files?.[0];
- if (!file) return;
- const url = URL.createObjectURL(file);
- setRawImageUrl(url);
- setShowCropModal(true);
- e.target.value = '';
- };
-
- const handleCropApply = (blob) => {
- const file = new File([blob], 'avatar.png', { type: 'image/png' });
- setAvatarFile(file);
- const previewUrl = URL.createObjectURL(blob);
- setAvatarPreview(previewUrl);
- if (rawImageUrl) URL.revokeObjectURL(rawImageUrl);
- setRawImageUrl(null);
- setShowCropModal(false);
- };
-
- const handleCropCancel = () => {
- if (rawImageUrl) URL.revokeObjectURL(rawImageUrl);
- setRawImageUrl(null);
- setShowCropModal(false);
- };
-
- const handleJoinSoundChange = (e) => {
- const file = e.target.files?.[0];
- if (!file) return;
- if (file.size > 10 * 1024 * 1024) {
- alert('Join sound must be under 10MB');
- e.target.value = '';
- return;
- }
- setJoinSoundFile(file);
- setJoinSoundPreviewName(file.name);
- setRemoveJoinSound(false);
- e.target.value = '';
- };
-
- const handleJoinSoundPreview = () => {
- if (joinSoundAudioRef.current) {
- joinSoundAudioRef.current.pause();
- joinSoundAudioRef.current = null;
- }
- let src;
- if (joinSoundFile) {
- src = URL.createObjectURL(joinSoundFile);
- } else if (currentUser?.joinSoundUrl) {
- src = currentUser.joinSoundUrl;
- }
- if (src) {
- const audio = new Audio(src);
- audio.volume = 0.5;
- audio.play().catch(e => console.error('Preview failed', e));
- joinSoundAudioRef.current = audio;
- }
- };
-
- const handleRemoveJoinSound = () => {
- setJoinSoundFile(null);
- setJoinSoundPreviewName(null);
- setRemoveJoinSound(true);
- };
-
- const handleSave = async () => {
- if (!userId || saving) return;
- setSaving(true);
- try {
- let avatarStorageId;
- if (avatarFile) {
- const uploadUrl = await convex.mutation(api.files.generateUploadUrl);
- const res = await fetch(uploadUrl, {
- method: 'POST',
- headers: { 'Content-Type': avatarFile.type },
- body: avatarFile,
- });
- const { storageId } = await res.json();
- avatarStorageId = storageId;
- }
- let joinSoundStorageId;
- if (joinSoundFile) {
- const jsUploadUrl = await convex.mutation(api.files.generateUploadUrl);
- const jsRes = await fetch(jsUploadUrl, {
- method: 'POST',
- headers: { 'Content-Type': joinSoundFile.type },
- body: joinSoundFile,
- });
- const jsData = await jsRes.json();
- joinSoundStorageId = jsData.storageId;
- }
- const args = { userId, displayName, aboutMe, customStatus };
- if (avatarStorageId) args.avatarStorageId = avatarStorageId;
- if (joinSoundStorageId) args.joinSoundStorageId = joinSoundStorageId;
- if (removeJoinSound) args.removeJoinSound = true;
- await convex.mutation(api.auth.updateProfile, args);
- setAvatarFile(null);
- setJoinSoundFile(null);
- setJoinSoundPreviewName(null);
- setRemoveJoinSound(false);
- if (avatarPreview) {
- URL.revokeObjectURL(avatarPreview);
- setAvatarPreview(null);
- }
- } catch (err) {
- console.error('Failed to save profile:', err);
- alert('Failed to save profile: ' + err.message);
- } finally {
- setSaving(false);
- }
- };
-
- const handleReset = () => {
- if (currentUser) {
- setDisplayName(currentUser.displayName || '');
- setAboutMe(currentUser.aboutMe || '');
- setCustomStatus(currentUser.customStatus || '');
- }
- setAvatarFile(null);
- setJoinSoundFile(null);
- setJoinSoundPreviewName(null);
- setRemoveJoinSound(false);
- if (avatarPreview) {
- URL.revokeObjectURL(avatarPreview);
- setAvatarPreview(null);
- }
- if (rawImageUrl) {
- URL.revokeObjectURL(rawImageUrl);
- setRawImageUrl(null);
- }
- setShowCropModal(false);
- };
-
- const avatarUrl = avatarPreview || currentUser?.avatarUrl;
-
- return (
-
-
My Account
-
- {/* Profile card */}
-
- {/* Banner */}
-
-
- {/* Profile body */}
-
- {/* Avatar */}
-
fileInputRef.current?.click()}
- style={{ marginTop: '-40px', marginBottom: '12px', width: 'fit-content', cursor: 'pointer', position: 'relative' }}
- >
-
-
- CHANGE
AVATAR
-
-
-
-
- {/* Fields */}
-
- {/* Username (read-only) */}
-
-
-
- {username}
-
-
-
- {/* Display Name */}
-
-
- setDisplayName(e.target.value)}
- placeholder="How others see you in chat"
- style={{
- width: '100%', backgroundColor: 'var(--bg-tertiary)', border: 'none',
- borderRadius: '4px', padding: '10px', color: 'var(--text-normal)',
- fontSize: '16px', outline: 'none', boxSizing: 'border-box',
- }}
- />
-
-
- {/* About Me */}
-
-
- {/* Custom Status */}
-
-
- setCustomStatus(e.target.value)}
- placeholder="Set a custom status"
- style={{
- width: '100%', backgroundColor: 'var(--bg-tertiary)', border: 'none',
- borderRadius: '4px', padding: '10px', color: 'var(--text-normal)',
- fontSize: '16px', outline: 'none', boxSizing: 'border-box',
- }}
- />
-
-
- {/* Voice Channel Join Sound */}
-
-
-
-
joinSoundInputRef.current?.click()}
- style={{
- backgroundColor: 'var(--brand-experiment)', color: 'white', border: 'none',
- borderRadius: '4px', padding: '8px 16px', cursor: 'pointer',
- fontSize: '14px', fontWeight: '500',
- }}
- >
- Upload Sound
-
- {(joinSoundPreviewName || (!removeJoinSound && currentUser?.joinSoundUrl)) && (
-
-
- Preview
-
- )}
- {(joinSoundPreviewName || (!removeJoinSound && currentUser?.joinSoundUrl)) && (
-
- Remove
-
- )}
-
-
-
- {joinSoundPreviewName
- ? `Selected: ${joinSoundPreviewName}`
- : removeJoinSound
- ? 'Join sound will be removed on save'
- : currentUser?.joinSoundUrl
- ? 'Custom sound set — plays when you join a voice channel'
- : 'Upload a short audio file (max 10MB) — plays for everyone when you join a voice channel'
- }
-
-
-
-
-
-
- {/* Save bar */}
- {hasChanges && (
-
-
- Careful — you have unsaved changes!
-
-
- Reset
-
-
- {saving ? 'Saving...' : 'Save Changes'}
-
-
- )}
-
- {showCropModal && rawImageUrl && (
-
- )}
-
- );
-};
-
-/* =========================================
- SECURITY TAB
- ========================================= */
-const SecurityTab = () => {
- const [masterKey, setMasterKey] = useState(null);
- const [revealed, setRevealed] = useState(false);
- const [confirmed, setConfirmed] = useState(false);
- const [copied, setCopied] = useState(false);
-
- useEffect(() => {
- const mk = sessionStorage.getItem('masterKey');
- setMasterKey(mk);
- }, []);
-
- const formatKey = (hex) => {
- if (!hex) return '';
- return hex.match(/.{1,4}/g).join(' ');
- };
-
- const handleCopy = () => {
- if (!masterKey) return;
- navigator.clipboard.writeText(masterKey).then(() => {
- setCopied(true);
- setTimeout(() => setCopied(false), 2000);
- });
- };
-
- const handleDownload = () => {
- if (!masterKey) return;
- const content = [
- '=== RECOVERY KEY ===',
- '',
- 'This is your Recovery Key for your encrypted account.',
- 'Store it in a safe place. If you lose your password, this key is the ONLY way to recover your account.',
- '',
- 'DO NOT share this key with anyone.',
- '',
- `Recovery Key: ${masterKey}`,
- '',
- `Exported: ${new Date().toISOString()}`,
- ].join('\n');
- const blob = new Blob([content], { type: 'text/plain' });
- const url = URL.createObjectURL(blob);
- const a = document.createElement('a');
- a.href = url;
- a.download = 'recovery-key.txt';
- a.click();
- URL.revokeObjectURL(url);
- };
-
- const labelStyle = {
- display: 'block', color: 'var(--header-secondary)', fontSize: '12px',
- fontWeight: '700', textTransform: 'uppercase', marginBottom: '8px',
- };
-
- return (
-
-
Security
-
-
-
- Recovery Key
-
-
- Your Recovery Key allows you to reset your password without losing access to your encrypted messages.
- Store it somewhere safe — if you forget your password, this is the only way to recover your account.
-
-
- {!masterKey ? (
-
- Recovery Key is not available in this session. Please log out and log back in to access it.
-
- ) : !revealed ? (
-
- {/* Warning box */}
-
-
- Warning
-
-
- Anyone with your Recovery Key can reset your password and take control of your account.
- Only reveal it in a private, secure environment.
-
-
-
-
-
-
setRevealed(true)}
- disabled={!confirmed}
- style={{
- backgroundColor: 'var(--brand-experiment)', color: 'white', border: 'none',
- borderRadius: '4px', padding: '10px 20px', cursor: confirmed ? 'pointer' : 'not-allowed',
- fontSize: '14px', fontWeight: '500', opacity: confirmed ? 1 : 0.5,
- }}
- >
- Reveal Recovery Key
-
-
- ) : (
-
-
-
- {formatKey(masterKey)}
-
-
-
-
- {copied ? 'Copied!' : 'Copy'}
-
-
- Download
-
- { setRevealed(false); setConfirmed(false); }}
- style={{
- backgroundColor: 'transparent', color: 'var(--text-muted)', border: '1px solid var(--border-subtle)',
- borderRadius: '4px', padding: '8px 16px', cursor: 'pointer',
- fontSize: '14px', fontWeight: '500',
- }}
- >
- Hide
-
-
-
- )}
-
-
- );
-};
-
-/* =========================================
- APPEARANCE TAB
- ========================================= */
-const AppearanceTab = () => {
- const { theme, setTheme } = useTheme();
-
- return (
-
-
Appearance
-
-
-
- {Object.values(THEMES).map((themeKey) => {
- const preview = THEME_PREVIEWS[themeKey];
- const isActive = theme === themeKey;
- return (
-
setTheme(themeKey)}
- >
-
-
-
-
{THEME_LABELS[themeKey]}
-
-
- );
- })}
-
-
-
- );
-};
-
-/* =========================================
- VOICE & VIDEO TAB
- ========================================= */
-const VoiceVideoTab = () => {
- const { switchDevice, setGlobalOutputVolume } = useVoice();
- const [inputDevices, setInputDevices] = useState([]);
- const [outputDevices, setOutputDevices] = useState([]);
- const [selectedInput, setSelectedInput] = useState(() => localStorage.getItem('voiceInputDevice') || 'default');
- const [selectedOutput, setSelectedOutput] = useState(() => localStorage.getItem('voiceOutputDevice') || 'default');
- const [inputVolume, setInputVolume] = useState(() => parseInt(localStorage.getItem('voiceInputVolume') || '100'));
- const [outputVolume, setOutputVolume] = useState(() => parseInt(localStorage.getItem('voiceOutputVolume') || '100'));
- const [micTesting, setMicTesting] = useState(false);
- const [micLevel, setMicLevel] = useState(0);
- const micStreamRef = useRef(null);
- const animFrameRef = useRef(null);
- const analyserRef = useRef(null);
-
- useEffect(() => {
- const enumerate = async () => {
- try {
- // Request permission to get labels
- const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
- stream.getTracks().forEach(t => t.stop());
-
- const devices = await navigator.mediaDevices.enumerateDevices();
- setInputDevices(devices.filter(d => d.kind === 'audioinput'));
- setOutputDevices(devices.filter(d => d.kind === 'audiooutput'));
- } catch (err) {
- console.error('Failed to enumerate devices:', err);
- }
- };
- enumerate();
- }, []);
-
- useEffect(() => {
- localStorage.setItem('voiceInputDevice', selectedInput);
- switchDevice('audioinput', selectedInput);
- }, [selectedInput]);
-
- useEffect(() => {
- localStorage.setItem('voiceOutputDevice', selectedOutput);
- switchDevice('audiooutput', selectedOutput);
- }, [selectedOutput]);
-
- useEffect(() => {
- localStorage.setItem('voiceInputVolume', String(inputVolume));
- }, [inputVolume]);
-
- useEffect(() => {
- localStorage.setItem('voiceOutputVolume', String(outputVolume));
- setGlobalOutputVolume(outputVolume);
- }, [outputVolume]);
-
- const startMicTest = async () => {
- try {
- const constraints = { audio: selectedInput !== 'default' ? { deviceId: { exact: selectedInput } } : true };
- const stream = await navigator.mediaDevices.getUserMedia(constraints);
- micStreamRef.current = stream;
-
- const audioCtx = new AudioContext();
- const source = audioCtx.createMediaStreamSource(stream);
- const analyser = audioCtx.createAnalyser();
- analyser.fftSize = 256;
- source.connect(analyser);
- analyserRef.current = analyser;
-
- setMicTesting(true);
-
- const dataArray = new Uint8Array(analyser.frequencyBinCount);
- const tick = () => {
- analyser.getByteFrequencyData(dataArray);
- const avg = dataArray.reduce((sum, v) => sum + v, 0) / dataArray.length;
- setMicLevel(Math.min(100, (avg / 128) * 100));
- animFrameRef.current = requestAnimationFrame(tick);
- };
- tick();
- } catch (err) {
- console.error('Mic test failed:', err);
- }
- };
-
- const stopMicTest = useCallback(() => {
- if (micStreamRef.current) {
- micStreamRef.current.getTracks().forEach(t => t.stop());
- micStreamRef.current = null;
- }
- if (animFrameRef.current) {
- cancelAnimationFrame(animFrameRef.current);
- animFrameRef.current = null;
- }
- setMicTesting(false);
- setMicLevel(0);
- }, []);
-
- useEffect(() => {
- return () => stopMicTest();
- }, [stopMicTest]);
-
- const selectStyle = {
- width: '100%', backgroundColor: 'var(--bg-tertiary)', border: 'none',
- borderRadius: '4px', padding: '10px', color: 'var(--text-normal)',
- fontSize: '14px', outline: 'none', boxSizing: 'border-box',
- };
-
- const labelStyle = {
- display: 'block', color: 'var(--header-secondary)', fontSize: '12px',
- fontWeight: '700', textTransform: 'uppercase', marginBottom: '8px',
- };
-
- return (
-
-
Voice & Video
-
-
- {/* Input Device */}
-
-
-
-
-
- {/* Output Device */}
-
-
-
-
-
-
- {/* Input Volume */}
-
-
-
- setInputVolume(parseInt(e.target.value))}
- className="voice-slider"
- />
- {inputVolume}%
-
-
-
- {/* Output Volume */}
-
-
-
- setOutputVolume(parseInt(e.target.value))}
- className="voice-slider"
- />
- {outputVolume}%
-
-
-
- {/* Mic Test */}
-
-
-
-
- {micTesting ? 'Stop Testing' : 'Let\'s Check'}
-
-
-
-
-
- {/* Noise Suppression */}
-
-
-
-
-
- );
-};
-
-/* =========================================
- KEYBINDS TAB
- ========================================= */
-const KeybindsTab = () => {
- const keybinds = [
- { action: 'Quick Switcher', keys: 'Ctrl+K' },
- { action: 'Toggle Mute', keys: 'Ctrl+Shift+M' },
- ];
-
- return (
-
-
Keybinds
-
-
-
- Keybind configuration coming soon. Current keybinds are shown below.
-
-
-
- {keybinds.map(kb => (
-
- {kb.action}
-
- {kb.keys}
-
-
- ))}
-
-
-
- );
-};
-
-/* =========================================
- SEARCH TAB
- ========================================= */
-const TAG_HEX_LEN = 32;
-
-const SearchTab = ({ userId }) => {
- const convex = useConvex();
- const { crypto } = usePlatform();
- const searchCtx = useSearch();
-
- const [status, setStatus] = useState('idle'); // idle | rebuilding | done | error
- const [progress, setProgress] = useState({ currentChannel: '', channelIndex: 0, totalChannels: 0, messagesIndexed: 0 });
- const [errorMsg, setErrorMsg] = useState('');
- const cancelledRef = useRef(false);
-
- const handleRebuild = async () => {
- if (!userId || !crypto || !searchCtx?.isReady) return;
-
- cancelledRef.current = false;
- setStatus('rebuilding');
- setProgress({ currentChannel: '', channelIndex: 0, totalChannels: 0, messagesIndexed: 0 });
- setErrorMsg('');
-
- try {
- // 1. Gather channels + DMs
- const [channels, dmChannels, rawKeys] = await Promise.all([
- convex.query(api.channels.list, {}),
- convex.query(api.dms.listDMs, { userId }),
- convex.query(api.channelKeys.getKeysForUser, { userId }),
- ]);
-
- // 2. Decrypt channel keys
- const privateKey = sessionStorage.getItem('privateKey');
- if (!privateKey) throw new Error('Private key not found in session. Please re-login.');
-
- const decryptedKeys = {};
- for (const item of rawKeys) {
- try {
- const bundleJson = await crypto.privateDecrypt(privateKey, item.encrypted_key_bundle);
- Object.assign(decryptedKeys, JSON.parse(bundleJson));
- } catch (e) {
- // Skip channels we can't decrypt
- }
- }
-
- // 3. Build channel list: text channels + DMs that have keys
- const textChannels = channels
- .filter(c => c.type === 'text' && decryptedKeys[c._id])
- .map(c => ({ id: c._id, name: '#' + c.name, key: decryptedKeys[c._id] }));
-
- const dmItems = (dmChannels || [])
- .filter(dm => decryptedKeys[dm.channel_id])
- .map(dm => ({ id: dm.channel_id, name: '@' + dm.other_username, key: decryptedKeys[dm.channel_id] }));
-
- const allChannels = [...textChannels, ...dmItems];
-
- if (allChannels.length === 0) {
- setStatus('done');
- setProgress(p => ({ ...p, totalChannels: 0 }));
- return;
- }
-
- setProgress(p => ({ ...p, totalChannels: allChannels.length }));
-
- let totalIndexed = 0;
-
- // 4. For each channel, paginate and decrypt
- for (let i = 0; i < allChannels.length; i++) {
- if (cancelledRef.current) break;
-
- const ch = allChannels[i];
- setProgress(p => ({ ...p, currentChannel: ch.name, channelIndex: i + 1 }));
-
- let cursor = null;
- let isDone = false;
-
- while (!isDone) {
- if (cancelledRef.current) break;
-
- const paginationOpts = { numItems: 100, cursor };
- const result = await convex.query(api.messages.fetchBulkPage, {
- channelId: ch.id,
- paginationOpts,
- });
-
- if (result.page.length > 0) {
- // Build decrypt batch
- const decryptItems = [];
- const msgMap = [];
-
- for (const msg of result.page) {
- if (msg.ciphertext && msg.ciphertext.length >= TAG_HEX_LEN) {
- const tag = msg.ciphertext.slice(-TAG_HEX_LEN);
- const content = msg.ciphertext.slice(0, -TAG_HEX_LEN);
- decryptItems.push({ ciphertext: content, key: ch.key, iv: msg.nonce, tag });
- msgMap.push(msg);
- }
- }
-
- if (decryptItems.length > 0) {
- const decryptResults = await crypto.decryptBatch(decryptItems);
-
- const indexItems = [];
- for (let j = 0; j < decryptResults.length; j++) {
- const plaintext = decryptResults[j];
- if (plaintext && plaintext !== '[Decryption Error]') {
- indexItems.push({
- id: msgMap[j].id,
- channel_id: msgMap[j].channel_id,
- sender_id: msgMap[j].sender_id,
- username: msgMap[j].username,
- content: plaintext,
- created_at: msgMap[j].created_at,
- pinned: msgMap[j].pinned,
- replyToId: msgMap[j].replyToId,
- });
- }
- }
-
- if (indexItems.length > 0) {
- searchCtx.indexMessages(indexItems);
- totalIndexed += indexItems.length;
- setProgress(p => ({ ...p, messagesIndexed: totalIndexed }));
- }
- }
- }
-
- isDone = result.isDone;
- cursor = result.continueCursor;
-
- // Yield to UI between pages
- await new Promise(r => setTimeout(r, 10));
- }
- }
-
- // 5. Save
- await searchCtx.save();
- setStatus(cancelledRef.current ? 'idle' : 'done');
- setProgress(p => ({ ...p, messagesIndexed: totalIndexed }));
- } catch (err) {
- console.error('Search index rebuild failed:', err);
- setErrorMsg(err.message || 'Unknown error');
- setStatus('error');
- }
- };
-
- const handleCancel = () => {
- cancelledRef.current = true;
- };
-
- const formatNumber = (n) => n.toLocaleString();
-
- return (
-
-
Search
-
-
-
- Search Index
-
-
- Rebuild your local search index by downloading and decrypting all messages from the server. This may take a while for large servers.
-
-
- {status === 'idle' && (
-
- Rebuild Search Index
-
- )}
-
- {status === 'rebuilding' && (
-
- {/* Progress bar */}
-
-
0
- ? `${(progress.channelIndex / progress.totalChannels) * 100}%`
- : '0%',
- transition: 'width 0.3s ease',
- }} />
-
-
-
- Indexing {progress.currentChannel}... ({progress.channelIndex} of {progress.totalChannels} channels)
-
-
- {formatNumber(progress.messagesIndexed)} messages indexed
-
-
-
- Cancel
-
-
- )}
-
- {status === 'done' && (
-
-
- Complete! {formatNumber(progress.messagesIndexed)} messages indexed across {progress.totalChannels} channels.
-
-
setStatus('idle')}
- style={{
- backgroundColor: 'var(--brand-experiment)', color: 'white', border: 'none',
- borderRadius: '4px', padding: '10px 20px', cursor: 'pointer',
- fontSize: '14px', fontWeight: '500',
- }}
- >
- Rebuild Again
-
-
- )}
-
- {status === 'error' && (
-
-
- Error: {errorMsg}
-
-
setStatus('idle')}
- style={{
- backgroundColor: 'var(--brand-experiment)', color: 'white', border: 'none',
- borderRadius: '4px', padding: '10px 20px', cursor: 'pointer',
- fontSize: '14px', fontWeight: '500',
- }}
- >
- Retry
-
-
- )}
-
-
- );
-};
-
-export default UserSettings;
diff --git a/packages/shared/src/components/VoiceStage.jsx b/packages/shared/src/components/VoiceStage.jsx
deleted file mode 100644
index 46601e0..0000000
--- a/packages/shared/src/components/VoiceStage.jsx
+++ /dev/null
@@ -1,1135 +0,0 @@
-import React, { useState, useEffect, useRef, useCallback } from 'react';
-import { Track, RoomEvent, ConnectionQuality } from 'livekit-client';
-import { useVoice } from '../contexts/VoiceContext';
-import { usePlatform } from '../platform/PlatformProvider';
-import ScreenShareModal from './ScreenShareModal';
-import Avatar from './Avatar';
-import { VideoRenderer, findTrackPubs, useParticipantTrack } from '../utils/streamUtils.jsx';
-
-// Icons
-import muteIcon from '../assets/icons/mute.svg';
-import mutedIcon from '../assets/icons/muted.svg';
-import cameraIcon from '../assets/icons/camera.svg';
-import screenIcon from '../assets/icons/screen.svg';
-import disconnectIcon from '../assets/icons/disconnect.svg';
-import personalMuteIcon from '../assets/icons/personal_mute.svg';
-import serverMuteIcon from '../assets/icons/server_mute.svg';
-import screenShareStartSound from '../assets/sounds/screenshare_start.mp3';
-import screenShareStopSound from '../assets/sounds/screenshare_stop.mp3';
-import ColoredIcon from './ColoredIcon';
-
-const SERVER_MUTE_RED = 'hsl(1.343, 84.81%, 69.02%)';
-
-const getInitials = (name) => (name || '?').substring(0, 1).toUpperCase();
-
-const getUserColor = (username) => {
- const colors = ['#5865F2', '#EDA843', '#3BA55D', '#FAA81A', '#EB459E'];
- let hash = 0;
- for (let i = 0; i < username.length; i++) {
- hash = username.charCodeAt(i) + ((hash << 5) - hash);
- }
- return colors[Math.abs(hash) % colors.length];
-};
-
-// Style constants
-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 LIVE_BADGE_STYLE = {
- backgroundColor: '#ed4245', borderRadius: '4px', padding: '2px 6px',
- color: 'white', fontSize: '11px', fontWeight: 'bold',
- textTransform: 'uppercase', letterSpacing: '0.5px',
-};
-const WATCH_STREAM_BUTTON_STYLE = {
- backgroundColor: 'rgba(0,0,0,0.6)', color: 'white', border: 'none',
- padding: '10px 20px', borderRadius: '4px', fontWeight: 'bold',
- fontSize: '14px', cursor: 'pointer',
-};
-const THUMBNAIL_SIZE = { width: 120, height: 68 };
-const BOTTOM_BAR_HEIGHT = 140;
-
-const ConnectionQualityIcon = ({ quality }) => {
- const getColor = () => {
- switch (quality) {
- case ConnectionQuality.Excellent: return '#3ba55d';
- case ConnectionQuality.Good: return '#3ba55d';
- case ConnectionQuality.Poor: return '#faa61a';
- case ConnectionQuality.Lost: return '#ed4245';
- default: return '#72767d';
- }
- };
- const getBars = () => {
- switch (quality) {
- case ConnectionQuality.Excellent: return 4;
- case ConnectionQuality.Good: return 3;
- case ConnectionQuality.Poor: return 2;
- case ConnectionQuality.Lost: return 1;
- default: return 0;
- }
- };
- const color = getColor();
- const bars = getBars();
- return (
-
- );
-};
-
-// --- Components ---
-
-const ParticipantTile = ({ participant, username, avatarUrl }) => {
- const cameraTrack = useParticipantTrack(participant, 'camera');
- const { isPersonallyMuted, voiceStates, connectionQualities, activeSpeakers } = useVoice();
- const isMicEnabled = participant.isMicrophoneEnabled;
- const isPersonalMuted = isPersonallyMuted(participant.identity);
- const displayName = username || participant.identity;
-
- // Look up server mute from voiceStates
- let isServerMutedUser = false;
- for (const users of Object.values(voiceStates)) {
- const u = users.find(u => u.userId === participant.identity);
- if (u) { isServerMutedUser = !!u.isServerMuted; break; }
- }
-
- return (
-
- {cameraTrack ? (
-
- ) : (
-
- )}
-
-
- {isServerMutedUser ? (
-
- ) : isPersonalMuted ? (
-
- ) : isMicEnabled ? '\u{1F3A4}' : '\u{1F507}'}
- {displayName}
-
-
-
- );
-};
-
-const StreamPreviewTile = ({ participant, username, onWatchStream }) => {
- const displayName = username || participant.identity;
- const [hover, setHover] = useState(false);
-
- return (
-
setHover(true)}
- onMouseLeave={() => setHover(false)}
- >
- {/* Static preview — no video subscription */}
-
-
-
-
- {/* Overlay */}
-
- { e.stopPropagation(); onWatchStream(); }}
- >
- Watch Stream
-
-
-
- {/* Bottom label */}
-
-
- {displayName}
- LIVE
-
-
-
- );
-};
-
-const ParticipantThumbnail = ({ participant, username, avatarUrl, isStreamer, isMuted }) => {
- const cameraTrack = useParticipantTrack(participant, 'camera');
- const { isPersonallyMuted, voiceStates } = useVoice();
- const isPersonalMuted = isPersonallyMuted(participant.identity);
- const displayName = username || participant.identity;
-
- // Look up server mute from voiceStates
- let isServerMutedUser = false;
- for (const users of Object.values(voiceStates)) {
- const u = users.find(u => u.userId === participant.identity);
- if (u) { isServerMutedUser = !!u.isServerMuted; break; }
- }
-
- return (
-
- {cameraTrack ? (
-
- ) : (
-
- )}
-
- {/* Bottom label */}
-
-
- {isServerMutedUser ? (
-
- ) : isPersonalMuted ? (
-
- ) : isMuted ? (
- {'\u{1F507}'}
- ) : null}
- {displayName}
- {isStreamer && LIVE}
-
-
-
- );
-};
-
-// Inline SVG icons for volume control
-const SpeakerIcon = ({ volume, muted }) => {
- if (muted || volume === 0) {
- return (
-
- );
- }
- if (volume < 50) {
- return (
-
- );
- }
- return (
-
- );
-};
-
-// Inline SVG icons for fullscreen
-const ExpandIcon = () => (
-
-);
-
-const CompressIcon = () => (
-
-);
-
-const FocusedStreamView = ({
- streamParticipant,
- streamerUsername,
- allParticipants,
- getUsername,
- getAvatarUrl,
- participantsCollapsed,
- onToggleCollapse,
- onStopWatching,
- streamingIdentities,
- voiceUsers,
- isTabVisible,
- isFullscreen,
- onToggleFullscreen,
- localIdentity,
-}) => {
- const screenTrack = useParticipantTrack(streamParticipant, 'screenshare');
- const [barHover, setBarHover] = useState(false);
- const [bottomEdgeHover, setBottomEdgeHover] = useState(false);
-
- // Volume control state
- const { getUserVolume, setUserVolume, togglePersonalMute, isPersonallyMuted } = useVoice();
- const streamerId = streamParticipant.identity;
- const isSelf = streamerId === localIdentity;
- const isMutedByMe = isPersonallyMuted(streamerId);
- const userVolume = getUserVolume(streamerId);
- const [volumeExpanded, setVolumeExpanded] = useState(false);
- const volumeHideTimeout = useRef(null);
-
- const handleVolumeMouseEnter = () => {
- if (volumeHideTimeout.current) clearTimeout(volumeHideTimeout.current);
- setVolumeExpanded(true);
- };
- const handleVolumeMouseLeave = () => {
- volumeHideTimeout.current = setTimeout(() => setVolumeExpanded(false), 1500);
- };
- useEffect(() => () => { if (volumeHideTimeout.current) clearTimeout(volumeHideTimeout.current); }, []);
-
- const sliderPercent = (userVolume / 200) * 100;
-
- // Auto-exit if stream track disappears
- useEffect(() => {
- if (!streamParticipant) {
- onStopWatching();
- return;
- }
-
- const checkTrack = () => {
- const { screenSharePub } = findTrackPubs(streamParticipant);
- if (!screenSharePub || !screenSharePub.track) {
- onStopWatching();
- }
- };
-
- // Give a brief grace period for track to appear
- const timeout = setTimeout(checkTrack, 3000);
-
- streamParticipant.on(RoomEvent.TrackUnpublished, checkTrack);
- streamParticipant.on('localTrackUnpublished', checkTrack);
-
- return () => {
- clearTimeout(timeout);
- streamParticipant.off(RoomEvent.TrackUnpublished, checkTrack);
- streamParticipant.off('localTrackUnpublished', checkTrack);
- };
- }, [streamParticipant, onStopWatching]);
-
- return (
-
- {/* Stream area */}
-
- {screenTrack && isTabVisible ? (
-
- ) : screenTrack && !isTabVisible ? (
-
-
- Stream paused
-
- ) : (
-
- )}
-
- {/* Top-left: streamer info */}
-
-
- {streamerUsername}
-
- LIVE
-
-
- {/* Top-right: button group (fullscreen + close) */}
-
- e.currentTarget.style.backgroundColor = 'rgba(0,0,0,0.8)'}
- onMouseLeave={e => e.currentTarget.style.backgroundColor = 'rgba(0,0,0,0.6)'}
- >
- {isFullscreen ? : }
-
- e.currentTarget.style.backgroundColor = 'rgba(0,0,0,0.8)'}
- onMouseLeave={e => e.currentTarget.style.backgroundColor = 'rgba(0,0,0,0.6)'}
- >
- ✕
-
-
-
- {/* Bottom-left: volume control (hidden when watching own stream) */}
- {!isSelf && (
-
- togglePersonalMute(streamerId)}
- title={isMutedByMe ? "Unmute" : "Mute"}
- style={{
- background: 'none', border: 'none', cursor: 'pointer',
- padding: 0, display: 'flex', alignItems: 'center',
- opacity: isMutedByMe ? 0.6 : 1,
- }}
- >
-
-
- {volumeExpanded && (
- <>
- setUserVolume(streamerId, Number(e.target.value))}
- onMouseDown={(e) => e.stopPropagation()}
- className="context-menu-volume-slider"
- style={{
- width: '100px',
- background: `linear-gradient(to right, hsl(235, 86%, 65%) ${isMutedByMe ? 0 : sliderPercent}%, rgba(255,255,255,0.2) ${isMutedByMe ? 0 : sliderPercent}%)`,
- }}
- />
-
- {isMutedByMe ? 0 : userVolume}%
-
- >
- )}
-
- )}
-
-
- {/* Bottom participants bar */}
-
setBarHover(true)}
- onMouseLeave={() => setBarHover(false)}
- >
- {/* Collapse/expand toggle */}
- {!participantsCollapsed && barHover && (
-
- ▼
-
- )}
-
-
-
- {allParticipants.map(p => {
- const uname = getUsername(p.identity);
- const user = voiceUsers?.find(u => u.userId === p.identity);
- return (
-
- );
- })}
-
-
-
-
- {/* Expand trigger when collapsed */}
- {participantsCollapsed && (
-
setBottomEdgeHover(true)}
- onMouseLeave={() => setBottomEdgeHover(false)}
- style={{
- position: 'absolute', bottom: 0, left: 0, right: 0,
- height: '24px', zIndex: 10,
- display: 'flex', alignItems: 'flex-end', justifyContent: 'center',
- }}
- >
- {bottomEdgeHover && (
-
- ▲
-
- )}
-
- )}
-
- );
-};
-
-const PreviewParticipantTile = ({ username, displayName, avatarUrl }) => {
- const name = displayName || username;
- return (
-
- );
-};
-
-// --- Main Component ---
-
-const VoiceStage = ({ room, channelId, voiceStates, channelName }) => {
- const [participants, setParticipants] = useState([]);
- const { isMuted, toggleMute, disconnectVoice, setScreenSharing, connectToVoice, watchingStreamOf, setWatchingStreamOf, isReceivingScreenShareAudio, isReconnecting } = useVoice();
- const { features } = usePlatform();
- const [isScreenShareModalOpen, setIsScreenShareModalOpen] = useState(false);
- const [isScreenShareActive, setIsScreenShareActive] = useState(false);
- const screenShareAudioTrackRef = useRef(null);
-
- const [participantsCollapsed, setParticipantsCollapsed] = useState(false);
-
- // Fullscreen support
- const stageContainerRef = useRef(null);
- const [isFullscreen, setIsFullscreen] = useState(false);
-
- useEffect(() => {
- const handleFullscreenChange = () => {
- setIsFullscreen(!!document.fullscreenElement);
- };
- document.addEventListener('fullscreenchange', handleFullscreenChange);
- return () => document.removeEventListener('fullscreenchange', handleFullscreenChange);
- }, []);
-
- const toggleFullscreen = useCallback(() => {
- if (!stageContainerRef.current) return;
- if (document.fullscreenElement) {
- document.exitFullscreen().catch(console.error);
- } else {
- stageContainerRef.current.requestFullscreen().catch(console.error);
- }
- }, []);
-
- const isReceivingScreenShareAudioRef = useRef(false);
- useEffect(() => { isReceivingScreenShareAudioRef.current = isReceivingScreenShareAudio; }, [isReceivingScreenShareAudio]);
-
- useEffect(() => {
- if (!room) return;
-
- const updateParticipants = () => {
- const remote = Array.from(room.remoteParticipants.values());
- const local = [room.localParticipant];
- setParticipants([...local, ...remote]);
- setIsScreenShareActive(room.localParticipant.isScreenShareEnabled);
- };
-
- updateParticipants();
-
- room.on(RoomEvent.ParticipantConnected, updateParticipants);
- room.on(RoomEvent.ParticipantDisconnected, updateParticipants);
- room.localParticipant.on('localTrackPublished', updateParticipants);
- room.localParticipant.on('localTrackUnpublished', (pub) => {
- if ((pub.source === Track.Source.ScreenShare || pub.source === 'screen_share') && !isReceivingScreenShareAudioRef.current) {
- new Audio(screenShareStopSound).play();
- }
- updateParticipants();
- });
-
- return () => {
- room.off(RoomEvent.ParticipantConnected, updateParticipants);
- room.off(RoomEvent.ParticipantDisconnected, updateParticipants);
- room.localParticipant.off('localTrackPublished', updateParticipants);
- room.localParticipant.off('localTrackUnpublished', updateParticipants);
- };
- }, [room]);
-
- // Reset collapsed state and exit fullscreen when room disconnects
- useEffect(() => {
- if (!room) {
- setParticipantsCollapsed(false);
- if (document.fullscreenElement) document.exitFullscreen().catch(console.error);
- }
- }, [room]);
-
- // Derive streaming identities from voiceStates
- const voiceUsers = voiceStates?.[channelId] || [];
- const streamingIdentities = new Set(
- voiceUsers.filter(u => u.isScreenSharing).map(u => u.userId)
- );
-
- const handleStopWatching = useCallback(() => {
- if (document.fullscreenElement) document.exitFullscreen().catch(console.error);
- setWatchingStreamOf(null);
- setParticipantsCollapsed(false);
- }, []);
-
- // Screen Share Handler
- const handleScreenShareSelect = async (selection) => {
- if (!room) return;
- try {
- if (room.localParticipant.isScreenShareEnabled) {
- await room.localParticipant.setScreenShareEnabled(false);
- }
- let stream;
- if (selection.type === 'web_stream') {
- // Web fallback: stream already obtained via getDisplayMedia
- stream = selection.stream;
- } else if (selection.type === 'device') {
- stream = await navigator.mediaDevices.getUserMedia({
- video: { deviceId: { exact: selection.deviceId } },
- audio: false
- });
- } else {
- // Try with audio if requested, fall back to video-only if it fails
- const audioConstraint = selection.shareAudio ? {
- mandatory: {
- chromeMediaSource: 'desktop',
- chromeMediaSourceId: selection.sourceId
- }
- } : false;
- try {
- stream = await navigator.mediaDevices.getUserMedia({
- audio: audioConstraint,
- video: {
- mandatory: {
- chromeMediaSource: 'desktop',
- chromeMediaSourceId: selection.sourceId,
- minWidth: 1280,
- maxWidth: 1920,
- minHeight: 720,
- maxHeight: 1080,
- maxFrameRate: 60
- }
- }
- });
- } catch (audioErr) {
- // Audio capture failed (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({
- audio: false,
- video: {
- mandatory: {
- chromeMediaSource: 'desktop',
- chromeMediaSourceId: selection.sourceId,
- minWidth: 1280,
- maxWidth: 1920,
- minHeight: 720,
- maxHeight: 1080,
- maxFrameRate: 60
- }
- }
- });
- } else {
- throw audioErr;
- }
- }
- }
- const track = stream.getVideoTracks()[0];
- if (track) {
- if (!isReceivingScreenShareAudio) new Audio(screenShareStartSound).play();
- 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
- });
- screenShareAudioTrackRef.current = audioTrack;
- }
-
- setScreenSharing(true);
-
- track.onended = () => {
- // Clean up audio track when video track ends
- if (screenShareAudioTrackRef.current) {
- screenShareAudioTrackRef.current.stop();
- room.localParticipant.unpublishTrack(screenShareAudioTrackRef.current);
- screenShareAudioTrackRef.current = null;
- }
- 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 (isScreenShareActive) {
- // Clean up audio track before stopping screen share
- if (screenShareAudioTrackRef.current) {
- screenShareAudioTrackRef.current.stop();
- room.localParticipant.unpublishTrack(screenShareAudioTrackRef.current);
- screenShareAudioTrackRef.current = null;
- }
- room.localParticipant.setScreenShareEnabled(false);
- setScreenSharing(false);
- } else {
- setIsScreenShareModalOpen(true);
- }
- };
-
- const getUsername = (identity) => {
- const user = voiceUsers.find(u => u.userId === identity);
- return user ? (user.displayName || user.username) : identity;
- };
-
- const getAvatarUrl = (identity) => {
- const user = voiceUsers.find(u => u.userId === identity);
- return user?.avatarUrl || null;
- };
-
- // Pause local stream preview when tab is not visible to save CPU/GPU
- const [isTabVisible, setIsTabVisible] = useState(!document.hidden);
- useEffect(() => {
- const handler = () => setIsTabVisible(!document.hidden);
- document.addEventListener('visibilitychange', handler);
- return () => document.removeEventListener('visibilitychange', handler);
- }, []);
-
- // F key shortcut to toggle fullscreen when watching a stream
- useEffect(() => {
- if (!watchingStreamOf) return;
- const handleKeyDown = (e) => {
- if (e.key === 'f' || e.key === 'F') {
- const tag = e.target.tagName;
- if (tag === 'INPUT' || tag === 'TEXTAREA' || e.target.isContentEditable) return;
- e.preventDefault();
- toggleFullscreen();
- }
- };
- document.addEventListener('keydown', handleKeyDown);
- return () => document.removeEventListener('keydown', handleKeyDown);
- }, [watchingStreamOf, toggleFullscreen]);
-
- if (!room) {
- return (
-
-
-
-
-
- {channelName || 'Voice Channel'}
-
-
- {voiceUsers.length === 0
- ? 'No one is currently in voice'
- : voiceUsers.length === 1
- ? '1 person is in voice'
- : `${voiceUsers.length} people are in voice`}
-
- {voiceUsers.length > 0 && (
-
- {voiceUsers.map(u => (
-
- ))}
-
- )}
-
connectToVoice(channelId, channelName, localStorage.getItem('userId'))}
- style={{
- backgroundColor: 'white',
- color: 'black',
- border: 'none',
- borderRadius: '24px',
- padding: '10px 24px',
- fontSize: '14px',
- fontWeight: '600',
- cursor: 'pointer',
- transition: 'transform 0.1s',
- boxShadow: '0 2px 4px rgba(0,0,0,0.2)'
- }}
- onMouseEnter={e => e.target.style.transform = 'scale(1.05)'}
- onMouseLeave={e => e.target.style.transform = 'scale(1)'}
- >
- Join Voice
-
-
-
- );
- }
-
- const isCameraOn = room.localParticipant.isCameraEnabled;
- const isScreenShareOn = isScreenShareActive;
-
- // Find the participant being watched
- const watchedParticipant = watchingStreamOf
- ? participants.find(p => p.identity === watchingStreamOf)
- : null;
-
- return (
-
- {watchingStreamOf && watchedParticipant ? (
- /* Focused/Fullscreen View */
-
setParticipantsCollapsed(c => !c)}
- onStopWatching={handleStopWatching}
- streamingIdentities={streamingIdentities}
- voiceUsers={voiceUsers}
- isTabVisible={isTabVisible}
- isFullscreen={isFullscreen}
- onToggleFullscreen={toggleFullscreen}
- localIdentity={room.localParticipant.identity}
- />
- ) : (
- /* Grid View */
-
- {participants.map(p => {
- const isStreaming = streamingIdentities.has(p.identity);
- return (
-
-
- {isStreaming && (
- setWatchingStreamOf(p.identity)}
- />
- )}
-
- );
- })}
-
- )}
-
- {/* Reconnection Banner */}
- {isReconnecting && (
-
- )}
-
- {/* Controls */}
-
-
-
-
-
-
- room.localParticipant.setCameraEnabled(!isCameraOn)}
- title="Toggle Camera"
- style={{
- width: '56px', height: '56px', borderRadius: '50%',
- backgroundColor: isCameraOn ? 'white' : '#202225',
- border: 'none', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center'
- }}
- >
-
-
-
- {features.hasScreenCapture && (
-
-
-
- )}
-
-
-
-
-
-
-
- {isScreenShareModalOpen && (
- setIsScreenShareModalOpen(false)}
- onSelectSource={handleScreenShareSelect}
- />
- )}
-
- );
-};
-
-export default VoiceStage;
diff --git a/packages/shared/src/components/auth/AuthLayout.module.css b/packages/shared/src/components/auth/AuthLayout.module.css
new file mode 100644
index 0000000..ce0c40c
--- /dev/null
+++ b/packages/shared/src/components/auth/AuthLayout.module.css
@@ -0,0 +1,165 @@
+.container {
+ position: relative;
+ min-height: 100vh;
+ min-height: 100dvh;
+ width: 100%;
+ background-color: var(--brand-primary);
+}
+
+/* Mobile-only logo banner inside the form column. Hidden by
+ default — only revealed when the desktop side-column is
+ collapsed in the mobile media query below. */
+.mobileLogo {
+ display: none;
+}
+
+.pattern {
+ position: fixed;
+ inset: 0;
+ opacity: 0.06;
+ pointer-events: none;
+ z-index: 0;
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='260' height='260' viewBox='0 0 260 260'%3E%3Cg fill='%23ffffff' fill-opacity='0.4'%3E%3Ccircle cx='30' cy='30' r='8'/%3E%3Ccircle cx='130' cy='30' r='6'/%3E%3Ccircle cx='230' cy='30' r='8'/%3E%3Ccircle cx='80' cy='80' r='10'/%3E%3Ccircle cx='180' cy='80' r='6'/%3E%3Ccircle cx='30' cy='130' r='6'/%3E%3Ccircle cx='130' cy='130' r='8'/%3E%3Ccircle cx='230' cy='130' r='10'/%3E%3Ccircle cx='80' cy='180' r='6'/%3E%3Ccircle cx='180' cy='180' r='8'/%3E%3Ccircle cx='30' cy='230' r='10'/%3E%3Ccircle cx='130' cy='230' r='6'/%3E%3Ccircle cx='230' cy='230' r='8'/%3E%3C/g%3E%3C/svg%3E");
+ background-repeat: repeat;
+ background-size: 260px 260px;
+}
+
+.cardContainer {
+ position: relative;
+ z-index: 10;
+ display: flex;
+ min-height: 100vh;
+ width: 100%;
+ align-items: center;
+ justify-content: center;
+ padding: clamp(2rem, 6vw, 4rem);
+ box-sizing: border-box;
+}
+
+.card {
+ display: flex;
+ height: auto;
+ min-height: 500px;
+ width: 100%;
+ max-width: 56rem;
+ overflow: hidden;
+ border-radius: 1rem;
+ background-color: var(--background-secondary);
+ box-shadow: 0 25px 50px -12px rgb(0 0 0 / 0.25);
+}
+
+.logoSide {
+ display: flex;
+ width: 33.333%;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ padding: 3rem 2rem;
+ border-right: 1px solid var(--background-modifier-accent);
+ background-color: var(--background-secondary);
+}
+
+.logoIcon {
+ width: 6rem;
+ height: 6rem;
+ margin-bottom: 1.5rem;
+ border-radius: 50%;
+ background-color: var(--brand-primary);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: white;
+ font-size: 2.5rem;
+ font-weight: 700;
+}
+
+.wordmark {
+ font-size: 1.75rem;
+ font-weight: 700;
+ color: var(--text-primary);
+ letter-spacing: -0.01em;
+}
+
+.formSide {
+ display: flex;
+ width: 66.667%;
+ flex-direction: column;
+ justify-content: center;
+ padding: 3rem;
+ background: var(--background-secondary);
+}
+
+@media (max-width: 768px) {
+ /* Strip the purple branded backdrop on mobile — Fluxer's
+ reference is just a flat dark surface with no card chrome.
+ Uses `--background-secondary` so the inputs (which use the
+ darker `--background-tertiary`) visibly recess into the page. */
+ .container {
+ background-color: var(--background-secondary);
+ }
+
+ .pattern {
+ display: none;
+ }
+
+ .cardContainer {
+ padding: 0;
+ min-height: 100vh;
+ min-height: 100dvh;
+ align-items: stretch;
+ }
+
+ /* The card collapses into a borderless full-screen surface and
+ the desktop side column is hidden — its content gets
+ re-rendered inside the form column via the `.mobileLogo`
+ banner so the layout becomes a single vertical stack. */
+ .card {
+ flex-direction: column;
+ min-height: 100vh;
+ min-height: 100dvh;
+ max-width: none;
+ border-radius: 0;
+ box-shadow: none;
+ background-color: var(--background-secondary);
+ }
+
+ .logoSide {
+ display: none;
+ }
+
+ .formSide {
+ width: 100%;
+ padding: 32px 24px 24px;
+ background-color: var(--background-secondary);
+ justify-content: flex-start;
+ }
+
+ /* Reveal the in-form-column logo banner. */
+ .mobileLogo {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 10px;
+ margin: 8px 0 28px;
+ }
+
+ .mobileLogoIcon {
+ width: 36px;
+ height: 36px;
+ border-radius: 50%;
+ background-color: var(--brand-primary);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: #fff;
+ font-size: 18px;
+ font-weight: 700;
+ }
+
+ .mobileWordmark {
+ font-size: 22px;
+ font-weight: 800;
+ color: var(--text-primary);
+ letter-spacing: -0.01em;
+ }
+}
diff --git a/packages/shared/src/components/auth/AuthLayout.tsx b/packages/shared/src/components/auth/AuthLayout.tsx
new file mode 100644
index 0000000..32ffe51
--- /dev/null
+++ b/packages/shared/src/components/auth/AuthLayout.tsx
@@ -0,0 +1,45 @@
+import styles from './AuthLayout.module.css';
+
+interface AuthLayoutProps {
+ children: React.ReactNode;
+}
+
+/**
+ * AuthLayout — frame for the login / register pages.
+ *
+ * Desktop: keeps the existing side-by-side "card" — purple branded
+ * background, centred dialog with a logo column on the left and
+ * the form on the right.
+ *
+ * Mobile (≤768px): collapses into a full-screen `--background-primary`
+ * surface with the logo + wordmark stacked at the top of the form
+ * column, matching the Fluxer mobile reference. The card chrome,
+ * purple background, and pattern are all hidden via the media
+ * query in `AuthLayout.module.css`.
+ */
+export function AuthLayout({ children }: AuthLayoutProps) {
+ return (
+
+
+
+
+
+
+ {/* Mobile-only logo banner — appears above the form
+ when the side column is hidden. The
+ `data-mobile-logo` attribute is what the CSS
+ media query targets to switch its display. */}
+
+ {children}
+
+
+
+
+ );
+}
diff --git a/packages/shared/src/components/auth/InviteAcceptPage.tsx b/packages/shared/src/components/auth/InviteAcceptPage.tsx
new file mode 100644
index 0000000..fa10f44
--- /dev/null
+++ b/packages/shared/src/components/auth/InviteAcceptPage.tsx
@@ -0,0 +1,136 @@
+/**
+ * InviteAcceptPage — handles `/invite/:code#key=
`.
+ *
+ * Pulls the encrypted payload from `api.invites.use`, decrypts it
+ * with the URL-fragment secret, stashes the decoded channel-key
+ * map in sessionStorage under `pendingInviteKeys` / `pendingInviteCode`,
+ * then forwards the user to the register page (or to `/channels/@me`
+ * if they're already logged in).
+ *
+ * The fragment secret never hits the server — browsers don't send
+ * `#…` to origin servers — so knowledge of it is the capability that
+ * unlocks the invite payload.
+ */
+import { useEffect, useState } from 'react';
+import { useNavigate, useParams } from 'react-router-dom';
+import { useConvex } from 'convex/react';
+import { api } from '../../../../../convex/_generated/api';
+import { usePlatform } from '../../platform';
+import { AuthLayout } from './AuthLayout';
+
+export function InviteAcceptPage() {
+ const { code } = useParams<{ code: string }>();
+ const navigate = useNavigate();
+ const convex = useConvex();
+ const { crypto } = usePlatform();
+ const [status, setStatus] = useState<'working' | 'error'>('working');
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ let cancelled = false;
+
+ const run = async () => {
+ if (!code) {
+ setStatus('error');
+ setError('Missing invite code.');
+ return;
+ }
+
+ const hash =
+ typeof window !== 'undefined' ? window.location.hash : '';
+ // Fragment looks like "#key=abcd…"
+ const match = hash.match(/[#&]key=([^&]+)/);
+ const secret = match ? match[1] : null;
+ if (!secret) {
+ setStatus('error');
+ setError(
+ 'Invite link is missing its secret. Ask the sender for a fresh link.',
+ );
+ return;
+ }
+
+ try {
+ const result = await convex.query(api.invites.use, { code });
+ if (cancelled) return;
+ if ('error' in result) {
+ setStatus('error');
+ setError(result.error);
+ return;
+ }
+
+ // Older invites used to send a placeholder empty payload.
+ // Treat that as "no channel keys to import" and continue
+ // so the user can still register with the code.
+ let keys: Record = {};
+ if (result.encryptedPayload) {
+ try {
+ const blob = JSON.parse(result.encryptedPayload) as {
+ c: string;
+ i: string;
+ t: string;
+ };
+ const plaintext = await crypto.decryptData(
+ blob.c,
+ secret,
+ blob.i,
+ blob.t,
+ );
+ keys = JSON.parse(plaintext) as Record;
+ } catch {
+ setStatus('error');
+ setError(
+ 'Failed to decrypt invite payload. The secret may be wrong or the invite corrupted.',
+ );
+ return;
+ }
+ }
+
+ try {
+ sessionStorage.setItem('pendingInviteCode', code);
+ sessionStorage.setItem('pendingInviteKeys', JSON.stringify(keys));
+ } catch {
+ /* storage blocked — flow still works if same tab */
+ }
+
+ // If the user is already signed in, just take them home —
+ // the invite keys are already theirs in that case.
+ const loggedIn = !!sessionStorage.getItem('privateKey');
+ if (loggedIn) {
+ navigate('/channels/@me', { replace: true });
+ } else {
+ navigate('/register', { replace: true });
+ }
+ } catch (err: any) {
+ if (cancelled) return;
+ setStatus('error');
+ setError(err?.message ?? 'Failed to accept invite.');
+ }
+ };
+
+ void run();
+ return () => {
+ cancelled = true;
+ };
+ }, [code, convex, crypto, navigate]);
+
+ return (
+
+
+ {status === 'working' ? 'Accepting invite…' : 'Invite Error'}
+
+ {error && (
+
+ {error}
+
+ )}
+
+ );
+}
+
+export default InviteAcceptPage;
diff --git a/packages/shared/src/components/auth/LoginPage.module.css b/packages/shared/src/components/auth/LoginPage.module.css
new file mode 100644
index 0000000..4624a45
--- /dev/null
+++ b/packages/shared/src/components/auth/LoginPage.module.css
@@ -0,0 +1,169 @@
+.title {
+ margin: 0 0 1.5rem;
+ text-align: center;
+ font-size: 1.5rem;
+ font-weight: 700;
+ color: var(--text-primary);
+}
+
+.form {
+ display: flex;
+ flex-direction: column;
+ gap: 1.25rem;
+}
+
+.field {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+
+.label {
+ font-size: 0.9375rem;
+ font-weight: 700;
+ color: var(--text-primary);
+ letter-spacing: 0;
+ text-transform: none;
+}
+
+/* Wrapper for inputs that have a trailing icon (eye toggle on
+ password fields). The `position: relative` lets the icon be
+ absolutely positioned inside the input area. */
+.inputWrap {
+ position: relative;
+}
+
+.input {
+ width: 100%;
+ height: 48px;
+ padding: 0 14px;
+ border-radius: 8px;
+ border: 1px solid transparent;
+ background-color: var(--form-surface-background);
+ color: var(--text-primary);
+ font-size: 1rem;
+ font-family: inherit;
+ outline: none;
+ box-shadow: none;
+ transition: border-color 0.15s, background-color 0.15s;
+ box-sizing: border-box;
+}
+
+.inputWithTrailing {
+ padding-right: 44px;
+}
+
+.input:focus,
+.input:focus-visible {
+ border-color: var(--brand-primary);
+ outline: none;
+}
+
+.input::placeholder {
+ color: var(--text-tertiary-muted, var(--text-tertiary));
+}
+
+/* Trailing icon button (e.g. password visibility toggle). Sits
+ inside `.inputWrap` and overlaps the right edge of `.input`. */
+.trailingIconButton {
+ position: absolute;
+ right: 8px;
+ top: 50%;
+ transform: translateY(-50%);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 32px;
+ height: 32px;
+ border: none;
+ border-radius: 50%;
+ background: transparent;
+ color: var(--text-primary-muted, #a0a3a8);
+ cursor: pointer;
+ transition: background-color 0.15s, color 0.15s;
+ -webkit-tap-highlight-color: transparent;
+}
+
+.trailingIconButton:hover {
+ background-color: var(--background-modifier-hover);
+ color: var(--text-primary);
+}
+
+.error {
+ color: hsl(0, calc(80% * var(--saturation-factor)), 70%);
+ font-size: 0.875rem;
+ background-color: hsl(0, calc(60% * var(--saturation-factor)), 22%);
+ padding: 10px 14px;
+ border-radius: 8px;
+}
+
+.submitButton {
+ width: 100%;
+ height: 48px;
+ padding: 0 1rem;
+ border-radius: 10px;
+ border: none;
+ background-color: var(--brand-primary);
+ color: #fff;
+ font-weight: 700;
+ font-size: 1rem;
+ font-family: inherit;
+ cursor: pointer;
+ transition: filter 120ms ease;
+ -webkit-tap-highlight-color: transparent;
+}
+
+.submitButton:disabled {
+ opacity: 0.55;
+ cursor: not-allowed;
+}
+
+.submitButton:hover:not(:disabled),
+.submitButton:active:not(:disabled) {
+ filter: brightness(0.92);
+}
+
+.divider {
+ display: flex;
+ align-items: center;
+ gap: 1rem;
+ margin: 0.25rem 0;
+}
+
+.dividerLine {
+ flex: 1;
+ border: none;
+ border-top: 1px solid var(--background-header-secondary);
+}
+
+.dividerText {
+ font-size: 0.8125rem;
+ font-weight: 600;
+ color: var(--text-primary-muted);
+ letter-spacing: 0.04em;
+}
+
+.footer {
+ margin-top: 0.5rem;
+ font-size: 0.9375rem;
+}
+
+.footerText {
+ color: var(--text-primary-muted);
+}
+
+.footerLink {
+ color: var(--brand-primary-light);
+ text-decoration: none;
+ cursor: pointer;
+ background: none;
+ border: none;
+ padding: 0;
+ font-family: inherit;
+ font-weight: 700;
+ font-size: inherit;
+}
+
+.footerLink:hover {
+ text-decoration: underline;
+}
diff --git a/packages/shared/src/components/auth/LoginPage.tsx b/packages/shared/src/components/auth/LoginPage.tsx
new file mode 100644
index 0000000..956bd84
--- /dev/null
+++ b/packages/shared/src/components/auth/LoginPage.tsx
@@ -0,0 +1,158 @@
+import { useState, type FormEvent } from 'react';
+import { useNavigate } from 'react-router-dom';
+import { Eye, EyeSlash } from '@phosphor-icons/react';
+import { useConvex } from 'convex/react';
+import { usePlatform } from '../../platform';
+import { useSearch } from '../../contexts/SearchContext';
+import { api } from '../../../../../convex/_generated/api';
+import { AuthLayout } from './AuthLayout';
+import styles from './LoginPage.module.css';
+
+export function LoginPage() {
+ const navigate = useNavigate();
+ const convex = useConvex();
+ const { crypto, session } = usePlatform();
+ const searchCtx = useSearch();
+
+ const [username, setUsername] = useState('');
+ const [password, setPassword] = useState('');
+ const [showPassword, setShowPassword] = useState(false);
+ const [error, setError] = useState(null);
+ const [loading, setLoading] = useState(false);
+
+ async function decryptEncryptedField(encryptedJson: string, keyHex: string): Promise {
+ const obj = JSON.parse(encryptedJson);
+ return crypto.decryptData(obj.content, keyHex, obj.iv, obj.tag);
+ }
+
+ const handleSubmit = async (e: FormEvent) => {
+ e.preventDefault();
+ setError(null);
+ setLoading(true);
+
+ try {
+ const { salt } = await convex.query(api.auth.getSalt, { username });
+ const { dek, dak } = await crypto.deriveAuthKeys(password, salt);
+
+ const searchKeys = await crypto.deriveAuthKeys(password, 'searchdb-' + username);
+ sessionStorage.setItem('searchDbKey', searchKeys.dak);
+
+ const verifyData = await convex.mutation(api.auth.verifyUser, { username, dak });
+ if (verifyData.error) throw new Error(verifyData.error);
+
+ if (verifyData.userId) localStorage.setItem('userId', verifyData.userId);
+
+ const mkHex = await decryptEncryptedField(verifyData.encryptedMK, dek);
+ sessionStorage.setItem('masterKey', mkHex);
+
+ const encryptedPrivateKeysObj = JSON.parse(verifyData.encryptedPrivateKeys);
+ const signingKey = await decryptEncryptedField(JSON.stringify(encryptedPrivateKeysObj.ed), mkHex);
+ const rsaPriv = await decryptEncryptedField(JSON.stringify(encryptedPrivateKeysObj.rsa), mkHex);
+
+ sessionStorage.setItem('signingKey', signingKey);
+ sessionStorage.setItem('privateKey', rsaPriv);
+ localStorage.setItem('username', username);
+ if (verifyData.publicKey) localStorage.setItem('publicKey', verifyData.publicKey);
+
+ if (session) {
+ try {
+ await session.save({
+ userId: verifyData.userId,
+ username,
+ publicKey: verifyData.publicKey || '',
+ signingKey,
+ privateKey: rsaPriv,
+ masterKey: mkHex,
+ searchDbKey: searchKeys.dak,
+ savedAt: Date.now(),
+ });
+ } catch (err) {
+ console.warn('Session persistence unavailable:', err);
+ }
+ }
+
+ searchCtx?.initialize();
+ navigate('/channels/@me');
+ } catch (err: any) {
+ setError(err?.message ?? 'Login failed');
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ return (
+
+ Welcome back
+
+
+
+ );
+}
+
+export default LoginPage;
diff --git a/packages/shared/src/components/auth/RegisterPage.tsx b/packages/shared/src/components/auth/RegisterPage.tsx
new file mode 100644
index 0000000..fb13156
--- /dev/null
+++ b/packages/shared/src/components/auth/RegisterPage.tsx
@@ -0,0 +1,500 @@
+import { useEffect, useRef, useState, type FormEvent } from 'react';
+import { useNavigate } from 'react-router-dom';
+import { CheckCircle, Eye, EyeSlash, Warning } from '@phosphor-icons/react';
+import { useConvex, useQuery } from 'convex/react';
+import { usePlatform } from '../../platform';
+import { useSearch } from '../../contexts/SearchContext';
+import { api } from '../../../../../convex/_generated/api';
+import { AuthLayout } from './AuthLayout';
+import styles from './LoginPage.module.css';
+
+const MIN_PASSWORD_LENGTH = 8;
+const MIN_USERNAME_LENGTH = 2;
+const USERNAME_PATTERN = /^[a-zA-Z0-9._-]+$/;
+
+type InviteState =
+ | { status: 'idle' }
+ | { status: 'checking' }
+ | {
+ status: 'valid';
+ code: string;
+ // Decoded channel-key map, if the pasted link carried a secret.
+ // When the user only pastes a bare code, the map stays null and
+ // the new account just won't have any existing channel keys.
+ keys: Record | null;
+ }
+ | { status: 'invalid'; message: string };
+
+/**
+ * Parse the invite-code field. Accepts either a bare code or a full
+ * invite URL of the form `${origin}/invite/CODE#key=SECRET`.
+ */
+function parseInviteInput(raw: string): { code: string; secret: string | null } | null {
+ const trimmed = raw.trim();
+ if (!trimmed) return null;
+ // Full URL (possibly with a fragment)
+ try {
+ const url = new URL(trimmed);
+ const match = url.pathname.match(/\/invite\/([^/]+)/);
+ if (match) {
+ const code = match[1];
+ const hash = url.hash; // "#key=abcd"
+ const keyMatch = hash.match(/[#&]key=([^&]+)/);
+ return { code, secret: keyMatch ? keyMatch[1] : null };
+ }
+ } catch {
+ /* not a URL — fall through to bare-code path */
+ }
+ // Bare code (treat whitespace-delimited first token as the code)
+ const code = trimmed.split(/\s+/)[0];
+ return { code, secret: null };
+}
+
+export function RegisterPage() {
+ const navigate = useNavigate();
+ const convex = useConvex();
+ const { crypto, session } = usePlatform();
+ const searchCtx = useSearch();
+
+ // Used to detect the first-user bootstrap case — before any user
+ // exists, the backend allows an empty invite code. This query is
+ // cheap and public, so it's fine to run during register.
+ const existingUsers = useQuery(api.auth.getPublicKeys) ?? [];
+ const isFirstUser = existingUsers.length === 0;
+
+ const [username, setUsername] = useState('');
+ const [password, setPassword] = useState('');
+ const [confirmPassword, setConfirmPassword] = useState('');
+ const [inviteInput, setInviteInput] = useState('');
+ const [invite, setInvite] = useState({ status: 'idle' });
+ const [showPassword, setShowPassword] = useState(false);
+ const [showConfirmPassword, setShowConfirmPassword] = useState(false);
+ const [error, setError] = useState(null);
+ const [isRegistering, setIsRegistering] = useState(false);
+
+ const debounceRef = useRef(null);
+
+ // Pick up an invite already accepted by /invite/:code — if
+ // InviteAcceptPage stashed keys in sessionStorage, we skip the
+ // validation UI and mark the invite as already valid.
+ useEffect(() => {
+ try {
+ const pendingCode = sessionStorage.getItem('pendingInviteCode');
+ const pendingRaw = sessionStorage.getItem('pendingInviteKeys');
+ if (pendingCode) {
+ setInviteInput(pendingCode);
+ if (pendingRaw) {
+ try {
+ const parsed = JSON.parse(pendingRaw) as Record;
+ setInvite({ status: 'valid', code: pendingCode, keys: parsed });
+ return;
+ } catch {
+ /* fall through to re-validation */
+ }
+ }
+ }
+ } catch {
+ /* ignore */
+ }
+ }, []);
+
+ // Live validation of whatever the user is typing. A 500 ms debounce
+ // keeps us from hammering Convex on every keystroke.
+ useEffect(() => {
+ if (debounceRef.current !== null) {
+ window.clearTimeout(debounceRef.current);
+ debounceRef.current = null;
+ }
+ const trimmed = inviteInput.trim();
+ if (!trimmed) {
+ setInvite({ status: 'idle' });
+ return;
+ }
+ // If this is the code we already validated on mount via
+ // sessionStorage, don't re-fetch.
+ if (invite.status === 'valid' && invite.code === trimmed) return;
+
+ setInvite({ status: 'checking' });
+ debounceRef.current = window.setTimeout(async () => {
+ const parsed = parseInviteInput(trimmed);
+ if (!parsed) {
+ setInvite({ status: 'invalid', message: 'Empty invite.' });
+ return;
+ }
+ try {
+ const result = await convex.query(api.invites.use, { code: parsed.code });
+ if ('error' in result) {
+ setInvite({ status: 'invalid', message: result.error });
+ return;
+ }
+
+ // If a secret was supplied, try to decrypt the payload
+ // right now so we can (a) verify the secret actually
+ // works, and (b) stash the decoded key map for the
+ // submit handler to upload after account creation.
+ let keys: Record | null = null;
+ if (parsed.secret && result.encryptedPayload) {
+ try {
+ const blob = JSON.parse(result.encryptedPayload) as {
+ c: string;
+ i: string;
+ t: string;
+ };
+ const plaintext = await crypto.decryptData(
+ blob.c,
+ parsed.secret,
+ blob.i,
+ blob.t,
+ );
+ keys = JSON.parse(plaintext) as Record;
+ } catch {
+ setInvite({
+ status: 'invalid',
+ message:
+ 'Invite secret does not match. Make sure you pasted the whole link.',
+ });
+ return;
+ }
+ }
+ setInvite({ status: 'valid', code: parsed.code, keys });
+ } catch (err: any) {
+ setInvite({
+ status: 'invalid',
+ message: err?.message ?? 'Unable to verify invite.',
+ });
+ }
+ }, 500);
+
+ return () => {
+ if (debounceRef.current !== null) {
+ window.clearTimeout(debounceRef.current);
+ }
+ };
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [inviteInput, convex, crypto]);
+
+ const canSubmit =
+ !isRegistering &&
+ (isFirstUser || invite.status === 'valid') &&
+ username.trim().length >= MIN_USERNAME_LENGTH &&
+ password.length >= MIN_PASSWORD_LENGTH &&
+ password === confirmPassword;
+
+ const handleSubmit = async (e: FormEvent) => {
+ e.preventDefault();
+ setError(null);
+
+ const trimmedUsername = username.trim();
+ if (trimmedUsername.length < MIN_USERNAME_LENGTH) {
+ setError(`Username must be at least ${MIN_USERNAME_LENGTH} characters`);
+ return;
+ }
+ if (!USERNAME_PATTERN.test(trimmedUsername)) {
+ setError(
+ 'Username may only contain letters, numbers, dots, underscores, or hyphens',
+ );
+ return;
+ }
+ if (password.length < MIN_PASSWORD_LENGTH) {
+ setError(`Password must be at least ${MIN_PASSWORD_LENGTH} characters`);
+ return;
+ }
+ if (password !== confirmPassword) {
+ setError('Passwords do not match');
+ return;
+ }
+ if (!isFirstUser && invite.status !== 'valid') {
+ setError('You need a valid invite to register.');
+ return;
+ }
+
+ setIsRegistering(true);
+
+ try {
+ const keys = await crypto.generateKeys();
+ const salt = await crypto.randomBytes(16);
+ const masterKeyHex = await crypto.randomBytes(32);
+ const { dek, dak } = await crypto.deriveAuthKeys(password, salt);
+ const hak = await crypto.sha256(dak);
+ const encryptedMK = JSON.stringify(await crypto.encryptData(masterKeyHex, dek));
+ const encryptedEd = JSON.stringify(
+ await crypto.encryptData(keys.edPriv, masterKeyHex),
+ );
+ const encryptedRsa = JSON.stringify(
+ await crypto.encryptData(keys.rsaPriv, masterKeyHex),
+ );
+ const encryptedPrivateKeys = JSON.stringify({
+ ed: JSON.parse(encryptedEd),
+ rsa: JSON.parse(encryptedRsa),
+ });
+
+ const result = await convex.mutation(api.auth.createUserWithProfile, {
+ username: trimmedUsername,
+ salt,
+ encryptedMK,
+ hak,
+ publicKey: keys.rsaPub,
+ signingKey: keys.edPub,
+ encryptedPrivateKeys,
+ inviteCode: invite.status === 'valid' ? invite.code : undefined,
+ });
+
+ if ('error' in result) {
+ throw new Error(result.error);
+ }
+
+ const searchKeys = await crypto.deriveAuthKeys(
+ password,
+ 'searchdb-' + trimmedUsername,
+ );
+ sessionStorage.setItem('searchDbKey', searchKeys.dak);
+ sessionStorage.setItem('masterKey', masterKeyHex);
+ sessionStorage.setItem('signingKey', keys.edPriv);
+ sessionStorage.setItem('privateKey', keys.rsaPriv);
+ localStorage.setItem('userId', result.userId);
+ localStorage.setItem('username', trimmedUsername);
+ localStorage.setItem('publicKey', keys.rsaPub);
+
+ if (session) {
+ try {
+ await session.save({
+ userId: result.userId,
+ username: trimmedUsername,
+ publicKey: keys.rsaPub,
+ signingKey: keys.edPriv,
+ privateKey: keys.rsaPriv,
+ masterKey: masterKeyHex,
+ searchDbKey: searchKeys.dak,
+ savedAt: Date.now(),
+ });
+ } catch (err) {
+ console.warn('Session persistence unavailable:', err);
+ }
+ }
+
+ // Upload the decoded invite channel keys. The keys map comes
+ // from the validated invite state (either from direct input
+ // or from InviteAcceptPage via sessionStorage).
+ const inviteKeys =
+ invite.status === 'valid' ? invite.keys : null;
+ try {
+ if (inviteKeys && Object.keys(inviteKeys).length > 0) {
+ const batch: Array<{
+ channelId: string;
+ userId: string;
+ encryptedKeyBundle: string;
+ keyVersion: number;
+ }> = [];
+ for (const [channelId, keyHex] of Object.entries(inviteKeys)) {
+ if (!keyHex) continue;
+ try {
+ const payload = JSON.stringify({ [channelId]: keyHex });
+ const encryptedKeyBundle = await crypto.publicEncrypt(
+ keys.rsaPub,
+ payload,
+ );
+ batch.push({
+ channelId,
+ userId: result.userId,
+ encryptedKeyBundle,
+ keyVersion: 1,
+ });
+ } catch (err) {
+ console.error(
+ 'Failed to encrypt invite key for channel',
+ channelId,
+ err,
+ );
+ }
+ }
+ if (batch.length > 0) {
+ await convex.mutation(api.channelKeys.uploadKeys, {
+ keys: batch as any,
+ });
+ }
+ }
+ } catch (err) {
+ console.error('Failed to import invite keys:', err);
+ } finally {
+ sessionStorage.removeItem('pendingInviteKeys');
+ sessionStorage.removeItem('pendingInviteCode');
+ }
+
+ searchCtx?.initialize();
+ navigate('/channels/@me');
+ } catch (err: any) {
+ setError(err?.message || 'Registration failed');
+ } finally {
+ setIsRegistering(false);
+ }
+ };
+
+ return (
+
+ Create an account
+
+
+
+ );
+}
+
+export default RegisterPage;
diff --git a/packages/shared/src/components/auth/SetupEncryptionPage.module.css b/packages/shared/src/components/auth/SetupEncryptionPage.module.css
new file mode 100644
index 0000000..e0376d2
--- /dev/null
+++ b/packages/shared/src/components/auth/SetupEncryptionPage.module.css
@@ -0,0 +1,199 @@
+.header {
+ text-align: center;
+ margin-bottom: 24px;
+}
+
+.icon {
+ font-size: 2.5rem;
+ margin-bottom: 12px;
+}
+
+.title {
+ font-size: 1.5rem;
+ font-weight: 700;
+ color: var(--text-primary);
+ margin: 0 0 8px;
+}
+
+.subtitle {
+ font-size: 0.9375rem;
+ color: var(--text-secondary);
+ margin: 0;
+ line-height: 1.4;
+}
+
+.content {
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+}
+
+.field {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+
+.label {
+ font-size: 0.75rem;
+ font-weight: 700;
+ text-transform: uppercase;
+ color: var(--text-secondary);
+ letter-spacing: 0.02em;
+}
+
+.recoveryKeyInput {
+ padding: 12px;
+ border-radius: var(--radius-md);
+ border: none;
+ background-color: var(--background-tertiary);
+ color: var(--text-primary);
+ font-size: 0.875rem;
+ font-family: monospace;
+ outline: none;
+ resize: none;
+}
+
+.recoveryKeyInput:focus {
+ outline: 2px solid var(--brand-primary);
+ outline-offset: -2px;
+}
+
+.recoveryKeyInput::placeholder {
+ color: var(--text-muted);
+ font-family: inherit;
+}
+
+.recoveryKeyDisplay {
+ background-color: var(--background-tertiary);
+ border-radius: var(--radius-md);
+ padding: 16px;
+ position: relative;
+}
+
+.recoveryKeyText {
+ font-size: 0.8125rem;
+ line-height: 1.6;
+ color: var(--text-primary);
+ word-break: break-all;
+ display: block;
+ margin-bottom: 8px;
+}
+
+.copyButton {
+ background: var(--brand-primary);
+ color: white;
+ border: none;
+ border-radius: var(--radius-sm);
+ padding: 4px 12px;
+ font-size: 0.75rem;
+ font-weight: 600;
+ cursor: pointer;
+ transition: background 0.15s;
+}
+
+.copyButton:hover {
+ background: var(--brand-primary-hover);
+}
+
+.infoBox {
+ background-color: var(--background-secondary);
+ border-radius: var(--radius-md);
+ padding: 12px 16px;
+ font-size: 0.875rem;
+ color: var(--text-secondary);
+ line-height: 1.4;
+}
+
+.infoList {
+ margin: 8px 0 0;
+ padding-left: 20px;
+}
+
+.infoList li {
+ margin-top: 4px;
+}
+
+.warningBox {
+ background-color: rgba(250, 166, 26, 0.1);
+ border: 1px solid rgba(250, 166, 26, 0.3);
+ border-radius: var(--radius-md);
+ padding: 12px 16px;
+ font-size: 0.8125rem;
+ color: var(--status-warning);
+ line-height: 1.4;
+}
+
+.divider {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+}
+
+.divider::before,
+.divider::after {
+ content: '';
+ flex: 1;
+ height: 1px;
+ background-color: var(--background-modifier-accent);
+}
+
+.dividerText {
+ font-size: 0.75rem;
+ color: var(--text-muted);
+ text-transform: uppercase;
+ font-weight: 600;
+}
+
+.input {
+ height: 40px;
+ padding: 0 12px;
+ border-radius: var(--radius-md);
+ border: none;
+ background-color: var(--background-tertiary);
+ color: var(--text-primary);
+ font-size: 1rem;
+ outline: none;
+ width: 100%;
+ box-sizing: border-box;
+}
+
+.input:focus {
+ outline: 2px solid var(--brand-primary);
+ outline-offset: -2px;
+}
+
+.input::placeholder {
+ color: var(--text-muted);
+}
+
+.passphraseForm {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+}
+
+.error {
+ color: var(--status-danger);
+ font-size: 0.875rem;
+}
+
+.skipButton {
+ background: none;
+ border: none;
+ color: var(--text-muted);
+ font-size: 0.8125rem;
+ cursor: pointer;
+ padding: 8px;
+ text-align: center;
+ transition: color 0.15s;
+}
+
+.skipButton:hover {
+ color: var(--text-secondary);
+}
+
+.skipButton:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
diff --git a/packages/shared/src/components/auth/SetupEncryptionPage.tsx b/packages/shared/src/components/auth/SetupEncryptionPage.tsx
new file mode 100644
index 0000000..ff9765b
--- /dev/null
+++ b/packages/shared/src/components/auth/SetupEncryptionPage.tsx
@@ -0,0 +1,27 @@
+/**
+ * SetupEncryptionPage — carried over from the new UI for API parity, but
+ * Discord Clone's Convex auth flow generates + encrypts private keys at
+ * registration time (RegisterPage.tsx) and there is no separate recovery-
+ * key / cross-signing setup. This page is therefore a no-op that simply
+ * continues into the app. It remains exported so any linked route keeps
+ * compiling; nothing currently routes here.
+ */
+import { useEffect } from 'react';
+import { useNavigate } from 'react-router-dom';
+import { AuthLayout } from './AuthLayout';
+
+export function SetupEncryptionPage() {
+ const navigate = useNavigate();
+
+ useEffect(() => {
+ navigate('/channels/@me', { replace: true });
+ }, [navigate]);
+
+ return (
+
+ Continuing…
+
+ );
+}
+
+export default SetupEncryptionPage;
diff --git a/packages/shared/src/components/auth/SplashScreen.module.css b/packages/shared/src/components/auth/SplashScreen.module.css
new file mode 100644
index 0000000..8dcf256
--- /dev/null
+++ b/packages/shared/src/components/auth/SplashScreen.module.css
@@ -0,0 +1,28 @@
+.container {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ height: 100vh;
+ height: 100dvh;
+ background-color: var(--background-secondary);
+}
+
+.content {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 16px;
+}
+
+.logo {
+ font-size: 2rem;
+ font-weight: 800;
+ color: var(--text-primary);
+ margin: 0;
+}
+
+.text {
+ font-size: 0.875rem;
+ color: var(--text-secondary);
+ margin: 0;
+}
diff --git a/packages/shared/src/components/auth/SplashScreen.tsx b/packages/shared/src/components/auth/SplashScreen.tsx
new file mode 100644
index 0000000..3ecaf8f
--- /dev/null
+++ b/packages/shared/src/components/auth/SplashScreen.tsx
@@ -0,0 +1,14 @@
+import { Spinner } from '@brycord/ui';
+import styles from './SplashScreen.module.css';
+
+export function SplashScreen() {
+ return (
+
+
+
Brycord
+
+
Connecting securely...
+
+
+ );
+}
diff --git a/packages/shared/src/components/auth/index.ts b/packages/shared/src/components/auth/index.ts
new file mode 100644
index 0000000..775cc5b
--- /dev/null
+++ b/packages/shared/src/components/auth/index.ts
@@ -0,0 +1,4 @@
+export { LoginPage } from './LoginPage';
+export { RegisterPage } from './RegisterPage';
+export { SplashScreen } from './SplashScreen';
+export { AuthLayout } from './AuthLayout';
diff --git a/packages/shared/src/components/channel/AccessPicker.module.css b/packages/shared/src/components/channel/AccessPicker.module.css
new file mode 100644
index 0000000..ef608ca
--- /dev/null
+++ b/packages/shared/src/components/channel/AccessPicker.module.css
@@ -0,0 +1,135 @@
+.root {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+
+.sectionLabel {
+ font-size: 0.75rem;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.02em;
+ color: var(--text-tertiary);
+}
+
+.sectionDescription {
+ font-size: 0.8125rem;
+ color: var(--text-secondary);
+ line-height: 1.4;
+ margin: 0;
+}
+
+.customNotice {
+ font-size: 0.8125rem;
+ color: var(--status-warning, #faa61a);
+ padding: 8px 12px;
+ background-color: color-mix(in srgb, var(--status-warning, #faa61a) 10%, transparent);
+ border-radius: 8px;
+ margin: 0;
+}
+
+.optionList {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ margin-top: 4px;
+}
+
+.option {
+ display: flex;
+ align-items: flex-start;
+ gap: 12px;
+ padding: 14px;
+ background-color: var(--background-tertiary, rgba(255, 255, 255, 0.03));
+ border: 1px solid var(--background-modifier-accent, rgba(255, 255, 255, 0.06));
+ border-radius: 10px;
+ color: var(--text-primary);
+ font: inherit;
+ text-align: left;
+ cursor: pointer;
+ transition: background-color 0.15s ease, border-color 0.15s ease;
+}
+
+.option:hover:not(:disabled) {
+ background-color: var(--background-modifier-hover, rgba(255, 255, 255, 0.05));
+}
+
+.option:disabled {
+ opacity: 0.55;
+ cursor: not-allowed;
+}
+
+.optionSelected {
+ border-color: var(--brand-primary, #5865f2);
+ background-color: color-mix(in srgb, var(--brand-primary, #5865f2) 10%, transparent);
+}
+
+.optionSelected:hover:not(:disabled) {
+ background-color: color-mix(in srgb, var(--brand-primary, #5865f2) 15%, transparent);
+}
+
+.optionIcon {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 40px;
+ height: 40px;
+ flex-shrink: 0;
+ border-radius: 50%;
+ background-color: var(--background-secondary, rgba(255, 255, 255, 0.05));
+ color: var(--text-secondary);
+}
+
+.optionSelected .optionIcon {
+ background-color: var(--brand-primary, #5865f2);
+ color: #ffffff;
+}
+
+.optionBody {
+ flex: 1;
+ min-width: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+}
+
+.optionTitle {
+ font-size: 0.9375rem;
+ font-weight: 600;
+ color: var(--text-primary);
+}
+
+.optionDescription {
+ font-size: 0.8125rem;
+ line-height: 1.35;
+ color: var(--text-secondary);
+}
+
+.optionCheck {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 22px;
+ height: 22px;
+ flex-shrink: 0;
+ border-radius: 50%;
+ background-color: var(--brand-primary, #5865f2);
+ color: #ffffff;
+ align-self: center;
+}
+
+.disabledHint {
+ font-size: 0.75rem;
+ color: var(--text-tertiary);
+ margin: 4px 0 0;
+}
+
+.status {
+ font-size: 0.8125rem;
+ margin: 6px 0 0;
+ line-height: 1.4;
+}
+
+.statusError {
+ color: var(--status-danger, #da373c);
+}
diff --git a/packages/shared/src/components/channel/AccessPicker.tsx b/packages/shared/src/components/channel/AccessPicker.tsx
new file mode 100644
index 0000000..b5da34f
--- /dev/null
+++ b/packages/shared/src/components/channel/AccessPicker.tsx
@@ -0,0 +1,136 @@
+import { RoomManager } from '@brycord/matrix-client';
+import { Check, GlobeHemisphereWest, Lock, UsersThree } from '@phosphor-icons/react';
+/**
+ * AccessPicker — three-option "who can join" picker for a channel
+ * or category. Mirrors Element's "Invite only / Space members /
+ * Anyone" control and writes the corresponding `m.room.join_rules`
+ * state event via `RoomManager.setChannelAccess`.
+ *
+ * Reads the current access on mount. Writes are optimistic —
+ * selection updates immediately; on server rejection we roll back
+ * and show an inline error. Disabled when the caller passes
+ * `disabled` (e.g., the user lacks `state_default` PL in the
+ * target room).
+ */
+import { useEffect, useState } from 'react';
+import styles from './AccessPicker.module.css';
+
+export type AccessMode = 'invite' | 'space_members' | 'public';
+
+interface AccessPickerProps {
+ /** The channel or category whose access we're editing. */
+ roomId: string;
+ /** Parent space — used when writing the `restricted` allow list. */
+ spaceId: string;
+ /** When true, every option is non-interactive. */
+ disabled?: boolean;
+}
+
+interface OptionDescriptor {
+ value: AccessMode;
+ icon: React.ReactNode;
+ title: string;
+ description: string;
+}
+
+const OPTIONS: OptionDescriptor[] = [
+ {
+ value: 'invite',
+ icon: ,
+ title: 'Invite Only',
+ description: 'Only invited members can see and join this channel.',
+ },
+ {
+ value: 'space_members',
+ icon: ,
+ title: 'Space Members',
+ description: 'Anyone in the server can join. Recommended.',
+ },
+ {
+ value: 'public',
+ icon: ,
+ title: 'Anyone',
+ description: 'Anyone with a link can join, even without a server invite.',
+ },
+];
+
+export function AccessPicker({ roomId, spaceId, disabled = false }: AccessPickerProps) {
+ const [current, setCurrent] = useState(null);
+ const [saving, setSaving] = useState(false);
+ const [error, setError] = useState(null);
+
+ // Read the current access on mount and whenever the target
+ // room changes. Synchronous — reads from matrix-js-sdk's
+ // in-memory room state.
+ useEffect(() => {
+ const mode = RoomManager.getInstance().getChannelAccess(roomId, spaceId);
+ setCurrent(mode);
+ }, [roomId, spaceId]);
+
+ const handleSelect = async (next: AccessMode) => {
+ if (disabled || saving || next === current) return;
+ const previous = current;
+ // Optimistic — flip the UI immediately, roll back on error.
+ setCurrent(next);
+ setSaving(true);
+ setError(null);
+ try {
+ await RoomManager.getInstance().setChannelAccess(roomId, next, spaceId);
+ } catch (err: any) {
+ setCurrent(previous);
+ setError(err?.message || 'Failed to update access.');
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ // If the room has join rules we don't recognise (e.g. `knock`
+ // or a `restricted` allow list pointing at a different room),
+ // surface that rather than silently overwriting the config.
+ const isCustom = current === 'other';
+
+ return (
+
+
Channel Access
+
+ Control who can join this channel. Existing members always keep their access — this only affects new joins.
+
+
+ {isCustom && (
+
+ Custom join rules are set on this channel. Pick an option below to replace them.
+
+ )}
+
+
+ {OPTIONS.map((opt) => {
+ const isSelected = current === opt.value;
+ return (
+ handleSelect(opt.value)}
+ disabled={disabled || saving}
+ >
+ {opt.icon}
+
+ {opt.title}
+ {opt.description}
+
+ {isSelected && (
+
+
+
+ )}
+
+ );
+ })}
+
+
+ {disabled &&
You need a higher role to change access.
}
+
+ {error &&
{error}
}
+
+ );
+}
diff --git a/packages/shared/src/components/channel/AttachmentAudio.module.css b/packages/shared/src/components/channel/AttachmentAudio.module.css
new file mode 100644
index 0000000..47d10e5
--- /dev/null
+++ b/packages/shared/src/components/channel/AttachmentAudio.module.css
@@ -0,0 +1,360 @@
+/* ── Audio player card ───────────────────────────────────────
+ Fluxer-style audio attachment surface. Rounded rectangle with
+ a subtle background, play button on the left, filename + seek
+ bar + time on the right, control row underneath. Width is
+ constrained so the card feels like a compact inline element
+ in a chat message, not a full-width banner. */
+
+.card {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+ width: 100%;
+ max-width: 480px;
+ padding: 14px 16px;
+ background-color: var(--background-secondary);
+ border: 1px solid var(--background-modifier-accent);
+ border-radius: 0.625rem;
+ box-sizing: border-box;
+}
+
+/* ── Top row: play button + filename on the same line ──────── */
+.topRow {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ min-width: 0;
+}
+
+.playButton {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 36px;
+ height: 36px;
+ padding: 0;
+ background-color: var(--brand-primary);
+ border: none;
+ border-radius: 50%;
+ color: var(--text-on-brand-primary, #fff);
+ cursor: pointer;
+ flex-shrink: 0;
+ transition: filter 0.12s, transform 0.12s;
+ -webkit-tap-highlight-color: transparent;
+}
+
+.playButton:hover:not(:disabled) {
+ filter: brightness(1.08);
+}
+
+.playButton:active:not(:disabled) {
+ transform: scale(0.96);
+}
+
+.playButton:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+/* ── Filename row ───────────────────────────────────────────
+ The stem is a single-line ellipsis'd span and the extension
+ is a separate right-flush span, mirroring the Fluxer pattern
+ where the extension always stays visible while the middle of
+ a long filename gets clipped. Lives inline with the play
+ button in the top row; progress bar sits on its own row
+ below so it can span the full card width. */
+.filenameRow {
+ flex: 1;
+ display: flex;
+ align-items: baseline;
+ gap: 2px;
+ min-width: 0;
+ font-size: 0.9375rem;
+ font-weight: 700;
+ color: var(--text-primary);
+}
+
+.filenameStem {
+ min-width: 0;
+ flex-shrink: 1;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.filenameExt {
+ flex-shrink: 0;
+}
+
+/* ── Progress row: seek bar + time label ───────────────────── */
+.progressRow {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ min-width: 0;
+}
+
+.progressTrack {
+ position: relative;
+ flex: 1;
+ height: 4px;
+ border-radius: 999px;
+ background-color: var(--background-modifier-accent);
+ cursor: pointer;
+}
+
+.progressFill {
+ position: absolute;
+ top: 0;
+ left: 0;
+ bottom: 0;
+ width: var(--progress, 0%);
+ border-radius: 999px;
+ background-color: var(--brand-primary);
+ pointer-events: none;
+}
+
+/* Invisible native range input sits on top of the visual track
+ so the user gets native keyboard + drag support for free.
+ The real styling comes from .progressTrack / .progressFill; we
+ just override the thumb to be clickable. */
+.progressInput {
+ position: absolute;
+ inset: -8px 0;
+ width: 100%;
+ height: calc(100% + 16px);
+ margin: 0;
+ padding: 0;
+ background: transparent;
+ border: none;
+ outline: none;
+ cursor: pointer;
+ opacity: 0;
+ -webkit-appearance: none;
+ appearance: none;
+}
+
+.progressInput:disabled {
+ cursor: not-allowed;
+}
+
+.progressInput::-webkit-slider-thumb {
+ -webkit-appearance: none;
+ width: 14px;
+ height: 14px;
+ border-radius: 50%;
+ background: var(--brand-primary);
+ cursor: pointer;
+}
+
+.progressInput::-moz-range-thumb {
+ width: 14px;
+ height: 14px;
+ border: none;
+ border-radius: 50%;
+ background: var(--brand-primary);
+ cursor: pointer;
+}
+
+.timeLabel {
+ flex-shrink: 0;
+ font-size: 0.75rem;
+ font-variant-numeric: tabular-nums;
+ color: var(--text-primary-muted, #a0a3a8);
+}
+
+/* ── Bottom row: volume • speed + favorite + download ──────── */
+.bottomRow {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+}
+
+.bottomRowRight {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+}
+
+.iconButton {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 32px;
+ height: 32px;
+ padding: 0;
+ background: transparent;
+ border: none;
+ border-radius: 6px;
+ color: var(--text-primary-muted, #a0a3a8);
+ cursor: pointer;
+ transition: background-color 0.12s, color 0.12s;
+ -webkit-tap-highlight-color: transparent;
+}
+
+.iconButton:hover:not(:disabled) {
+ background-color: var(--background-modifier-hover);
+ color: var(--text-primary);
+}
+
+.iconButton:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+/* Favorited state — brand-primary fill to match the lightbox
+ star treatment. */
+.iconButtonActive {
+ color: var(--brand-primary);
+}
+
+.iconButtonActive:hover:not(:disabled) {
+ color: var(--brand-primary);
+ background-color: var(--background-modifier-hover);
+ filter: brightness(1.08);
+}
+
+.speedButton {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ min-width: 32px;
+ height: 32px;
+ padding: 0 10px;
+ background: transparent;
+ border: none;
+ border-radius: 6px;
+ color: var(--text-primary-muted, #a0a3a8);
+ font: inherit;
+ font-size: 0.75rem;
+ font-weight: 700;
+ cursor: pointer;
+ transition: background-color 0.12s, color 0.12s;
+ -webkit-tap-highlight-color: transparent;
+ font-variant-numeric: tabular-nums;
+}
+
+.speedButton:hover {
+ background-color: var(--background-modifier-hover);
+ color: var(--text-primary);
+}
+
+.hiddenAudio {
+ display: none;
+}
+
+/* ── Volume slider popover ─────────────────────────────────
+ Speaker button + a hover-revealed vertical slider above it.
+ The popover sits on top of the bottom row, anchored to the
+ button, and fades in when the user hovers either the icon
+ or the popover itself — the shared `.volumeWrap` wrapper
+ keeps the hover state alive across both. */
+
+.volumeWrap {
+ position: relative;
+ display: inline-flex;
+ align-items: center;
+}
+
+.volumePopover {
+ position: absolute;
+ bottom: calc(100% + 6px);
+ left: 50%;
+ transform: translateX(-50%);
+ width: 44px;
+ padding: 10px 0 14px;
+ background-color: var(--background-floating, #18191c);
+ border: 1px solid var(--background-modifier-accent);
+ border-radius: 0.5rem;
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.35);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ opacity: 0;
+ visibility: hidden;
+ transition: opacity 0.12s ease, visibility 0.12s ease;
+ z-index: 5;
+}
+
+/* Invisible bridge below the popover so the mouse can move
+ from the speaker icon into the slider without briefly
+ leaving the hover chain and closing the popover. */
+.volumePopover::after {
+ content: '';
+ position: absolute;
+ top: 100%;
+ left: 0;
+ right: 0;
+ height: 6px;
+ pointer-events: auto;
+}
+
+.volumeWrap:hover .volumePopover,
+.volumeWrap:focus-within .volumePopover {
+ opacity: 1;
+ visibility: visible;
+}
+
+/* Vertical track. 4px wide, 90px tall — same fill-via-CSS-var
+ technique the seek bar uses, just with the axis flipped. */
+.volumeTrack {
+ position: relative;
+ width: 4px;
+ height: 90px;
+ border-radius: 999px;
+ background-color: var(--background-modifier-accent);
+}
+
+.volumeFill {
+ position: absolute;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ height: var(--volume, 0%);
+ border-radius: 999px;
+ background-color: var(--brand-primary);
+ pointer-events: none;
+}
+
+/* Rotated native range input layered on top of the visual
+ track. The -90deg rotation makes it drag vertically while
+ keeping all the native keyboard + pointer behaviour. The
+ hit area is deliberately larger than the visible track so
+ it's easy to grab without pixel-perfect aim. */
+.volumeInput {
+ position: absolute;
+ width: 90px;
+ height: 28px;
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%) rotate(-90deg);
+ transform-origin: center;
+ margin: 0;
+ padding: 0;
+ background: transparent;
+ border: none;
+ outline: none;
+ cursor: pointer;
+ opacity: 0;
+ -webkit-appearance: none;
+ appearance: none;
+}
+
+.volumeInput::-webkit-slider-thumb {
+ -webkit-appearance: none;
+ width: 14px;
+ height: 14px;
+ border-radius: 50%;
+ background: var(--brand-primary);
+ cursor: pointer;
+}
+
+.volumeInput::-moz-range-thumb {
+ width: 14px;
+ height: 14px;
+ border: none;
+ border-radius: 50%;
+ background: var(--brand-primary);
+ cursor: pointer;
+}
diff --git a/packages/shared/src/components/channel/AttachmentAudio.tsx b/packages/shared/src/components/channel/AttachmentAudio.tsx
new file mode 100644
index 0000000..fb8beab
--- /dev/null
+++ b/packages/shared/src/components/channel/AttachmentAudio.tsx
@@ -0,0 +1,335 @@
+/**
+ * AttachmentAudio — custom audio player for `audio/*` attachments.
+ * Ported from the new UI's Fluxer-style layout but simplified for
+ * our Convex pipeline: takes the already-decrypted blob URL from
+ * `EncryptedAttachment` instead of resolving an MXC URL itself.
+ *
+ * ┌──────────────────────────────────────────────────────────┐
+ * │ ┌───┐ filename-truncated… .mp3 │
+ * │ │ ▶ │ ────────────────────────●─── 0:12/3:04│
+ * │ └───┘ │
+ * │ ◀) 1x ↓ │
+ * └──────────────────────────────────────────────────────────┘
+ */
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import {
+ Download,
+ Pause,
+ Play,
+ SpeakerHigh,
+ SpeakerSlash,
+ Star,
+} from '@phosphor-icons/react';
+import { useMutation, useQuery } from 'convex/react';
+import { api } from '../../../../../convex/_generated/api';
+import type { Id } from '../../../../../convex/_generated/dataModel';
+import type { AttachmentMetadata } from './EncryptedAttachment';
+import styles from './AttachmentAudio.module.css';
+
+interface AttachmentAudioProps {
+ src: string;
+ filename: string;
+ attachment?: AttachmentMetadata;
+}
+
+const PLAYBACK_SPEEDS = [1, 1.25, 1.5, 2, 0.5, 0.75];
+
+function splitFilename(filename: string): { stem: string; ext: string } {
+ const dot = filename.lastIndexOf('.');
+ if (dot <= 0 || dot === filename.length - 1) {
+ return { stem: filename, ext: '' };
+ }
+ return { stem: filename.slice(0, dot), ext: filename.slice(dot) };
+}
+
+function formatTime(seconds: number): string {
+ if (!Number.isFinite(seconds) || seconds < 0) return '0:00';
+ const total = Math.floor(seconds);
+ const m = Math.floor(total / 60);
+ const s = total % 60;
+ return `${m}:${s.toString().padStart(2, '0')}`;
+}
+
+export function AttachmentAudio({ src, filename, attachment }: AttachmentAudioProps) {
+ const audioRef = useRef(null);
+ const [isPlaying, setIsPlaying] = useState(false);
+ const [currentTime, setCurrentTime] = useState(0);
+ const [duration, setDuration] = useState(0);
+ const [volume, setVolume] = useState(1);
+ const [isMuted, setIsMuted] = useState(false);
+ const [playbackRateIndex, setPlaybackRateIndex] = useState(0);
+
+ const { stem, ext } = useMemo(() => splitFilename(filename), [filename]);
+
+ // Saved-media wiring — mirrors the ImageLightbox star button so
+ // audio attachments can be bookmarked into the Media tab.
+ const myUserId =
+ typeof localStorage !== 'undefined' ? localStorage.getItem('userId') : null;
+ const savedList = useQuery(
+ api.savedMedia.list,
+ myUserId && attachment
+ ? { userId: myUserId as Id<'userProfiles'> }
+ : 'skip',
+ );
+ const isSaved = !!(
+ attachment && savedList?.some((m) => m.url === attachment.url)
+ );
+ const saveMutation = useMutation(api.savedMedia.save);
+ const removeMutation = useMutation(api.savedMedia.remove);
+
+ const handleToggleSaved = useCallback(async () => {
+ if (!attachment || !myUserId) return;
+ try {
+ if (isSaved) {
+ await removeMutation({
+ userId: myUserId as Id<'userProfiles'>,
+ url: attachment.url,
+ });
+ } else {
+ await saveMutation({
+ userId: myUserId as Id<'userProfiles'>,
+ url: attachment.url,
+ kind: attachment.mimeType.split('/')[0],
+ filename: attachment.filename,
+ mimeType: attachment.mimeType,
+ width: attachment.width,
+ height: attachment.height,
+ size: attachment.size,
+ encryptionKey: attachment.key,
+ encryptionIv: attachment.iv,
+ });
+ }
+ } catch (err) {
+ console.warn('Failed to toggle saved audio:', err);
+ }
+ }, [attachment, isSaved, myUserId, removeMutation, saveMutation]);
+
+ useEffect(() => {
+ const el = audioRef.current;
+ if (!el) return;
+ const handleTime = () => setCurrentTime(el.currentTime);
+ const handleDuration = () => setDuration(el.duration);
+ const handlePlay = () => setIsPlaying(true);
+ const handlePause = () => setIsPlaying(false);
+ const handleEnded = () => setIsPlaying(false);
+ const handleVolume = () => {
+ setVolume(el.volume);
+ setIsMuted(el.muted);
+ };
+ el.addEventListener('timeupdate', handleTime);
+ el.addEventListener('loadedmetadata', handleDuration);
+ el.addEventListener('durationchange', handleDuration);
+ el.addEventListener('play', handlePlay);
+ el.addEventListener('pause', handlePause);
+ el.addEventListener('ended', handleEnded);
+ el.addEventListener('volumechange', handleVolume);
+ return () => {
+ el.removeEventListener('timeupdate', handleTime);
+ el.removeEventListener('loadedmetadata', handleDuration);
+ el.removeEventListener('durationchange', handleDuration);
+ el.removeEventListener('play', handlePlay);
+ el.removeEventListener('pause', handlePause);
+ el.removeEventListener('ended', handleEnded);
+ el.removeEventListener('volumechange', handleVolume);
+ };
+ }, []);
+
+ useEffect(() => {
+ const el = audioRef.current;
+ if (!el) return;
+ el.playbackRate = PLAYBACK_SPEEDS[playbackRateIndex];
+ }, [playbackRateIndex]);
+
+ const handleTogglePlay = useCallback(() => {
+ const el = audioRef.current;
+ if (!el || !src) return;
+ if (el.paused) {
+ void el.play().catch(() => {});
+ } else {
+ el.pause();
+ }
+ }, [src]);
+
+ const handleSeek = useCallback(
+ (e: React.ChangeEvent) => {
+ const el = audioRef.current;
+ if (!el) return;
+ const next = Number(e.target.value);
+ el.currentTime = next;
+ setCurrentTime(next);
+ },
+ [],
+ );
+
+ const handleToggleMute = useCallback(() => {
+ const el = audioRef.current;
+ if (!el) return;
+ el.muted = !el.muted;
+ }, []);
+
+ const handleVolumeSlider = useCallback(
+ (e: React.ChangeEvent) => {
+ const el = audioRef.current;
+ if (!el) return;
+ const next = Number(e.target.value);
+ el.volume = next;
+ if (next > 0 && el.muted) el.muted = false;
+ else if (next === 0 && !el.muted) el.muted = true;
+ },
+ [],
+ );
+
+ const handleCycleSpeed = useCallback(() => {
+ setPlaybackRateIndex((i) => (i + 1) % PLAYBACK_SPEEDS.length);
+ }, []);
+
+ const handleDownload = useCallback(() => {
+ if (!src) return;
+ const a = document.createElement('a');
+ a.href = src;
+ a.download = filename;
+ document.body.appendChild(a);
+ a.click();
+ document.body.removeChild(a);
+ }, [src, filename]);
+
+ const progressRatio =
+ duration > 0 ? Math.max(0, Math.min(1, currentTime / duration)) : 0;
+ const progressPercent = `${(progressRatio * 100).toFixed(2)}%`;
+ const playbackSpeedLabel = `${PLAYBACK_SPEEDS[playbackRateIndex]}x`;
+
+ return (
+
+
+
+ {isPlaying ? (
+
+ ) : (
+
+ )}
+
+
+
+
+ {stem}
+
+ {ext && {ext}}
+
+
+
+
+
+
+ {formatTime(currentTime)} / {formatTime(duration)}
+
+
+
+
+
+
+
+
+ {isMuted || volume === 0 ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+ {playbackSpeedLabel}
+
+ {attachment && (
+ void handleToggleSaved()}
+ aria-label={isSaved ? 'Unfavorite' : 'Favorite'}
+ title={isSaved ? 'Unfavorite' : 'Favorite'}
+ style={
+ isSaved
+ ? { color: 'var(--brand-primary, #5865f2)' }
+ : undefined
+ }
+ >
+
+
+ )}
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/packages/shared/src/components/channel/AttachmentVideo.module.css b/packages/shared/src/components/channel/AttachmentVideo.module.css
new file mode 100644
index 0000000..5947cdc
--- /dev/null
+++ b/packages/shared/src/components/channel/AttachmentVideo.module.css
@@ -0,0 +1,289 @@
+/* ── Video attachment card ────────────────────────────────
+ Inline video renderer for `video/*` message attachments.
+ Poster → click → inline playback with a custom control
+ overlay (top hover bar + bottom bar with seek + play +
+ volume + time + fullscreen). */
+
+.wrapper {
+ position: relative;
+ display: inline-block;
+ max-width: 100%;
+ border-radius: var(--radius-lg, 12px);
+ overflow: hidden;
+ background-color: var(--background-secondary);
+}
+
+.video {
+ display: block;
+ width: 100%;
+ height: 100%;
+ max-width: 100%;
+ max-height: inherit;
+ border-radius: var(--radius-lg, 12px);
+ /* `object-fit: contain` keeps portrait videos centered inside
+ the 400 × 300 cap instead of being cropped. */
+ object-fit: contain;
+ background-color: #000;
+ cursor: pointer;
+}
+
+/* ── Center play overlay (poster state) ───────────────────
+ Shown before the user first clicks play. Fades away once
+ hasStarted is true so the native video surface + custom
+ control bar take over. */
+.playOverlay {
+ position: absolute;
+ inset: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: transparent;
+ border: none;
+ padding: 0;
+ cursor: pointer;
+ background-color: rgba(0, 0, 0, 0.25);
+ transition: background-color 0.12s;
+ z-index: 2;
+}
+
+.playOverlay:hover {
+ background-color: rgba(0, 0, 0, 0.35);
+}
+
+.playBadge {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 48px;
+ height: 48px;
+ border-radius: 50%;
+ background-color: rgba(0, 0, 0, 0.6);
+ border: none;
+ color: #fff;
+ /* Nudge the play triangle 2px right so the visual weight
+ sits centered inside the circle — the triangle's
+ geometric centroid is left of its bounding box. */
+ padding-left: 4px;
+ box-sizing: border-box;
+ transition: transform 0.12s;
+}
+
+.playOverlay:hover .playBadge {
+ transform: scale(1.05);
+}
+
+/* ── Top hover bar (Trash / Download / Favorite) ──────────
+ Pinned to the top-right of the card. Hidden at rest,
+ fades in on wrapper hover or focus-within. */
+.topBar {
+ position: absolute;
+ top: 8px;
+ right: 8px;
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ opacity: 0;
+ visibility: hidden;
+ transition: opacity 0.15s ease, visibility 0.15s ease;
+ z-index: 3;
+}
+
+.wrapper:hover .topBar,
+.wrapper:focus-within .topBar {
+ opacity: 1;
+ visibility: visible;
+}
+
+.topBarButton {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 32px;
+ height: 32px;
+ padding: 0;
+ background-color: rgba(0, 0, 0, 0.6);
+ border: none;
+ border-radius: 6px;
+ color: #fff;
+ cursor: pointer;
+ transition: background-color 0.12s, color 0.12s;
+}
+
+.topBarButton:hover:not(:disabled) {
+ background-color: rgba(0, 0, 0, 0.8);
+}
+
+.topBarButton:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+/* Favorited state — brand-primary fill matches the image
+ lightbox's star treatment so the visual language stays
+ consistent across all three attachment viewers. */
+.topBarButtonActive {
+ background-color: var(--brand-primary);
+ color: var(--text-on-brand-primary, #fff);
+}
+
+.topBarButtonActive:hover:not(:disabled) {
+ background-color: var(--brand-primary);
+ filter: brightness(1.08);
+}
+
+.topBarButtonDanger:hover:not(:disabled) {
+ background-color: hsl(0, calc(70% * var(--saturation-factor, 1)), 55%);
+}
+
+/* ── Bottom control bar (after hasStarted) ────────────────
+ Seek bar + play + volume + time + fullscreen. Fades in on
+ wrapper hover so it doesn't clutter a video the user is
+ actively watching at rest. Permanently visible inside
+ fullscreen because the wrapper receives the :hover state
+ from the system cursor whenever it moves. */
+.bottomBar {
+ position: absolute;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ padding: 8px 12px 10px;
+ /* Gradient so the white controls stay readable against
+ bright video content without a hard edge. */
+ background: linear-gradient(
+ to top,
+ rgba(0, 0, 0, 0.75) 0%,
+ rgba(0, 0, 0, 0) 100%
+ );
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ opacity: 0;
+ visibility: hidden;
+ transition: opacity 0.15s ease, visibility 0.15s ease;
+ z-index: 3;
+}
+
+.wrapperPlaying:hover .bottomBar,
+.wrapperPlaying:focus-within .bottomBar {
+ opacity: 1;
+ visibility: visible;
+}
+
+/* ── Seek track ──────────────────────────────────────────
+ Native range input layered over a visual track+fill so we
+ get keyboard + drag support for free while keeping full
+ visual control. Same pattern as AttachmentAudio. */
+.seekTrack {
+ position: relative;
+ height: 4px;
+ border-radius: 999px;
+ background-color: rgba(255, 255, 255, 0.25);
+ cursor: pointer;
+}
+
+.seekFill {
+ position: absolute;
+ top: 0;
+ left: 0;
+ bottom: 0;
+ width: var(--progress, 0%);
+ border-radius: 999px;
+ background-color: var(--brand-primary);
+ pointer-events: none;
+}
+
+.seekInput {
+ position: absolute;
+ inset: -8px 0;
+ width: 100%;
+ height: calc(100% + 16px);
+ margin: 0;
+ padding: 0;
+ background: transparent;
+ border: none;
+ outline: none;
+ cursor: pointer;
+ opacity: 0;
+ -webkit-appearance: none;
+ appearance: none;
+}
+
+.seekInput::-webkit-slider-thumb {
+ -webkit-appearance: none;
+ width: 14px;
+ height: 14px;
+ border-radius: 50%;
+ background: var(--brand-primary);
+ cursor: pointer;
+}
+
+.seekInput::-moz-range-thumb {
+ width: 14px;
+ height: 14px;
+ border: none;
+ border-radius: 50%;
+ background: var(--brand-primary);
+ cursor: pointer;
+}
+
+/* ── Control row (play / volume / time / fullscreen) ─────── */
+.controlsRow {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.controlButton {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 28px;
+ height: 28px;
+ padding: 0;
+ background: transparent;
+ border: none;
+ border-radius: 4px;
+ color: #fff;
+ cursor: pointer;
+ transition: background-color 0.12s;
+}
+
+.controlButton:hover {
+ background-color: rgba(255, 255, 255, 0.14);
+}
+
+.timeLabel {
+ font-size: 0.75rem;
+ font-weight: 600;
+ color: #fff;
+ font-variant-numeric: tabular-nums;
+ margin-left: 2px;
+}
+
+.controlsSpacer {
+ flex: 1;
+}
+
+/* ── Spoiler state ────────────────────────────────────────
+ Blurs the poster and swaps the play badge for a SPOILER
+ label. First click reveals, a second click plays — same
+ two-step reveal the image attachment uses. */
+.wrapperBlurred .video {
+ filter: blur(44px);
+ clip-path: inset(0 round var(--radius-lg, 12px));
+}
+
+.wrapperBlurred .playOverlay {
+ background-color: rgba(0, 0, 0, 0.45);
+}
+
+.spoilerCoverLabel {
+ padding: 8px 16px;
+ background-color: rgba(0, 0, 0, 0.75);
+ border-radius: 999px;
+ color: #fff;
+ font-size: 0.8125rem;
+ font-weight: 800;
+ letter-spacing: 0.08em;
+ pointer-events: none;
+}
diff --git a/packages/shared/src/components/channel/AttachmentVideo.tsx b/packages/shared/src/components/channel/AttachmentVideo.tsx
new file mode 100644
index 0000000..c7fd470
--- /dev/null
+++ b/packages/shared/src/components/channel/AttachmentVideo.tsx
@@ -0,0 +1,348 @@
+/**
+ * AttachmentVideo — custom video player with Fluxer-style overlay
+ * controls. Ported from the new UI and adapted for our pipeline:
+ * takes the already-decrypted blob URL from `EncryptedAttachment`.
+ *
+ * Two playback states:
+ * 1. Not started → poster + center play button.
+ * 2. Playing → bottom seek bar, play/pause, mute, time, fullscreen.
+ *
+ * Top-right hover bar surfaces Download regardless of playback state.
+ */
+import { useCallback, useEffect, useRef, useState } from 'react';
+import {
+ CornersIn,
+ CornersOut,
+ Download,
+ Pause,
+ Play,
+ SpeakerHigh,
+ SpeakerSlash,
+ Star,
+} from '@phosphor-icons/react';
+import { useMutation, useQuery } from 'convex/react';
+import { api } from '../../../../../convex/_generated/api';
+import type { Id } from '../../../../../convex/_generated/dataModel';
+import type { AttachmentMetadata } from './EncryptedAttachment';
+import styles from './AttachmentVideo.module.css';
+
+interface AttachmentVideoProps {
+ src: string;
+ filename: string;
+ width?: number;
+ height?: number;
+ attachment?: AttachmentMetadata;
+}
+
+function formatTime(seconds: number): string {
+ if (!Number.isFinite(seconds) || seconds < 0) return '0:00';
+ const total = Math.floor(seconds);
+ const m = Math.floor(total / 60);
+ const s = total % 60;
+ return `${m}:${s.toString().padStart(2, '0')}`;
+}
+
+export function AttachmentVideo({
+ src,
+ filename,
+ width,
+ height,
+ attachment,
+}: AttachmentVideoProps) {
+ const wrapperRef = useRef(null);
+ const videoRef = useRef(null);
+
+ const [hasStarted, setHasStarted] = useState(false);
+ const [isPlaying, setIsPlaying] = useState(false);
+ const [currentTime, setCurrentTime] = useState(0);
+ const [duration, setDuration] = useState(0);
+ const [volume, setVolume] = useState(1);
+ const [isMuted, setIsMuted] = useState(false);
+ const [isFullscreen, setIsFullscreen] = useState(false);
+
+ // Saved-media wiring — mirrors ImageLightbox / AttachmentAudio
+ // so video attachments can be bookmarked into the Media tab.
+ const myUserId =
+ typeof localStorage !== 'undefined' ? localStorage.getItem('userId') : null;
+ const savedList = useQuery(
+ api.savedMedia.list,
+ myUserId && attachment
+ ? { userId: myUserId as Id<'userProfiles'> }
+ : 'skip',
+ );
+ const isSaved = !!(
+ attachment && savedList?.some((m) => m.url === attachment.url)
+ );
+ const saveMutation = useMutation(api.savedMedia.save);
+ const removeMutation = useMutation(api.savedMedia.remove);
+
+ const handleToggleSaved = useCallback(async () => {
+ if (!attachment || !myUserId) return;
+ try {
+ if (isSaved) {
+ await removeMutation({
+ userId: myUserId as Id<'userProfiles'>,
+ url: attachment.url,
+ });
+ } else {
+ await saveMutation({
+ userId: myUserId as Id<'userProfiles'>,
+ url: attachment.url,
+ kind: attachment.mimeType.split('/')[0],
+ filename: attachment.filename,
+ mimeType: attachment.mimeType,
+ width: attachment.width,
+ height: attachment.height,
+ size: attachment.size,
+ encryptionKey: attachment.key,
+ encryptionIv: attachment.iv,
+ });
+ }
+ } catch (err) {
+ console.warn('Failed to toggle saved video:', err);
+ }
+ }, [attachment, isSaved, myUserId, removeMutation, saveMutation]);
+
+ const widthCap = Math.min(width || 400, 400);
+ const heightCap = Math.min(height || 300, 300);
+
+ useEffect(() => {
+ const el = videoRef.current;
+ if (!el) return;
+ const onTime = () => setCurrentTime(el.currentTime);
+ const onDur = () => setDuration(el.duration);
+ const onPlay = () => setIsPlaying(true);
+ const onPause = () => setIsPlaying(false);
+ const onEnded = () => setIsPlaying(false);
+ const onVol = () => {
+ setVolume(el.volume);
+ setIsMuted(el.muted);
+ };
+ el.addEventListener('timeupdate', onTime);
+ el.addEventListener('loadedmetadata', onDur);
+ el.addEventListener('durationchange', onDur);
+ el.addEventListener('play', onPlay);
+ el.addEventListener('pause', onPause);
+ el.addEventListener('ended', onEnded);
+ el.addEventListener('volumechange', onVol);
+ return () => {
+ el.removeEventListener('timeupdate', onTime);
+ el.removeEventListener('loadedmetadata', onDur);
+ el.removeEventListener('durationchange', onDur);
+ el.removeEventListener('play', onPlay);
+ el.removeEventListener('pause', onPause);
+ el.removeEventListener('ended', onEnded);
+ el.removeEventListener('volumechange', onVol);
+ };
+ }, []);
+
+ useEffect(() => {
+ const handler = () => {
+ setIsFullscreen(document.fullscreenElement === wrapperRef.current);
+ };
+ document.addEventListener('fullscreenchange', handler);
+ return () => document.removeEventListener('fullscreenchange', handler);
+ }, []);
+
+ const handleStartPlay = useCallback(() => {
+ const el = videoRef.current;
+ if (!el) return;
+ setHasStarted(true);
+ void el.play().catch(() => {});
+ }, []);
+
+ const handleTogglePlay = useCallback(() => {
+ const el = videoRef.current;
+ if (!el) return;
+ if (el.paused) void el.play().catch(() => {});
+ else el.pause();
+ }, []);
+
+ const handleSeek = useCallback(
+ (e: React.ChangeEvent) => {
+ const el = videoRef.current;
+ if (!el) return;
+ const next = Number(e.target.value);
+ el.currentTime = next;
+ setCurrentTime(next);
+ },
+ [],
+ );
+
+ const handleToggleMute = useCallback(() => {
+ const el = videoRef.current;
+ if (!el) return;
+ if (el.muted && el.volume === 0) el.volume = 1;
+ el.muted = !el.muted;
+ }, []);
+
+ const handleToggleFullscreen = useCallback(() => {
+ const wrapper = wrapperRef.current;
+ if (!wrapper) return;
+ if (document.fullscreenElement) {
+ void document.exitFullscreen().catch(() => {});
+ } else {
+ void wrapper.requestFullscreen().catch(() => {});
+ }
+ }, []);
+
+ const handleDownload = useCallback(() => {
+ if (!src) return;
+ const a = document.createElement('a');
+ a.href = src;
+ a.download = filename;
+ document.body.appendChild(a);
+ a.click();
+ document.body.removeChild(a);
+ }, [src, filename]);
+
+ const stop =
+ (fn: () => void) =>
+ (e: React.MouseEvent) => {
+ e.stopPropagation();
+ fn();
+ };
+
+ const progressRatio = duration > 0 ? currentTime / duration : 0;
+ const progressPercent = `${Math.max(0, Math.min(100, progressRatio * 100)).toFixed(2)}%`;
+
+ return (
+
+
+
+
+ {attachment && (
+ void handleToggleSaved())}
+ aria-label={isSaved ? 'Unfavorite' : 'Favorite'}
+ title={isSaved ? 'Unfavorite' : 'Favorite'}
+ style={
+ isSaved
+ ? { color: 'var(--brand-primary, #5865f2)' }
+ : undefined
+ }
+ >
+
+
+ )}
+
+
+
+
+
+ {!hasStarted && (
+
+
+
+
+
+ )}
+
+ {hasStarted && (
+
e.stopPropagation()}
+ >
+
+
+
+
+ {isPlaying ? (
+
+ ) : (
+
+ )}
+
+
+
+ {isMuted || volume === 0 ? (
+
+ ) : (
+
+ )}
+
+
+
+ {formatTime(currentTime)} / {formatTime(duration)}
+
+
+
+
+
+ {isFullscreen ? (
+
+ ) : (
+
+ )}
+
+
+
+ )}
+
+ );
+}
diff --git a/packages/shared/src/components/channel/ChannelChatLayout.module.css b/packages/shared/src/components/channel/ChannelChatLayout.module.css
new file mode 100644
index 0000000..d260854
--- /dev/null
+++ b/packages/shared/src/components/channel/ChannelChatLayout.module.css
@@ -0,0 +1,18 @@
+.container {
+ display: flex;
+ flex-direction: column;
+ flex: 1;
+ min-width: 0;
+ min-height: 0;
+ background-color: var(--background-secondary-lighter, var(--background-primary));
+}
+
+.messagesWrapper {
+ flex: 1;
+ min-height: 0;
+ overflow: hidden;
+}
+
+.inputArea {
+ flex-shrink: 0;
+}
diff --git a/packages/shared/src/components/channel/ChannelChatLayout.tsx b/packages/shared/src/components/channel/ChannelChatLayout.tsx
new file mode 100644
index 0000000..3a193d9
--- /dev/null
+++ b/packages/shared/src/components/channel/ChannelChatLayout.tsx
@@ -0,0 +1,38 @@
+import { useCallback, useState } from 'react';
+import { ChannelTextarea } from './ChannelTextarea';
+import { Messages } from './Messages';
+import { TypingUsers } from './TypingUsers';
+import styles from './ChannelChatLayout.module.css';
+
+interface ChannelChatLayoutProps {
+ channelId: string;
+}
+
+interface ReplyState {
+ eventId: string;
+ username: string;
+}
+
+export function ChannelChatLayout({ channelId }: ChannelChatLayoutProps) {
+ const [replyTo, setReplyTo] = useState(null);
+
+ const handleReply = useCallback((eventId: string, username: string) => {
+ setReplyTo({ eventId, username });
+ }, []);
+
+ const handleCancelReply = useCallback(() => {
+ setReplyTo(null);
+ }, []);
+
+ return (
+
+ );
+}
diff --git a/packages/shared/src/components/channel/ChannelDetailsDrawer.module.css b/packages/shared/src/components/channel/ChannelDetailsDrawer.module.css
new file mode 100644
index 0000000..a89ace4
--- /dev/null
+++ b/packages/shared/src/components/channel/ChannelDetailsDrawer.module.css
@@ -0,0 +1,255 @@
+/* ── Channel details drawer — header ─────────────────────────────────── */
+
+.header {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 16px 20px 12px;
+ border-bottom: 1px solid var(--user-area-divider-color, rgba(255, 255, 255, 0.06));
+}
+
+.headerMain {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ flex: 1;
+ min-width: 0;
+}
+
+.headerIcon {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 40px;
+ height: 40px;
+ border-radius: 50%;
+ background-color: var(--background-tertiary, rgba(255, 255, 255, 0.05));
+ color: var(--text-secondary);
+ flex-shrink: 0;
+}
+
+.headerTextBlock {
+ display: flex;
+ flex-direction: column;
+ min-width: 0;
+ flex: 1;
+}
+
+.headerTitleRow {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ min-width: 0;
+}
+
+.headerTitleIcon {
+ color: var(--text-secondary);
+ flex-shrink: 0;
+}
+
+.headerTitle {
+ font-size: 1rem;
+ font-weight: 600;
+ color: var(--text-primary);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ min-width: 0;
+}
+
+.headerSubtitle {
+ font-size: 0.8125rem;
+ color: var(--text-tertiary);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ margin-top: 2px;
+}
+
+.headerCloseButton {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 32px;
+ height: 32px;
+ flex-shrink: 0;
+ background: transparent;
+ border: none;
+ color: var(--text-secondary);
+ border-radius: 50%;
+ cursor: pointer;
+ transition: background-color 0.15s, color 0.15s;
+}
+
+.headerCloseButton:hover {
+ background-color: var(--background-modifier-hover, rgba(255, 255, 255, 0.04));
+ color: var(--text-primary);
+}
+
+/* ── Quick actions row (Mute / Search / More) ────────────────────────── */
+
+.quickActions {
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ gap: 8px;
+ padding: 16px 20px;
+}
+
+.quickAction {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: 6px;
+ padding: 12px 8px;
+ background-color: var(--background-tertiary, rgba(255, 255, 255, 0.04));
+ border: none;
+ border-radius: 12px;
+ color: var(--text-primary);
+ cursor: pointer;
+ font: inherit;
+ font-size: 0.75rem;
+ font-weight: 500;
+ transition: background-color 0.15s;
+}
+
+.quickAction:hover:not(:disabled) {
+ background-color: var(--background-modifier-hover, rgba(255, 255, 255, 0.08));
+}
+
+.quickAction:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+.quickActionIcon {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 24px;
+ height: 24px;
+ color: var(--text-secondary);
+}
+
+.quickActionLabel {
+ line-height: 1;
+}
+
+/* ── Tab bar (Members / Pins) ────────────────────────────────────────
+ Flex row of two equal-width tab buttons. Each button is a column
+ layout: icon + label side by side, padded to ~12px tall. The
+ active tab swaps the text + icon colour to --brand-primary-light
+ and draws a 2px underline via an absolutely-positioned ::after
+ that hangs off the bottom edge of the button and overlaps the
+ row's 1px divider. Inactive tabs stay muted. */
+
+.tabBar {
+ position: relative;
+ display: flex;
+ align-items: stretch;
+ padding: 0;
+ border-bottom: 1px solid var(--user-area-divider-color, rgba(255, 255, 255, 0.06));
+}
+
+.tabButton {
+ position: relative;
+ flex: 1;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 8px;
+ padding: 14px 8px;
+ background: none;
+ border: none;
+ color: var(--text-secondary, var(--text-primary-muted));
+ cursor: pointer;
+ font: inherit;
+ font-size: 0.9375rem;
+ font-weight: 600;
+ transition: color 0.15s;
+ -webkit-tap-highlight-color: transparent;
+}
+
+.tabButton svg {
+ flex-shrink: 0;
+ color: var(--text-secondary, var(--text-primary-muted));
+ transition: color 0.15s;
+}
+
+.tabButtonActive {
+ color: var(--brand-primary-light);
+}
+
+.tabButtonActive svg {
+ color: var(--brand-primary-light);
+}
+
+/* Active tab underline — a 2px bar drawn at the bottom of the button,
+ translated down 1px so it overlaps and visually replaces the tab
+ row's divider underneath that tab. */
+.tabButtonActive::after {
+ content: '';
+ position: absolute;
+ left: 0;
+ right: 0;
+ bottom: -1px;
+ height: 2px;
+ background-color: var(--brand-primary-light);
+ border-radius: 2px 2px 0 0;
+}
+
+.membersWrapper {
+ padding: 1rem;
+}
+
+.emptyState {
+ padding: 16px 20px;
+ font-size: 0.875rem;
+ color: var(--text-tertiary);
+ text-align: center;
+}
+
+/* ── Pins tab ───────────────────────────────────────────────────────*/
+
+.pinsList {
+ display: flex;
+ flex-direction: column;
+ padding: 4px 0 16px;
+}
+
+.pinsLoading {
+ padding: 16px 20px;
+ text-align: center;
+ font-size: 0.8125rem;
+ color: var(--text-primary-muted);
+}
+
+/* Fluxer-style empty state: flag icon + "You've reached the end"
+ title + explanatory body. Matches the reference screenshot. */
+.pinsEmpty {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: 8px;
+ padding: 48px 32px 32px;
+ text-align: center;
+}
+
+.pinsEmptyIcon {
+ color: var(--text-primary-muted);
+ margin-bottom: 4px;
+}
+
+.pinsEmptyTitle {
+ font-size: 1.125rem;
+ font-weight: 800;
+ color: var(--text-primary);
+}
+
+.pinsEmptyBody {
+ font-size: 0.875rem;
+ color: var(--text-primary-muted);
+ line-height: 1.4;
+ max-width: 280px;
+}
diff --git a/packages/shared/src/components/channel/ChannelDetailsDrawer.tsx b/packages/shared/src/components/channel/ChannelDetailsDrawer.tsx
new file mode 100644
index 0000000..c2b41dc
--- /dev/null
+++ b/packages/shared/src/components/channel/ChannelDetailsDrawer.tsx
@@ -0,0 +1,301 @@
+/**
+ * ChannelDetailsDrawer — mobile bottom sheet opened by tapping the
+ * channel name in the ChannelHeader. Shows a Members/Pins tab switcher
+ * with the channel's member list and pinned messages.
+ */
+import { useEffect, useMemo, useState } from 'react';
+import { Users, PushPin } from '@phosphor-icons/react';
+import { useQuery } from 'convex/react';
+import { BottomSheet } from '@discord-clone/ui';
+import { api } from '../../../../../convex/_generated/api';
+import { usePlatform } from '../../platform';
+import { MemberListContainer } from '../member/MemberListContainer';
+import { PinnedMessageRow, ReachedEndNotice, type PinnedMessage } from './PinnedMessageRow';
+import type { AttachmentMetadata } from './EncryptedAttachment';
+
+type DrawerTab = 'members' | 'pins';
+
+interface ChannelDetailsDrawerProps {
+ isOpen: boolean;
+ onClose: () => void;
+ channelId: string;
+ channelName?: string;
+ channelType?: string;
+}
+
+const tabBarStyle: React.CSSProperties = {
+ display: 'flex',
+ gap: 8,
+ padding: '4px 12px 12px',
+ borderBottom: '1px solid var(--border-subtle, rgba(255,255,255,0.06))',
+};
+
+const tabButtonStyle = (active: boolean): React.CSSProperties => ({
+ flex: 1,
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ gap: 8,
+ padding: '10px 12px',
+ borderRadius: 10,
+ border: 'none',
+ background: active ? 'var(--bg-hover, rgba(255,255,255,0.08))' : 'transparent',
+ color: active ? 'var(--text-primary, #fff)' : 'var(--text-muted, #a0a0a8)',
+ fontSize: 14,
+ fontWeight: 600,
+ cursor: 'pointer',
+});
+
+const tabBodyStyle: React.CSSProperties = {
+ padding: '12px',
+ minHeight: 240,
+};
+
+const emptyStateStyle: React.CSSProperties = {
+ padding: '24px 12px',
+ textAlign: 'center',
+ color: 'var(--text-muted, #a0a0a8)',
+ fontSize: 14,
+};
+
+export function ChannelDetailsDrawer({
+ isOpen,
+ onClose,
+ channelId,
+ channelName,
+ channelType,
+}: ChannelDetailsDrawerProps) {
+ const [activeTab, setActiveTab] = useState('members');
+
+ // Reset to Members whenever the drawer re-opens so a previously
+ // selected Pins tab doesn't follow across channel changes.
+ useEffect(() => {
+ if (isOpen) setActiveTab('members');
+ }, [isOpen, channelId]);
+
+ const title = channelName ?? 'Channel';
+
+ return (
+
+
+
setActiveTab('members')}
+ role="tab"
+ aria-selected={activeTab === 'members'}
+ >
+
+ Members
+
+
setActiveTab('pins')}
+ role="tab"
+ aria-selected={activeTab === 'pins'}
+ >
+
+ Pins
+
+
+
+
+ {activeTab === 'members' ? (
+
+ ) : (
+
+ )}
+ {/* channelType currently unused; reserved for future voice-specific UI */}
+
{channelType}
+
+
+ );
+}
+
+// ── Pins tab ────────────────────────────────────────────────────────
+
+const TAG_LENGTH = 32;
+
+interface PinsTabContentProps {
+ channelId: string;
+ isOpen: boolean;
+ onClose: () => void;
+}
+
+function PinsTabContent({ channelId, isOpen, onClose }: PinsTabContentProps) {
+ const { crypto } = usePlatform();
+
+ const userId =
+ typeof localStorage !== 'undefined' ? localStorage.getItem('userId') : null;
+ const privateKeyPem =
+ typeof sessionStorage !== 'undefined'
+ ? sessionStorage.getItem('privateKey')
+ : null;
+
+ const allKeys = useQuery(
+ api.channelKeys.getKeysForUser,
+ userId && isOpen ? ({ userId: userId as any } as any) : 'skip',
+ );
+
+ // Merge all encrypted key bundles → { channelId: keyHex }
+ const [channelKey, setChannelKey] = useState(null);
+ useEffect(() => {
+ let cancelled = false;
+ if (!allKeys || !privateKeyPem) {
+ setChannelKey(null);
+ return;
+ }
+ (async () => {
+ const merged: Record = {};
+ for (const item of allKeys) {
+ try {
+ const bundleJson = await crypto.privateDecrypt(
+ privateKeyPem,
+ (item as any).encrypted_key_bundle,
+ );
+ Object.assign(merged, JSON.parse(bundleJson));
+ } catch (err) {
+ console.error('Failed to decrypt key bundle:', err);
+ }
+ }
+ if (cancelled) return;
+ setChannelKey(merged[channelId] ?? null);
+ })();
+ return () => {
+ cancelled = true;
+ };
+ }, [allKeys, privateKeyPem, channelId, crypto]);
+
+ const pinnedRaw = useQuery(
+ api.messages.listPinned,
+ isOpen
+ ? ({
+ channelId: channelId as any,
+ userId: (userId as any) ?? undefined,
+ } as any)
+ : 'skip',
+ );
+
+ const [decryptedMap, setDecryptedMap] = useState