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

@@ -19,7 +19,11 @@
"WebFetch(domain:github.com)", "WebFetch(domain:github.com)",
"WebFetch(domain:docs.gitea.com)", "WebFetch(domain:docs.gitea.com)",
"WebFetch(domain:www.electron.build)", "WebFetch(domain:www.electron.build)",
"WebFetch(domain:forum.gitea.com)" "WebFetch(domain:forum.gitea.com)",
"WebFetch(domain:www.convex.dev)",
"WebFetch(domain:www.npmjs.com)",
"WebFetch(domain:stack.convex.dev)",
"WebFetch(domain:raw.githubusercontent.com)"
] ]
} }
} }

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 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 https = require('https');
const http = require('http'); const http = require('http');
const { checkForUpdates } = require('./updater.cjs'); 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() { function createWindow() {
const win = new BrowserWindow({ const settings = loadSettings();
width: 1200,
height: 800, const windowOptions = {
width: settings.windowWidth,
height: settings.windowHeight,
frame: false, frame: false,
webPreferences: { webPreferences: {
nodeIntegration: false, nodeIntegration: false,
@@ -15,17 +62,44 @@ function createWindow() {
preload: path.join(__dirname, 'preload.cjs'), preload: path.join(__dirname, 'preload.cjs'),
sandbox: false 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'; const isDev = process.env.npm_lifecycle_event === 'electron:dev';
if (isDev) { if (isDev) {
win.loadURL('http://localhost:5173'); mainWindow.loadURL('http://localhost:5173');
win.webContents.openDevTools(); mainWindow.webContents.openDevTools();
} else { } else {
// Production: Load the built file // Production: Load the built file
// dist-react is in the same directory as main.cjs // 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) // 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) => { ipcMain.handle('fetch-metadata', async (event, url) => {
return new Promise((resolve) => { try {
// Check for direct video links to avoid downloading large files // Check for direct video links
const videoExtensions = ['.mp4', '.webm', '.ogg', '.mov']; const videoExtensions = ['.mp4', '.webm', '.ogg', '.mov'];
const imageExtensions = ['.gif', '.png', '.jpg', '.jpeg', '.webp']; const imageExtensions = ['.gif', '.png', '.jpg', '.jpeg', '.webp'];
const lowerUrl = url.toLowerCase(); const lowerUrl = url.toLowerCase();
if (videoExtensions.some(ext => lowerUrl.endsWith(ext))) { if (videoExtensions.some(ext => lowerUrl.endsWith(ext))) {
const filename = url.split('/').pop(); return { title: url.split('/').pop(), siteName: new URL(url).hostname, video: url, image: null, description: 'Video File' };
resolve({
title: filename,
siteName: new URL(url).hostname,
video: url,
image: null, // No thumbnail for now unless we generate one
description: 'Video File'
});
return;
} }
if (imageExtensions.some(ext => lowerUrl.endsWith(ext))) { if (imageExtensions.some(ext => lowerUrl.endsWith(ext))) {
const filename = url.split('/').pop(); return { title: url.split('/').pop(), siteName: new URL(url).hostname, video: null, image: url, description: 'Image File' };
resolve({
title: filename,
siteName: new URL(url).hostname,
video: null,
image: url, // Direct image/gif
description: 'Image File'
});
return;
} }
const client = url.startsWith('https') ? https : http; const hostname = new URL(url).hostname.replace(/^www\./, '');
const req = client.get(url, (res) => { const oembedBuilder = OEMBED_PROVIDERS[hostname] || OEMBED_PROVIDERS['www.' + hostname];
let data = ''; const oembedUrl = oembedBuilder ? oembedBuilder(url) : null;
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 metadata = { let metadata;
title: getMeta('title') || getTitle(),
description: getMeta('description'), if (oembedUrl) {
image: getMeta('image'), // Fetch oEmbed JSON and page HTML in parallel
siteName: getMeta('site_name'), const [oembedResult, htmlResult] = await Promise.allSettled([
themeColor: getMeta('theme-color') fetchJson(oembedUrl),
}; httpGet(url),
resolve(metadata); ]);
});
}); const oembed = oembedResult.status === 'fulfilled' ? oembedResult.value : null;
req.on('error', (err) => { const html = htmlResult.status === 'fulfilled' ? htmlResult.value : '';
console.error('Metadata fetch error:', err); const meta = html ? parseMetaTags(html) : {};
resolve(null); 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) => { ipcMain.handle('open-external', async (event, url) => {
await shell.openExternal(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 () => { ipcMain.handle('get-screen-sources', async () => {
const { desktopCapturer } = require('electron'); const { desktopCapturer } = require('electron');
const sources = await desktopCapturer.getSources({ const sources = await desktopCapturer.getSources({

View File

@@ -1,13 +1,14 @@
{ {
"name": "discord", "name": "discord",
"version": "0.0.0", "version": "1.0.2",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "discord", "name": "discord",
"version": "0.0.0", "version": "1.0.2",
"dependencies": { "dependencies": {
"@convex-dev/presence": "^0.3.0",
"@livekit/components-react": "^2.9.17", "@livekit/components-react": "^2.9.17",
"@livekit/components-styles": "^1.2.0", "@livekit/components-styles": "^1.2.0",
"convex": "^1.31.2", "convex": "^1.31.2",
@@ -16,6 +17,7 @@
"livekit-client": "^2.16.1", "livekit-client": "^2.16.1",
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0", "react-dom": "^19.2.0",
"react-easy-crop": "^5.5.6",
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
"react-router-dom": "^7.11.0", "react-router-dom": "^7.11.0",
"react-syntax-highlighter": "^16.1.0", "react-syntax-highlighter": "^16.1.0",
@@ -334,6 +336,27 @@
"integrity": "sha512-wJ8ReQbHxsAfXhrf9ixl0aYbZorRuOWpBNzm8pL8ftmSxQx/wnJD5Eg861NwJU/czy2VXFIebCeZnZrI9rktIQ==", "integrity": "sha512-wJ8ReQbHxsAfXhrf9ixl0aYbZorRuOWpBNzm8pL8ftmSxQx/wnJD5Eg861NwJU/czy2VXFIebCeZnZrI9rktIQ==",
"license": "(Apache-2.0 AND BSD-3-Clause)" "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": { "node_modules/@develar/schema-utils": {
"version": "2.6.5", "version": "2.6.5",
"resolved": "https://registry.npmjs.org/@develar/schema-utils/-/schema-utils-2.6.5.tgz", "resolved": "https://registry.npmjs.org/@develar/schema-utils/-/schema-utils-2.6.5.tgz",
@@ -8176,6 +8199,12 @@
"url": "https://github.com/sponsors/sindresorhus" "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": { "node_modules/npmlog": {
"version": "6.0.2", "version": "6.0.2",
"resolved": "https://registry.npmjs.org/npmlog/-/npmlog-6.0.2.tgz", "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-6.0.2.tgz",
@@ -8670,6 +8699,20 @@
"react": "^19.2.3" "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": { "node_modules/react-markdown": {
"version": "10.1.0", "version": "10.1.0",
"resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz", "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz",

View File

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

View File

@@ -24,3 +24,14 @@ contextBridge.exposeInMainWorld('windowControls', {
maximize: () => ipcRenderer.send('window-maximize'), maximize: () => ipcRenderer.send('window-maximize'),
close: () => ipcRenderer.send('window-close'), 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 React, { useState, useEffect, useRef } from 'react';
import { Routes, Route } from 'react-router-dom'; import { Routes, Route, Navigate, useLocation, useNavigate } from 'react-router-dom';
import Login from './pages/Login'; import Login from './pages/Login';
import Register from './pages/Register'; import Register from './pages/Register';
import Chat from './pages/Chat'; 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() { function App() {
return ( return (
<Routes> <AuthGuard>
<Route path="/" element={<Login />} /> <Routes>
<Route path="/register" element={<Register />} /> <Route path="/" element={<Login />} />
<Route path="/chat" element={<Chat />} /> <Route path="/register" element={<Register />} />
</Routes> <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) || []; 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 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); const match = link.match(regExp);
return (match && match[2].length === 11) ? match[2] : null; return (match && match[2].length === 11) ? match[2] : null;
}; };
@@ -105,6 +133,16 @@ const isNewDay = (current, previous) => {
|| current.getFullYear() !== previous.getFullYear(); || 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 LinkPreview = ({ url }) => {
const [metadata, setMetadata] = useState(metadataCache.get(url) || null); const [metadata, setMetadata] = useState(metadataCache.get(url) || null);
const [loading, setLoading] = useState(!metadataCache.has(url)); const [loading, setLoading] = useState(!metadataCache.has(url));
@@ -139,6 +177,11 @@ const LinkPreview = ({ url }) => {
const videoId = getYouTubeId(url); const videoId = getYouTubeId(url);
const isYouTube = !!videoId; const isYouTube = !!videoId;
const isDirectVideoUrl = isVideoUrl(url);
if (isDirectVideoUrl) {
return <DirectVideo src={url} />;
}
if (loading || !metadata || (!metadata.title && !metadata.image && !metadata.video)) return null; 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 ( 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"> <div className="preview-content">
{metadata.siteName && <div className="preview-site-name">{metadata.siteName}</div>} {metadata.siteName && <div className="preview-site-name">{metadata.siteName}</div>}
{metadata.author && <div className="preview-author">{metadata.author}</div>}
{metadata.title && ( {metadata.title && (
<a href={url} onClick={(e) => { e.preventDefault(); window.cryptoAPI.openExternal(url); }} className="preview-title"> <a href={url} onClick={(e) => { e.preventDefault(); window.cryptoAPI.openExternal(url); }} className="preview-title">
{metadata.title} {metadata.title}
@@ -194,11 +241,21 @@ const LinkPreview = ({ url }) => {
/> />
</div> </div>
)} )}
{isLargeImage && !isYouTube && metadata.image && (
<div className="preview-image-container large-image">
<img src={metadata.image} alt="Preview" className="preview-image" />
</div>
)}
</div> </div>
{metadata.image && (!isYouTube || !playing) && ( {!isLargeImage && !isYouTube && metadata.image && (
<div className="preview-image-container" onClick={() => isYouTube && setPlaying(true)} style={isYouTube ? { cursor: 'pointer' } : {}}> <div className="preview-image-container">
<img src={metadata.image} alt="Preview" className="preview-image" /> <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>
)} )}
</div> </div>
@@ -1042,15 +1099,19 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
const urls = extractUrls(msg.content); const urls = extractUrls(msg.content);
const isOnlyUrl = urls.length === 1 && msg.content.trim() === urls[0]; 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 isGif = isOnlyUrl && (urls[0].includes('tenor.com') || urls[0].includes('giphy.com') || urls[0].endsWith('.gif'));
const isDirectVideo = isOnlyUrl && isVideoUrl(urls[0]);
return ( return (
<> <>
{!isGif && ( {!isGif && !isDirectVideo && (
<ReactMarkdown remarkPlugins={[remarkGfm]} urlTransform={(url) => url} components={markdownComponents}> <ReactMarkdown remarkPlugins={[remarkGfm]} urlTransform={(url) => url} components={markdownComponents}>
{formatEmojis(formatMentions(msg.content))} {formatEmojis(formatMentions(msg.content))}
</ReactMarkdown> </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} onBlur={saveSelection}
onMouseUp={saveSelection} onMouseUp={saveSelection}
onKeyUp={saveSelection} onKeyUp={saveSelection}
onPaste={(e) => {
e.preventDefault();
const text = e.clipboardData.getData('text/plain');
document.execCommand('insertText', false, text);
}}
onInput={(e) => { onInput={(e) => {
const textContent = e.currentTarget.textContent; const textContent = e.currentTarget.textContent;
setInput(textContent); setInput(textContent);

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,6 @@
import React, { useState } from 'react'; 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 { api } from '../../../../convex/_generated/api';
import Tooltip from './Tooltip'; import Tooltip from './Tooltip';
import { useVoice } from '../contexts/VoiceContext'; import { useVoice } from '../contexts/VoiceContext';
@@ -8,7 +9,7 @@ import ServerSettingsModal from './ServerSettingsModal';
import ScreenShareModal from './ScreenShareModal'; import ScreenShareModal from './ScreenShareModal';
import DMList from './DMList'; import DMList from './DMList';
import Avatar from './Avatar'; import Avatar from './Avatar';
import ThemeSelector from './ThemeSelector'; import UserSettings from './UserSettings';
import { Track } from 'livekit-client'; import { Track } from 'livekit-client';
import muteIcon from '../assets/icons/mute.svg'; import muteIcon from '../assets/icons/mute.svg';
import mutedIcon from '../assets/icons/muted.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 disconnectIcon from '../assets/icons/disconnect.svg';
import cameraIcon from '../assets/icons/camera.svg'; import cameraIcon from '../assets/icons/camera.svg';
import screenIcon from '../assets/icons/screen.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']; const USER_COLORS = ['#5865F2', '#EBA7CD', '#57F287', '#FEE75C', '#EB459E', '#ED4245'];
@@ -101,11 +103,46 @@ const STATUS_OPTIONS = [
]; ];
const UserControlPanel = ({ username, userId }) => { 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 [showStatusMenu, setShowStatusMenu] = useState(false);
const [showThemeSelector, setShowThemeSelector] = useState(false); const [showUserSettings, setShowUserSettings] = useState(false);
const [currentStatus, setCurrentStatus] = useState('online'); const [currentStatus, setCurrentStatus] = useState('online');
const updateStatusMutation = useMutation(api.auth.updateStatus); 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 effectiveMute = isMuted || isDeafened;
const statusColor = STATUS_OPTIONS.find(s => s.value === currentStatus)?.color || '#3ba55c'; const statusColor = STATUS_OPTIONS.find(s => s.value === currentStatus)?.color || '#3ba55c';
@@ -191,15 +228,31 @@ const UserControlPanel = ({ username, userId }) => {
</button> </button>
</Tooltip> </Tooltip>
<Tooltip text="User Settings" position="top"> <Tooltip text="User Settings" position="top">
<button style={controlButtonStyle} onClick={() => setShowThemeSelector(true)}> <button style={controlButtonStyle} onClick={() => setShowUserSettings(true)}>
<ColoredIcon <ColoredIcon
src={settingsIcon} src={settingsIcon}
color={ICON_COLOR_DEFAULT} color={ICON_COLOR_DEFAULT}
/> />
</button> </button>
</Tooltip> </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> </div>
{showThemeSelector && <ThemeSelector onClose={() => setShowThemeSelector(false)} />} {showUserSettings && (
<UserSettings
onClose={() => setShowUserSettings(false)}
userId={userId}
username={username}
onLogout={handleLogout}
/>
)}
</div> </div>
); );
}; };
@@ -445,7 +498,7 @@ const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKe
}; };
const renderDMView = () => ( 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 <DMList
dmChannels={dmChannels} dmChannels={dmChannels}
activeDMChannel={activeDMChannel} activeDMChannel={activeDMChannel}
@@ -504,13 +557,15 @@ const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKe
}, [channels]); }, [channels]);
const renderServerView = () => ( const renderServerView = () => (
<div style={{ flex: 1, overflowY: 'auto', display: 'flex', flexDirection: 'column' }}> <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" onClick={() => setIsServerSettingsOpen(true)}> <div className="server-header" style={{ borderBottom: '1px solid var(--app-frame-border)' }}>
<span>Secure Chat</span> <span className="server-header-name" onClick={() => setIsServerSettingsOpen(true)}>Secure Chat</span>
<span className="server-header-chevron"></span> <button className="server-header-invite" onClick={handleCreateInvite} title="Invite People">
<img src={inviteUserIcon} alt="Invite" />
</button>
</div> </div>
<div className="channel-list" style={{ flex: 1, overflowY: 'auto' }}> <div className="channel-list" style={{ flex: 1, overflowY: 'auto', backgroundColor: 'var(--bg-tertiary)' }}>
{isCreating && ( {isCreating && (
<div style={{ padding: '0 8px', marginBottom: '4px' }}> <div style={{ padding: '0 8px', marginBottom: '4px' }}>
<form onSubmit={handleSubmitCreate}> <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; 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(() => { useEffect(() => {
document.documentElement.className = theme; document.documentElement.className = theme;
localStorage.setItem(STORAGE_KEY, theme); localStorage.setItem(STORAGE_KEY, theme);
window.appSettings?.set('theme', theme);
}, [theme]); }, [theme]);
return ( return (

View File

@@ -135,7 +135,7 @@ body {
.sidebar { .sidebar {
width: 312px; width: 312px;
min-width: 312px; min-width: 312px;
background-color: var(--bg-secondary); background-color: var(--bg-tertiary);
display: flex; display: flex;
flex-direction: row; flex-direction: row;
flex-shrink: 0; flex-shrink: 0;
@@ -152,6 +152,11 @@ body {
flex-shrink: 0; flex-shrink: 0;
} }
.ownerIcon {
color: var(--text-feedback-warning);
margin-inline-start: 4px;
}
.server-icon { .server-icon {
width: 48px; width: 48px;
height: 48px; height: 48px;
@@ -242,6 +247,7 @@ body {
.messages-list { .messages-list {
flex: 1; flex: 1;
overflow-x: hidden;
overflow-y: auto; overflow-y: auto;
padding: 0 0 20px 0; padding: 0 0 20px 0;
display: flex; display: flex;
@@ -250,11 +256,11 @@ body {
.messages-list::-webkit-scrollbar { .messages-list::-webkit-scrollbar {
width: 8px; width: 8px;
background-color: var(--bg-secondary); background-color: var(--bg-primary);
} }
.messages-list::-webkit-scrollbar-thumb { .messages-list::-webkit-scrollbar-thumb {
background-color: var(--bg-tertiary); background-color: #666770;
border-radius: 4px; border-radius: 4px;
} }
@@ -574,6 +580,50 @@ body {
font-size: 20px; 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 { .youtube-preview {
flex-direction: column; flex-direction: column;
align-items: flex-start; align-items: flex-start;
@@ -722,7 +772,6 @@ body {
-webkit-app-region: drag; -webkit-app-region: drag;
z-index: 10000; z-index: 10000;
flex-shrink: 0; flex-shrink: 0;
border-bottom: 1px solid var(--bg-tertiary);
} }
.titlebar-drag-region { .titlebar-drag-region {
@@ -864,7 +913,7 @@ body {
.members-list { .members-list {
width: 240px; width: 240px;
min-width: 240px; min-width: 240px;
background-color: var(--bg-secondary); background-color: var(--bg-primary);
border-left: 1px solid var(--border-subtle); border-left: 1px solid var(--border-subtle);
overflow-y: auto; overflow-y: auto;
padding: 16px 8px; padding: 16px 8px;
@@ -1894,25 +1943,58 @@ body {
height: 48px; height: 48px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between;
padding: 0 16px; padding: 0 16px;
border-bottom: 1px solid var(--bg-tertiary); border-bottom: 1px solid var(--bg-tertiary);
cursor: pointer;
flex-shrink: 0; flex-shrink: 0;
font-weight: 600; font-weight: 600;
font-size: 15px; font-size: 15px;
color: var(--header-primary); 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; transition: background-color 0.1s;
} }
.server-header:hover { .server-header-name:hover {
background-color: var(--background-modifier-hover); background-color: var(--background-modifier-hover);
} }
.server-header-chevron { .server-header-invite {
font-size: 10px; flex-shrink: 0;
color: var(--text-muted); background: none;
transition: transform 0.2s; 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);
} }
/* ============================================ /* ============================================
@@ -2281,3 +2363,172 @@ body {
border-radius: 50%; border-radius: 50%;
background-color: var(--control-primary-background-default); 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 MembersList from '../components/MembersList';
import ChatHeader from '../components/ChatHeader'; import ChatHeader from '../components/ChatHeader';
import { useToasts } from '../components/Toast'; import { useToasts } from '../components/Toast';
import { PresenceProvider } from '../contexts/PresenceContext';
const Chat = () => { const Chat = () => {
const [view, setView] = useState('server'); 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 ( return (
<div className="app-container"> <PresenceProvider userId={userId}>
<Sidebar <div className="app-container">
channels={channels} <Sidebar
activeChannel={activeChannel} channels={channels}
onSelectChannel={handleSelectChannel} activeChannel={activeChannel}
username={username} onSelectChannel={handleSelectChannel}
channelKeys={channelKeys} username={username}
view={view} channelKeys={channelKeys}
onViewChange={setView} view={view}
onOpenDM={openDM} onViewChange={setView}
activeDMChannel={activeDMChannel} onOpenDM={openDM}
setActiveDMChannel={setActiveDMChannel} activeDMChannel={activeDMChannel}
dmChannels={dmChannels} setActiveDMChannel={setActiveDMChannel}
userId={userId} dmChannels={dmChannels}
/> userId={userId}
{renderMainContent()} />
<ToastContainer /> {renderMainContent()}
</div> <ToastContainer />
</div>
</PresenceProvider>
); );
}; };

View File

@@ -63,6 +63,22 @@ const Login = () => {
localStorage.setItem('publicKey', verifyData.publicKey); 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')); console.log('Immediate localStorage read check:', localStorage.getItem('userId'));
navigate('/chat'); navigate('/chat');

View File

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

17
TODO.md
View File

@@ -1,7 +1,16 @@
- Create auto updater for app When i scroll up one time with my scroll wheel and move my mouse it scrolls down to the start.
- Save app x and y position on close to a settings.json file
- Save app width and height on close to a settings.json file
- Save app theme on close to a settings.json file - 955px
<!-- - When you upload your avatar lets make it so they can resize it and zoom in where they want in the photo and show them a circle like discord so they can see how it will look. -->
- Make it so we can see the avatar in the user control info also.
- Make it so the server-pill active is actually touching the left of the server-list because right now its not all the way to the left.
In our server header we have a server-header-chevron. I want to replace that with invite_user.svg. This will open the invite modal. Use the frontend design skill to help with that. To access the server settings we will click on the server name in that server header. Make it so the name will not go past the invite_user.svg icon and will ellipsis if it needs to.

View File

@@ -17,6 +17,7 @@ import type * as gifs from "../gifs.js";
import type * as invites from "../invites.js"; import type * as invites from "../invites.js";
import type * as members from "../members.js"; import type * as members from "../members.js";
import type * as messages from "../messages.js"; import type * as messages from "../messages.js";
import type * as presence from "../presence.js";
import type * as reactions from "../reactions.js"; import type * as reactions from "../reactions.js";
import type * as roles from "../roles.js"; import type * as roles from "../roles.js";
import type * as typing from "../typing.js"; import type * as typing from "../typing.js";
@@ -39,6 +40,7 @@ declare const fullApi: ApiFromModules<{
invites: typeof invites; invites: typeof invites;
members: typeof members; members: typeof members;
messages: typeof messages; messages: typeof messages;
presence: typeof presence;
reactions: typeof reactions; reactions: typeof reactions;
roles: typeof roles; roles: typeof roles;
typing: typeof typing; typing: typeof typing;
@@ -72,4 +74,67 @@ export declare const internal: FilterApi<
FunctionReference<any, "internal"> FunctionReference<any, "internal">
>; >;
export declare const components: {}; export declare const components: {
presence: {
public: {
disconnect: FunctionReference<
"mutation",
"internal",
{ sessionToken: string },
null
>;
heartbeat: FunctionReference<
"mutation",
"internal",
{
interval?: number;
roomId: string;
sessionId: string;
userId: string;
},
{ roomToken: string; sessionToken: string }
>;
list: FunctionReference<
"query",
"internal",
{ limit?: number; roomToken: string },
Array<{
data?: any;
lastDisconnected: number;
online: boolean;
userId: string;
}>
>;
listRoom: FunctionReference<
"query",
"internal",
{ limit?: number; onlineOnly?: boolean; roomId: string },
Array<{ lastDisconnected: number; online: boolean; userId: string }>
>;
listUser: FunctionReference<
"query",
"internal",
{ limit?: number; onlineOnly?: boolean; userId: string },
Array<{ lastDisconnected: number; online: boolean; roomId: string }>
>;
removeRoom: FunctionReference<
"mutation",
"internal",
{ roomId: string },
null
>;
removeRoomUser: FunctionReference<
"mutation",
"internal",
{ roomId: string; userId: string },
null
>;
updateRoomUser: FunctionReference<
"mutation",
"internal",
{ data?: any; roomId: string; userId: string },
null
>;
};
};
};

View File

@@ -198,6 +198,7 @@ export const getPublicKeys = query({
username: v.string(), username: v.string(),
public_identity_key: v.string(), public_identity_key: v.string(),
status: v.optional(v.string()), status: v.optional(v.string()),
displayName: v.optional(v.string()),
avatarUrl: v.optional(v.union(v.string(), v.null())), avatarUrl: v.optional(v.union(v.string(), v.null())),
aboutMe: v.optional(v.string()), aboutMe: v.optional(v.string()),
customStatus: v.optional(v.string()), customStatus: v.optional(v.string()),
@@ -215,7 +216,8 @@ export const getPublicKeys = query({
id: u._id, id: u._id,
username: u.username, username: u.username,
public_identity_key: u.publicIdentityKey, public_identity_key: u.publicIdentityKey,
status: u.status || "online", status: u.status || "offline",
displayName: u.displayName,
avatarUrl, avatarUrl,
aboutMe: u.aboutMe, aboutMe: u.aboutMe,
customStatus: u.customStatus, customStatus: u.customStatus,
@@ -229,6 +231,7 @@ export const getPublicKeys = query({
export const updateProfile = mutation({ export const updateProfile = mutation({
args: { args: {
userId: v.id("userProfiles"), userId: v.id("userProfiles"),
displayName: v.optional(v.string()),
aboutMe: v.optional(v.string()), aboutMe: v.optional(v.string()),
avatarStorageId: v.optional(v.id("_storage")), avatarStorageId: v.optional(v.id("_storage")),
customStatus: v.optional(v.string()), customStatus: v.optional(v.string()),
@@ -236,6 +239,7 @@ export const updateProfile = mutation({
returns: v.null(), returns: v.null(),
handler: async (ctx, args) => { handler: async (ctx, args) => {
const patch: Record<string, unknown> = {}; const patch: Record<string, unknown> = {};
if (args.displayName !== undefined) patch.displayName = args.displayName;
if (args.aboutMe !== undefined) patch.aboutMe = args.aboutMe; if (args.aboutMe !== undefined) patch.aboutMe = args.aboutMe;
if (args.avatarStorageId !== undefined) patch.avatarStorageId = args.avatarStorageId; if (args.avatarStorageId !== undefined) patch.avatarStorageId = args.avatarStorageId;
if (args.customStatus !== undefined) patch.customStatus = args.customStatus; if (args.customStatus !== undefined) patch.customStatus = args.customStatus;

6
convex/convex.config.ts Normal file
View File

@@ -0,0 +1,6 @@
import { defineApp } from "convex/server";
import presence from "@convex-dev/presence/convex.config.js";
const app = defineApp();
app.use(presence);
export default app;

View File

@@ -79,7 +79,7 @@ export const listDMs = query({
channel_name: channel.name, channel_name: channel.name,
other_user_id: otherUser._id as string, other_user_id: otherUser._id as string,
other_username: otherUser.username, other_username: otherUser.username,
other_user_status: otherUser.status || "online", other_user_status: otherUser.status || "offline",
}; };
}) })
); );

View File

@@ -50,7 +50,7 @@ export const getChannelMembers = query({
members.push({ members.push({
id: user._id, id: user._id,
username: user.username, username: user.username,
status: user.status || "online", status: user.status || "offline",
roles: roles.sort((a, b) => b.position - a.position), roles: roles.sort((a, b) => b.position - a.position),
avatarUrl, avatarUrl,
aboutMe: user.aboutMe, aboutMe: user.aboutMe,

32
convex/presence.ts Normal file
View File

@@ -0,0 +1,32 @@
import { mutation, query } from "./_generated/server";
import { components } from "./_generated/api";
import { v } from "convex/values";
import { Presence } from "@convex-dev/presence";
const presence = new Presence(components.presence);
export const heartbeat = mutation({
args: {
roomId: v.string(),
userId: v.string(),
sessionId: v.string(),
interval: v.number(),
},
handler: async (ctx, { roomId, userId, sessionId, interval }) => {
return await presence.heartbeat(ctx, roomId, userId, sessionId, interval);
},
});
export const list = query({
args: { roomToken: v.string() },
handler: async (ctx, { roomToken }) => {
return await presence.list(ctx, roomToken);
},
});
export const disconnect = mutation({
args: { sessionToken: v.string() },
handler: async (ctx, { sessionToken }) => {
return await presence.disconnect(ctx, sessionToken);
},
});

View File

@@ -12,6 +12,7 @@ export default defineSchema({
encryptedPrivateKeys: v.string(), encryptedPrivateKeys: v.string(),
isAdmin: v.boolean(), isAdmin: v.boolean(),
status: v.optional(v.string()), status: v.optional(v.string()),
displayName: v.optional(v.string()),
avatarStorageId: v.optional(v.id("_storage")), avatarStorageId: v.optional(v.id("_storage")),
aboutMe: v.optional(v.string()), aboutMe: v.optional(v.string()),
customStatus: v.optional(v.string()), customStatus: v.optional(v.string()),

File diff suppressed because one or more lines are too long

52
package-lock.json generated
View File

@@ -6,6 +6,7 @@
"": { "": {
"name": "discord-clone", "name": "discord-clone",
"dependencies": { "dependencies": {
"@convex-dev/presence": "^0.3.0",
"convex": "^1.31.2", "convex": "^1.31.2",
"livekit-server-sdk": "^2.15.0" "livekit-server-sdk": "^2.15.0"
} }
@@ -16,6 +17,27 @@
"integrity": "sha512-wJ8ReQbHxsAfXhrf9ixl0aYbZorRuOWpBNzm8pL8ftmSxQx/wnJD5Eg861NwJU/czy2VXFIebCeZnZrI9rktIQ==", "integrity": "sha512-wJ8ReQbHxsAfXhrf9ixl0aYbZorRuOWpBNzm8pL8ftmSxQx/wnJD5Eg861NwJU/czy2VXFIebCeZnZrI9rktIQ==",
"license": "(Apache-2.0 AND BSD-3-Clause)" "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/@esbuild/aix-ppc64": { "node_modules/@esbuild/aix-ppc64": {
"version": "0.27.0", "version": "0.27.0",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.0.tgz", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.0.tgz",
@@ -608,6 +630,36 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/react": {
"version": "19.2.4",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/react-dom": {
"version": "19.2.4",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
"integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
"peerDependencies": {
"react": "^19.2.4"
}
},
"node_modules/scheduler": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
"license": "MIT",
"peer": true
},
"node_modules/type-fest": { "node_modules/type-fest": {
"version": "4.41.0", "version": "4.41.0",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz",

View File

@@ -11,6 +11,7 @@
"install:all": "npm install && cd Frontend/Electron && npm install" "install:all": "npm install && cd Frontend/Electron && npm install"
}, },
"dependencies": { "dependencies": {
"@convex-dev/presence": "^0.3.0",
"convex": "^1.31.2", "convex": "^1.31.2",
"livekit-server-sdk": "^2.15.0" "livekit-server-sdk": "^2.15.0"
} }