feat: Implement core Discord features including members list, direct messages, user presence, authentication, and chat UI.
Some checks failed
Build and Release / build-and-release (push) Has been cancelled

This commit is contained in:
Bryan1029384756
2026-02-11 04:36:40 -06:00
parent a29858fd32
commit cb4361da1a
32 changed files with 2051 additions and 144 deletions

View File

@@ -1,13 +1,60 @@
const { app, BrowserWindow, ipcMain, shell } = require('electron');
const { app, BrowserWindow, ipcMain, shell, screen, safeStorage } = require('electron');
const path = require('path');
const fs = require('fs');
// --- Secure session persistence ---
const SESSION_FILE = path.join(app.getPath('userData'), 'secure-session.dat');
const https = require('https');
const http = require('http');
const { checkForUpdates } = require('./updater.cjs');
// --- Settings persistence ---
const SETTINGS_FILE = path.join(app.getPath('userData'), 'settings.json');
const DEFAULT_SETTINGS = {
windowX: undefined,
windowY: undefined,
windowWidth: 1200,
windowHeight: 800,
isMaximized: false,
theme: 'theme-dark',
};
let mainWindow = null;
function loadSettings() {
try {
const data = fs.readFileSync(SETTINGS_FILE, 'utf8');
return { ...DEFAULT_SETTINGS, ...JSON.parse(data) };
} catch {
return { ...DEFAULT_SETTINGS };
}
}
function saveSettings(settings) {
try {
fs.writeFileSync(SETTINGS_FILE, JSON.stringify(settings, null, 2), 'utf8');
} catch (err) {
console.error('Failed to save settings:', err.message);
}
}
function isPositionOnScreen(x, y, w, h) {
const displays = screen.getAllDisplays();
const MIN_OVERLAP = 100;
return displays.some(display => {
const { x: dx, y: dy, width: dw, height: dh } = display.bounds;
const overlapX = Math.max(0, Math.min(x + w, dx + dw) - Math.max(x, dx));
const overlapY = Math.max(0, Math.min(y + h, dy + dh) - Math.max(y, dy));
return overlapX >= MIN_OVERLAP && overlapY >= MIN_OVERLAP;
});
}
function createWindow() {
const win = new BrowserWindow({
width: 1200,
height: 800,
const settings = loadSettings();
const windowOptions = {
width: settings.windowWidth,
height: settings.windowHeight,
frame: false,
webPreferences: {
nodeIntegration: false,
@@ -15,17 +62,44 @@ function createWindow() {
preload: path.join(__dirname, 'preload.cjs'),
sandbox: false
}
};
// Only restore position if it's valid on a connected display
if (settings.windowX !== undefined && settings.windowY !== undefined &&
isPositionOnScreen(settings.windowX, settings.windowY, settings.windowWidth, settings.windowHeight)) {
windowOptions.x = settings.windowX;
windowOptions.y = settings.windowY;
}
mainWindow = new BrowserWindow(windowOptions);
if (settings.isMaximized) {
mainWindow.maximize();
}
// Save window state on close
mainWindow.on('close', () => {
const current = loadSettings(); // re-read to preserve theme changes
if (!mainWindow.isMaximized()) {
const bounds = mainWindow.getBounds();
current.windowX = bounds.x;
current.windowY = bounds.y;
current.windowWidth = bounds.width;
current.windowHeight = bounds.height;
}
current.isMaximized = mainWindow.isMaximized();
saveSettings(current);
});
const isDev = process.env.npm_lifecycle_event === 'electron:dev';
if (isDev) {
win.loadURL('http://localhost:5173');
win.webContents.openDevTools();
mainWindow.loadURL('http://localhost:5173');
mainWindow.webContents.openDevTools();
} else {
// Production: Load the built file
// dist-react is in the same directory as main.cjs
win.loadFile(path.join(__dirname, 'dist-react', 'index.html'));
mainWindow.loadFile(path.join(__dirname, 'dist-react', 'index.html'));
}
}
@@ -77,75 +151,254 @@ app.whenReady().then(async () => {
});
// Helper to fetch metadata (Zero-Knowledge: Client fetches previews)
const OEMBED_PROVIDERS = {
'twitter.com': (url) => url.includes('/status/') ? `https://publish.twitter.com/oembed?url=${encodeURIComponent(url)}&format=json` : null,
'x.com': (url) => url.includes('/status/') ? `https://publish.twitter.com/oembed?url=${encodeURIComponent(url)}&format=json` : null,
'youtube.com': (url) => `https://www.youtube.com/oembed?url=${encodeURIComponent(url)}&format=json`,
'www.youtube.com': (url) => `https://www.youtube.com/oembed?url=${encodeURIComponent(url)}&format=json`,
'youtu.be': (url) => `https://www.youtube.com/oembed?url=${encodeURIComponent(url)}&format=json`,
'open.spotify.com': (url) => `https://open.spotify.com/oembed?url=${encodeURIComponent(url)}`,
'www.tiktok.com': (url) => `https://www.tiktok.com/oembed?url=${encodeURIComponent(url)}`,
'tiktok.com': (url) => `https://www.tiktok.com/oembed?url=${encodeURIComponent(url)}`,
'www.reddit.com': (url) => `https://www.reddit.com/oembed?url=${encodeURIComponent(url)}&format=json`,
'reddit.com': (url) => `https://www.reddit.com/oembed?url=${encodeURIComponent(url)}&format=json`,
};
const FETCH_HEADERS = {
'User-Agent': 'Mozilla/5.0 (compatible; DiscordBot/1.0)',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
'Accept-Language': 'en-US,en;q=0.5',
};
const MAX_RESPONSE_SIZE = 256 * 1024; // 256KB
const FETCH_TIMEOUT = 8000;
const MAX_REDIRECTS = 5;
function httpGet(url, redirectsLeft = MAX_REDIRECTS) {
return new Promise((resolve, reject) => {
const client = url.startsWith('https') ? https : http;
const req = client.get(url, { headers: FETCH_HEADERS, timeout: FETCH_TIMEOUT }, (res) => {
if ([301, 302, 303, 307, 308].includes(res.statusCode) && res.headers.location) {
if (redirectsLeft <= 0) { resolve(''); return; }
let redirectUrl = res.headers.location;
if (redirectUrl.startsWith('/')) {
const parsed = new URL(url);
redirectUrl = parsed.origin + redirectUrl;
}
res.resume();
httpGet(redirectUrl, redirectsLeft - 1).then(resolve).catch(reject);
return;
}
let data = '';
let size = 0;
res.setEncoding('utf8');
res.on('data', (chunk) => {
size += Buffer.byteLength(chunk);
if (size > MAX_RESPONSE_SIZE) { res.destroy(); resolve(data); return; }
data += chunk;
});
res.on('end', () => resolve(data));
res.on('error', () => resolve(data));
});
req.on('timeout', () => { req.destroy(); resolve(''); });
req.on('error', (err) => { console.error('httpGet error:', err.message); resolve(''); });
});
}
function fetchJson(url, redirectsLeft = MAX_REDIRECTS) {
return new Promise((resolve, reject) => {
const client = url.startsWith('https') ? https : http;
const req = client.get(url, { headers: { 'User-Agent': FETCH_HEADERS['User-Agent'], 'Accept': 'application/json' }, timeout: FETCH_TIMEOUT }, (res) => {
if ([301, 302, 303, 307, 308].includes(res.statusCode) && res.headers.location) {
if (redirectsLeft <= 0) { resolve(null); return; }
let redirectUrl = res.headers.location;
if (redirectUrl.startsWith('/')) {
const parsed = new URL(url);
redirectUrl = parsed.origin + redirectUrl;
}
res.resume();
fetchJson(redirectUrl, redirectsLeft - 1).then(resolve).catch(reject);
return;
}
let data = '';
res.setEncoding('utf8');
res.on('data', (chunk) => { data += chunk; });
res.on('end', () => { try { resolve(JSON.parse(data)); } catch { resolve(null); } });
res.on('error', () => resolve(null));
});
req.on('timeout', () => { req.destroy(); resolve(null); });
req.on('error', () => resolve(null));
});
}
function parseMetaTags(html) {
const meta = {};
// Match both orderings: property/name before content AND content before property/name
const metaRegex = /<meta\s+([^>]*?)\/?\s*>/gi;
let match;
while ((match = metaRegex.exec(html)) !== null) {
const attrs = match[1];
let name = null, content = null;
// Extract property or name attribute
const propMatch = attrs.match(/(?:property|name)\s*=\s*["']([^"']+)["']/i);
const contentMatch = attrs.match(/content\s*=\s*["']([^"']*?)["']/i);
if (propMatch) name = propMatch[1].toLowerCase();
if (contentMatch) content = contentMatch[1];
if (name && content !== null) meta[name] = content;
}
// Extract <title> tag
const titleMatch = html.match(/<title[^>]*>([\s\S]*?)<\/title>/i);
if (titleMatch) meta['_title'] = titleMatch[1].trim();
return meta;
}
function buildMetadata(meta) {
const get = (...keys) => { for (const k of keys) { if (meta[k]) return meta[k]; } return null; };
return {
title: get('og:title', 'twitter:title', '_title'),
description: get('og:description', 'twitter:description', 'description'),
image: get('og:image', 'og:image:secure_url', 'twitter:image', 'twitter:image:src'),
siteName: get('og:site_name'),
themeColor: get('theme-color'),
video: get('og:video:secure_url', 'og:video:url', 'og:video'),
type: get('og:type', 'twitter:card'),
author: get('article:author', 'author'),
};
}
function sanitizeMetadata(metadata) {
const decodeEntities = (str) => {
if (!str) return str;
return str.replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>')
.replace(/&quot;/g, '"').replace(/&#39;/g, "'").replace(/&#x27;/g, "'")
.replace(/&#(\d+);/g, (_, n) => String.fromCharCode(n))
.replace(/&#x([0-9a-fA-F]+);/g, (_, n) => String.fromCharCode(parseInt(n, 16)));
};
const stripTags = (str) => str ? str.replace(/<[^>]*>/g, '') : str;
const limit = (str, max = 1000) => str && str.length > max ? str.substring(0, max) : str;
const result = {};
for (const [key, value] of Object.entries(metadata)) {
if (typeof value === 'string') {
result[key] = limit(decodeEntities(stripTags(value)).trim());
} else {
result[key] = value;
}
}
return result;
}
ipcMain.handle('fetch-metadata', async (event, url) => {
return new Promise((resolve) => {
// Check for direct video links to avoid downloading large files
try {
// Check for direct video links
const videoExtensions = ['.mp4', '.webm', '.ogg', '.mov'];
const imageExtensions = ['.gif', '.png', '.jpg', '.jpeg', '.webp'];
const lowerUrl = url.toLowerCase();
if (videoExtensions.some(ext => lowerUrl.endsWith(ext))) {
const filename = url.split('/').pop();
resolve({
title: filename,
siteName: new URL(url).hostname,
video: url,
image: null, // No thumbnail for now unless we generate one
description: 'Video File'
});
return;
return { title: url.split('/').pop(), siteName: new URL(url).hostname, video: url, image: null, description: 'Video File' };
}
if (imageExtensions.some(ext => lowerUrl.endsWith(ext))) {
const filename = url.split('/').pop();
resolve({
title: filename,
siteName: new URL(url).hostname,
video: null,
image: url, // Direct image/gif
description: 'Image File'
});
return;
return { title: url.split('/').pop(), siteName: new URL(url).hostname, video: null, image: url, description: 'Image File' };
}
const client = url.startsWith('https') ? https : http;
const req = client.get(url, (res) => {
let data = '';
res.on('data', (chunk) => data += chunk);
res.on('end', () => {
// Simple Regex Parser for OG Tags to avoid dependencies
const getMeta = (prop) => {
const regex = new RegExp(`<meta\\s+(?:name|property)=["'](?:og:)?${prop}["']\\s+content=["'](.*?)["']`, 'i');
const match = data.match(regex);
return match ? match[1] : null;
};
const getTitle = () => {
const regex = /<title>(.*?)<\/title>/i;
const match = data.match(regex);
return match ? match[1] : null;
};
const hostname = new URL(url).hostname.replace(/^www\./, '');
const oembedBuilder = OEMBED_PROVIDERS[hostname] || OEMBED_PROVIDERS['www.' + hostname];
const oembedUrl = oembedBuilder ? oembedBuilder(url) : null;
const metadata = {
title: getMeta('title') || getTitle(),
description: getMeta('description'),
image: getMeta('image'),
siteName: getMeta('site_name'),
themeColor: getMeta('theme-color')
};
resolve(metadata);
});
});
req.on('error', (err) => {
console.error('Metadata fetch error:', err);
resolve(null);
});
});
let metadata;
if (oembedUrl) {
// Fetch oEmbed JSON and page HTML in parallel
const [oembedResult, htmlResult] = await Promise.allSettled([
fetchJson(oembedUrl),
httpGet(url),
]);
const oembed = oembedResult.status === 'fulfilled' ? oembedResult.value : null;
const html = htmlResult.status === 'fulfilled' ? htmlResult.value : '';
const meta = html ? parseMetaTags(html) : {};
const ogData = buildMetadata(meta);
metadata = {
title: oembed?.title || ogData.title,
description: ogData.description,
image: oembed?.thumbnail_url || ogData.image,
siteName: oembed?.provider_name || ogData.siteName,
themeColor: ogData.themeColor,
video: ogData.video,
type: ogData.type,
author: oembed?.author_name || ogData.author,
};
} else {
// Standard HTML fetch
const html = await httpGet(url);
if (!html) return null;
const meta = parseMetaTags(html);
metadata = buildMetadata(meta);
}
return sanitizeMetadata(metadata);
} catch (err) {
console.error('Metadata fetch error:', err);
return null;
}
});
ipcMain.handle('open-external', async (event, url) => {
await shell.openExternal(url);
});
// Settings IPC handlers
ipcMain.handle('get-setting', (event, key) => {
const settings = loadSettings();
return settings[key];
});
ipcMain.handle('set-setting', (event, key, value) => {
const settings = loadSettings();
settings[key] = value;
saveSettings(settings);
});
// Secure session persistence handlers
ipcMain.handle('save-session', (event, data) => {
try {
if (!safeStorage.isEncryptionAvailable()) return false;
const encrypted = safeStorage.encryptString(JSON.stringify(data));
fs.writeFileSync(SESSION_FILE, encrypted);
return true;
} catch (err) {
console.error('Failed to save session:', err.message);
return false;
}
});
ipcMain.handle('load-session', () => {
try {
if (!safeStorage.isEncryptionAvailable()) return null;
if (!fs.existsSync(SESSION_FILE)) return null;
const encrypted = fs.readFileSync(SESSION_FILE);
const decrypted = safeStorage.decryptString(encrypted);
return JSON.parse(decrypted);
} catch (err) {
console.error('Failed to load session (clearing corrupt file):', err.message);
try { fs.unlinkSync(SESSION_FILE); } catch {}
return null;
}
});
ipcMain.handle('clear-session', () => {
try {
if (fs.existsSync(SESSION_FILE)) fs.unlinkSync(SESSION_FILE);
return true;
} catch (err) {
console.error('Failed to clear session:', err.message);
return false;
}
});
ipcMain.handle('get-screen-sources', async () => {
const { desktopCapturer } = require('electron');
const sources = await desktopCapturer.getSources({

View File

@@ -1,13 +1,14 @@
{
"name": "discord",
"version": "0.0.0",
"version": "1.0.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "discord",
"version": "0.0.0",
"version": "1.0.2",
"dependencies": {
"@convex-dev/presence": "^0.3.0",
"@livekit/components-react": "^2.9.17",
"@livekit/components-styles": "^1.2.0",
"convex": "^1.31.2",
@@ -16,6 +17,7 @@
"livekit-client": "^2.16.1",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-easy-crop": "^5.5.6",
"react-markdown": "^10.1.0",
"react-router-dom": "^7.11.0",
"react-syntax-highlighter": "^16.1.0",
@@ -334,6 +336,27 @@
"integrity": "sha512-wJ8ReQbHxsAfXhrf9ixl0aYbZorRuOWpBNzm8pL8ftmSxQx/wnJD5Eg861NwJU/czy2VXFIebCeZnZrI9rktIQ==",
"license": "(Apache-2.0 AND BSD-3-Clause)"
},
"node_modules/@convex-dev/presence": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@convex-dev/presence/-/presence-0.3.0.tgz",
"integrity": "sha512-adV+ao1L77u+egobyJabNwuai/0y/VgzMbqZiy+Q49JmX6fbSaiYg6FpFVACqPaq3giOfjrN2k+5mK2jTAUG0g==",
"license": "Apache-2.0",
"peerDependencies": {
"convex": "^1.24.8",
"expo-crypto": ">=14.1.0",
"react": "~18.3.1 || ^19.0.0",
"react-dom": "~18.3.1 || ^19.0.0",
"react-native": ">=0.79.0"
},
"peerDependenciesMeta": {
"expo-crypto": {
"optional": true
},
"react-native": {
"optional": true
}
}
},
"node_modules/@develar/schema-utils": {
"version": "2.6.5",
"resolved": "https://registry.npmjs.org/@develar/schema-utils/-/schema-utils-2.6.5.tgz",
@@ -8176,6 +8199,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/normalize-wheel": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/normalize-wheel/-/normalize-wheel-1.0.1.tgz",
"integrity": "sha512-1OnlAPZ3zgrk8B91HyRj+eVv+kS5u+Z0SCsak6Xil/kmgEia50ga7zfkumayonZrImffAxPU/5WcyGhzetHNPA==",
"license": "BSD-3-Clause"
},
"node_modules/npmlog": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/npmlog/-/npmlog-6.0.2.tgz",
@@ -8670,6 +8699,20 @@
"react": "^19.2.3"
}
},
"node_modules/react-easy-crop": {
"version": "5.5.6",
"resolved": "https://registry.npmjs.org/react-easy-crop/-/react-easy-crop-5.5.6.tgz",
"integrity": "sha512-Jw3/ozs8uXj3NpL511Suc4AHY+mLRO23rUgipXvNYKqezcFSYHxe4QXibBymkOoY6oOtLVMPO2HNPRHYvMPyTw==",
"license": "MIT",
"dependencies": {
"normalize-wheel": "^1.0.1",
"tslib": "^2.0.1"
},
"peerDependencies": {
"react": ">=16.4.0",
"react-dom": ">=16.4.0"
}
},
"node_modules/react-markdown": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz",

View File

@@ -34,14 +34,21 @@
}
],
"win": {
"target": ["nsis"]
"target": [
"nsis"
]
},
"mac": {
"target": ["dmg", "zip"],
"target": [
"dmg",
"zip"
],
"category": "public.app-category.social-networking"
},
"linux": {
"target": ["AppImage"]
"target": [
"AppImage"
]
},
"nsis": {
"oneClick": true,
@@ -49,6 +56,7 @@
}
},
"dependencies": {
"@convex-dev/presence": "^0.3.0",
"@livekit/components-react": "^2.9.17",
"@livekit/components-styles": "^1.2.0",
"convex": "^1.31.2",
@@ -57,6 +65,7 @@
"livekit-client": "^2.16.1",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-easy-crop": "^5.5.6",
"react-markdown": "^10.1.0",
"react-router-dom": "^7.11.0",
"react-syntax-highlighter": "^16.1.0",

View File

@@ -24,3 +24,14 @@ contextBridge.exposeInMainWorld('windowControls', {
maximize: () => ipcRenderer.send('window-maximize'),
close: () => ipcRenderer.send('window-close'),
});
contextBridge.exposeInMainWorld('appSettings', {
get: (key) => ipcRenderer.invoke('get-setting', key),
set: (key, value) => ipcRenderer.invoke('set-setting', key, value),
});
contextBridge.exposeInMainWorld('sessionPersistence', {
save: (data) => ipcRenderer.invoke('save-session', data),
load: () => ipcRenderer.invoke('load-session'),
clear: () => ipcRenderer.invoke('clear-session'),
});

View File

@@ -1,16 +1,100 @@
import React from 'react';
import { Routes, Route } from 'react-router-dom';
import React, { useState, useEffect, useRef } from 'react';
import { Routes, Route, Navigate, useLocation, useNavigate } from 'react-router-dom';
import Login from './pages/Login';
import Register from './pages/Register';
import Chat from './pages/Chat';
const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000;
function AuthGuard({ children }) {
const [authState, setAuthState] = useState('loading'); // 'loading' | 'authenticated' | 'unauthenticated'
const location = useLocation();
const navigate = useNavigate();
useEffect(() => {
let cancelled = false;
async function restoreSession() {
// Already have keys in sessionStorage — current session is active
if (sessionStorage.getItem('privateKey') && sessionStorage.getItem('signingKey')) {
if (!cancelled) setAuthState('authenticated');
return;
}
// Try restoring from safeStorage
if (window.sessionPersistence) {
try {
const session = await window.sessionPersistence.load();
if (session && session.savedAt && (Date.now() - session.savedAt) < THIRTY_DAYS_MS) {
// Restore to localStorage + sessionStorage
localStorage.setItem('userId', session.userId);
localStorage.setItem('username', session.username);
if (session.publicKey) localStorage.setItem('publicKey', session.publicKey);
sessionStorage.setItem('signingKey', session.signingKey);
sessionStorage.setItem('privateKey', session.privateKey);
if (!cancelled) setAuthState('authenticated');
return;
}
// Expired — clear stale session
if (session && session.savedAt) {
await window.sessionPersistence.clear();
}
} catch (err) {
console.error('Session restore failed:', err);
}
}
if (!cancelled) setAuthState('unauthenticated');
}
restoreSession();
return () => { cancelled = true; };
}, []);
// Redirect once after auth state is determined (not on every route change)
const hasRedirected = useRef(false);
useEffect(() => {
if (authState === 'loading' || hasRedirected.current) return;
hasRedirected.current = true;
const isAuthPage = location.pathname === '/' || location.pathname === '/register';
if (authState === 'authenticated' && isAuthPage) {
navigate('/chat', { replace: true });
} else if (authState === 'unauthenticated' && !isAuthPage) {
navigate('/', { replace: true });
}
}, [authState]);
if (authState === 'loading') {
return (
<div style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '100vh',
backgroundColor: 'var(--bg-primary, #313338)',
color: 'var(--text-normal, #dbdee1)',
fontSize: '16px',
}}>
Loading...
</div>
);
}
return children;
}
function App() {
return (
<Routes>
<Route path="/" element={<Login />} />
<Route path="/register" element={<Register />} />
<Route path="/chat" element={<Chat />} />
</Routes>
<AuthGuard>
<Routes>
<Route path="/" element={<Login />} />
<Route path="/register" element={<Register />} />
<Route path="/chat" element={<Chat />} />
</Routes>
</AuthGuard>
);
}

View File

@@ -0,0 +1 @@
<svg class="ownerIcon__5d473 icon__5d473" aria-describedby="«r7fs»" aria-hidden="true" role="img" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24"><path fill="currentColor" d="M5 18a1 1 0 0 0-1 1 3 3 0 0 0 3 3h10a3 3 0 0 0 3-3 1 1 0 0 0-1-1H5ZM3.04 7.76a1 1 0 0 0-1.52 1.15l2.25 6.42a1 1 0 0 0 .94.67h14.55a1 1 0 0 0 .95-.71l1.94-6.45a1 1 0 0 0-1.55-1.1l-4.11 3-3.55-5.33.82-.82a.83.83 0 0 0 0-1.18l-1.17-1.17a.83.83 0 0 0-1.18 0l-1.17 1.17a.83.83 0 0 0 0 1.18l.82.82-3.61 5.42-4.41-3.07Z" class=""></path></svg>

After

Width:  |  Height:  |  Size: 555 B

View File

@@ -0,0 +1 @@
<svg aria-hidden="true" role="img" xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="none" viewBox="0 0 24 24"><path fill="currentColor" d="M14.5 8a3 3 0 1 0-2.7-4.3c-.2.4.06.86.44 1.12a5 5 0 0 1 2.14 3.08c.01.06.06.1.12.1ZM16.62 13.17c-.22.29-.65.37-.92.14-.34-.3-.7-.57-1.09-.82-.52-.33-.7-1.05-.47-1.63.11-.27.2-.57.26-.87.11-.54.55-1 1.1-.92 1.6.2 3.04.92 4.15 1.98.3.27-.25.95-.65.95a3 3 0 0 0-2.38 1.17ZM15.19 15.61c.13.16.02.39-.19.39a3 3 0 0 0-1.52 5.59c.2.12.26.41.02.41h-8a.5.5 0 0 1-.5-.5v-2.1c0-.25-.31-.33-.42-.1-.32.67-.67 1.58-.88 2.54a.2.2 0 0 1-.2.16A1.5 1.5 0 0 1 2 20.5a7.5 7.5 0 0 1 13.19-4.89ZM9.5 12a3 3 0 1 0 0-6 3 3 0 0 0 0 6ZM15.5 22Z" class=""></path><path fill="currentColor" d="M19 14a1 1 0 0 1 1 1v3h3a1 1 0 0 1 0 2h-3v3a1 1 0 0 1-2 0v-3h-3a1 1 0 1 1 0-2h3v-3a1 1 0 0 1 1-1Z" class=""></path></svg>

After

Width:  |  Height:  |  Size: 841 B

View File

@@ -0,0 +1,134 @@
import React, { useState, useCallback, useEffect } from 'react';
import Cropper from 'react-easy-crop';
function getCroppedImg(imageSrc, pixelCrop) {
return new Promise((resolve, reject) => {
const image = new Image();
image.crossOrigin = 'anonymous';
image.onload = () => {
const canvas = document.createElement('canvas');
canvas.width = 256;
canvas.height = 256;
const ctx = canvas.getContext('2d');
ctx.drawImage(
image,
pixelCrop.x, pixelCrop.y, pixelCrop.width, pixelCrop.height,
0, 0, 256, 256
);
canvas.toBlob((blob) => {
if (!blob) return reject(new Error('Canvas toBlob failed'));
resolve(blob);
}, 'image/png');
};
image.onerror = reject;
image.src = imageSrc;
});
}
const AvatarCropModal = ({ imageUrl, onApply, onCancel }) => {
const [crop, setCrop] = useState({ x: 0, y: 0 });
const [zoom, setZoom] = useState(1);
const [croppedAreaPixels, setCroppedAreaPixels] = useState(null);
const onCropComplete = useCallback((_croppedArea, croppedPixels) => {
setCroppedAreaPixels(croppedPixels);
}, []);
const handleApply = useCallback(async () => {
if (!croppedAreaPixels) return;
const blob = await getCroppedImg(imageUrl, croppedAreaPixels);
onApply(blob);
}, [imageUrl, croppedAreaPixels, onApply]);
useEffect(() => {
const handleKey = (e) => {
if (e.key === 'Escape') {
e.stopPropagation();
onCancel();
}
};
window.addEventListener('keydown', handleKey, true);
return () => window.removeEventListener('keydown', handleKey, true);
}, [onCancel]);
return (
<div className="avatar-crop-overlay" onMouseDown={(e) => { if (e.target === e.currentTarget) onCancel(); }}>
<div className="avatar-crop-dialog">
{/* Header */}
<div className="avatar-crop-header">
<h2 style={{ margin: 0, fontSize: '20px', fontWeight: 700, color: 'var(--header-primary)' }}>
Edit Image
</h2>
<button
onClick={onCancel}
style={{
background: 'none', border: 'none', color: 'var(--header-secondary)',
fontSize: '24px', cursor: 'pointer', padding: '4px', lineHeight: 1,
}}
>
</button>
</div>
{/* Crop area */}
<div className="avatar-crop-area">
<Cropper
image={imageUrl}
crop={crop}
zoom={zoom}
aspect={1}
cropShape="round"
showGrid={false}
onCropChange={setCrop}
onZoomChange={setZoom}
onCropComplete={onCropComplete}
/>
</div>
{/* Zoom slider */}
<div className="avatar-crop-slider-row">
<svg width="16" height="16" viewBox="0 0 24 24" fill="var(--header-secondary)">
<path d="M15 3H9v2h6V3zm-4 18h2v-2h-2v2zm8-15.97l-1.41-1.41-1.76 1.76 1.41 1.41L19 5.03zM4.76 4.38L3.34 5.8 5.1 7.56 6.52 6.14 4.76 4.38zM21 11h-2v2h2v-2zM5 11H3v2h2v-2zm7-4a5 5 0 100 10 5 5 0 000-10z"/>
</svg>
<input
type="range"
min={1}
max={3}
step={0.01}
value={zoom}
onChange={(e) => setZoom(Number(e.target.value))}
className="avatar-crop-slider"
/>
<svg width="20" height="20" viewBox="0 0 24 24" fill="var(--header-secondary)">
<path d="M15 3H9v2h6V3zm-4 18h2v-2h-2v2zm8-15.97l-1.41-1.41-1.76 1.76 1.41 1.41L19 5.03zM4.76 4.38L3.34 5.8 5.1 7.56 6.52 6.14 4.76 4.38zM21 11h-2v2h2v-2zM5 11H3v2h2v-2zm7-4a5 5 0 100 10 5 5 0 000-10z"/>
</svg>
</div>
{/* Actions */}
<div className="avatar-crop-actions">
<button
onClick={onCancel}
style={{
background: 'none', border: 'none', color: 'var(--header-primary)',
cursor: 'pointer', fontSize: '14px', fontWeight: 500, padding: '8px 16px',
}}
>
Cancel
</button>
<button
onClick={handleApply}
style={{
backgroundColor: 'var(--brand-experiment)', color: 'white', border: 'none',
borderRadius: '4px', padding: '8px 24px', cursor: 'pointer',
fontSize: '14px', fontWeight: 500,
}}
>
Apply
</button>
</div>
</div>
</div>
);
};
export default AvatarCropModal;

View File

@@ -65,8 +65,36 @@ const extractUrls = (text) => {
return text.match(urlRegex) || [];
};
const VIDEO_EXT_RE = /\.(mp4|webm|ogg|mov)(\?[^\s]*)?$/i;
const isVideoUrl = (url) => VIDEO_EXT_RE.test(url);
const DirectVideo = ({ src, marginTop = 8 }) => {
const ref = useRef(null);
const [showControls, setShowControls] = useState(false);
const handlePlay = () => {
setShowControls(true);
if (ref.current) ref.current.play();
};
return (
<div style={{ marginTop, position: 'relative', display: 'inline-block', maxWidth: '100%' }}>
<video
ref={ref}
src={src}
controls={showControls}
preload="metadata"
style={{ maxWidth: '100%', maxHeight: '300px', borderRadius: '8px', backgroundColor: 'black', display: 'block' }}
/>
{!showControls && (
<div className="play-icon" onClick={handlePlay} style={{ cursor: 'pointer' }}>
</div>
)}
</div>
);
};
const getYouTubeId = (link) => {
const regExp = /^.*(youtu.be\/|v\/|u\/\w\/|embed\/|watch\?v=|&v=)([^#&?]*).*/;
const regExp = /^.*(youtu.be\/|v\/|u\/\w\/|embed\/|shorts\/|watch\?v=|&v=)([^#&?]*).*/;
const match = link.match(regExp);
return (match && match[2].length === 11) ? match[2] : null;
};
@@ -105,6 +133,16 @@ const isNewDay = (current, previous) => {
|| current.getFullYear() !== previous.getFullYear();
};
const getProviderClass = (url) => {
try {
const hostname = new URL(url).hostname.replace(/^www\./, '');
if (hostname === 'twitter.com' || hostname === 'x.com') return 'twitter-preview';
if (hostname === 'open.spotify.com') return 'spotify-preview';
if (hostname === 'reddit.com') return 'reddit-preview';
} catch {}
return '';
};
const LinkPreview = ({ url }) => {
const [metadata, setMetadata] = useState(metadataCache.get(url) || null);
const [loading, setLoading] = useState(!metadataCache.has(url));
@@ -139,6 +177,11 @@ const LinkPreview = ({ url }) => {
const videoId = getYouTubeId(url);
const isYouTube = !!videoId;
const isDirectVideoUrl = isVideoUrl(url);
if (isDirectVideoUrl) {
return <DirectVideo src={url} />;
}
if (loading || !metadata || (!metadata.title && !metadata.image && !metadata.video)) return null;
@@ -173,10 +216,14 @@ const LinkPreview = ({ url }) => {
);
}
const providerClass = getProviderClass(url);
const isLargeImage = providerClass === 'twitter-preview' || metadata.type === 'article' || metadata.type === 'summary_large_image';
return (
<div className={`link-preview ${isYouTube ? 'youtube-preview' : ''}`} style={{ borderLeftColor: metadata.themeColor || '#202225' }}>
<div className={`link-preview ${isYouTube ? 'youtube-preview' : ''} ${providerClass} ${isLargeImage && !isYouTube ? 'large-image-layout' : ''}`} style={{ borderLeftColor: metadata.themeColor || '#202225' }}>
<div className="preview-content">
{metadata.siteName && <div className="preview-site-name">{metadata.siteName}</div>}
{metadata.author && <div className="preview-author">{metadata.author}</div>}
{metadata.title && (
<a href={url} onClick={(e) => { e.preventDefault(); window.cryptoAPI.openExternal(url); }} className="preview-title">
{metadata.title}
@@ -194,11 +241,21 @@ const LinkPreview = ({ url }) => {
/>
</div>
)}
{isLargeImage && !isYouTube && metadata.image && (
<div className="preview-image-container large-image">
<img src={metadata.image} alt="Preview" className="preview-image" />
</div>
)}
</div>
{metadata.image && (!isYouTube || !playing) && (
<div className="preview-image-container" onClick={() => isYouTube && setPlaying(true)} style={isYouTube ? { cursor: 'pointer' } : {}}>
{!isLargeImage && !isYouTube && metadata.image && (
<div className="preview-image-container">
<img src={metadata.image} alt="Preview" className="preview-image" />
{isYouTube && <div className="play-icon"></div>}
</div>
)}
{isYouTube && metadata.image && !playing && (
<div className="preview-image-container" onClick={() => setPlaying(true)} style={{ cursor: 'pointer' }}>
<img src={metadata.image} alt="Preview" className="preview-image" />
<div className="play-icon"></div>
</div>
)}
</div>
@@ -1042,15 +1099,19 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
const urls = extractUrls(msg.content);
const isOnlyUrl = urls.length === 1 && msg.content.trim() === urls[0];
const isGif = isOnlyUrl && (urls[0].includes('tenor.com') || urls[0].includes('giphy.com') || urls[0].endsWith('.gif'));
const isDirectVideo = isOnlyUrl && isVideoUrl(urls[0]);
return (
<>
{!isGif && (
{!isGif && !isDirectVideo && (
<ReactMarkdown remarkPlugins={[remarkGfm]} urlTransform={(url) => url} components={markdownComponents}>
{formatEmojis(formatMentions(msg.content))}
</ReactMarkdown>
)}
{urls.map((url, i) => <LinkPreview key={i} url={url} />)}
{isDirectVideo && <DirectVideo src={urls[0]} marginTop={4} />}
{urls.filter(u => !(isDirectVideo && u === urls[0])).map((url, i) => (
<LinkPreview key={i} url={url} />
))}
</>
);
};
@@ -1263,6 +1324,11 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
onBlur={saveSelection}
onMouseUp={saveSelection}
onKeyUp={saveSelection}
onPaste={(e) => {
e.preventDefault();
const text = e.clipboardData.getData('text/plain');
document.execCommand('insertText', false, text);
}}
onInput={(e) => {
const textContent = e.currentTarget.textContent;
setInput(textContent);

View File

@@ -3,6 +3,7 @@ import { useConvex } from 'convex/react';
import { api } from '../../../../convex/_generated/api';
import Tooltip from './Tooltip';
import Avatar from './Avatar';
import { useOnlineUsers } from '../contexts/PresenceContext';
const STATUS_COLORS = {
online: '#3ba55c',
@@ -37,6 +38,7 @@ const DMList = ({ dmChannels, activeDMChannel, onSelectDM, onOpenDM }) => {
const [searchFocused, setSearchFocused] = useState(false);
const searchRef = useRef(null);
const searchInputRef = useRef(null);
const { resolveStatus } = useOnlineUsers();
const convex = useConvex();
@@ -200,7 +202,7 @@ const DMList = ({ dmChannels, activeDMChannel, onSelectDM, onOpenDM }) => {
<div style={{ flex: 1, overflowY: 'auto' }}>
{(dmChannels || []).map(dm => {
const isActive = activeDMChannel?.channel_id === dm.channel_id;
const status = dm.other_user_status || 'online';
const effectiveStatus = resolveStatus(dm.other_user_status, dm.other_user_id);
return (
<div
key={dm.channel_id}
@@ -213,7 +215,7 @@ const DMList = ({ dmChannels, activeDMChannel, onSelectDM, onOpenDM }) => {
<div style={{
position: 'absolute', bottom: -2, right: -2,
width: 10, height: 10, borderRadius: '50%',
backgroundColor: STATUS_COLORS[status] || STATUS_COLORS.online,
backgroundColor: STATUS_COLORS[effectiveStatus] || STATUS_COLORS.offline,
border: '2px solid var(--bg-secondary)'
}} />
</div>
@@ -222,7 +224,7 @@ const DMList = ({ dmChannels, activeDMChannel, onSelectDM, onOpenDM }) => {
{dm.other_username}
</div>
<div className="dm-item-status">
{STATUS_LABELS[status] || 'Online'}
{STATUS_LABELS[effectiveStatus] || 'Offline'}
</div>
</div>
</div>

View File

@@ -2,12 +2,14 @@ import React, { useState } from 'react';
import { useQuery } from 'convex/react';
import { api } from '../../../../convex/_generated/api';
import Avatar from './Avatar';
import { useOnlineUsers } from '../contexts/PresenceContext';
const FriendsView = ({ onOpenDM }) => {
const [activeTab, setActiveTab] = useState('Online');
const [addFriendSearch, setAddFriendSearch] = useState('');
const myId = localStorage.getItem('userId');
const { resolveStatus } = useOnlineUsers();
const allUsers = useQuery(api.auth.getPublicKeys) || [];
const users = allUsers.filter(u => u.id !== myId);
@@ -31,7 +33,7 @@ const FriendsView = ({ onOpenDM }) => {
};
const filteredUsers = activeTab === 'Online'
? users.filter(u => u.status !== 'offline' && u.status !== 'invisible')
? users.filter(u => resolveStatus(u.status, u.id) !== 'offline')
: activeTab === 'Add Friend'
? users.filter(u => u.username?.toLowerCase().includes(addFriendSearch.toLowerCase()))
: users;
@@ -91,7 +93,9 @@ const FriendsView = ({ onOpenDM }) => {
{/* Friends List */}
<div style={{ flex: 1, overflowY: 'auto', padding: '0 20px' }}>
{filteredUsers.map(user => (
{filteredUsers.map(user => {
const effectiveStatus = resolveStatus(user.status, user.id);
return (
<div
key={user.id}
className="friend-item"
@@ -102,7 +106,7 @@ const FriendsView = ({ onOpenDM }) => {
<div style={{
position: 'absolute', bottom: -2, right: -2,
width: 10, height: 10, borderRadius: '50%',
backgroundColor: STATUS_COLORS[user.status] || STATUS_COLORS.online,
backgroundColor: STATUS_COLORS[effectiveStatus] || STATUS_COLORS.offline,
border: '2px solid var(--bg-primary)'
}} />
</div>
@@ -111,7 +115,7 @@ const FriendsView = ({ onOpenDM }) => {
{user.username ?? 'Unknown'}
</div>
<div style={{ color: 'var(--header-secondary)', fontSize: '12px' }}>
{user.status === 'dnd' ? 'Do Not Disturb' : (user.status || 'Online').charAt(0).toUpperCase() + (user.status || 'online').slice(1)}
{effectiveStatus === 'dnd' ? 'Do Not Disturb' : effectiveStatus.charAt(0).toUpperCase() + effectiveStatus.slice(1)}
</div>
</div>
</div>
@@ -134,7 +138,8 @@ const FriendsView = ({ onOpenDM }) => {
</div>
</div>
</div>
))}
);
})}
</div>
</div>
);

View File

@@ -1,6 +1,7 @@
import React from 'react';
import { useQuery } from 'convex/react';
import { api } from '../../../../convex/_generated/api';
import { useOnlineUsers } from '../contexts/PresenceContext';
const USER_COLORS = ['#5865F2', '#EBA7CD', '#57F287', '#FEE75C', '#EB459E', '#ED4245'];
@@ -25,11 +26,12 @@ const MembersList = ({ channelId, visible, onMemberClick }) => {
api.members.getChannelMembers,
channelId ? { channelId } : "skip"
) || [];
const { resolveStatus } = useOnlineUsers();
if (!visible) return null;
const onlineMembers = members.filter(m => m.status !== 'offline' && m.status !== 'invisible');
const offlineMembers = members.filter(m => m.status === 'offline' || m.status === 'invisible');
const onlineMembers = members.filter(m => resolveStatus(m.status, m.id) !== 'offline');
const offlineMembers = members.filter(m => resolveStatus(m.status, m.id) === 'offline');
// Group online members by highest hoisted role
const roleGroups = {};
@@ -54,13 +56,14 @@ const MembersList = ({ channelId, visible, onMemberClick }) => {
const renderMember = (member) => {
const topRole = member.roles.length > 0 ? member.roles[0] : null;
const nameColor = topRole && topRole.name !== '@everyone' ? topRole.color : '#fff';
const effectiveStatus = resolveStatus(member.status, member.id);
return (
<div
key={member.id}
className="member-item"
onClick={() => onMemberClick && onMemberClick(member)}
style={member.status === 'offline' || member.status === 'invisible' ? { opacity: 0.3 } : {}}
style={effectiveStatus === 'offline' ? { opacity: 0.3 } : {}}
>
<div className="member-avatar-wrapper">
{member.avatarUrl ? (
@@ -80,7 +83,7 @@ const MembersList = ({ channelId, visible, onMemberClick }) => {
)}
<div
className="member-status-dot"
style={{ backgroundColor: STATUS_COLORS[member.status] || STATUS_COLORS.online }}
style={{ backgroundColor: STATUS_COLORS[effectiveStatus] || STATUS_COLORS.offline }}
/>
</div>
<div className="member-info">

View File

@@ -1,5 +1,6 @@
import React, { useState } from 'react';
import { useConvex, useMutation } from 'convex/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';
@@ -8,7 +9,7 @@ import ServerSettingsModal from './ServerSettingsModal';
import ScreenShareModal from './ScreenShareModal';
import DMList from './DMList';
import Avatar from './Avatar';
import ThemeSelector from './ThemeSelector';
import UserSettings from './UserSettings';
import { Track } from 'livekit-client';
import muteIcon from '../assets/icons/mute.svg';
import mutedIcon from '../assets/icons/muted.svg';
@@ -19,6 +20,7 @@ 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';
const USER_COLORS = ['#5865F2', '#EBA7CD', '#57F287', '#FEE75C', '#EB459E', '#ED4245'];
@@ -101,11 +103,46 @@ const STATUS_OPTIONS = [
];
const UserControlPanel = ({ username, userId }) => {
const { isMuted, isDeafened, toggleMute, toggleDeafen, connectionState } = useVoice();
const { isMuted, isDeafened, toggleMute, toggleDeafen, connectionState, disconnectVoice } = useVoice();
const [showStatusMenu, setShowStatusMenu] = useState(false);
const [showThemeSelector, setShowThemeSelector] = useState(false);
const [showUserSettings, setShowUserSettings] = useState(false);
const [currentStatus, setCurrentStatus] = useState('online');
const updateStatusMutation = useMutation(api.auth.updateStatus);
const navigate = useNavigate();
// 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) {
if (myUser.status && myUser.status !== 'offline') {
setCurrentStatus(myUser.status);
} else if (!myUser.status || myUser.status === 'offline') {
// First login or no preference set yet — default to "online"
setCurrentStatus('online');
if (userId) {
updateStatusMutation({ userId, status: 'online' }).catch(() => {});
}
}
}
}, [myUser?.status]);
const handleLogout = async () => {
// Disconnect voice if connected
if (connectionState === 'connected') {
try { disconnectVoice(); } catch {}
}
// Clear persisted session
if (window.sessionPersistence) {
try { await window.sessionPersistence.clear(); } catch {}
}
// Clear storage (preserve theme)
const theme = localStorage.getItem('theme');
localStorage.clear();
if (theme) localStorage.setItem('theme', theme);
sessionStorage.clear();
navigate('/');
};
const effectiveMute = isMuted || isDeafened;
const statusColor = STATUS_OPTIONS.find(s => s.value === currentStatus)?.color || '#3ba55c';
@@ -191,15 +228,31 @@ const UserControlPanel = ({ username, userId }) => {
</button>
</Tooltip>
<Tooltip text="User Settings" position="top">
<button style={controlButtonStyle} onClick={() => setShowThemeSelector(true)}>
<button style={controlButtonStyle} onClick={() => setShowUserSettings(true)}>
<ColoredIcon
src={settingsIcon}
color={ICON_COLOR_DEFAULT}
/>
</button>
</Tooltip>
<Tooltip text="Log Out" position="top">
<button style={controlButtonStyle} onClick={handleLogout}>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
<path d="M16 17L21 12L16 7" stroke={ICON_COLOR_DEFAULT} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
<path d="M21 12H9" stroke={ICON_COLOR_DEFAULT} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
<path d="M9 21H5C4.46957 21 3.96086 20.7893 3.58579 20.4142C3.21071 20.0391 3 19.5304 3 19V5C3 4.46957 3.21071 3.96086 3.58579 3.58579C3.96086 3.21071 4.46957 3 5 3H9" stroke={ICON_COLOR_DEFAULT} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
</button>
</Tooltip>
</div>
{showThemeSelector && <ThemeSelector onClose={() => setShowThemeSelector(false)} />}
{showUserSettings && (
<UserSettings
onClose={() => setShowUserSettings(false)}
userId={userId}
username={username}
onLogout={handleLogout}
/>
)}
</div>
);
};
@@ -445,7 +498,7 @@ const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKe
};
const renderDMView = () => (
<div className="channel-list" style={{ flex: 1, overflowY: 'auto', display: 'flex', flexDirection: 'column' }}>
<div className="channel-list" style={{ flex: 1, overflowY: 'auto', display: 'flex', flexDirection: 'column', backgroundColor: 'var(--bg-tertiary)', borderLeft: '1px solid var(--app-frame-border)', borderTop: '1px solid var(--app-frame-border)', borderRadius: '8px 0 0 0' }}>
<DMList
dmChannels={dmChannels}
activeDMChannel={activeDMChannel}
@@ -504,13 +557,15 @@ const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKe
}, [channels]);
const renderServerView = () => (
<div style={{ flex: 1, overflowY: 'auto', display: 'flex', flexDirection: 'column' }}>
<div className="server-header" onClick={() => setIsServerSettingsOpen(true)}>
<span>Secure Chat</span>
<span className="server-header-chevron"></span>
<div style={{ flex: 1, overflowY: 'auto', display: 'flex', flexDirection: 'column', backgroundColor: 'var(--bg-tertiary)', borderLeft: '1px solid var(--app-frame-border)', borderTop: '1px solid var(--app-frame-border)', borderRadius: '8px 0 0 0' }}>
<div className="server-header" style={{ borderBottom: '1px solid var(--app-frame-border)' }}>
<span className="server-header-name" onClick={() => setIsServerSettingsOpen(true)}>Secure Chat</span>
<button className="server-header-invite" onClick={handleCreateInvite} title="Invite People">
<img src={inviteUserIcon} alt="Invite" />
</button>
</div>
<div className="channel-list" style={{ flex: 1, overflowY: 'auto' }}>
<div className="channel-list" style={{ flex: 1, overflowY: 'auto', backgroundColor: 'var(--bg-tertiary)' }}>
{isCreating && (
<div style={{ padding: '0 8px', marginBottom: '4px' }}>
<form onSubmit={handleSubmitCreate}>

View File

@@ -0,0 +1,712 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
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';
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: 'appearance', label: 'Appearance', section: 'USER SETTINGS' },
{ id: 'voice', label: 'Voice & Video', section: 'APP SETTINGS' },
{ id: 'keybinds', label: 'Keybinds', section: 'APP SETTINGS' },
];
const UserSettings = ({ onClose, userId, username, onLogout }) => {
const [activeTab, setActiveTab] = useState('account');
useEffect(() => {
const handleKey = (e) => {
if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', handleKey);
return () => window.removeEventListener('keydown', handleKey);
}, [onClose]);
const renderSidebar = () => {
let lastSection = null;
const items = [];
TABS.forEach((tab, i) => {
if (tab.section !== lastSection) {
if (lastSection !== null) {
items.push(<div key={`sep-${i}`} style={{ height: '1px', backgroundColor: 'var(--border-subtle)', margin: '8px 10px' }} />);
}
items.push(
<div key={`hdr-${tab.section}`} style={{
fontSize: '12px', fontWeight: '700', color: 'var(--text-muted)',
marginBottom: '6px', textTransform: 'uppercase', padding: '0 10px'
}}>
{tab.section}
</div>
);
lastSection = tab.section;
}
items.push(
<div
key={tab.id}
onClick={() => 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}
</div>
);
});
items.push(<div key="sep-logout" style={{ height: '1px', backgroundColor: 'var(--border-subtle)', margin: '8px 10px' }} />);
items.push(
<div
key="logout"
onClick={onLogout}
style={{
padding: '6px 10px', borderRadius: '4px', cursor: 'pointer', fontSize: '15px',
color: '#ed4245', display: 'flex', justifyContent: 'space-between', alignItems: 'center',
}}
>
Log Out
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
<path d="M16 17L21 12L16 7" stroke="#ed4245" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
<path d="M21 12H9" stroke="#ed4245" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
<path d="M9 21H5C4.46957 21 3.96086 20.7893 3.58579 20.4142C3.21071 20.0391 3 19.5304 3 19V5C3 4.46957 3.21071 3.96086 3.58579 3.58579C3.96086 3.21071 4.46957 3 5 3H9" stroke="#ed4245" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
</div>
);
return items;
};
return (
<div style={{
position: 'fixed', top: 0, left: 0, right: 0, bottom: 0,
backgroundColor: 'var(--bg-primary)', zIndex: 1000,
display: 'flex', color: 'var(--text-normal)',
}}>
{/* Sidebar */}
<div style={{
width: '218px', backgroundColor: 'var(--bg-secondary)',
display: 'flex', flexDirection: 'column', alignItems: 'flex-end',
padding: '60px 6px 60px 20px', overflowY: 'auto',
}}>
<div style={{ width: '100%' }}>
{renderSidebar()}
</div>
</div>
{/* Content */}
<div style={{ flex: 1, display: 'flex', justifyContent: 'flex-start', overflowY: 'auto' }}>
<div style={{ flex: 1, maxWidth: '740px', padding: '60px 40px 80px', position: 'relative' }}>
{activeTab === 'account' && <MyAccountTab userId={userId} username={username} />}
{activeTab === 'appearance' && <AppearanceTab />}
{activeTab === 'voice' && <VoiceVideoTab />}
{activeTab === 'keybinds' && <KeybindsTab />}
</div>
{/* Right spacer with close button */}
<div style={{ flex: '0 0 36px', paddingTop: '60px', marginLeft: '8px' }}>
<button
onClick={onClose}
style={{
width: '36px', height: '36px', borderRadius: '50%',
border: '2px solid var(--header-secondary)', background: 'transparent',
color: 'var(--header-secondary)', cursor: 'pointer',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: '18px',
}}
>
</button>
<div style={{ fontSize: '13px', fontWeight: '600', color: 'var(--header-secondary)', textAlign: 'center', marginTop: '4px' }}>
ESC
</div>
</div>
<div style={{ flex: '0.5' }} />
</div>
</div>
);
};
/* =========================================
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);
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;
setHasChanges(changed);
}, [displayName, aboutMe, customStatus, avatarFile, 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 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;
}
const args = { userId, displayName, aboutMe, customStatus };
if (avatarStorageId) args.avatarStorageId = avatarStorageId;
await convex.mutation(api.auth.updateProfile, args);
setAvatarFile(null);
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);
if (avatarPreview) {
URL.revokeObjectURL(avatarPreview);
setAvatarPreview(null);
}
if (rawImageUrl) {
URL.revokeObjectURL(rawImageUrl);
setRawImageUrl(null);
}
setShowCropModal(false);
};
const avatarUrl = avatarPreview || currentUser?.avatarUrl;
return (
<div>
<h2 style={{ color: 'var(--header-primary)', margin: '0 0 20px', fontSize: '20px' }}>My Account</h2>
{/* Profile card */}
<div style={{ backgroundColor: 'var(--bg-secondary)', borderRadius: '8px', overflow: 'hidden' }}>
{/* Banner */}
<div style={{ height: '100px', backgroundColor: 'var(--brand-experiment)' }} />
{/* Profile body */}
<div style={{ padding: '0 16px 16px', position: 'relative' }}>
{/* Avatar */}
<div
className="user-settings-avatar-wrapper"
onClick={() => fileInputRef.current?.click()}
style={{ marginTop: '-40px', marginBottom: '12px', width: 'fit-content', cursor: 'pointer', position: 'relative' }}
>
<Avatar username={username} avatarUrl={avatarUrl} size={80} />
<div className="user-settings-avatar-overlay">
CHANGE<br/>AVATAR
</div>
<input
ref={fileInputRef}
type="file"
accept="image/*"
onChange={handleAvatarChange}
style={{ display: 'none' }}
/>
</div>
{/* Fields */}
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
{/* Username (read-only) */}
<div>
<label style={{
display: 'block', color: 'var(--header-secondary)', fontSize: '12px',
fontWeight: '700', textTransform: 'uppercase', marginBottom: '8px',
}}>
Username
</label>
<div style={{
backgroundColor: 'var(--bg-tertiary)', borderRadius: '4px', padding: '10px',
color: 'var(--text-muted)', fontSize: '16px',
}}>
{username}
</div>
</div>
{/* Display Name */}
<div>
<label style={{
display: 'block', color: 'var(--header-secondary)', fontSize: '12px',
fontWeight: '700', textTransform: 'uppercase', marginBottom: '8px',
}}>
Display Name
</label>
<input
type="text"
value={displayName}
onChange={(e) => 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',
}}
/>
</div>
{/* About Me */}
<div>
<label style={{
display: 'block', color: 'var(--header-secondary)', fontSize: '12px',
fontWeight: '700', textTransform: 'uppercase', marginBottom: '8px',
}}>
About Me
</label>
<textarea
value={aboutMe}
onChange={(e) => setAboutMe(e.target.value.slice(0, 190))}
placeholder="Tell others about yourself"
rows={3}
style={{
width: '100%', backgroundColor: 'var(--bg-tertiary)', border: 'none',
borderRadius: '4px', padding: '10px', color: 'var(--text-normal)',
fontSize: '16px', outline: 'none', resize: 'none', fontFamily: 'inherit',
boxSizing: 'border-box',
}}
/>
<div style={{ fontSize: '12px', color: 'var(--text-muted)', textAlign: 'right' }}>
{aboutMe.length}/190
</div>
</div>
{/* Custom Status */}
<div>
<label style={{
display: 'block', color: 'var(--header-secondary)', fontSize: '12px',
fontWeight: '700', textTransform: 'uppercase', marginBottom: '8px',
}}>
Custom Status
</label>
<input
type="text"
value={customStatus}
onChange={(e) => 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',
}}
/>
</div>
</div>
</div>
</div>
{/* Save bar */}
{hasChanges && (
<div style={{
position: 'sticky', bottom: '0', left: 0, right: 0,
backgroundColor: 'var(--bg-tertiary)', borderRadius: '4px',
padding: '10px 16px', marginTop: '16px',
display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: '12px',
boxShadow: '0 -2px 10px rgba(0,0,0,0.2)',
}}>
<span style={{ color: 'var(--text-muted)', fontSize: '14px', marginRight: 'auto' }}>
Careful you have unsaved changes!
</span>
<button
onClick={handleReset}
style={{
background: 'transparent', border: 'none', color: 'var(--header-primary)',
cursor: 'pointer', fontSize: '14px', fontWeight: '500', padding: '8px 16px',
}}
>
Reset
</button>
<button
onClick={handleSave}
disabled={saving}
style={{
backgroundColor: '#3ba55c', color: 'white', border: 'none',
borderRadius: '4px', padding: '8px 24px', cursor: saving ? 'not-allowed' : 'pointer',
fontSize: '14px', fontWeight: '500', opacity: saving ? 0.7 : 1,
}}
>
{saving ? 'Saving...' : 'Save Changes'}
</button>
</div>
)}
{showCropModal && rawImageUrl && (
<AvatarCropModal
imageUrl={rawImageUrl}
onApply={handleCropApply}
onCancel={handleCropCancel}
/>
)}
</div>
);
};
/* =========================================
APPEARANCE TAB
========================================= */
const AppearanceTab = () => {
const { theme, setTheme } = useTheme();
return (
<div>
<h2 style={{ color: 'var(--header-primary)', margin: '0 0 20px', fontSize: '20px' }}>Appearance</h2>
<div style={{ marginBottom: '16px' }}>
<label style={{
display: 'block', color: 'var(--header-secondary)', fontSize: '12px',
fontWeight: '700', textTransform: 'uppercase', marginBottom: '12px',
}}>
Theme
</label>
<div className="theme-selector-grid">
{Object.values(THEMES).map((themeKey) => {
const preview = THEME_PREVIEWS[themeKey];
const isActive = theme === themeKey;
return (
<div
key={themeKey}
className={`theme-card ${isActive ? 'active' : ''}`}
onClick={() => setTheme(themeKey)}
>
<div className="theme-preview" style={{ backgroundColor: preview.bg }}>
<div className="theme-preview-sidebar" style={{ backgroundColor: preview.sidebar }}>
<div className="theme-preview-channel" style={{ backgroundColor: preview.tertiary }} />
<div className="theme-preview-channel" style={{ backgroundColor: preview.tertiary, width: '60%' }} />
</div>
<div className="theme-preview-chat">
<div className="theme-preview-message" style={{ backgroundColor: preview.sidebar, opacity: 0.6 }} />
<div className="theme-preview-message" style={{ backgroundColor: preview.sidebar, opacity: 0.4, width: '70%' }} />
</div>
</div>
<div className="theme-card-label">
<div className={`theme-radio ${isActive ? 'active' : ''}`}>
{isActive && <div className="theme-radio-dot" />}
</div>
<span>{THEME_LABELS[themeKey]}</span>
</div>
</div>
);
})}
</div>
</div>
</div>
);
};
/* =========================================
VOICE & VIDEO TAB
========================================= */
const VoiceVideoTab = () => {
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);
}, [selectedInput]);
useEffect(() => {
localStorage.setItem('voiceOutputDevice', selectedOutput);
}, [selectedOutput]);
useEffect(() => {
localStorage.setItem('voiceInputVolume', String(inputVolume));
}, [inputVolume]);
useEffect(() => {
localStorage.setItem('voiceOutputVolume', String(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 (
<div>
<h2 style={{ color: 'var(--header-primary)', margin: '0 0 20px', fontSize: '20px' }}>Voice & Video</h2>
<div style={{ display: 'flex', gap: '16px', marginBottom: '24px' }}>
{/* Input Device */}
<div style={{ flex: 1 }}>
<label style={labelStyle}>Input Device</label>
<select
value={selectedInput}
onChange={(e) => setSelectedInput(e.target.value)}
style={selectStyle}
>
<option value="default">Default</option>
{inputDevices.map(d => (
<option key={d.deviceId} value={d.deviceId}>
{d.label || `Microphone ${d.deviceId.slice(0, 8)}`}
</option>
))}
</select>
</div>
{/* Output Device */}
<div style={{ flex: 1 }}>
<label style={labelStyle}>Output Device</label>
<select
value={selectedOutput}
onChange={(e) => setSelectedOutput(e.target.value)}
style={selectStyle}
>
<option value="default">Default</option>
{outputDevices.map(d => (
<option key={d.deviceId} value={d.deviceId}>
{d.label || `Speaker ${d.deviceId.slice(0, 8)}`}
</option>
))}
</select>
</div>
</div>
{/* Input Volume */}
<div style={{ marginBottom: '20px' }}>
<label style={labelStyle}>Input Volume</label>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
<input
type="range"
min="0"
max="100"
value={inputVolume}
onChange={(e) => setInputVolume(parseInt(e.target.value))}
className="voice-slider"
/>
<span style={{ color: 'var(--text-normal)', fontSize: '14px', minWidth: '36px' }}>{inputVolume}%</span>
</div>
</div>
{/* Output Volume */}
<div style={{ marginBottom: '24px' }}>
<label style={labelStyle}>Output Volume</label>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
<input
type="range"
min="0"
max="100"
value={outputVolume}
onChange={(e) => setOutputVolume(parseInt(e.target.value))}
className="voice-slider"
/>
<span style={{ color: 'var(--text-normal)', fontSize: '14px', minWidth: '36px' }}>{outputVolume}%</span>
</div>
</div>
{/* Mic Test */}
<div style={{ marginBottom: '24px' }}>
<label style={labelStyle}>Mic Test</label>
<div style={{ display: 'flex', alignItems: 'center', gap: '16px' }}>
<button
onClick={micTesting ? stopMicTest : startMicTest}
style={{
backgroundColor: micTesting ? '#ed4245' : 'var(--brand-experiment)',
color: 'white', border: 'none', borderRadius: '4px',
padding: '8px 16px', cursor: 'pointer', fontSize: '14px', fontWeight: '500',
flexShrink: 0,
}}
>
{micTesting ? 'Stop Testing' : 'Let\'s Check'}
</button>
<div className="mic-level-bar">
<div className="mic-level-fill" style={{ width: `${micLevel}%` }} />
</div>
</div>
</div>
</div>
);
};
/* =========================================
KEYBINDS TAB
========================================= */
const KeybindsTab = () => {
const keybinds = [
{ action: 'Quick Switcher', keys: 'Ctrl+K' },
{ action: 'Toggle Mute', keys: 'Ctrl+Shift+M' },
];
return (
<div>
<h2 style={{ color: 'var(--header-primary)', margin: '0 0 20px', fontSize: '20px' }}>Keybinds</h2>
<div style={{
backgroundColor: 'var(--bg-secondary)', borderRadius: '8px', padding: '16px',
marginBottom: '16px',
}}>
<p style={{ color: 'var(--text-muted)', fontSize: '14px', margin: '0 0 16px' }}>
Keybind configuration coming soon. Current keybinds are shown below.
</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
{keybinds.map(kb => (
<div key={kb.action} style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '10px 12px', backgroundColor: 'var(--bg-tertiary)', borderRadius: '4px',
}}>
<span style={{ color: 'var(--text-normal)', fontSize: '14px' }}>{kb.action}</span>
<kbd style={{
backgroundColor: 'var(--bg-primary)', padding: '4px 8px', borderRadius: '4px',
fontSize: '13px', color: 'var(--header-primary)', fontFamily: 'inherit',
border: '1px solid var(--border-subtle)',
}}>
{kb.keys}
</kbd>
</div>
))}
</div>
</div>
</div>
);
};
export default UserSettings;

View File

@@ -0,0 +1,47 @@
import React, { createContext, useContext, useMemo } from 'react';
import usePresence from '@convex-dev/presence/react';
import { api } from '../../../../convex/_generated/api';
const PresenceContext = createContext({
onlineUsers: new Set(),
resolveStatus: (storedStatus, userId) => storedStatus || 'offline',
});
export const useOnlineUsers = () => useContext(PresenceContext);
/**
* Status resolution logic:
* - If user is NOT connected (no heartbeat) → "offline"
* - If user IS connected and chose "invisible" → "offline"
* - If user IS connected → show their chosen status (online/idle/dnd)
*/
function resolveStatusFn(onlineUsers, storedStatus, userId) {
if (!onlineUsers.has(userId)) return 'offline';
if (storedStatus === 'invisible') return 'offline';
return storedStatus || 'online';
}
export const PresenceProvider = ({ userId, children }) => {
const presenceState = usePresence(api.presence, 'global', userId);
const onlineUsers = useMemo(() => {
const set = new Set();
if (presenceState) {
for (const p of presenceState) {
if (p.online) set.add(p.userId);
}
}
return set;
}, [presenceState]);
const value = useMemo(() => ({
onlineUsers,
resolveStatus: (storedStatus, uid) => resolveStatusFn(onlineUsers, storedStatus, uid),
}), [onlineUsers]);
return (
<PresenceContext.Provider value={value}>
{children}
</PresenceContext.Provider>
);
};

View File

@@ -23,9 +23,21 @@ export function ThemeProvider({ children }) {
return localStorage.getItem(STORAGE_KEY) || THEMES.DARK;
});
// On mount, check settings.json as fallback when localStorage is empty
useEffect(() => {
if (!localStorage.getItem(STORAGE_KEY) && window.appSettings) {
window.appSettings.get('theme').then((saved) => {
if (saved && Object.values(THEMES).includes(saved)) {
setTheme(saved);
}
});
}
}, []);
useEffect(() => {
document.documentElement.className = theme;
localStorage.setItem(STORAGE_KEY, theme);
window.appSettings?.set('theme', theme);
}, [theme]);
return (

View File

@@ -135,7 +135,7 @@ body {
.sidebar {
width: 312px;
min-width: 312px;
background-color: var(--bg-secondary);
background-color: var(--bg-tertiary);
display: flex;
flex-direction: row;
flex-shrink: 0;
@@ -152,6 +152,11 @@ body {
flex-shrink: 0;
}
.ownerIcon {
color: var(--text-feedback-warning);
margin-inline-start: 4px;
}
.server-icon {
width: 48px;
height: 48px;
@@ -242,6 +247,7 @@ body {
.messages-list {
flex: 1;
overflow-x: hidden;
overflow-y: auto;
padding: 0 0 20px 0;
display: flex;
@@ -250,11 +256,11 @@ body {
.messages-list::-webkit-scrollbar {
width: 8px;
background-color: var(--bg-secondary);
background-color: var(--bg-primary);
}
.messages-list::-webkit-scrollbar-thumb {
background-color: var(--bg-tertiary);
background-color: #666770;
border-radius: 4px;
}
@@ -574,6 +580,50 @@ body {
font-size: 20px;
}
/* Preview author line */
.preview-author {
font-size: 13px;
color: var(--header-primary);
font-weight: 500;
margin-bottom: 2px;
}
/* Provider-branded previews */
.twitter-preview {
border-left-color: #1da1f2 !important;
}
.twitter-preview .preview-description {
-webkit-line-clamp: 6;
line-clamp: 6;
}
.spotify-preview {
border-left-color: #1db954 !important;
}
.reddit-preview {
border-left-color: #ff4500 !important;
}
/* Large image layout: image below content at full width */
.large-image-layout {
flex-direction: column;
gap: 8px;
}
.large-image-layout .preview-image-container.large-image {
width: 100%;
max-width: 400px;
}
.large-image-layout .preview-image-container.large-image .preview-image {
width: 100%;
max-width: 100%;
max-height: 300px;
object-fit: cover;
}
.youtube-preview {
flex-direction: column;
align-items: flex-start;
@@ -722,7 +772,6 @@ body {
-webkit-app-region: drag;
z-index: 10000;
flex-shrink: 0;
border-bottom: 1px solid var(--bg-tertiary);
}
.titlebar-drag-region {
@@ -864,7 +913,7 @@ body {
.members-list {
width: 240px;
min-width: 240px;
background-color: var(--bg-secondary);
background-color: var(--bg-primary);
border-left: 1px solid var(--border-subtle);
overflow-y: auto;
padding: 16px 8px;
@@ -1894,25 +1943,58 @@ body {
height: 48px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 16px;
border-bottom: 1px solid var(--bg-tertiary);
cursor: pointer;
flex-shrink: 0;
font-weight: 600;
font-size: 15px;
color: var(--header-primary);
gap: 8px;
}
.server-header-name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
cursor: pointer;
border-radius: 4px;
padding: 4px 4px;
transition: background-color 0.1s;
}
.server-header:hover {
.server-header-name:hover {
background-color: var(--background-modifier-hover);
}
.server-header-chevron {
font-size: 10px;
color: var(--text-muted);
transition: transform 0.2s;
.server-header-invite {
flex-shrink: 0;
background: none;
border: none;
padding: 4px;
border-radius: 4px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
color: var(--interactive-normal);
transition: color 0.15s, background-color 0.15s;
}
.server-header-invite:hover {
color: var(--interactive-hover);
background-color: var(--background-modifier-hover);
}
.server-header-invite img {
width: 20px;
height: 20px;
filter: brightness(0) invert(0.7);
}
.server-header-invite:hover img {
filter: brightness(0) invert(0.9);
}
/* ============================================
@@ -2280,4 +2362,173 @@ body {
height: 10px;
border-radius: 50%;
background-color: var(--control-primary-background-default);
}
/* ============================================
USER SETTINGS - AVATAR OVERLAY
============================================ */
.user-settings-avatar-wrapper {
position: relative;
border-radius: 50%;
overflow: hidden;
}
.user-settings-avatar-overlay {
position: absolute;
top: 0;
left: 0;
width: 80px;
height: 80px;
border-radius: 50%;
background-color: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
text-align: center;
line-height: 1.3;
opacity: 0;
transition: opacity 0.15s;
pointer-events: none;
}
.user-settings-avatar-wrapper:hover .user-settings-avatar-overlay {
opacity: 1;
}
/* ============================================
AVATAR CROP MODAL
============================================ */
.avatar-crop-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 10001;
}
.avatar-crop-dialog {
width: 440px;
background-color: var(--bg-tertiary);
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
overflow: hidden;
}
.avatar-crop-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
}
.avatar-crop-area {
position: relative;
height: 300px;
background-color: #000;
}
.avatar-crop-slider-row {
display: flex;
align-items: center;
gap: 12px;
padding: 16px 20px;
}
.avatar-crop-slider {
flex: 1;
-webkit-appearance: none;
appearance: none;
height: 6px;
background: var(--bg-secondary);
border-radius: 3px;
outline: none;
cursor: pointer;
}
.avatar-crop-slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 14px;
height: 14px;
border-radius: 50%;
background: var(--header-primary);
cursor: pointer;
border: none;
}
.avatar-crop-slider::-moz-range-thumb {
width: 14px;
height: 14px;
border-radius: 50%;
background: var(--header-primary);
cursor: pointer;
border: none;
}
.avatar-crop-actions {
display: flex;
justify-content: flex-end;
gap: 12px;
padding: 0 20px 20px;
}
/* ============================================
USER SETTINGS - MIC LEVEL METER
============================================ */
.mic-level-bar {
flex: 1;
height: 8px;
background-color: var(--bg-tertiary);
border-radius: 4px;
overflow: hidden;
}
.mic-level-fill {
height: 100%;
background-color: #3ba55c;
border-radius: 4px;
transition: width 0.05s ease;
}
/* ============================================
USER SETTINGS - VOICE SLIDER
============================================ */
.voice-slider {
flex: 1;
-webkit-appearance: none;
appearance: none;
height: 6px;
background: var(--bg-tertiary);
border-radius: 3px;
outline: none;
cursor: pointer;
}
.voice-slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 14px;
height: 14px;
border-radius: 50%;
background: var(--header-primary);
cursor: pointer;
border: none;
}
.voice-slider::-moz-range-thumb {
width: 14px;
height: 14px;
border-radius: 50%;
background: var(--header-primary);
cursor: pointer;
border: none;
}

View File

@@ -9,6 +9,7 @@ import FriendsView from '../components/FriendsView';
import MembersList from '../components/MembersList';
import ChatHeader from '../components/ChatHeader';
import { useToasts } from '../components/Toast';
import { PresenceProvider } from '../contexts/PresenceContext';
const Chat = () => {
const [view, setView] = useState('server');
@@ -230,25 +231,37 @@ const Chat = () => {
);
}
if (!userId) {
return (
<div className="app-container">
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#b9bbbe' }}>
Loading...
</div>
</div>
);
}
return (
<div className="app-container">
<Sidebar
channels={channels}
activeChannel={activeChannel}
onSelectChannel={handleSelectChannel}
username={username}
channelKeys={channelKeys}
view={view}
onViewChange={setView}
onOpenDM={openDM}
activeDMChannel={activeDMChannel}
setActiveDMChannel={setActiveDMChannel}
dmChannels={dmChannels}
userId={userId}
/>
{renderMainContent()}
<ToastContainer />
</div>
<PresenceProvider userId={userId}>
<div className="app-container">
<Sidebar
channels={channels}
activeChannel={activeChannel}
onSelectChannel={handleSelectChannel}
username={username}
channelKeys={channelKeys}
view={view}
onViewChange={setView}
onOpenDM={openDM}
activeDMChannel={activeDMChannel}
setActiveDMChannel={setActiveDMChannel}
dmChannels={dmChannels}
userId={userId}
/>
{renderMainContent()}
<ToastContainer />
</div>
</PresenceProvider>
);
};

View File

@@ -63,6 +63,22 @@ const Login = () => {
localStorage.setItem('publicKey', verifyData.publicKey);
}
// Persist session via safeStorage for auto-login on restart
if (window.sessionPersistence) {
try {
await window.sessionPersistence.save({
userId: verifyData.userId,
username,
publicKey: verifyData.publicKey || '',
signingKey,
privateKey: rsaPriv,
savedAt: Date.now(),
});
} catch (e) {
console.warn('Session persistence unavailable:', e);
}
}
console.log('Immediate localStorage read check:', localStorage.getItem('userId'));
navigate('/chat');

View File

@@ -50,6 +50,7 @@
--border-muted: rgba(255, 255, 255, 0.04);
--border-normal: rgba(255, 255, 255, 0.2);
--border-strong: rgba(255, 255, 255, 0.44);
--app-frame-border: color-mix(in oklab,hsl(240 calc(1*4%) 60.784% /0.12156862745098039) 100%,hsl(0 0% 0% /0.12156862745098039) 0%);
/* Icons */
--icon-default: #dbdee1;
@@ -93,6 +94,8 @@
--background-modifier-active: rgba(78, 80, 88, 0.48);
--background-modifier-selected: rgba(78, 80, 88, 0.6);
--div-border: #1e1f22;
--text-feedback-warning: color-mix(in oklab, hsl(38.455 calc(1*100%) 43.137% /1) 100%, #000 0%);
}
@@ -138,6 +141,7 @@
--border-muted: rgba(0, 0, 0, 0.2);
--border-normal: rgba(0, 0, 0, 0.36);
--border-strong: rgba(0, 0, 0, 0.48);
--app-frame-border: color-mix(in oklab,hsl(240 calc(1*4%) 60.784% /0.12156862745098039) 100%,hsl(0 0% 0% /0.12156862745098039) 0%);
/* Icons */
--icon-default: #313338;
@@ -181,6 +185,8 @@
--background-modifier-active: rgba(116, 124, 138, 0.22);
--background-modifier-selected: rgba(116, 124, 138, 0.30);
--div-border: #e1e2e4;
--text-feedback-warning: color-mix(in oklab, hsl(38.455 calc(1*100%) 43.137% /1) 100%, #000 0%);
}
@@ -198,7 +204,7 @@
--chat-background: #202225;
--channeltextarea-background: #252529;
--modal-background: #292b2f;
--panel-bg: #1a1b1e;
--panel-bg: color-mix(in oklab, hsl(240 calc(1*5.882%) 13.333% /1) 100%, #000 0%);
--embed-background: #242529;
/* Text */
@@ -226,6 +232,7 @@
--border-muted: rgba(255, 255, 255, 0.04);
--border-normal: rgba(255, 255, 255, 0.2);
--border-strong: rgba(255, 255, 255, 0.44);
--app-frame-border: color-mix(in oklab,hsl(240 calc(1*4%) 60.784% /0.12156862745098039) 100%,hsl(0 0% 0% /0.12156862745098039) 0%);
/* Icons */
--icon-default: #dddfe4;
@@ -254,7 +261,7 @@
/* Compatibility aliases */
--bg-primary: #202225;
--bg-secondary: #1a1b1e;
--bg-tertiary: #111214;
--bg-tertiary: #121214;
--text-normal: #dddfe4;
--header-primary: #f5f5f7;
--header-secondary: #a0a4ad;
@@ -269,6 +276,8 @@
--background-modifier-active: rgba(78, 80, 88, 0.3);
--background-modifier-selected: rgba(78, 80, 88, 0.4);
--div-border: #111214;
--text-feedback-warning: color-mix(in oklab, hsl(38.455 calc(1*100%) 43.137% /1) 100%, #000 0%);
}
@@ -314,6 +323,7 @@
--border-muted: rgba(255, 255, 255, 0.16);
--border-normal: rgba(255, 255, 255, 0.24);
--border-strong: rgba(255, 255, 255, 0.44);
--app-frame-border: color-mix(in oklab,hsl(240 calc(1*4%) 60.784% /0.12156862745098039) 100%,hsl(0 0% 0% /0.12156862745098039) 0%);
/* Icons */
--icon-default: #e0def0;
@@ -357,4 +367,6 @@
--background-modifier-active: rgba(78, 73, 106, 0.36);
--background-modifier-selected: rgba(78, 73, 106, 0.48);
--div-border: #080810;
--text-feedback-warning: color-mix(in oklab, hsl(38.455 calc(1*100%) 43.137% /1) 100%, #000 0%);
}