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
Some checks failed
Build and Release / build-and-release (push) Has been cancelled
This commit is contained in:
@@ -19,7 +19,11 @@
|
||||
"WebFetch(domain:github.com)",
|
||||
"WebFetch(domain:docs.gitea.com)",
|
||||
"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)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,60 @@
|
||||
const { app, BrowserWindow, ipcMain, shell } = require('electron');
|
||||
const { app, BrowserWindow, ipcMain, shell, screen, safeStorage } = require('electron');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
// --- Secure session persistence ---
|
||||
const SESSION_FILE = path.join(app.getPath('userData'), 'secure-session.dat');
|
||||
const https = require('https');
|
||||
const http = require('http');
|
||||
const { checkForUpdates } = require('./updater.cjs');
|
||||
|
||||
// --- Settings persistence ---
|
||||
const SETTINGS_FILE = path.join(app.getPath('userData'), 'settings.json');
|
||||
const DEFAULT_SETTINGS = {
|
||||
windowX: undefined,
|
||||
windowY: undefined,
|
||||
windowWidth: 1200,
|
||||
windowHeight: 800,
|
||||
isMaximized: false,
|
||||
theme: 'theme-dark',
|
||||
};
|
||||
|
||||
let mainWindow = null;
|
||||
|
||||
function loadSettings() {
|
||||
try {
|
||||
const data = fs.readFileSync(SETTINGS_FILE, 'utf8');
|
||||
return { ...DEFAULT_SETTINGS, ...JSON.parse(data) };
|
||||
} catch {
|
||||
return { ...DEFAULT_SETTINGS };
|
||||
}
|
||||
}
|
||||
|
||||
function saveSettings(settings) {
|
||||
try {
|
||||
fs.writeFileSync(SETTINGS_FILE, JSON.stringify(settings, null, 2), 'utf8');
|
||||
} catch (err) {
|
||||
console.error('Failed to save settings:', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
function isPositionOnScreen(x, y, w, h) {
|
||||
const displays = screen.getAllDisplays();
|
||||
const MIN_OVERLAP = 100;
|
||||
return displays.some(display => {
|
||||
const { x: dx, y: dy, width: dw, height: dh } = display.bounds;
|
||||
const overlapX = Math.max(0, Math.min(x + w, dx + dw) - Math.max(x, dx));
|
||||
const overlapY = Math.max(0, Math.min(y + h, dy + dh) - Math.max(y, dy));
|
||||
return overlapX >= MIN_OVERLAP && overlapY >= MIN_OVERLAP;
|
||||
});
|
||||
}
|
||||
|
||||
function createWindow() {
|
||||
const win = new BrowserWindow({
|
||||
width: 1200,
|
||||
height: 800,
|
||||
const settings = loadSettings();
|
||||
|
||||
const windowOptions = {
|
||||
width: settings.windowWidth,
|
||||
height: settings.windowHeight,
|
||||
frame: false,
|
||||
webPreferences: {
|
||||
nodeIntegration: false,
|
||||
@@ -15,17 +62,44 @@ function createWindow() {
|
||||
preload: path.join(__dirname, 'preload.cjs'),
|
||||
sandbox: false
|
||||
}
|
||||
};
|
||||
|
||||
// Only restore position if it's valid on a connected display
|
||||
if (settings.windowX !== undefined && settings.windowY !== undefined &&
|
||||
isPositionOnScreen(settings.windowX, settings.windowY, settings.windowWidth, settings.windowHeight)) {
|
||||
windowOptions.x = settings.windowX;
|
||||
windowOptions.y = settings.windowY;
|
||||
}
|
||||
|
||||
mainWindow = new BrowserWindow(windowOptions);
|
||||
|
||||
if (settings.isMaximized) {
|
||||
mainWindow.maximize();
|
||||
}
|
||||
|
||||
// Save window state on close
|
||||
mainWindow.on('close', () => {
|
||||
const current = loadSettings(); // re-read to preserve theme changes
|
||||
if (!mainWindow.isMaximized()) {
|
||||
const bounds = mainWindow.getBounds();
|
||||
current.windowX = bounds.x;
|
||||
current.windowY = bounds.y;
|
||||
current.windowWidth = bounds.width;
|
||||
current.windowHeight = bounds.height;
|
||||
}
|
||||
current.isMaximized = mainWindow.isMaximized();
|
||||
saveSettings(current);
|
||||
});
|
||||
|
||||
const isDev = process.env.npm_lifecycle_event === 'electron:dev';
|
||||
|
||||
if (isDev) {
|
||||
win.loadURL('http://localhost:5173');
|
||||
win.webContents.openDevTools();
|
||||
mainWindow.loadURL('http://localhost:5173');
|
||||
mainWindow.webContents.openDevTools();
|
||||
} else {
|
||||
// Production: Load the built file
|
||||
// dist-react is in the same directory as main.cjs
|
||||
win.loadFile(path.join(__dirname, 'dist-react', 'index.html'));
|
||||
mainWindow.loadFile(path.join(__dirname, 'dist-react', 'index.html'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,75 +151,254 @@ app.whenReady().then(async () => {
|
||||
});
|
||||
|
||||
// Helper to fetch metadata (Zero-Knowledge: Client fetches previews)
|
||||
|
||||
const OEMBED_PROVIDERS = {
|
||||
'twitter.com': (url) => url.includes('/status/') ? `https://publish.twitter.com/oembed?url=${encodeURIComponent(url)}&format=json` : null,
|
||||
'x.com': (url) => url.includes('/status/') ? `https://publish.twitter.com/oembed?url=${encodeURIComponent(url)}&format=json` : null,
|
||||
'youtube.com': (url) => `https://www.youtube.com/oembed?url=${encodeURIComponent(url)}&format=json`,
|
||||
'www.youtube.com': (url) => `https://www.youtube.com/oembed?url=${encodeURIComponent(url)}&format=json`,
|
||||
'youtu.be': (url) => `https://www.youtube.com/oembed?url=${encodeURIComponent(url)}&format=json`,
|
||||
'open.spotify.com': (url) => `https://open.spotify.com/oembed?url=${encodeURIComponent(url)}`,
|
||||
'www.tiktok.com': (url) => `https://www.tiktok.com/oembed?url=${encodeURIComponent(url)}`,
|
||||
'tiktok.com': (url) => `https://www.tiktok.com/oembed?url=${encodeURIComponent(url)}`,
|
||||
'www.reddit.com': (url) => `https://www.reddit.com/oembed?url=${encodeURIComponent(url)}&format=json`,
|
||||
'reddit.com': (url) => `https://www.reddit.com/oembed?url=${encodeURIComponent(url)}&format=json`,
|
||||
};
|
||||
|
||||
const FETCH_HEADERS = {
|
||||
'User-Agent': 'Mozilla/5.0 (compatible; DiscordBot/1.0)',
|
||||
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
|
||||
'Accept-Language': 'en-US,en;q=0.5',
|
||||
};
|
||||
|
||||
const MAX_RESPONSE_SIZE = 256 * 1024; // 256KB
|
||||
const FETCH_TIMEOUT = 8000;
|
||||
const MAX_REDIRECTS = 5;
|
||||
|
||||
function httpGet(url, redirectsLeft = MAX_REDIRECTS) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const client = url.startsWith('https') ? https : http;
|
||||
const req = client.get(url, { headers: FETCH_HEADERS, timeout: FETCH_TIMEOUT }, (res) => {
|
||||
if ([301, 302, 303, 307, 308].includes(res.statusCode) && res.headers.location) {
|
||||
if (redirectsLeft <= 0) { resolve(''); return; }
|
||||
let redirectUrl = res.headers.location;
|
||||
if (redirectUrl.startsWith('/')) {
|
||||
const parsed = new URL(url);
|
||||
redirectUrl = parsed.origin + redirectUrl;
|
||||
}
|
||||
res.resume();
|
||||
httpGet(redirectUrl, redirectsLeft - 1).then(resolve).catch(reject);
|
||||
return;
|
||||
}
|
||||
let data = '';
|
||||
let size = 0;
|
||||
res.setEncoding('utf8');
|
||||
res.on('data', (chunk) => {
|
||||
size += Buffer.byteLength(chunk);
|
||||
if (size > MAX_RESPONSE_SIZE) { res.destroy(); resolve(data); return; }
|
||||
data += chunk;
|
||||
});
|
||||
res.on('end', () => resolve(data));
|
||||
res.on('error', () => resolve(data));
|
||||
});
|
||||
req.on('timeout', () => { req.destroy(); resolve(''); });
|
||||
req.on('error', (err) => { console.error('httpGet error:', err.message); resolve(''); });
|
||||
});
|
||||
}
|
||||
|
||||
function fetchJson(url, redirectsLeft = MAX_REDIRECTS) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const client = url.startsWith('https') ? https : http;
|
||||
const req = client.get(url, { headers: { 'User-Agent': FETCH_HEADERS['User-Agent'], 'Accept': 'application/json' }, timeout: FETCH_TIMEOUT }, (res) => {
|
||||
if ([301, 302, 303, 307, 308].includes(res.statusCode) && res.headers.location) {
|
||||
if (redirectsLeft <= 0) { resolve(null); return; }
|
||||
let redirectUrl = res.headers.location;
|
||||
if (redirectUrl.startsWith('/')) {
|
||||
const parsed = new URL(url);
|
||||
redirectUrl = parsed.origin + redirectUrl;
|
||||
}
|
||||
res.resume();
|
||||
fetchJson(redirectUrl, redirectsLeft - 1).then(resolve).catch(reject);
|
||||
return;
|
||||
}
|
||||
let data = '';
|
||||
res.setEncoding('utf8');
|
||||
res.on('data', (chunk) => { data += chunk; });
|
||||
res.on('end', () => { try { resolve(JSON.parse(data)); } catch { resolve(null); } });
|
||||
res.on('error', () => resolve(null));
|
||||
});
|
||||
req.on('timeout', () => { req.destroy(); resolve(null); });
|
||||
req.on('error', () => resolve(null));
|
||||
});
|
||||
}
|
||||
|
||||
function parseMetaTags(html) {
|
||||
const meta = {};
|
||||
// Match both orderings: property/name before content AND content before property/name
|
||||
const metaRegex = /<meta\s+([^>]*?)\/?\s*>/gi;
|
||||
let match;
|
||||
while ((match = metaRegex.exec(html)) !== null) {
|
||||
const attrs = match[1];
|
||||
let name = null, content = null;
|
||||
// Extract property or name attribute
|
||||
const propMatch = attrs.match(/(?:property|name)\s*=\s*["']([^"']+)["']/i);
|
||||
const contentMatch = attrs.match(/content\s*=\s*["']([^"']*?)["']/i);
|
||||
if (propMatch) name = propMatch[1].toLowerCase();
|
||||
if (contentMatch) content = contentMatch[1];
|
||||
if (name && content !== null) meta[name] = content;
|
||||
}
|
||||
// Extract <title> tag
|
||||
const titleMatch = html.match(/<title[^>]*>([\s\S]*?)<\/title>/i);
|
||||
if (titleMatch) meta['_title'] = titleMatch[1].trim();
|
||||
return meta;
|
||||
}
|
||||
|
||||
function buildMetadata(meta) {
|
||||
const get = (...keys) => { for (const k of keys) { if (meta[k]) return meta[k]; } return null; };
|
||||
return {
|
||||
title: get('og:title', 'twitter:title', '_title'),
|
||||
description: get('og:description', 'twitter:description', 'description'),
|
||||
image: get('og:image', 'og:image:secure_url', 'twitter:image', 'twitter:image:src'),
|
||||
siteName: get('og:site_name'),
|
||||
themeColor: get('theme-color'),
|
||||
video: get('og:video:secure_url', 'og:video:url', 'og:video'),
|
||||
type: get('og:type', 'twitter:card'),
|
||||
author: get('article:author', 'author'),
|
||||
};
|
||||
}
|
||||
|
||||
function sanitizeMetadata(metadata) {
|
||||
const decodeEntities = (str) => {
|
||||
if (!str) return str;
|
||||
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
.replace(/"/g, '"').replace(/'/g, "'").replace(/'/g, "'")
|
||||
.replace(/&#(\d+);/g, (_, n) => String.fromCharCode(n))
|
||||
.replace(/&#x([0-9a-fA-F]+);/g, (_, n) => String.fromCharCode(parseInt(n, 16)));
|
||||
};
|
||||
const stripTags = (str) => str ? str.replace(/<[^>]*>/g, '') : str;
|
||||
const limit = (str, max = 1000) => str && str.length > max ? str.substring(0, max) : str;
|
||||
|
||||
const result = {};
|
||||
for (const [key, value] of Object.entries(metadata)) {
|
||||
if (typeof value === 'string') {
|
||||
result[key] = limit(decodeEntities(stripTags(value)).trim());
|
||||
} else {
|
||||
result[key] = value;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
ipcMain.handle('fetch-metadata', async (event, url) => {
|
||||
return new Promise((resolve) => {
|
||||
// Check for direct video links to avoid downloading large files
|
||||
try {
|
||||
// Check for direct video links
|
||||
const videoExtensions = ['.mp4', '.webm', '.ogg', '.mov'];
|
||||
const imageExtensions = ['.gif', '.png', '.jpg', '.jpeg', '.webp'];
|
||||
const lowerUrl = url.toLowerCase();
|
||||
|
||||
if (videoExtensions.some(ext => lowerUrl.endsWith(ext))) {
|
||||
const filename = url.split('/').pop();
|
||||
resolve({
|
||||
title: filename,
|
||||
siteName: new URL(url).hostname,
|
||||
video: url,
|
||||
image: null, // No thumbnail for now unless we generate one
|
||||
description: 'Video File'
|
||||
});
|
||||
return;
|
||||
return { title: url.split('/').pop(), siteName: new URL(url).hostname, video: url, image: null, description: 'Video File' };
|
||||
}
|
||||
|
||||
if (imageExtensions.some(ext => lowerUrl.endsWith(ext))) {
|
||||
const filename = url.split('/').pop();
|
||||
resolve({
|
||||
title: filename,
|
||||
siteName: new URL(url).hostname,
|
||||
video: null,
|
||||
image: url, // Direct image/gif
|
||||
description: 'Image File'
|
||||
});
|
||||
return;
|
||||
return { title: url.split('/').pop(), siteName: new URL(url).hostname, video: null, image: url, description: 'Image File' };
|
||||
}
|
||||
|
||||
const client = url.startsWith('https') ? https : http;
|
||||
const req = client.get(url, (res) => {
|
||||
let data = '';
|
||||
res.on('data', (chunk) => data += chunk);
|
||||
res.on('end', () => {
|
||||
// Simple Regex Parser for OG Tags to avoid dependencies
|
||||
const getMeta = (prop) => {
|
||||
const regex = new RegExp(`<meta\\s+(?:name|property)=["'](?:og:)?${prop}["']\\s+content=["'](.*?)["']`, 'i');
|
||||
const match = data.match(regex);
|
||||
return match ? match[1] : null;
|
||||
};
|
||||
const getTitle = () => {
|
||||
const regex = /<title>(.*?)<\/title>/i;
|
||||
const match = data.match(regex);
|
||||
return match ? match[1] : null;
|
||||
};
|
||||
const hostname = new URL(url).hostname.replace(/^www\./, '');
|
||||
const oembedBuilder = OEMBED_PROVIDERS[hostname] || OEMBED_PROVIDERS['www.' + hostname];
|
||||
const oembedUrl = oembedBuilder ? oembedBuilder(url) : null;
|
||||
|
||||
const metadata = {
|
||||
title: getMeta('title') || getTitle(),
|
||||
description: getMeta('description'),
|
||||
image: getMeta('image'),
|
||||
siteName: getMeta('site_name'),
|
||||
themeColor: getMeta('theme-color')
|
||||
};
|
||||
resolve(metadata);
|
||||
});
|
||||
});
|
||||
req.on('error', (err) => {
|
||||
console.error('Metadata fetch error:', err);
|
||||
resolve(null);
|
||||
});
|
||||
});
|
||||
let metadata;
|
||||
|
||||
if (oembedUrl) {
|
||||
// Fetch oEmbed JSON and page HTML in parallel
|
||||
const [oembedResult, htmlResult] = await Promise.allSettled([
|
||||
fetchJson(oembedUrl),
|
||||
httpGet(url),
|
||||
]);
|
||||
|
||||
const oembed = oembedResult.status === 'fulfilled' ? oembedResult.value : null;
|
||||
const html = htmlResult.status === 'fulfilled' ? htmlResult.value : '';
|
||||
const meta = html ? parseMetaTags(html) : {};
|
||||
const ogData = buildMetadata(meta);
|
||||
|
||||
metadata = {
|
||||
title: oembed?.title || ogData.title,
|
||||
description: ogData.description,
|
||||
image: oembed?.thumbnail_url || ogData.image,
|
||||
siteName: oembed?.provider_name || ogData.siteName,
|
||||
themeColor: ogData.themeColor,
|
||||
video: ogData.video,
|
||||
type: ogData.type,
|
||||
author: oembed?.author_name || ogData.author,
|
||||
};
|
||||
} else {
|
||||
// Standard HTML fetch
|
||||
const html = await httpGet(url);
|
||||
if (!html) return null;
|
||||
const meta = parseMetaTags(html);
|
||||
metadata = buildMetadata(meta);
|
||||
}
|
||||
|
||||
return sanitizeMetadata(metadata);
|
||||
} catch (err) {
|
||||
console.error('Metadata fetch error:', err);
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('open-external', async (event, url) => {
|
||||
await shell.openExternal(url);
|
||||
});
|
||||
|
||||
// Settings IPC handlers
|
||||
ipcMain.handle('get-setting', (event, key) => {
|
||||
const settings = loadSettings();
|
||||
return settings[key];
|
||||
});
|
||||
|
||||
ipcMain.handle('set-setting', (event, key, value) => {
|
||||
const settings = loadSettings();
|
||||
settings[key] = value;
|
||||
saveSettings(settings);
|
||||
});
|
||||
|
||||
// Secure session persistence handlers
|
||||
ipcMain.handle('save-session', (event, data) => {
|
||||
try {
|
||||
if (!safeStorage.isEncryptionAvailable()) return false;
|
||||
const encrypted = safeStorage.encryptString(JSON.stringify(data));
|
||||
fs.writeFileSync(SESSION_FILE, encrypted);
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error('Failed to save session:', err.message);
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('load-session', () => {
|
||||
try {
|
||||
if (!safeStorage.isEncryptionAvailable()) return null;
|
||||
if (!fs.existsSync(SESSION_FILE)) return null;
|
||||
const encrypted = fs.readFileSync(SESSION_FILE);
|
||||
const decrypted = safeStorage.decryptString(encrypted);
|
||||
return JSON.parse(decrypted);
|
||||
} catch (err) {
|
||||
console.error('Failed to load session (clearing corrupt file):', err.message);
|
||||
try { fs.unlinkSync(SESSION_FILE); } catch {}
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('clear-session', () => {
|
||||
try {
|
||||
if (fs.existsSync(SESSION_FILE)) fs.unlinkSync(SESSION_FILE);
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error('Failed to clear session:', err.message);
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('get-screen-sources', async () => {
|
||||
const { desktopCapturer } = require('electron');
|
||||
const sources = await desktopCapturer.getSources({
|
||||
|
||||
47
Frontend/Electron/package-lock.json
generated
47
Frontend/Electron/package-lock.json
generated
@@ -1,13 +1,14 @@
|
||||
{
|
||||
"name": "discord",
|
||||
"version": "0.0.0",
|
||||
"version": "1.0.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "discord",
|
||||
"version": "0.0.0",
|
||||
"version": "1.0.2",
|
||||
"dependencies": {
|
||||
"@convex-dev/presence": "^0.3.0",
|
||||
"@livekit/components-react": "^2.9.17",
|
||||
"@livekit/components-styles": "^1.2.0",
|
||||
"convex": "^1.31.2",
|
||||
@@ -16,6 +17,7 @@
|
||||
"livekit-client": "^2.16.1",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-easy-crop": "^5.5.6",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router-dom": "^7.11.0",
|
||||
"react-syntax-highlighter": "^16.1.0",
|
||||
@@ -334,6 +336,27 @@
|
||||
"integrity": "sha512-wJ8ReQbHxsAfXhrf9ixl0aYbZorRuOWpBNzm8pL8ftmSxQx/wnJD5Eg861NwJU/czy2VXFIebCeZnZrI9rktIQ==",
|
||||
"license": "(Apache-2.0 AND BSD-3-Clause)"
|
||||
},
|
||||
"node_modules/@convex-dev/presence": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@convex-dev/presence/-/presence-0.3.0.tgz",
|
||||
"integrity": "sha512-adV+ao1L77u+egobyJabNwuai/0y/VgzMbqZiy+Q49JmX6fbSaiYg6FpFVACqPaq3giOfjrN2k+5mK2jTAUG0g==",
|
||||
"license": "Apache-2.0",
|
||||
"peerDependencies": {
|
||||
"convex": "^1.24.8",
|
||||
"expo-crypto": ">=14.1.0",
|
||||
"react": "~18.3.1 || ^19.0.0",
|
||||
"react-dom": "~18.3.1 || ^19.0.0",
|
||||
"react-native": ">=0.79.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"expo-crypto": {
|
||||
"optional": true
|
||||
},
|
||||
"react-native": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@develar/schema-utils": {
|
||||
"version": "2.6.5",
|
||||
"resolved": "https://registry.npmjs.org/@develar/schema-utils/-/schema-utils-2.6.5.tgz",
|
||||
@@ -8176,6 +8199,12 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/normalize-wheel": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/normalize-wheel/-/normalize-wheel-1.0.1.tgz",
|
||||
"integrity": "sha512-1OnlAPZ3zgrk8B91HyRj+eVv+kS5u+Z0SCsak6Xil/kmgEia50ga7zfkumayonZrImffAxPU/5WcyGhzetHNPA==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/npmlog": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/npmlog/-/npmlog-6.0.2.tgz",
|
||||
@@ -8670,6 +8699,20 @@
|
||||
"react": "^19.2.3"
|
||||
}
|
||||
},
|
||||
"node_modules/react-easy-crop": {
|
||||
"version": "5.5.6",
|
||||
"resolved": "https://registry.npmjs.org/react-easy-crop/-/react-easy-crop-5.5.6.tgz",
|
||||
"integrity": "sha512-Jw3/ozs8uXj3NpL511Suc4AHY+mLRO23rUgipXvNYKqezcFSYHxe4QXibBymkOoY6oOtLVMPO2HNPRHYvMPyTw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"normalize-wheel": "^1.0.1",
|
||||
"tslib": "^2.0.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.4.0",
|
||||
"react-dom": ">=16.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-markdown": {
|
||||
"version": "10.1.0",
|
||||
"resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz",
|
||||
|
||||
@@ -34,14 +34,21 @@
|
||||
}
|
||||
],
|
||||
"win": {
|
||||
"target": ["nsis"]
|
||||
"target": [
|
||||
"nsis"
|
||||
]
|
||||
},
|
||||
"mac": {
|
||||
"target": ["dmg", "zip"],
|
||||
"target": [
|
||||
"dmg",
|
||||
"zip"
|
||||
],
|
||||
"category": "public.app-category.social-networking"
|
||||
},
|
||||
"linux": {
|
||||
"target": ["AppImage"]
|
||||
"target": [
|
||||
"AppImage"
|
||||
]
|
||||
},
|
||||
"nsis": {
|
||||
"oneClick": true,
|
||||
@@ -49,6 +56,7 @@
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@convex-dev/presence": "^0.3.0",
|
||||
"@livekit/components-react": "^2.9.17",
|
||||
"@livekit/components-styles": "^1.2.0",
|
||||
"convex": "^1.31.2",
|
||||
@@ -57,6 +65,7 @@
|
||||
"livekit-client": "^2.16.1",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-easy-crop": "^5.5.6",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router-dom": "^7.11.0",
|
||||
"react-syntax-highlighter": "^16.1.0",
|
||||
|
||||
@@ -24,3 +24,14 @@ contextBridge.exposeInMainWorld('windowControls', {
|
||||
maximize: () => ipcRenderer.send('window-maximize'),
|
||||
close: () => ipcRenderer.send('window-close'),
|
||||
});
|
||||
|
||||
contextBridge.exposeInMainWorld('appSettings', {
|
||||
get: (key) => ipcRenderer.invoke('get-setting', key),
|
||||
set: (key, value) => ipcRenderer.invoke('set-setting', key, value),
|
||||
});
|
||||
|
||||
contextBridge.exposeInMainWorld('sessionPersistence', {
|
||||
save: (data) => ipcRenderer.invoke('save-session', data),
|
||||
load: () => ipcRenderer.invoke('load-session'),
|
||||
clear: () => ipcRenderer.invoke('clear-session'),
|
||||
});
|
||||
|
||||
@@ -1,16 +1,100 @@
|
||||
import React from 'react';
|
||||
import { Routes, Route } from 'react-router-dom';
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { Routes, Route, Navigate, useLocation, useNavigate } from 'react-router-dom';
|
||||
import Login from './pages/Login';
|
||||
import Register from './pages/Register';
|
||||
import Chat from './pages/Chat';
|
||||
|
||||
const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000;
|
||||
|
||||
function AuthGuard({ children }) {
|
||||
const [authState, setAuthState] = useState('loading'); // 'loading' | 'authenticated' | 'unauthenticated'
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
async function restoreSession() {
|
||||
// Already have keys in sessionStorage — current session is active
|
||||
if (sessionStorage.getItem('privateKey') && sessionStorage.getItem('signingKey')) {
|
||||
if (!cancelled) setAuthState('authenticated');
|
||||
return;
|
||||
}
|
||||
|
||||
// Try restoring from safeStorage
|
||||
if (window.sessionPersistence) {
|
||||
try {
|
||||
const session = await window.sessionPersistence.load();
|
||||
if (session && session.savedAt && (Date.now() - session.savedAt) < THIRTY_DAYS_MS) {
|
||||
// Restore to localStorage + sessionStorage
|
||||
localStorage.setItem('userId', session.userId);
|
||||
localStorage.setItem('username', session.username);
|
||||
if (session.publicKey) localStorage.setItem('publicKey', session.publicKey);
|
||||
sessionStorage.setItem('signingKey', session.signingKey);
|
||||
sessionStorage.setItem('privateKey', session.privateKey);
|
||||
if (!cancelled) setAuthState('authenticated');
|
||||
return;
|
||||
}
|
||||
// Expired — clear stale session
|
||||
if (session && session.savedAt) {
|
||||
await window.sessionPersistence.clear();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Session restore failed:', err);
|
||||
}
|
||||
}
|
||||
|
||||
if (!cancelled) setAuthState('unauthenticated');
|
||||
}
|
||||
|
||||
restoreSession();
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
// Redirect once after auth state is determined (not on every route change)
|
||||
const hasRedirected = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (authState === 'loading' || hasRedirected.current) return;
|
||||
hasRedirected.current = true;
|
||||
|
||||
const isAuthPage = location.pathname === '/' || location.pathname === '/register';
|
||||
|
||||
if (authState === 'authenticated' && isAuthPage) {
|
||||
navigate('/chat', { replace: true });
|
||||
} else if (authState === 'unauthenticated' && !isAuthPage) {
|
||||
navigate('/', { replace: true });
|
||||
}
|
||||
}, [authState]);
|
||||
|
||||
if (authState === 'loading') {
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '100vh',
|
||||
backgroundColor: 'var(--bg-primary, #313338)',
|
||||
color: 'var(--text-normal, #dbdee1)',
|
||||
fontSize: '16px',
|
||||
}}>
|
||||
Loading...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return children;
|
||||
}
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/" element={<Login />} />
|
||||
<Route path="/register" element={<Register />} />
|
||||
<Route path="/chat" element={<Chat />} />
|
||||
</Routes>
|
||||
<AuthGuard>
|
||||
<Routes>
|
||||
<Route path="/" element={<Login />} />
|
||||
<Route path="/register" element={<Register />} />
|
||||
<Route path="/chat" element={<Chat />} />
|
||||
</Routes>
|
||||
</AuthGuard>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
1
Frontend/Electron/src/assets/icons/crown.svg
Normal file
1
Frontend/Electron/src/assets/icons/crown.svg
Normal 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 |
1
Frontend/Electron/src/assets/icons/invite_user.svg
Normal file
1
Frontend/Electron/src/assets/icons/invite_user.svg
Normal 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 |
134
Frontend/Electron/src/components/AvatarCropModal.jsx
Normal file
134
Frontend/Electron/src/components/AvatarCropModal.jsx
Normal 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;
|
||||
@@ -65,8 +65,36 @@ const extractUrls = (text) => {
|
||||
return text.match(urlRegex) || [];
|
||||
};
|
||||
|
||||
const VIDEO_EXT_RE = /\.(mp4|webm|ogg|mov)(\?[^\s]*)?$/i;
|
||||
const isVideoUrl = (url) => VIDEO_EXT_RE.test(url);
|
||||
|
||||
const DirectVideo = ({ src, marginTop = 8 }) => {
|
||||
const ref = useRef(null);
|
||||
const [showControls, setShowControls] = useState(false);
|
||||
const handlePlay = () => {
|
||||
setShowControls(true);
|
||||
if (ref.current) ref.current.play();
|
||||
};
|
||||
return (
|
||||
<div style={{ marginTop, position: 'relative', display: 'inline-block', maxWidth: '100%' }}>
|
||||
<video
|
||||
ref={ref}
|
||||
src={src}
|
||||
controls={showControls}
|
||||
preload="metadata"
|
||||
style={{ maxWidth: '100%', maxHeight: '300px', borderRadius: '8px', backgroundColor: 'black', display: 'block' }}
|
||||
/>
|
||||
{!showControls && (
|
||||
<div className="play-icon" onClick={handlePlay} style={{ cursor: 'pointer' }}>
|
||||
▶
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const getYouTubeId = (link) => {
|
||||
const regExp = /^.*(youtu.be\/|v\/|u\/\w\/|embed\/|watch\?v=|&v=)([^#&?]*).*/;
|
||||
const regExp = /^.*(youtu.be\/|v\/|u\/\w\/|embed\/|shorts\/|watch\?v=|&v=)([^#&?]*).*/;
|
||||
const match = link.match(regExp);
|
||||
return (match && match[2].length === 11) ? match[2] : null;
|
||||
};
|
||||
@@ -105,6 +133,16 @@ const isNewDay = (current, previous) => {
|
||||
|| current.getFullYear() !== previous.getFullYear();
|
||||
};
|
||||
|
||||
const getProviderClass = (url) => {
|
||||
try {
|
||||
const hostname = new URL(url).hostname.replace(/^www\./, '');
|
||||
if (hostname === 'twitter.com' || hostname === 'x.com') return 'twitter-preview';
|
||||
if (hostname === 'open.spotify.com') return 'spotify-preview';
|
||||
if (hostname === 'reddit.com') return 'reddit-preview';
|
||||
} catch {}
|
||||
return '';
|
||||
};
|
||||
|
||||
const LinkPreview = ({ url }) => {
|
||||
const [metadata, setMetadata] = useState(metadataCache.get(url) || null);
|
||||
const [loading, setLoading] = useState(!metadataCache.has(url));
|
||||
@@ -139,6 +177,11 @@ const LinkPreview = ({ url }) => {
|
||||
|
||||
const videoId = getYouTubeId(url);
|
||||
const isYouTube = !!videoId;
|
||||
const isDirectVideoUrl = isVideoUrl(url);
|
||||
|
||||
if (isDirectVideoUrl) {
|
||||
return <DirectVideo src={url} />;
|
||||
}
|
||||
|
||||
if (loading || !metadata || (!metadata.title && !metadata.image && !metadata.video)) return null;
|
||||
|
||||
@@ -173,10 +216,14 @@ const LinkPreview = ({ url }) => {
|
||||
);
|
||||
}
|
||||
|
||||
const providerClass = getProviderClass(url);
|
||||
const isLargeImage = providerClass === 'twitter-preview' || metadata.type === 'article' || metadata.type === 'summary_large_image';
|
||||
|
||||
return (
|
||||
<div className={`link-preview ${isYouTube ? 'youtube-preview' : ''}`} style={{ borderLeftColor: metadata.themeColor || '#202225' }}>
|
||||
<div className={`link-preview ${isYouTube ? 'youtube-preview' : ''} ${providerClass} ${isLargeImage && !isYouTube ? 'large-image-layout' : ''}`} style={{ borderLeftColor: metadata.themeColor || '#202225' }}>
|
||||
<div className="preview-content">
|
||||
{metadata.siteName && <div className="preview-site-name">{metadata.siteName}</div>}
|
||||
{metadata.author && <div className="preview-author">{metadata.author}</div>}
|
||||
{metadata.title && (
|
||||
<a href={url} onClick={(e) => { e.preventDefault(); window.cryptoAPI.openExternal(url); }} className="preview-title">
|
||||
{metadata.title}
|
||||
@@ -194,11 +241,21 @@ const LinkPreview = ({ url }) => {
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{isLargeImage && !isYouTube && metadata.image && (
|
||||
<div className="preview-image-container large-image">
|
||||
<img src={metadata.image} alt="Preview" className="preview-image" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{metadata.image && (!isYouTube || !playing) && (
|
||||
<div className="preview-image-container" onClick={() => isYouTube && setPlaying(true)} style={isYouTube ? { cursor: 'pointer' } : {}}>
|
||||
{!isLargeImage && !isYouTube && metadata.image && (
|
||||
<div className="preview-image-container">
|
||||
<img src={metadata.image} alt="Preview" className="preview-image" />
|
||||
{isYouTube && <div className="play-icon">▶</div>}
|
||||
</div>
|
||||
)}
|
||||
{isYouTube && metadata.image && !playing && (
|
||||
<div className="preview-image-container" onClick={() => setPlaying(true)} style={{ cursor: 'pointer' }}>
|
||||
<img src={metadata.image} alt="Preview" className="preview-image" />
|
||||
<div className="play-icon">▶</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -1042,15 +1099,19 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
|
||||
const urls = extractUrls(msg.content);
|
||||
const isOnlyUrl = urls.length === 1 && msg.content.trim() === urls[0];
|
||||
const isGif = isOnlyUrl && (urls[0].includes('tenor.com') || urls[0].includes('giphy.com') || urls[0].endsWith('.gif'));
|
||||
const isDirectVideo = isOnlyUrl && isVideoUrl(urls[0]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{!isGif && (
|
||||
{!isGif && !isDirectVideo && (
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]} urlTransform={(url) => url} components={markdownComponents}>
|
||||
{formatEmojis(formatMentions(msg.content))}
|
||||
</ReactMarkdown>
|
||||
)}
|
||||
{urls.map((url, i) => <LinkPreview key={i} url={url} />)}
|
||||
{isDirectVideo && <DirectVideo src={urls[0]} marginTop={4} />}
|
||||
{urls.filter(u => !(isDirectVideo && u === urls[0])).map((url, i) => (
|
||||
<LinkPreview key={i} url={url} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1263,6 +1324,11 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
|
||||
onBlur={saveSelection}
|
||||
onMouseUp={saveSelection}
|
||||
onKeyUp={saveSelection}
|
||||
onPaste={(e) => {
|
||||
e.preventDefault();
|
||||
const text = e.clipboardData.getData('text/plain');
|
||||
document.execCommand('insertText', false, text);
|
||||
}}
|
||||
onInput={(e) => {
|
||||
const textContent = e.currentTarget.textContent;
|
||||
setInput(textContent);
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useConvex } from 'convex/react';
|
||||
import { api } from '../../../../convex/_generated/api';
|
||||
import Tooltip from './Tooltip';
|
||||
import Avatar from './Avatar';
|
||||
import { useOnlineUsers } from '../contexts/PresenceContext';
|
||||
|
||||
const STATUS_COLORS = {
|
||||
online: '#3ba55c',
|
||||
@@ -37,6 +38,7 @@ const DMList = ({ dmChannels, activeDMChannel, onSelectDM, onOpenDM }) => {
|
||||
const [searchFocused, setSearchFocused] = useState(false);
|
||||
const searchRef = useRef(null);
|
||||
const searchInputRef = useRef(null);
|
||||
const { resolveStatus } = useOnlineUsers();
|
||||
|
||||
const convex = useConvex();
|
||||
|
||||
@@ -200,7 +202,7 @@ const DMList = ({ dmChannels, activeDMChannel, onSelectDM, onOpenDM }) => {
|
||||
<div style={{ flex: 1, overflowY: 'auto' }}>
|
||||
{(dmChannels || []).map(dm => {
|
||||
const isActive = activeDMChannel?.channel_id === dm.channel_id;
|
||||
const status = dm.other_user_status || 'online';
|
||||
const effectiveStatus = resolveStatus(dm.other_user_status, dm.other_user_id);
|
||||
return (
|
||||
<div
|
||||
key={dm.channel_id}
|
||||
@@ -213,7 +215,7 @@ const DMList = ({ dmChannels, activeDMChannel, onSelectDM, onOpenDM }) => {
|
||||
<div style={{
|
||||
position: 'absolute', bottom: -2, right: -2,
|
||||
width: 10, height: 10, borderRadius: '50%',
|
||||
backgroundColor: STATUS_COLORS[status] || STATUS_COLORS.online,
|
||||
backgroundColor: STATUS_COLORS[effectiveStatus] || STATUS_COLORS.offline,
|
||||
border: '2px solid var(--bg-secondary)'
|
||||
}} />
|
||||
</div>
|
||||
@@ -222,7 +224,7 @@ const DMList = ({ dmChannels, activeDMChannel, onSelectDM, onOpenDM }) => {
|
||||
{dm.other_username}
|
||||
</div>
|
||||
<div className="dm-item-status">
|
||||
{STATUS_LABELS[status] || 'Online'}
|
||||
{STATUS_LABELS[effectiveStatus] || 'Offline'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,12 +2,14 @@ import React, { useState } from 'react';
|
||||
import { useQuery } from 'convex/react';
|
||||
import { api } from '../../../../convex/_generated/api';
|
||||
import Avatar from './Avatar';
|
||||
import { useOnlineUsers } from '../contexts/PresenceContext';
|
||||
|
||||
const FriendsView = ({ onOpenDM }) => {
|
||||
const [activeTab, setActiveTab] = useState('Online');
|
||||
const [addFriendSearch, setAddFriendSearch] = useState('');
|
||||
|
||||
const myId = localStorage.getItem('userId');
|
||||
const { resolveStatus } = useOnlineUsers();
|
||||
|
||||
const allUsers = useQuery(api.auth.getPublicKeys) || [];
|
||||
const users = allUsers.filter(u => u.id !== myId);
|
||||
@@ -31,7 +33,7 @@ const FriendsView = ({ onOpenDM }) => {
|
||||
};
|
||||
|
||||
const filteredUsers = activeTab === 'Online'
|
||||
? users.filter(u => u.status !== 'offline' && u.status !== 'invisible')
|
||||
? users.filter(u => resolveStatus(u.status, u.id) !== 'offline')
|
||||
: activeTab === 'Add Friend'
|
||||
? users.filter(u => u.username?.toLowerCase().includes(addFriendSearch.toLowerCase()))
|
||||
: users;
|
||||
@@ -91,7 +93,9 @@ const FriendsView = ({ onOpenDM }) => {
|
||||
|
||||
{/* Friends List */}
|
||||
<div style={{ flex: 1, overflowY: 'auto', padding: '0 20px' }}>
|
||||
{filteredUsers.map(user => (
|
||||
{filteredUsers.map(user => {
|
||||
const effectiveStatus = resolveStatus(user.status, user.id);
|
||||
return (
|
||||
<div
|
||||
key={user.id}
|
||||
className="friend-item"
|
||||
@@ -102,7 +106,7 @@ const FriendsView = ({ onOpenDM }) => {
|
||||
<div style={{
|
||||
position: 'absolute', bottom: -2, right: -2,
|
||||
width: 10, height: 10, borderRadius: '50%',
|
||||
backgroundColor: STATUS_COLORS[user.status] || STATUS_COLORS.online,
|
||||
backgroundColor: STATUS_COLORS[effectiveStatus] || STATUS_COLORS.offline,
|
||||
border: '2px solid var(--bg-primary)'
|
||||
}} />
|
||||
</div>
|
||||
@@ -111,7 +115,7 @@ const FriendsView = ({ onOpenDM }) => {
|
||||
{user.username ?? 'Unknown'}
|
||||
</div>
|
||||
<div style={{ color: 'var(--header-secondary)', fontSize: '12px' }}>
|
||||
{user.status === 'dnd' ? 'Do Not Disturb' : (user.status || 'Online').charAt(0).toUpperCase() + (user.status || 'online').slice(1)}
|
||||
{effectiveStatus === 'dnd' ? 'Do Not Disturb' : effectiveStatus.charAt(0).toUpperCase() + effectiveStatus.slice(1)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -134,7 +138,8 @@ const FriendsView = ({ onOpenDM }) => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from 'react';
|
||||
import { useQuery } from 'convex/react';
|
||||
import { api } from '../../../../convex/_generated/api';
|
||||
import { useOnlineUsers } from '../contexts/PresenceContext';
|
||||
|
||||
const USER_COLORS = ['#5865F2', '#EBA7CD', '#57F287', '#FEE75C', '#EB459E', '#ED4245'];
|
||||
|
||||
@@ -25,11 +26,12 @@ const MembersList = ({ channelId, visible, onMemberClick }) => {
|
||||
api.members.getChannelMembers,
|
||||
channelId ? { channelId } : "skip"
|
||||
) || [];
|
||||
const { resolveStatus } = useOnlineUsers();
|
||||
|
||||
if (!visible) return null;
|
||||
|
||||
const onlineMembers = members.filter(m => m.status !== 'offline' && m.status !== 'invisible');
|
||||
const offlineMembers = members.filter(m => m.status === 'offline' || m.status === 'invisible');
|
||||
const onlineMembers = members.filter(m => resolveStatus(m.status, m.id) !== 'offline');
|
||||
const offlineMembers = members.filter(m => resolveStatus(m.status, m.id) === 'offline');
|
||||
|
||||
// Group online members by highest hoisted role
|
||||
const roleGroups = {};
|
||||
@@ -54,13 +56,14 @@ const MembersList = ({ channelId, visible, onMemberClick }) => {
|
||||
const renderMember = (member) => {
|
||||
const topRole = member.roles.length > 0 ? member.roles[0] : null;
|
||||
const nameColor = topRole && topRole.name !== '@everyone' ? topRole.color : '#fff';
|
||||
const effectiveStatus = resolveStatus(member.status, member.id);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={member.id}
|
||||
className="member-item"
|
||||
onClick={() => onMemberClick && onMemberClick(member)}
|
||||
style={member.status === 'offline' || member.status === 'invisible' ? { opacity: 0.3 } : {}}
|
||||
style={effectiveStatus === 'offline' ? { opacity: 0.3 } : {}}
|
||||
>
|
||||
<div className="member-avatar-wrapper">
|
||||
{member.avatarUrl ? (
|
||||
@@ -80,7 +83,7 @@ const MembersList = ({ channelId, visible, onMemberClick }) => {
|
||||
)}
|
||||
<div
|
||||
className="member-status-dot"
|
||||
style={{ backgroundColor: STATUS_COLORS[member.status] || STATUS_COLORS.online }}
|
||||
style={{ backgroundColor: STATUS_COLORS[effectiveStatus] || STATUS_COLORS.offline }}
|
||||
/>
|
||||
</div>
|
||||
<div className="member-info">
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useConvex, useMutation } from 'convex/react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useConvex, useMutation, useQuery } from 'convex/react';
|
||||
import { api } from '../../../../convex/_generated/api';
|
||||
import Tooltip from './Tooltip';
|
||||
import { useVoice } from '../contexts/VoiceContext';
|
||||
@@ -8,7 +9,7 @@ import ServerSettingsModal from './ServerSettingsModal';
|
||||
import ScreenShareModal from './ScreenShareModal';
|
||||
import DMList from './DMList';
|
||||
import Avatar from './Avatar';
|
||||
import ThemeSelector from './ThemeSelector';
|
||||
import UserSettings from './UserSettings';
|
||||
import { Track } from 'livekit-client';
|
||||
import muteIcon from '../assets/icons/mute.svg';
|
||||
import mutedIcon from '../assets/icons/muted.svg';
|
||||
@@ -19,6 +20,7 @@ import voiceIcon from '../assets/icons/voice.svg';
|
||||
import disconnectIcon from '../assets/icons/disconnect.svg';
|
||||
import cameraIcon from '../assets/icons/camera.svg';
|
||||
import screenIcon from '../assets/icons/screen.svg';
|
||||
import inviteUserIcon from '../assets/icons/invite_user.svg';
|
||||
|
||||
const USER_COLORS = ['#5865F2', '#EBA7CD', '#57F287', '#FEE75C', '#EB459E', '#ED4245'];
|
||||
|
||||
@@ -101,11 +103,46 @@ const STATUS_OPTIONS = [
|
||||
];
|
||||
|
||||
const UserControlPanel = ({ username, userId }) => {
|
||||
const { isMuted, isDeafened, toggleMute, toggleDeafen, connectionState } = useVoice();
|
||||
const { isMuted, isDeafened, toggleMute, toggleDeafen, connectionState, disconnectVoice } = useVoice();
|
||||
const [showStatusMenu, setShowStatusMenu] = useState(false);
|
||||
const [showThemeSelector, setShowThemeSelector] = useState(false);
|
||||
const [showUserSettings, setShowUserSettings] = useState(false);
|
||||
const [currentStatus, setCurrentStatus] = useState('online');
|
||||
const updateStatusMutation = useMutation(api.auth.updateStatus);
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Fetch stored status preference from server and sync local state
|
||||
const allUsers = useQuery(api.auth.getPublicKeys) || [];
|
||||
const myUser = allUsers.find(u => u.id === userId);
|
||||
React.useEffect(() => {
|
||||
if (myUser) {
|
||||
if (myUser.status && myUser.status !== 'offline') {
|
||||
setCurrentStatus(myUser.status);
|
||||
} else if (!myUser.status || myUser.status === 'offline') {
|
||||
// First login or no preference set yet — default to "online"
|
||||
setCurrentStatus('online');
|
||||
if (userId) {
|
||||
updateStatusMutation({ userId, status: 'online' }).catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [myUser?.status]);
|
||||
|
||||
const handleLogout = async () => {
|
||||
// Disconnect voice if connected
|
||||
if (connectionState === 'connected') {
|
||||
try { disconnectVoice(); } catch {}
|
||||
}
|
||||
// Clear persisted session
|
||||
if (window.sessionPersistence) {
|
||||
try { await window.sessionPersistence.clear(); } catch {}
|
||||
}
|
||||
// Clear storage (preserve theme)
|
||||
const theme = localStorage.getItem('theme');
|
||||
localStorage.clear();
|
||||
if (theme) localStorage.setItem('theme', theme);
|
||||
sessionStorage.clear();
|
||||
navigate('/');
|
||||
};
|
||||
|
||||
const effectiveMute = isMuted || isDeafened;
|
||||
const statusColor = STATUS_OPTIONS.find(s => s.value === currentStatus)?.color || '#3ba55c';
|
||||
@@ -191,15 +228,31 @@ const UserControlPanel = ({ username, userId }) => {
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip text="User Settings" position="top">
|
||||
<button style={controlButtonStyle} onClick={() => setShowThemeSelector(true)}>
|
||||
<button style={controlButtonStyle} onClick={() => setShowUserSettings(true)}>
|
||||
<ColoredIcon
|
||||
src={settingsIcon}
|
||||
color={ICON_COLOR_DEFAULT}
|
||||
/>
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip text="Log Out" position="top">
|
||||
<button style={controlButtonStyle} onClick={handleLogout}>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M16 17L21 12L16 7" stroke={ICON_COLOR_DEFAULT} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
<path d="M21 12H9" stroke={ICON_COLOR_DEFAULT} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
<path d="M9 21H5C4.46957 21 3.96086 20.7893 3.58579 20.4142C3.21071 20.0391 3 19.5304 3 19V5C3 4.46957 3.21071 3.96086 3.58579 3.58579C3.96086 3.21071 4.46957 3 5 3H9" stroke={ICON_COLOR_DEFAULT} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
{showThemeSelector && <ThemeSelector onClose={() => setShowThemeSelector(false)} />}
|
||||
{showUserSettings && (
|
||||
<UserSettings
|
||||
onClose={() => setShowUserSettings(false)}
|
||||
userId={userId}
|
||||
username={username}
|
||||
onLogout={handleLogout}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -445,7 +498,7 @@ const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKe
|
||||
};
|
||||
|
||||
const renderDMView = () => (
|
||||
<div className="channel-list" style={{ flex: 1, overflowY: 'auto', display: 'flex', flexDirection: 'column' }}>
|
||||
<div className="channel-list" style={{ flex: 1, overflowY: 'auto', display: 'flex', flexDirection: 'column', backgroundColor: 'var(--bg-tertiary)', borderLeft: '1px solid var(--app-frame-border)', borderTop: '1px solid var(--app-frame-border)', borderRadius: '8px 0 0 0' }}>
|
||||
<DMList
|
||||
dmChannels={dmChannels}
|
||||
activeDMChannel={activeDMChannel}
|
||||
@@ -504,13 +557,15 @@ const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKe
|
||||
}, [channels]);
|
||||
|
||||
const renderServerView = () => (
|
||||
<div style={{ flex: 1, overflowY: 'auto', display: 'flex', flexDirection: 'column' }}>
|
||||
<div className="server-header" onClick={() => setIsServerSettingsOpen(true)}>
|
||||
<span>Secure Chat</span>
|
||||
<span className="server-header-chevron">▾</span>
|
||||
<div style={{ flex: 1, overflowY: 'auto', display: 'flex', flexDirection: 'column', backgroundColor: 'var(--bg-tertiary)', borderLeft: '1px solid var(--app-frame-border)', borderTop: '1px solid var(--app-frame-border)', borderRadius: '8px 0 0 0' }}>
|
||||
<div className="server-header" style={{ borderBottom: '1px solid var(--app-frame-border)' }}>
|
||||
<span className="server-header-name" onClick={() => setIsServerSettingsOpen(true)}>Secure Chat</span>
|
||||
<button className="server-header-invite" onClick={handleCreateInvite} title="Invite People">
|
||||
<img src={inviteUserIcon} alt="Invite" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="channel-list" style={{ flex: 1, overflowY: 'auto' }}>
|
||||
<div className="channel-list" style={{ flex: 1, overflowY: 'auto', backgroundColor: 'var(--bg-tertiary)' }}>
|
||||
{isCreating && (
|
||||
<div style={{ padding: '0 8px', marginBottom: '4px' }}>
|
||||
<form onSubmit={handleSubmitCreate}>
|
||||
|
||||
712
Frontend/Electron/src/components/UserSettings.jsx
Normal file
712
Frontend/Electron/src/components/UserSettings.jsx
Normal 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;
|
||||
47
Frontend/Electron/src/contexts/PresenceContext.jsx
Normal file
47
Frontend/Electron/src/contexts/PresenceContext.jsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -23,9 +23,21 @@ export function ThemeProvider({ children }) {
|
||||
return localStorage.getItem(STORAGE_KEY) || THEMES.DARK;
|
||||
});
|
||||
|
||||
// On mount, check settings.json as fallback when localStorage is empty
|
||||
useEffect(() => {
|
||||
if (!localStorage.getItem(STORAGE_KEY) && window.appSettings) {
|
||||
window.appSettings.get('theme').then((saved) => {
|
||||
if (saved && Object.values(THEMES).includes(saved)) {
|
||||
setTheme(saved);
|
||||
}
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.className = theme;
|
||||
localStorage.setItem(STORAGE_KEY, theme);
|
||||
window.appSettings?.set('theme', theme);
|
||||
}, [theme]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -135,7 +135,7 @@ body {
|
||||
.sidebar {
|
||||
width: 312px;
|
||||
min-width: 312px;
|
||||
background-color: var(--bg-secondary);
|
||||
background-color: var(--bg-tertiary);
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-shrink: 0;
|
||||
@@ -152,6 +152,11 @@ body {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ownerIcon {
|
||||
color: var(--text-feedback-warning);
|
||||
margin-inline-start: 4px;
|
||||
}
|
||||
|
||||
.server-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
@@ -242,6 +247,7 @@ body {
|
||||
|
||||
.messages-list {
|
||||
flex: 1;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
padding: 0 0 20px 0;
|
||||
display: flex;
|
||||
@@ -250,11 +256,11 @@ body {
|
||||
|
||||
.messages-list::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
background-color: var(--bg-secondary);
|
||||
background-color: var(--bg-primary);
|
||||
}
|
||||
|
||||
.messages-list::-webkit-scrollbar-thumb {
|
||||
background-color: var(--bg-tertiary);
|
||||
background-color: #666770;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
@@ -574,6 +580,50 @@ body {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
/* Preview author line */
|
||||
.preview-author {
|
||||
font-size: 13px;
|
||||
color: var(--header-primary);
|
||||
font-weight: 500;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
/* Provider-branded previews */
|
||||
.twitter-preview {
|
||||
border-left-color: #1da1f2 !important;
|
||||
}
|
||||
|
||||
.twitter-preview .preview-description {
|
||||
-webkit-line-clamp: 6;
|
||||
line-clamp: 6;
|
||||
}
|
||||
|
||||
.spotify-preview {
|
||||
border-left-color: #1db954 !important;
|
||||
}
|
||||
|
||||
.reddit-preview {
|
||||
border-left-color: #ff4500 !important;
|
||||
}
|
||||
|
||||
/* Large image layout: image below content at full width */
|
||||
.large-image-layout {
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.large-image-layout .preview-image-container.large-image {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.large-image-layout .preview-image-container.large-image .preview-image {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
max-height: 300px;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.youtube-preview {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
@@ -722,7 +772,6 @@ body {
|
||||
-webkit-app-region: drag;
|
||||
z-index: 10000;
|
||||
flex-shrink: 0;
|
||||
border-bottom: 1px solid var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.titlebar-drag-region {
|
||||
@@ -864,7 +913,7 @@ body {
|
||||
.members-list {
|
||||
width: 240px;
|
||||
min-width: 240px;
|
||||
background-color: var(--bg-secondary);
|
||||
background-color: var(--bg-primary);
|
||||
border-left: 1px solid var(--border-subtle);
|
||||
overflow-y: auto;
|
||||
padding: 16px 8px;
|
||||
@@ -1894,25 +1943,58 @@ body {
|
||||
height: 48px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 16px;
|
||||
border-bottom: 1px solid var(--bg-tertiary);
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
font-weight: 600;
|
||||
font-size: 15px;
|
||||
color: var(--header-primary);
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.server-header-name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
padding: 4px 4px;
|
||||
transition: background-color 0.1s;
|
||||
}
|
||||
|
||||
.server-header:hover {
|
||||
.server-header-name:hover {
|
||||
background-color: var(--background-modifier-hover);
|
||||
}
|
||||
|
||||
.server-header-chevron {
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
transition: transform 0.2s;
|
||||
.server-header-invite {
|
||||
flex-shrink: 0;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 4px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--interactive-normal);
|
||||
transition: color 0.15s, background-color 0.15s;
|
||||
}
|
||||
|
||||
.server-header-invite:hover {
|
||||
color: var(--interactive-hover);
|
||||
background-color: var(--background-modifier-hover);
|
||||
}
|
||||
|
||||
.server-header-invite img {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
filter: brightness(0) invert(0.7);
|
||||
}
|
||||
|
||||
.server-header-invite:hover img {
|
||||
filter: brightness(0) invert(0.9);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
@@ -2281,3 +2363,172 @@ body {
|
||||
border-radius: 50%;
|
||||
background-color: var(--control-primary-background-default);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
USER SETTINGS - AVATAR OVERLAY
|
||||
============================================ */
|
||||
.user-settings-avatar-wrapper {
|
||||
position: relative;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.user-settings-avatar-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 50%;
|
||||
background-color: rgba(0, 0, 0, 0.6);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
text-align: center;
|
||||
line-height: 1.3;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.user-settings-avatar-wrapper:hover .user-settings-avatar-overlay {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
AVATAR CROP MODAL
|
||||
============================================ */
|
||||
.avatar-crop-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.7);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 10001;
|
||||
}
|
||||
|
||||
.avatar-crop-dialog {
|
||||
width: 440px;
|
||||
background-color: var(--bg-tertiary);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.avatar-crop-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 20px;
|
||||
}
|
||||
|
||||
.avatar-crop-area {
|
||||
position: relative;
|
||||
height: 300px;
|
||||
background-color: #000;
|
||||
}
|
||||
|
||||
.avatar-crop-slider-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 16px 20px;
|
||||
}
|
||||
|
||||
.avatar-crop-slider {
|
||||
flex: 1;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
height: 6px;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 3px;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.avatar-crop-slider::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
background: var(--header-primary);
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.avatar-crop-slider::-moz-range-thumb {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
background: var(--header-primary);
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.avatar-crop-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
padding: 0 20px 20px;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
USER SETTINGS - MIC LEVEL METER
|
||||
============================================ */
|
||||
.mic-level-bar {
|
||||
flex: 1;
|
||||
height: 8px;
|
||||
background-color: var(--bg-tertiary);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.mic-level-fill {
|
||||
height: 100%;
|
||||
background-color: #3ba55c;
|
||||
border-radius: 4px;
|
||||
transition: width 0.05s ease;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
USER SETTINGS - VOICE SLIDER
|
||||
============================================ */
|
||||
.voice-slider {
|
||||
flex: 1;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
height: 6px;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 3px;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.voice-slider::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
background: var(--header-primary);
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.voice-slider::-moz-range-thumb {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
background: var(--header-primary);
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import FriendsView from '../components/FriendsView';
|
||||
import MembersList from '../components/MembersList';
|
||||
import ChatHeader from '../components/ChatHeader';
|
||||
import { useToasts } from '../components/Toast';
|
||||
import { PresenceProvider } from '../contexts/PresenceContext';
|
||||
|
||||
const Chat = () => {
|
||||
const [view, setView] = useState('server');
|
||||
@@ -230,25 +231,37 @@ const Chat = () => {
|
||||
);
|
||||
}
|
||||
|
||||
if (!userId) {
|
||||
return (
|
||||
<div className="app-container">
|
||||
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#b9bbbe' }}>
|
||||
Loading...
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="app-container">
|
||||
<Sidebar
|
||||
channels={channels}
|
||||
activeChannel={activeChannel}
|
||||
onSelectChannel={handleSelectChannel}
|
||||
username={username}
|
||||
channelKeys={channelKeys}
|
||||
view={view}
|
||||
onViewChange={setView}
|
||||
onOpenDM={openDM}
|
||||
activeDMChannel={activeDMChannel}
|
||||
setActiveDMChannel={setActiveDMChannel}
|
||||
dmChannels={dmChannels}
|
||||
userId={userId}
|
||||
/>
|
||||
{renderMainContent()}
|
||||
<ToastContainer />
|
||||
</div>
|
||||
<PresenceProvider userId={userId}>
|
||||
<div className="app-container">
|
||||
<Sidebar
|
||||
channels={channels}
|
||||
activeChannel={activeChannel}
|
||||
onSelectChannel={handleSelectChannel}
|
||||
username={username}
|
||||
channelKeys={channelKeys}
|
||||
view={view}
|
||||
onViewChange={setView}
|
||||
onOpenDM={openDM}
|
||||
activeDMChannel={activeDMChannel}
|
||||
setActiveDMChannel={setActiveDMChannel}
|
||||
dmChannels={dmChannels}
|
||||
userId={userId}
|
||||
/>
|
||||
{renderMainContent()}
|
||||
<ToastContainer />
|
||||
</div>
|
||||
</PresenceProvider>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -63,6 +63,22 @@ const Login = () => {
|
||||
localStorage.setItem('publicKey', verifyData.publicKey);
|
||||
}
|
||||
|
||||
// Persist session via safeStorage for auto-login on restart
|
||||
if (window.sessionPersistence) {
|
||||
try {
|
||||
await window.sessionPersistence.save({
|
||||
userId: verifyData.userId,
|
||||
username,
|
||||
publicKey: verifyData.publicKey || '',
|
||||
signingKey,
|
||||
privateKey: rsaPriv,
|
||||
savedAt: Date.now(),
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn('Session persistence unavailable:', e);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('Immediate localStorage read check:', localStorage.getItem('userId'));
|
||||
|
||||
navigate('/chat');
|
||||
|
||||
@@ -50,6 +50,7 @@
|
||||
--border-muted: rgba(255, 255, 255, 0.04);
|
||||
--border-normal: rgba(255, 255, 255, 0.2);
|
||||
--border-strong: rgba(255, 255, 255, 0.44);
|
||||
--app-frame-border: color-mix(in oklab,hsl(240 calc(1*4%) 60.784% /0.12156862745098039) 100%,hsl(0 0% 0% /0.12156862745098039) 0%);
|
||||
|
||||
/* Icons */
|
||||
--icon-default: #dbdee1;
|
||||
@@ -93,6 +94,8 @@
|
||||
--background-modifier-active: rgba(78, 80, 88, 0.48);
|
||||
--background-modifier-selected: rgba(78, 80, 88, 0.6);
|
||||
--div-border: #1e1f22;
|
||||
|
||||
--text-feedback-warning: color-mix(in oklab, hsl(38.455 calc(1*100%) 43.137% /1) 100%, #000 0%);
|
||||
}
|
||||
|
||||
|
||||
@@ -138,6 +141,7 @@
|
||||
--border-muted: rgba(0, 0, 0, 0.2);
|
||||
--border-normal: rgba(0, 0, 0, 0.36);
|
||||
--border-strong: rgba(0, 0, 0, 0.48);
|
||||
--app-frame-border: color-mix(in oklab,hsl(240 calc(1*4%) 60.784% /0.12156862745098039) 100%,hsl(0 0% 0% /0.12156862745098039) 0%);
|
||||
|
||||
/* Icons */
|
||||
--icon-default: #313338;
|
||||
@@ -181,6 +185,8 @@
|
||||
--background-modifier-active: rgba(116, 124, 138, 0.22);
|
||||
--background-modifier-selected: rgba(116, 124, 138, 0.30);
|
||||
--div-border: #e1e2e4;
|
||||
|
||||
--text-feedback-warning: color-mix(in oklab, hsl(38.455 calc(1*100%) 43.137% /1) 100%, #000 0%);
|
||||
}
|
||||
|
||||
|
||||
@@ -198,7 +204,7 @@
|
||||
--chat-background: #202225;
|
||||
--channeltextarea-background: #252529;
|
||||
--modal-background: #292b2f;
|
||||
--panel-bg: #1a1b1e;
|
||||
--panel-bg: color-mix(in oklab, hsl(240 calc(1*5.882%) 13.333% /1) 100%, #000 0%);
|
||||
--embed-background: #242529;
|
||||
|
||||
/* Text */
|
||||
@@ -226,6 +232,7 @@
|
||||
--border-muted: rgba(255, 255, 255, 0.04);
|
||||
--border-normal: rgba(255, 255, 255, 0.2);
|
||||
--border-strong: rgba(255, 255, 255, 0.44);
|
||||
--app-frame-border: color-mix(in oklab,hsl(240 calc(1*4%) 60.784% /0.12156862745098039) 100%,hsl(0 0% 0% /0.12156862745098039) 0%);
|
||||
|
||||
/* Icons */
|
||||
--icon-default: #dddfe4;
|
||||
@@ -254,7 +261,7 @@
|
||||
/* Compatibility aliases */
|
||||
--bg-primary: #202225;
|
||||
--bg-secondary: #1a1b1e;
|
||||
--bg-tertiary: #111214;
|
||||
--bg-tertiary: #121214;
|
||||
--text-normal: #dddfe4;
|
||||
--header-primary: #f5f5f7;
|
||||
--header-secondary: #a0a4ad;
|
||||
@@ -269,6 +276,8 @@
|
||||
--background-modifier-active: rgba(78, 80, 88, 0.3);
|
||||
--background-modifier-selected: rgba(78, 80, 88, 0.4);
|
||||
--div-border: #111214;
|
||||
|
||||
--text-feedback-warning: color-mix(in oklab, hsl(38.455 calc(1*100%) 43.137% /1) 100%, #000 0%);
|
||||
}
|
||||
|
||||
|
||||
@@ -314,6 +323,7 @@
|
||||
--border-muted: rgba(255, 255, 255, 0.16);
|
||||
--border-normal: rgba(255, 255, 255, 0.24);
|
||||
--border-strong: rgba(255, 255, 255, 0.44);
|
||||
--app-frame-border: color-mix(in oklab,hsl(240 calc(1*4%) 60.784% /0.12156862745098039) 100%,hsl(0 0% 0% /0.12156862745098039) 0%);
|
||||
|
||||
/* Icons */
|
||||
--icon-default: #e0def0;
|
||||
@@ -357,4 +367,6 @@
|
||||
--background-modifier-active: rgba(78, 73, 106, 0.36);
|
||||
--background-modifier-selected: rgba(78, 73, 106, 0.48);
|
||||
--div-border: #080810;
|
||||
|
||||
--text-feedback-warning: color-mix(in oklab, hsl(38.455 calc(1*100%) 43.137% /1) 100%, #000 0%);
|
||||
}
|
||||
|
||||
17
TODO.md
17
TODO.md
@@ -1,7 +1,16 @@
|
||||
- Create auto updater for app
|
||||
- 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
|
||||
When i scroll up one time with my scroll wheel and move my mouse it scrolls down to the start.
|
||||
|
||||
|
||||
- 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.
|
||||
67
convex/_generated/api.d.ts
vendored
67
convex/_generated/api.d.ts
vendored
@@ -17,6 +17,7 @@ import type * as gifs from "../gifs.js";
|
||||
import type * as invites from "../invites.js";
|
||||
import type * as members from "../members.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 roles from "../roles.js";
|
||||
import type * as typing from "../typing.js";
|
||||
@@ -39,6 +40,7 @@ declare const fullApi: ApiFromModules<{
|
||||
invites: typeof invites;
|
||||
members: typeof members;
|
||||
messages: typeof messages;
|
||||
presence: typeof presence;
|
||||
reactions: typeof reactions;
|
||||
roles: typeof roles;
|
||||
typing: typeof typing;
|
||||
@@ -72,4 +74,67 @@ export declare const internal: FilterApi<
|
||||
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
|
||||
>;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
@@ -198,6 +198,7 @@ export const getPublicKeys = query({
|
||||
username: v.string(),
|
||||
public_identity_key: v.string(),
|
||||
status: v.optional(v.string()),
|
||||
displayName: v.optional(v.string()),
|
||||
avatarUrl: v.optional(v.union(v.string(), v.null())),
|
||||
aboutMe: v.optional(v.string()),
|
||||
customStatus: v.optional(v.string()),
|
||||
@@ -215,7 +216,8 @@ export const getPublicKeys = query({
|
||||
id: u._id,
|
||||
username: u.username,
|
||||
public_identity_key: u.publicIdentityKey,
|
||||
status: u.status || "online",
|
||||
status: u.status || "offline",
|
||||
displayName: u.displayName,
|
||||
avatarUrl,
|
||||
aboutMe: u.aboutMe,
|
||||
customStatus: u.customStatus,
|
||||
@@ -229,6 +231,7 @@ export const getPublicKeys = query({
|
||||
export const updateProfile = mutation({
|
||||
args: {
|
||||
userId: v.id("userProfiles"),
|
||||
displayName: v.optional(v.string()),
|
||||
aboutMe: v.optional(v.string()),
|
||||
avatarStorageId: v.optional(v.id("_storage")),
|
||||
customStatus: v.optional(v.string()),
|
||||
@@ -236,6 +239,7 @@ export const updateProfile = mutation({
|
||||
returns: v.null(),
|
||||
handler: async (ctx, args) => {
|
||||
const patch: Record<string, unknown> = {};
|
||||
if (args.displayName !== undefined) patch.displayName = args.displayName;
|
||||
if (args.aboutMe !== undefined) patch.aboutMe = args.aboutMe;
|
||||
if (args.avatarStorageId !== undefined) patch.avatarStorageId = args.avatarStorageId;
|
||||
if (args.customStatus !== undefined) patch.customStatus = args.customStatus;
|
||||
|
||||
6
convex/convex.config.ts
Normal file
6
convex/convex.config.ts
Normal 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;
|
||||
@@ -79,7 +79,7 @@ export const listDMs = query({
|
||||
channel_name: channel.name,
|
||||
other_user_id: otherUser._id as string,
|
||||
other_username: otherUser.username,
|
||||
other_user_status: otherUser.status || "online",
|
||||
other_user_status: otherUser.status || "offline",
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
@@ -50,7 +50,7 @@ export const getChannelMembers = query({
|
||||
members.push({
|
||||
id: user._id,
|
||||
username: user.username,
|
||||
status: user.status || "online",
|
||||
status: user.status || "offline",
|
||||
roles: roles.sort((a, b) => b.position - a.position),
|
||||
avatarUrl,
|
||||
aboutMe: user.aboutMe,
|
||||
|
||||
32
convex/presence.ts
Normal file
32
convex/presence.ts
Normal 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);
|
||||
},
|
||||
});
|
||||
@@ -12,6 +12,7 @@ export default defineSchema({
|
||||
encryptedPrivateKeys: v.string(),
|
||||
isAdmin: v.boolean(),
|
||||
status: v.optional(v.string()),
|
||||
displayName: v.optional(v.string()),
|
||||
avatarStorageId: v.optional(v.id("_storage")),
|
||||
aboutMe: v.optional(v.string()),
|
||||
customStatus: v.optional(v.string()),
|
||||
|
||||
3
discord-html-copy/Settings Panel/settings snippit.txt
Normal file
3
discord-html-copy/Settings Panel/settings snippit.txt
Normal file
File diff suppressed because one or more lines are too long
52
package-lock.json
generated
52
package-lock.json
generated
@@ -6,6 +6,7 @@
|
||||
"": {
|
||||
"name": "discord-clone",
|
||||
"dependencies": {
|
||||
"@convex-dev/presence": "^0.3.0",
|
||||
"convex": "^1.31.2",
|
||||
"livekit-server-sdk": "^2.15.0"
|
||||
}
|
||||
@@ -16,6 +17,27 @@
|
||||
"integrity": "sha512-wJ8ReQbHxsAfXhrf9ixl0aYbZorRuOWpBNzm8pL8ftmSxQx/wnJD5Eg861NwJU/czy2VXFIebCeZnZrI9rktIQ==",
|
||||
"license": "(Apache-2.0 AND BSD-3-Clause)"
|
||||
},
|
||||
"node_modules/@convex-dev/presence": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@convex-dev/presence/-/presence-0.3.0.tgz",
|
||||
"integrity": "sha512-adV+ao1L77u+egobyJabNwuai/0y/VgzMbqZiy+Q49JmX6fbSaiYg6FpFVACqPaq3giOfjrN2k+5mK2jTAUG0g==",
|
||||
"license": "Apache-2.0",
|
||||
"peerDependencies": {
|
||||
"convex": "^1.24.8",
|
||||
"expo-crypto": ">=14.1.0",
|
||||
"react": "~18.3.1 || ^19.0.0",
|
||||
"react-dom": "~18.3.1 || ^19.0.0",
|
||||
"react-native": ">=0.79.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"expo-crypto": {
|
||||
"optional": true
|
||||
},
|
||||
"react-native": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.27.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.0.tgz",
|
||||
@@ -608,6 +630,36 @@
|
||||
"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": {
|
||||
"version": "4.41.0",
|
||||
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz",
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"install:all": "npm install && cd Frontend/Electron && npm install"
|
||||
},
|
||||
"dependencies": {
|
||||
"@convex-dev/presence": "^0.3.0",
|
||||
"convex": "^1.31.2",
|
||||
"livekit-server-sdk": "^2.15.0"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user