feat: Implement core Discord clone functionality including Convex backend, Electron frontend, and UI components for chat, voice, and settings.

This commit is contained in:
Bryan1029384756
2026-02-10 04:41:10 -06:00
parent 516cfdbbd8
commit 47f173c79b
63 changed files with 4467 additions and 5292 deletions

View File

@@ -9,7 +9,8 @@
"Bash(node -e:*)",
"Bash(npx convex dev:*)",
"Bash(npx convex:*)",
"Bash(npx @convex-dev/auth:*)"
"Bash(npx @convex-dev/auth:*)",
"Bash(dir:*)"
]
}
}

3
.gitignore vendored
View File

@@ -1,4 +1,5 @@
node_modules
.env
.env.local
.vscode
./backend/uploads
./backend/uploads/

View File

@@ -1,240 +0,0 @@
{
"categories": [
{
"name": "excited",
"src": "https://media.tenor.com/nb9yRj8rHo4AAAPs/excited-ah.webm"
},
{
"name": "bye",
"src": "https://media.tenor.com/63TUZpfzGHQAAAPs/peach-and-goma.webm"
},
{
"name": "sorry",
"src": "https://media.tenor.com/MXhgLF7-FwAAAAPs/sorry.webm"
},
{
"name": "congratulations",
"src": "https://media.tenor.com/Twpbh1jgXD4AAAPs/congratulations-congrats.webm"
},
{
"name": "sleepy",
"src": "https://media.tenor.com/Clvc7JcH7z4AAAPs/sleepy-bed-time.webm"
},
{
"name": "hello",
"src": "https://media.tenor.com/Y6Qq56KBIjUAAAPs/hello-cute.webm"
},
{
"name": "hugs",
"src": "https://media.tenor.com/oZtU0xcJCdMAAAPs/i-love-you-love-you.webm"
},
{
"name": "ok",
"src": "https://media.tenor.com/UrIakXGExfUAAAPs/mr-bean.webm"
},
{
"name": "please",
"src": "https://media.tenor.com/heEyHbV8iaUAAAPs/puss-in-boots-shrek.webm"
},
{
"name": "thank you",
"src": "https://media.tenor.com/L30xqxi-6L4AAAPs/gitapro3-gitagita.webm"
},
{
"name": "miss you",
"src": "https://media.tenor.com/rzG9YBjxW-0AAAPs/peach-sad.webm"
},
{
"name": "wink",
"src": "https://media.tenor.com/KfRIf22QpTQAAAPs/wink-eye-wink.webm"
},
{
"name": "whatever",
"src": "https://media.tenor.com/5AdGWfejMcoAAAPs/whatever-you-say.webm"
},
{
"name": "hungry",
"src": "https://media.tenor.com/7tTScpb6gt0AAAPs/spearhyunho.webm"
},
{
"name": "dance",
"src": "https://media.tenor.com/HCyNMWQv868AAAPs/good-night.webm"
},
{
"name": "annoyed",
"src": "https://media.tenor.com/6DRQNAOEavcAAAPs/cat-annoyed.webm"
},
{
"name": "omg",
"src": "https://media.tenor.com/FtUKdJxBRH0AAAPs/cat-cat-memes.webm"
},
{
"name": "crazy",
"src": "https://media.tenor.com/o2yRyjihS1wAAAPs/balthazar-crazy.webm"
},
{
"name": "shrug",
"src": "https://media.tenor.com/Zc-ZTPzlEHoAAAPs/i-don%27t-know-idk.webm"
},
{
"name": "smile",
"src": "https://media.tenor.com/NrEEi7ZDo8cAAAPs/as.webm"
},
{
"name": "awkward",
"src": "https://media.tenor.com/6Ug_C4RjC4kAAAPs/%D9%84%D9%8A%D9%8A%D9%88%D9%88.webm"
},
{
"name": "ew",
"src": "https://media.tenor.com/oj4p9mRXeoIAAAPs/dol-huh.webm"
},
{
"name": "angry",
"src": "https://media.tenor.com/RZzU2_IbHDEAAAPs/cat-side-eye.webm"
},
{
"name": "surprised",
"src": "https://media.tenor.com/CNI1fSM1XSoAAAPs/shocked-surprised.webm"
},
{
"name": "why",
"src": "https://media.tenor.com/HewFmNRxtT4AAAPs/why-persian-room-cat-guardian.webm"
},
{
"name": "thumbs up",
"src": "https://media.tenor.com/LpEzkHFtdgUAAAPs/gif-emoji.webm"
},
{
"name": "wow",
"src": "https://media.tenor.com/VdsC5bF7CMAAAAPs/smu.webm"
},
{
"name": "ouch",
"src": "https://media.tenor.com/qG2Qj0vvLwMAAAPs/ouch.webm"
},
{
"name": "oops",
"src": "https://media.tenor.com/izYQUXHzhxoAAAPs/oops.webm"
},
{
"name": "youre welcome",
"src": "https://media.tenor.com/CXZLJK_6sa0AAAPs/you%27re-welcome.webm"
},
{
"name": "lazy",
"src": "https://media.tenor.com/Xt7ns1EZUEIAAAPs/panda-animated.webm"
},
{
"name": "stressed",
"src": "https://media.tenor.com/Yc-62-d3QCAAAAPs/stressed.webm"
},
{
"name": "embarrassed",
"src": "https://media.tenor.com/EIlaqgHLePUAAAPs/look-away-simpson.webm"
},
{
"name": "clapping",
"src": "https://media.tenor.com/3yPBPC_dwe8AAAPs/leonardo-dicaprio-clapping.webm"
},
{
"name": "awesome",
"src": "https://media.tenor.com/WFhElAbsdqcAAAPs/awesome-minions.webm"
},
{
"name": "jk",
"src": "https://media.tenor.com/Fy0hkZaMgakAAAPs/nah-im-just-kidding-jk.webm"
},
{
"name": "good luck",
"src": "https://media.tenor.com/VK6Wv4nxX10AAAPs/good-luck.webm"
},
{
"name": "high five",
"src": "https://media.tenor.com/HozyHCAac-kAAAPs/high-five-patrick-star.webm"
},
{
"name": "nervous",
"src": "https://media.tenor.com/tDfXlJnVctgAAAPs/sweating-nervous.webm"
},
{
"name": "duh",
"src": "https://media.tenor.com/DCScm2moJ7EAAAPs/duh-sarcastic.webm"
},
{
"name": "aww",
"src": "https://media.tenor.com/XwxrDV7VKuEAAAPs/love-languages.webm"
},
{
"name": "scared",
"src": "https://media.tenor.com/Io0g8LOf8nMAAAPs/dog-awkward.webm"
},
{
"name": "bored",
"src": "https://media.tenor.com/f4d9ExQT1voAAAPs/h2di-cat-dead.webm"
},
{
"name": "sigh",
"src": "https://media.tenor.com/ZFc20z8DItkAAAPs/facepalm-really.webm"
},
{
"name": "kiss",
"src": "https://media.tenor.com/RPq56gVYswUAAAPs/twitter-kiwi.webm"
},
{
"name": "sad",
"src": "https://media.tenor.com/lV1EF4I83MkAAAPs/bubu-dudu-twitter.webm"
},
{
"name": "good night",
"src": "https://media.tenor.com/95lulIY57IwAAAPs/sweet-dreams-sleep-good.webm"
},
{
"name": "good morning",
"src": "https://media.tenor.com/25Y5C_HeRckAAAPs/good-morning.webm"
},
{
"name": "confused",
"src": "https://media.tenor.com/ASAsHQNVvacAAAPs/midnight-the-cat-stopped-working-midnight-the-cat.webm"
},
{
"name": "chill out",
"src": "https://media.tenor.com/65BBEN4WDcUAAAPs/chillin-chilling.webm"
},
{
"name": "love",
"src": "https://media.tenor.com/1nIDXbABxgsAAAPs/gif-gifkk.webm"
},
{
"name": "happy",
"src": "https://media.tenor.com/gotOLnyvy4YAAAPs/bubu-dancing-dance.webm"
},
{
"name": "cry",
"src": "https://media.tenor.com/rokauCI9nPYAAAPs/crying-sad.webm"
},
{
"name": "yes",
"src": "https://media.tenor.com/7iq8qyXvKHsAAAPs/yes-monkey.webm"
},
{
"name": "no",
"src": "https://media.tenor.com/vLK4Mq3jiKIAAAPs/cat-no.webm"
},
{
"name": "lol",
"src": "https://media.tenor.com/JgCg0MPmMWQAAAPs/shirley-temple-lol.webm"
}
],
"gifs": [
{
"id": "13011029513751011075",
"title": "",
"url": "https://tenor.com/view/feliz-dia-de-reyes-happy-three-kings-day-epiphany-3kings-day-magos-or-el-d%C3%ADa-de-reyes-gif-19847132",
"src": "https://media.tenor.com/tJB2aElAnwMAAAPs/feliz-dia-de-reyes-happy-three-kings-day.webm",
"gif_src": "https://media.tenor.com/tJB2aElAnwMAAAAC/feliz-dia-de-reyes-happy-three-kings-day.gif",
"width": 640,
"height": 640,
"preview": "https://media.tenor.com/tJB2aElAnwMAAAAD/feliz-dia-de-reyes-happy-three-kings-day.png"
}
]
}

View File

@@ -1,10 +0,0 @@
const { Pool } = require('pg');
require('dotenv').config();
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
});
module.exports = {
query: (text, params) => pool.query(text, params),
};

1735
Backend/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,23 +0,0 @@
{
"name": "backend",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"axios": "^1.13.2",
"cors": "^2.8.5",
"dotenv": "^17.2.3",
"express": "^5.2.1",
"livekit-server-sdk": "^2.15.0",
"multer": "^2.0.2",
"pg": "^8.16.3",
"redis": "^5.10.0",
"socket.io": "^4.8.3"
}
}

View File

@@ -1,15 +0,0 @@
const { createClient } = require('redis');
require('dotenv').config();
const client = createClient({
url: process.env.REDIS_URL
});
client.on('error', (err) => console.log('Redis Client Error', err));
(async () => {
await client.connect();
console.log('Redis connected');
})();
module.exports = client;

View File

@@ -1,158 +0,0 @@
const express = require('express');
const router = express.Router();
const db = require('../db');
const crypto = require('crypto');
// Helper to generate fake salt for user privacy
function generateFakeSalt(username) {
return crypto.createHmac('sha256', 'SERVER_SECRET_KEY') // In prod, use env var
.update(username)
.digest('hex');
}
router.post('/register', async (req, res) => {
const { username, salt, encryptedMK, hak, publicKey, signingKey, encryptedPrivateKeys, inviteCode } = req.body;
try {
// Step 1: Enforce Invite (unless first user)
const userCountRes = await db.query('SELECT count(*) FROM users');
const userCount = parseInt(userCountRes.rows[0].count);
if (userCount > 0) {
if (!inviteCode) {
return res.status(403).json({ error: 'Invite code required' });
}
// Check Invite validity
const inviteRes = await db.query('SELECT * FROM invites WHERE code = $1', [inviteCode]);
if (inviteRes.rows.length === 0) {
return res.status(403).json({ error: 'Invalid invite code' });
}
var invite = inviteRes.rows[0];
// Check Expiration
if (invite.expires_at && new Date() > new Date(invite.expires_at)) {
return res.status(410).json({ error: 'Invite expired' });
}
// Check Usage Limits
if (invite.max_uses !== null && invite.uses >= invite.max_uses) {
return res.status(410).json({ error: 'Invite max uses reached' });
}
}
// START TRANSACTION - To ensure invite usage and user creation are atomic
await db.query('BEGIN');
try {
// Update Invite Usage (only if enforced)
if (userCount > 0) {
await db.query('UPDATE invites SET uses = uses + 1 WHERE code = $1', [inviteCode]);
}
// Create User
// Create User
const result = await db.query(
`INSERT INTO users (username, client_salt, encrypted_master_key, hashed_auth_key, public_identity_key, public_signing_key, encrypted_private_keys)
VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id`,
[username, salt, encryptedMK, hak, publicKey, signingKey, encryptedPrivateKeys]
);
const newUserId = result.rows[0].id;
// Assign Roles
// 1. @everyone (Always)
await db.query(`
INSERT INTO user_roles (user_id, role_id)
SELECT $1, id FROM roles WHERE name = '@everyone'
`, [newUserId]);
// 2. Owner (If first user or if admin logic allows)
if (userCount === 0) {
await db.query(`
INSERT INTO user_roles (user_id, role_id)
SELECT $1, id FROM roles WHERE name = 'Owner'
`, [newUserId]);
// Also set is_admin = true for legacy support
await db.query('UPDATE users SET is_admin = TRUE WHERE id = $1', [newUserId]);
}
await db.query('COMMIT');
res.json({ success: true, userId: result.rows[0].id });
} catch (txErr) {
await db.query('ROLLBACK');
throw txErr;
}
} catch (err) {
console.error(err);
if (err.code === '23505') { // Unique violation
res.status(400).json({ error: 'Username taken' });
} else {
res.status(500).json({ error: 'Server error' });
}
}
});
router.post('/login/salt', async (req, res) => {
const { username } = req.body;
try {
const result = await db.query('SELECT client_salt FROM users WHERE username = $1', [username]);
if (result.rows.length > 0) {
res.json({ salt: result.rows[0].client_salt });
} else {
// Return fake salt to prevent enumeration
res.json({ salt: generateFakeSalt(username) });
}
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Server error' });
}
});
router.post('/login/verify', async (req, res) => {
const { username, dak } = req.body;
try {
const result = await db.query(
'SELECT id, hashed_auth_key, encrypted_master_key, encrypted_private_keys, public_identity_key FROM users WHERE username = $1',
[username]
);
if (result.rows.length === 0) {
return res.status(401).json({ error: 'Invalid credentials' });
}
const user = result.rows[0];
const hashedDAK = crypto.createHash('sha256').update(dak).digest('hex');
if (hashedDAK === user.hashed_auth_key) {
res.json({
success: true,
userId: user.id,
encryptedMK: user.encrypted_master_key,
encryptedPrivateKeys: user.encrypted_private_keys,
publicKey: user.public_identity_key // Return Public Key
});
} else {
res.status(401).json({ error: 'Invalid credentials' });
}
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Server error' });
}
});
router.get('/users/public-keys', async (req, res) => {
try {
const result = await db.query('SELECT id, username, public_identity_key FROM users');
res.json(result.rows);
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Server error' });
}
});
module.exports = router;

View File

@@ -1,160 +0,0 @@
const express = require('express');
const router = express.Router();
const db = require('../db');
router.get('/', async (req, res) => {
try {
const result = await db.query("SELECT * FROM channels WHERE type != 'dm' ORDER BY name ASC");
res.json(result.rows);
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Server error' });
}
});
// Create New Channel
router.post('/create', async (req, res) => {
console.log('Creates Channel Body:', req.body);
const { name, type } = req.body;
if (!name) return res.status(400).json({ error: 'Channel name required' });
try {
const result = await db.query(
'INSERT INTO channels (name, type) VALUES ($1, $2) RETURNING *',
[name, type || 'text']
);
const newChannel = result.rows[0];
// DO NOT emit 'new_channel' here. Wait until keys are uploaded.
res.json({ id: newChannel.id });
} catch (err) {
console.error('Error creating channel:', err);
if (err.code === '23505') {
res.status(400).json({ error: 'Channel already exists' });
} else {
res.status(500).json({ error: 'Server error' });
}
}
});
// Notify Channel Creation (Called AFTER keys are uploaded)
router.post('/:id/notify', async (req, res) => {
const { id } = req.params;
try {
const result = await db.query('SELECT * FROM channels WHERE id = $1', [id]);
if (result.rows.length === 0) return res.status(404).json({ error: 'Channel not found' });
const channel = result.rows[0];
if (req.io) req.io.emit('new_channel', channel); // Emit NOW
res.json({ success: true });
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Server error' });
}
});
// Upload Channel Keys (for self or others)
// Upload Channel Keys (for self or others) - Supports Batch
router.post('/keys', async (req, res) => {
// Check if body is array
const keysToUpload = Array.isArray(req.body) ? req.body : [req.body];
if (keysToUpload.length === 0) return res.json({ success: true });
try {
await db.query('BEGIN');
for (const keyData of keysToUpload) {
const { channelId, userId, encryptedKeyBundle, keyVersion } = keyData;
if (!channelId || !userId || !encryptedKeyBundle) {
continue;
}
await db.query(
`INSERT INTO channel_keys (channel_id, user_id, encrypted_key_bundle, key_version)
VALUES ($1, $2, $3, $4)
ON CONFLICT (channel_id, user_id) DO UPDATE
SET encrypted_key_bundle = EXCLUDED.encrypted_key_bundle,
key_version = EXCLUDED.key_version`,
[channelId, userId, encryptedKeyBundle, keyVersion || 1]
);
}
await db.query('COMMIT');
res.json({ success: true, count: keysToUpload.length });
} catch (err) {
await db.query('ROLLBACK');
console.error('Error uploading channel keys:', err);
res.status(500).json({ error: 'Server error' });
}
});
// Get User's Channel Keys
router.get('/keys/:userId', async (req, res) => {
const { userId } = req.params;
try {
const result = await db.query(
'SELECT channel_id, encrypted_key_bundle, key_version FROM channel_keys WHERE user_id = $1',
[userId]
);
res.json(result.rows);
} catch (err) {
console.error('Error fetching channel keys:', err);
res.status(500).json({ error: 'Server error' });
}
});
// Update Channel Name
router.put('/:id', async (req, res) => {
const { id } = req.params;
const { name } = req.body;
if (!name) return res.status(400).json({ error: 'Name required' });
try {
const result = await db.query(
'UPDATE channels SET name = $1 WHERE id = $2 RETURNING *',
[name, id]
);
if (result.rows.length === 0) return res.status(404).json({ error: 'Channel not found' });
const updatedChannel = result.rows[0];
if (req.io) req.io.emit('channel_renamed', updatedChannel);
res.json(updatedChannel);
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Server error' });
}
});
// Delete Channel
router.delete('/:id', async (req, res) => {
const { id } = req.params;
try {
await db.query('BEGIN');
// Manual Cascade (since we didn't set FK CASCADE in schema for these yet)
await db.query('DELETE FROM messages WHERE channel_id = $1', [id]);
await db.query('DELETE FROM channel_keys WHERE channel_id = $1', [id]);
const result = await db.query('DELETE FROM channels WHERE id = $1 RETURNING *', [id]);
if (result.rows.length === 0) {
await db.query('ROLLBACK');
return res.status(404).json({ error: 'Channel not found' });
}
await db.query('COMMIT');
if (req.io) req.io.emit('channel_deleted', id);
res.json({ success: true, deletedId: id });
} catch (err) {
await db.query('ROLLBACK');
console.error(err);
res.status(500).json({ error: 'Server error' });
}
});
module.exports = router;

View File

@@ -1,79 +0,0 @@
const express = require('express');
const router = express.Router();
const db = require('../db');
// POST /api/dms/open — Find-or-create a DM channel between two users
router.post('/open', async (req, res) => {
const { userId, targetUserId } = req.body;
if (!userId || !targetUserId) {
return res.status(400).json({ error: 'userId and targetUserId required' });
}
if (userId === targetUserId) {
return res.status(400).json({ error: 'Cannot DM yourself' });
}
// Deterministic channel name so the same pair always maps to one channel
const sorted = [userId, targetUserId].sort();
const dmName = `dm-${sorted[0]}-${sorted[1]}`;
try {
// Check if DM channel already exists
const existing = await db.query(
'SELECT id FROM channels WHERE name = $1 AND type = $2',
[dmName, 'dm']
);
if (existing.rows.length > 0) {
return res.json({ channelId: existing.rows[0].id, created: false });
}
// Create the DM channel + participants in a transaction
await db.query('BEGIN');
const channelResult = await db.query(
'INSERT INTO channels (name, type) VALUES ($1, $2) RETURNING id',
[dmName, 'dm']
);
const channelId = channelResult.rows[0].id;
await db.query(
'INSERT INTO dm_participants (channel_id, user_id) VALUES ($1, $2), ($1, $3)',
[channelId, userId, targetUserId]
);
await db.query('COMMIT');
res.json({ channelId, created: true });
} catch (err) {
await db.query('ROLLBACK');
console.error('Error opening DM:', err);
res.status(500).json({ error: 'Server error' });
}
});
// GET /api/dms/user/:userId — List all DM channels for a user with the other user's info
router.get('/user/:userId', async (req, res) => {
const { userId } = req.params;
try {
const result = await db.query(`
SELECT c.id AS channel_id, c.name AS channel_name, c.created_at,
other_user.id AS other_user_id, other_user.username AS other_username
FROM dm_participants my
JOIN channels c ON c.id = my.channel_id AND c.type = 'dm'
JOIN dm_participants other ON other.channel_id = my.channel_id AND other.user_id != $1
JOIN users other_user ON other_user.id = other.user_id
WHERE my.user_id = $1
ORDER BY c.created_at DESC
`, [userId]);
res.json(result.rows);
} catch (err) {
console.error('Error fetching DM channels:', err);
res.status(500).json({ error: 'Server error' });
}
});
module.exports = router;

View File

@@ -1,75 +0,0 @@
const express = require('express');
const router = express.Router();
const db = require('../db');
// Create a new invite
router.post('/create', async (req, res) => {
const { code, encryptedPayload, createdBy, maxUses, expiresAt, keyVersion } = req.body;
if (!code || !encryptedPayload || !createdBy || !keyVersion) {
return res.status(400).json({ error: 'Missing required fields' });
}
try {
await db.query(
`INSERT INTO invites (code, encrypted_payload, created_by, max_uses, expires_at, key_version)
VALUES ($1, $2, $3, $4, $5, $6)`,
[code, encryptedPayload, createdBy, maxUses || null, expiresAt || null, keyVersion]
);
res.json({ success: true });
} catch (err) {
console.error('Error creating invite:', err);
res.status(500).json({ error: 'Server error' });
}
});
// Fetch an invite (and validate it)
router.get('/:code', async (req, res) => {
const { code } = req.params;
try {
const result = await db.query('SELECT * FROM invites WHERE code = $1', [code]);
if (result.rows.length === 0) {
return res.status(404).json({ error: 'Invite not found' });
}
const invite = result.rows[0];
// Check Expiration
if (invite.expires_at && new Date() > new Date(invite.expires_at)) {
return res.status(410).json({ error: 'Invite expired' });
}
// Check Usage Limits
if (invite.max_uses !== null && invite.uses >= invite.max_uses) {
return res.status(410).json({ error: 'Invite max uses reached' });
}
// Increment Uses
await db.query('UPDATE invites SET uses = uses + 1 WHERE code = $1', [code]);
res.json({
encryptedPayload: invite.encrypted_payload,
keyVersion: invite.key_version
});
} catch (err) {
console.error('Error fetching invite:', err);
res.status(500).json({ error: 'Server error' });
}
});
// Delete an invite (Revoke) -> Triggers client-side key rotation policy warning?
// The client should call this, then rotate keys.
router.delete('/:code', async (req, res) => {
const { code } = req.params;
try {
await db.query('DELETE FROM invites WHERE code = $1', [code]);
res.json({ success: true });
} catch (err) {
console.error('Error deleting invite:', err);
res.status(500).json({ error: 'Server error' });
}
});
module.exports = router;

View File

@@ -1,213 +0,0 @@
const express = require('express');
const router = express.Router();
const db = require('../db');
// Middleware to check for permissions (simplified for now)
// In a real app, you'd check if req.user has the permission
// Middleware to check for permissions
const checkPermission = (requiredPerm) => {
return async (req, res, next) => {
const userId = req.headers['x-user-id'];
if (!userId) {
return res.status(401).json({ error: 'Unauthorized: No User ID' });
}
try {
// Fetch all roles for user and aggregate permissions
const result = await db.query(`
SELECT r.permissions
FROM user_roles ur
JOIN roles r ON ur.role_id = r.id
WHERE ur.user_id = $1
`, [userId]);
let hasPermission = false;
// Check if ANY of the user's roles has the permission
for (const row of result.rows) {
const perms = row.permissions || {};
// If Manage Roles is true, or if checking for something else and they have it
if (perms[requiredPerm] === true) {
hasPermission = true;
break;
}
// Implicit Admin/Owner check: if they have 'manage_roles' and we are checking something lower?
// For "Owner" role, we seeded it with specific true values.
// But let's check for 'administrator' equivalent if we had it.
// For now, implicit check: if we need 'manage_roles', look for it.
}
if (!hasPermission) {
return res.status(403).json({ error: `Forbidden: Missing ${requiredPerm}` });
}
next();
} catch (err) {
console.error('Permission check failed:', err);
return res.status(500).json({ error: 'Internal Server Error' });
}
};
};
// GET /api/roles/permissions - Get current user's permissions
router.get('/permissions', async (req, res) => {
const userId = req.headers['x-user-id'];
if (!userId) return res.json({}); // No perms if no ID
try {
const result = await db.query(`
SELECT r.permissions
FROM user_roles ur
JOIN roles r ON ur.role_id = r.id
WHERE ur.user_id = $1
`, [userId]);
const finalPerms = {
manage_channels: false,
manage_roles: false,
create_invite: false,
embed_links: false,
attach_files: false
};
for (const row of result.rows) {
const p = row.permissions || {};
if (p.manage_channels) finalPerms.manage_channels = true;
if (p.manage_roles) finalPerms.manage_roles = true;
if (p.create_invite) finalPerms.create_invite = true;
if (p.embed_links) finalPerms.embed_links = true;
if (p.attach_files) finalPerms.attach_files = true;
}
res.json(finalPerms);
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Server error' });
}
});
// GET /api/roles - List all roles
router.get('/', async (req, res) => {
try {
const result = await db.query('SELECT * FROM roles ORDER BY position DESC, id ASC');
res.json(result.rows);
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Server error' });
}
});
// POST /api/roles - Create new role
router.post('/', checkPermission('manage_roles'), async (req, res) => {
const { name, color, permissions, position, is_hoist } = req.body;
try {
const result = await db.query(
'INSERT INTO roles (name, color, permissions, position, is_hoist) VALUES ($1, $2, $3, $4, $5) RETURNING *',
[name || 'new role', color || '#99aab5', permissions || {}, position || 0, is_hoist || false]
);
res.json(result.rows[0]);
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Server error' });
}
});
// PUT /api/roles/:id - Update role
router.put('/:id', checkPermission('manage_roles'), async (req, res) => {
const { id } = req.params;
const { name, color, permissions, position, is_hoist } = req.body;
// Dynamic update query
const fields = [];
const values = [];
let idx = 1;
if (name !== undefined) { fields.push(`name = $${idx++}`); values.push(name); }
if (color !== undefined) { fields.push(`color = $${idx++}`); values.push(color); }
if (permissions !== undefined) { fields.push(`permissions = $${idx++}`); values.push(permissions); }
if (position !== undefined) { fields.push(`position = $${idx++}`); values.push(position); }
if (is_hoist !== undefined) { fields.push(`is_hoist = $${idx++}`); values.push(is_hoist); }
if (fields.length === 0) return res.json({ success: true }); // Nothing to update
values.push(id);
const query = `UPDATE roles SET ${fields.join(', ')} WHERE id = $${idx} RETURNING *`;
try {
const result = await db.query(query, values);
if (result.rows.length === 0) return res.status(404).json({ error: 'Role not found' });
res.json(result.rows[0]);
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Server error' });
}
});
// DELETE /api/roles/:id - Delete role
router.delete('/:id', checkPermission('manage_roles'), async (req, res) => {
const { id } = req.params;
try {
// user_roles cascade handled by DB
const result = await db.query('DELETE FROM roles WHERE id = $1 RETURNING *', [id]);
if (result.rows.length === 0) return res.status(404).json({ error: 'Role not found' });
res.json({ success: true, deletedId: id });
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Server error' });
}
});
// GET /api/roles/members - List members with roles
// (This is effectively GET /api/users but enriched with roles)
router.get('/members', async (req, res) => {
try {
const result = await db.query(`
SELECT u.id, u.username, u.public_identity_key,
json_agg(r.*) FILTER (WHERE r.id IS NOT NULL) as roles
FROM users u
LEFT JOIN user_roles ur ON u.id = ur.user_id
LEFT JOIN roles r ON ur.role_id = r.id
GROUP BY u.id
`);
res.json(result.rows);
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Server error' });
}
});
// POST /api/roles/:id/assign - Assign role to user
router.post('/:id/assign', checkPermission('manage_roles'), async (req, res) => {
const { id } = req.params; // role id
const { userId } = req.body;
try {
await db.query(
'INSERT INTO user_roles (user_id, role_id) VALUES ($1, $2) ON CONFLICT DO NOTHING',
[userId, id]
);
res.json({ success: true });
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Server error' });
}
});
// POST /api/roles/:id/remove - Remove role from user
router.post('/:id/remove', checkPermission('manage_roles'), async (req, res) => {
const { id } = req.params; // role id
const { userId } = req.body;
try {
await db.query(
'DELETE FROM user_roles WHERE user_id = $1 AND role_id = $2',
[userId, id]
);
res.json({ success: true });
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Server error' });
}
});
module.exports = router;

View File

@@ -1,41 +0,0 @@
const express = require('express');
const multer = require('multer');
const path = require('path');
const fs = require('fs');
const router = express.Router();
// Ensure uploads directory exists
const uploadDir = path.join(__dirname, '../uploads');
if (!fs.existsSync(uploadDir)) {
fs.mkdirSync(uploadDir);
}
// Configure Multer Storage
const storage = multer.diskStorage({
destination: (req, file, cb) => {
cb(null, uploadDir);
},
filename: (req, file, cb) => {
// Generate unique filename: timestamp-random.ext
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
const ext = path.extname(file.originalname);
cb(null, uniqueSuffix + ext);
}
});
const upload = multer({ storage: storage });
// POST /api/upload
router.post('/', upload.single('file'), (req, res) => {
if (!req.file) {
return res.status(400).json({ error: 'No file uploaded' });
}
// Return the URL to access the file
// Assumes server serves 'uploads' folder at '/uploads'
const fileUrl = `/uploads/${req.file.filename}`;
res.json({ url: fileUrl, filename: req.file.filename });
});
module.exports = router;

View File

@@ -1,64 +0,0 @@
const express = require('express');
const router = express.Router();
const { AccessToken } = require('livekit-server-sdk');
const db = require('../db');
// Middleware to check permissions?
// For now, simpler: assuming user is logged in (via x-user-id header check in frontend)
// Real implementation should use the checkPermission middleware or verify session
router.post('/token', async (req, res) => {
const { channelId } = req.body;
const userId = req.headers['x-user-id']; // Sent by frontend
// Default fallback if no user (should rely on auth middleware ideally)
if (!userId) {
return res.status(401).json({ error: 'Unauthorized' });
}
try {
// 1. Get Username (for display)
const userRes = await db.query('SELECT username FROM users WHERE id = $1', [userId]);
if (userRes.rows.length === 0) {
return res.status(404).json({ error: 'User not found' });
}
const username = userRes.rows[0].username;
// 2. Get Channel Name (Optional, for room name check)
// Ensure channel exists and is of type 'voice'
const channelRes = await db.query('SELECT id, type FROM channels WHERE id = $1', [channelId]);
if (channelRes.rows.length === 0) {
return res.status(404).json({ error: 'Channel not found' });
}
if (channelRes.rows[0].type !== 'voice') {
return res.status(400).json({ error: 'Not a voice channel' });
}
// 3. Generate Token
// API Key/Secret from env
const apiKey = process.env.LIVEKIT_API_KEY || 'devkey';
const apiSecret = process.env.LIVEKIT_API_SECRET || 'secret';
const at = new AccessToken(apiKey, apiSecret, {
identity: userId,
name: username,
});
at.addGrant({
roomJoin: true,
room: channelId,
canPublish: true,
canSubscribe: true,
});
const token = await at.toJwt();
res.json({ token });
} catch (err) {
console.error('Error creating voice token:', err);
res.status(500).json({ error: 'Server error' });
}
});
module.exports = router;

View File

@@ -1,73 +0,0 @@
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
username TEXT UNIQUE NOT NULL,
client_salt TEXT NOT NULL,
encrypted_master_key TEXT NOT NULL,
hashed_auth_key TEXT NOT NULL,
public_identity_key TEXT NOT NULL,
public_signing_key TEXT NOT NULL,
encrypted_private_keys TEXT NOT NULL,
is_admin BOOLEAN DEFAULT FALSE,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS channels (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT UNIQUE NOT NULL,
type TEXT DEFAULT 'text' CHECK (type IN ('text', 'voice', 'dm')),
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS roles (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
color TEXT DEFAULT '#99aab5',
position INTEGER DEFAULT 0,
permissions JSONB DEFAULT '{}',
is_hoist BOOLEAN DEFAULT FALSE,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS user_roles (
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
role_id INTEGER REFERENCES roles(id) ON DELETE CASCADE,
PRIMARY KEY (user_id, role_id)
);
CREATE TABLE IF NOT EXISTS channel_keys (
channel_id UUID NOT NULL,
user_id UUID NOT NULL,
encrypted_key_bundle TEXT NOT NULL,
key_version INTEGER DEFAULT 1,
PRIMARY KEY (channel_id, user_id)
);
CREATE TABLE IF NOT EXISTS messages (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
channel_id UUID NOT NULL,
sender_id UUID NOT NULL,
ciphertext TEXT NOT NULL,
nonce TEXT NOT NULL,
signature TEXT NOT NULL,
key_version INTEGER NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS invites (
code TEXT PRIMARY KEY, -- e.g. "8f1a4c..." (The ID of the invite)
encrypted_payload TEXT NOT NULL, -- The AES-Encrypted Key Bundle (Server can't read this)
created_by UUID REFERENCES users(id) ON DELETE CASCADE,
max_uses INTEGER DEFAULT NULL, -- NULL = Infinite
uses INTEGER DEFAULT 0,
expires_at TIMESTAMPTZ DEFAULT NULL, -- NULL = Never
key_version INTEGER NOT NULL, -- Which key version is inside? (So we can invalidate leaks)
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS dm_participants (
channel_id UUID REFERENCES channels(id) ON DELETE CASCADE,
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
PRIMARY KEY (channel_id, user_id)
);

View File

@@ -1,47 +0,0 @@
const fs = require('fs');
const path = require('path');
require('dotenv').config({ path: path.join(__dirname, '../.env') });
const db = require('../db');
async function initDb() {
try {
const schemaPath = path.join(__dirname, '../schema.sql');
const schemaSql = fs.readFileSync(schemaPath, 'utf8');
console.log('Dropping existing tables...');
await db.query(`
DROP TABLE IF EXISTS invites CASCADE;
DROP TABLE IF EXISTS messages CASCADE;
DROP TABLE IF EXISTS channel_keys CASCADE;
DROP TABLE IF EXISTS roles CASCADE;
DROP TABLE IF EXISTS channels CASCADE;
DROP TABLE IF EXISTS users CASCADE;
`);
console.log('Applying schema...');
await db.query(schemaSql);
console.log('Schema applied successfully.');
console.log('Seeding Default Roles...');
// 1. @everyone (Limited)
await db.query(`
INSERT INTO roles (name, color, position, is_hoist, permissions)
VALUES ('@everyone', '#99aab5', 0, false, '{"manage_channels": false, "manage_roles": false, "create_invite": false, "embed_links": true, "attach_files": true}')
`);
// 2. Owner (All Permissions)
await db.query(`
INSERT INTO roles (name, color, position, is_hoist, permissions)
VALUES ('Owner', '#ED4245', 100, true, '{"manage_channels": true, "manage_roles": true, "create_invite": true, "embed_links": true, "attach_files": true}')
`);
console.log('Schema applied successfully.');
process.exit(0);
} catch (err) {
console.error('Error applying schema:', err);
process.exit(1);
}
}
initDb();

View File

@@ -1,32 +0,0 @@
const path = require('path');
require('dotenv').config({ path: path.join(__dirname, '../.env') });
const db = require('../db');
async function migrate() {
try {
console.log('Running DM migration...');
// 1. Update the check constraint to allow 'dm' type
await db.query("ALTER TABLE channels DROP CONSTRAINT IF EXISTS channels_type_check");
await db.query("ALTER TABLE channels ADD CONSTRAINT channels_type_check CHECK (type IN ('text', 'voice', 'dm'))");
console.log(' Updated channels type constraint to allow dm.');
// 2. Create dm_participants table
await db.query(`
CREATE TABLE IF NOT EXISTS dm_participants (
channel_id UUID REFERENCES channels(id) ON DELETE CASCADE,
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
PRIMARY KEY (channel_id, user_id)
)
`);
console.log(' Created dm_participants table.');
console.log('DM migration complete.');
process.exit(0);
} catch (err) {
console.error('DM migration failed:', err);
process.exit(1);
}
}
migrate();

View File

@@ -1,17 +0,0 @@
const path = require('path');
require('dotenv').config({ path: path.join(__dirname, '../.env') });
const db = require('../db');
async function migrate() {
try {
console.log('Running migration...');
await db.query("ALTER TABLE channels ADD COLUMN IF NOT EXISTS type TEXT DEFAULT 'text' CHECK (type IN ('text', 'voice'))");
console.log('Migration complete: Added type column to channels.');
process.exit(0);
} catch (err) {
console.error('Migration failed:', err);
process.exit(1);
}
}
migrate();

View File

@@ -1,271 +0,0 @@
const express = require('express');
const http = require('http');
const { Server } = require('socket.io');
const cors = require('cors');
const axios = require('axios');
require('dotenv').config();
const app = express();
const server = http.createServer(app);
const io = new Server(server, {
cors: {
origin: '*',
methods: ['GET', 'POST']
}
});
const authRoutes = require('./routes/auth');
const channelRoutes = require('./routes/channels');
const uploadRoutes = require('./routes/upload');
const inviteRoutes = require('./routes/invites');
const rolesRoutes = require('./routes/roles');
const dmRoutes = require('./routes/dms');
app.use(cors());
app.use(express.json());
// Attach IO to request
app.use((req, res, next) => {
req.io = io;
next();
});
// Serve uploads folder based on relative path from server.js
const path = require('path');
app.use('/uploads', express.static(path.join(__dirname, 'uploads')));
app.use('/api/auth', authRoutes);
app.use('/api/channels', channelRoutes);
app.use('/api/upload', uploadRoutes);
app.use('/api/invites', inviteRoutes);
app.use('/api/invites', inviteRoutes);
app.use('/api/roles', rolesRoutes);
app.use('/api/dms', dmRoutes);
app.use('/api/voice', require('./routes/voice'));
app.get('/', (req, res) => {
res.send('Secure Chat Backend Running');
});
// GIF Routes
const gifCategories = require('./data/gif_categories.json');
app.get('/api/gifs/categories', (req, res) => {
res.json(gifCategories);
});
app.get('/api/gifs/search', async (req, res) => {
const { q, limit = 8 } = req.query;
const apiKey = process.env.TENOR_API_KEY;
if (!apiKey) {
// Return mock data or error if no key
console.warn('TENOR_API_KEY missing in .env');
// Return dummy response to prevent crash
return res.json({ results: [] });
}
try {
const response = await axios.get(`https://tenor.googleapis.com/v2/search`, {
params: {
q,
key: apiKey,
limit
}
});
res.json(response.data);
} catch (err) {
console.error('Tenor API Error:', err.message);
res.status(500).json({ error: 'Failed to fetch GIFs' });
}
});
const redisClient = require('./redis');
const db = require('./db');
io.on('connection', (socket) => {
console.log('User connected:', socket.id);
socket.on('join_channel', async (data) => {
// Handle both simple string (legacy) and object payload
const channelId = typeof data === 'object' ? data.channelId : data;
const userId = typeof data === 'object' ? data.userId : null;
socket.join(channelId);
console.log(`User ${socket.id} (ID: ${userId}) joined channel ${channelId}`);
// Load recent messages with reactions
try {
const query = `
SELECT m.*, u.username, u.public_signing_key,
(
SELECT json_object_agg(res.emoji, res.data)
FROM (
SELECT r.emoji, json_build_object(
'count', COUNT(*)::int,
'me', BOOL_OR(r.user_id = $2)
) as data
FROM message_reactions r
WHERE r.message_id = m.id
GROUP BY r.emoji
) res
) as reactions
FROM messages m
JOIN users u ON m.sender_id = u.id
WHERE m.channel_id = $1
ORDER BY m.created_at DESC LIMIT 50
`;
const result = await db.query(query, [channelId, userId]);
socket.emit('recent_messages', result.rows.reverse());
} catch (err) {
console.error('Error fetching messages:', err);
}
});
socket.on('send_message', async (data) => {
// data: { channelId, senderId, ciphertext, nonce, signature, keyVersion }
const { channelId, senderId, ciphertext, nonce, signature, keyVersion } = data;
try {
// Store in DB
const result = await db.query(
`INSERT INTO messages (channel_id, sender_id, ciphertext, nonce, signature, key_version)
VALUES ($1, $2, $3, $4, $5, $6) RETURNING id, created_at`,
[channelId, senderId, ciphertext, nonce, signature, keyVersion]
);
const message = {
id: result.rows[0].id,
created_at: result.rows[0].created_at,
...data
};
// Get username and signing key for display/verification
const userRes = await db.query('SELECT username, public_signing_key FROM users WHERE id = $1', [senderId]);
if (userRes.rows.length > 0) {
message.username = userRes.rows[0].username;
message.public_signing_key = userRes.rows[0].public_signing_key;
}
// Broadcast to channel
io.to(channelId).emit('new_message', message);
} catch (err) {
console.error('Error saving message:', err);
}
});
socket.on('typing_start', (data) => {
io.to(data.channelId).emit('typing_start', data);
});
socket.on('typing_stop', (data) => {
io.to(data.channelId).emit('typing_stop', data);
});
// ... Message handling ...
// Voice State Handling
// Stores: channelId -> Set(serializedUser: {userId, username})
// For simplicity: channelId -> [{userId, username}]
// We need a global variable outside the connection loop, but for this file structure 'socket.on' is inside io.on
// So we need to define it outside.
// See defining it at top of file logic.
// Reactions
socket.on('add_reaction', async ({ channelId, messageId, userId, emoji }) => {
try {
if (!messageId || !userId || !emoji) return;
const result = await db.query(
`INSERT INTO message_reactions (message_id, user_id, emoji)
VALUES ($1, $2, $3)
ON CONFLICT (message_id, user_id, emoji) DO NOTHING`,
[messageId, userId, emoji]
);
if (result.rowCount > 0) {
io.to(channelId).emit('reaction_added', { messageId, userId, emoji });
}
} catch (err) {
console.error('Error adding reaction:', err);
}
});
socket.on('remove_reaction', async ({ channelId, messageId, userId, emoji }) => {
try {
if (!messageId || !userId || !emoji) return;
const result = await db.query(
`DELETE FROM message_reactions
WHERE message_id = $1 AND user_id = $2 AND emoji = $3`,
[messageId, userId, emoji]
);
if (result.rowCount > 0) {
io.to(channelId).emit('reaction_removed', { messageId, userId, emoji });
}
} catch (err) {
console.error('Error removing reaction:', err);
}
});
socket.on('join_voice', (channelId) => { }); // Deprecated/Unused placeholder
socket.on('voice_state_change', (data) => {
// data: { channelId, userId, username, action: 'joined' | 'left' | 'state_update', isMuted, isDeafened }
console.log(`Voice State Update: ${data.username} (${data.userId}) ${data.action} ${data.channelId}`);
// Update Server State
if (!global.voiceStates) global.voiceStates = {};
const currentUsers = global.voiceStates[data.channelId] || [];
if (data.action === 'joined') {
const existingUser = currentUsers.find(u => u.userId === data.userId);
if (!existingUser) {
currentUsers.push({
userId: data.userId,
username: data.username,
isMuted: data.isMuted || false,
isDeafened: data.isDeafened || false
});
} else {
// Update existing on re-join (or just state sync)
existingUser.isMuted = data.isMuted || false;
existingUser.isDeafened = data.isDeafened || false;
}
global.voiceStates[data.channelId] = currentUsers;
} else if (data.action === 'left') {
const index = currentUsers.findIndex(u => u.userId === data.userId);
if (index !== -1) {
currentUsers.splice(index, 1);
global.voiceStates[data.channelId] = currentUsers;
}
} else if (data.action === 'state_update') {
const user = currentUsers.find(u => u.userId === data.userId);
if (user) {
if (data.isMuted !== undefined) user.isMuted = data.isMuted;
if (data.isDeafened !== undefined) user.isDeafened = data.isDeafened;
global.voiceStates[data.channelId] = currentUsers;
}
}
io.emit('voice_state_update', data);
});
socket.on('request_voice_state', () => {
socket.emit('full_voice_state', global.voiceStates || {});
});
socket.on('disconnect', () => {
console.log('User disconnected:', socket.id);
// TODO: Auto-leave logic would require mapping socketId -> userId/channelId
});
});
const PORT = process.env.PORT || 3000;
server.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});

View File

@@ -1 +1,74 @@
**Update this file when making significant changes.**
See also: [CONVEX_RULES.md](./CONVEX_RULES.md) | [CONVEX_EXAMPLES.md](./CONVEX_EXAMPLES.md)
## Architecture
- **Backend**: Convex (reactive database + serverless functions)
- **Frontend**: React + Vite (Electron app)
- **Auth**: Zero-knowledge custom auth via Convex mutations (getSalt, verifyUser, createUserWithProfile)
- **Real-time**: Convex reactive queries (`useQuery` auto-updates all connected clients)
- **Voice/Video**: LiveKit (token generation via Convex Node action)
- **E2E Encryption**: Client-side via Electron IPC (`window.cryptoAPI`)
- **File Storage**: Convex built-in storage (`generateUploadUrl` + `getUrl`)
## Key Convex Files (convex/)
- `schema.ts` - Full schema: userProfiles, channels, messages, messageReactions, channelKeys, roles, userRoles, invites, dmParticipants, typingIndicators, voiceStates
- `auth.ts` - getSalt, verifyUser, createUserWithProfile, getPublicKeys
- `channels.ts` - list, get, create, rename, remove (with cascade delete)
- `channelKeys.ts` - uploadKeys, getKeysForUser
- `messages.ts` - list (with reactions + username), send, remove
- `reactions.ts` - add, remove
- `typing.ts` - startTyping, stopTyping, getTyping, cleanExpired (scheduled)
- `dms.ts` - openDM, listDMs
- `invites.ts` - create, use, revoke
- `roles.ts` - list, create, update, remove, listMembers, assign, unassign, getMyPermissions
- `voiceState.ts` - join, leave, updateState, getAll
- `voice.ts` - getToken (Node action, livekit-server-sdk)
- `files.ts` - generateUploadUrl, getFileUrl
- `gifs.ts` - search, categories (Node actions, Tenor API)
## Frontend Structure (FrontEnd/Electron/src/)
- `main.jsx` - ConvexProvider + VoiceProvider + HashRouter
- `pages/Login.jsx` - Convex auth (getSalt + verifyUser)
- `pages/Register.jsx` - Convex auth (createUserWithProfile + invite flow)
- `pages/Chat.jsx` - useQuery for channels, channelKeys, DMs
- `components/ChatArea.jsx` - Messages, typing, reactions via Convex queries/mutations
- `components/Sidebar.jsx` - Channel creation, key distribution, invites via Convex
- `contexts/VoiceContext.jsx` - Voice state via Convex + LiveKit room management
- `components/ChannelSettingsModal.jsx` - Channel rename/delete via Convex mutations
- `components/ServerSettingsModal.jsx` - Role management via Convex queries/mutations
- `components/FriendsView.jsx` - User list via Convex query
- `components/DMList.jsx` - DM user picker via Convex query
- `components/GifPicker.jsx` - GIF search via Convex action
- `components/VoiceRoom.jsx` - LiveKit token via Convex action
## Important Patterns
- Channel IDs use Convex `_id` (not `id`) - all references use `channel._id`
- Auth: client hashes DAK -> HAK before sending, server does string comparison
- First user bootstrap: createUserWithProfile creates Owner + @everyone roles
- Vite config uses `envDir: '../../'` to pick up root `.env.local`
- `socket.io-client` fully removed, all socket refs replaced with Convex
- No Express backend needed - `Backend/` directory is legacy and can be deleted
- Convex queries are reactive - no need for manual refresh or socket listeners
- File uploads use Convex storage: `generateUploadUrl` -> POST blob -> `getFileUrl`
- Typing indicators use scheduled functions for TTL cleanup
## Environment Variables
In `.env.local` at project root:
- `CONVEX_DEPLOYMENT` - Convex deployment URL (set by `npx convex dev`)
- `VITE_CONVEX_URL` - Convex URL for frontend (set by `npx convex dev`)
- `VITE_LIVEKIT_URL` - LiveKit server URL
- `LIVEKIT_API_KEY` - LiveKit API key (used in Convex Node action)
- `LIVEKIT_API_SECRET` - LiveKit API secret (used in Convex Node action)
- `TENOR_API_KEY` - Tenor GIF API key (used in Convex Node action)
## Running the App
1. `npm install && npm run install:frontend`
2. `npx convex dev` (starts Convex backend, creates `.env.local`)
3. In another terminal: `cd FrontEnd/Electron && npm run dev` (or `npm run electron:dev`)

307
CONVEX_EXAMPLES.md Normal file
View File

@@ -0,0 +1,307 @@
# Convex Examples
Reference implementations for common Convex patterns.
---
## Example: Chat App with AI Responses
A real-time chat backend demonstrating:
- User and channel management
- Message storage with proper ordering
- OpenAI integration for AI responses
- Background job scheduling
### Task Requirements
- Allow creating users with names
- Support multiple chat channels
- Enable users to send messages to channels
- Automatically generate AI responses to user messages
- Show recent message history (10 most recent per channel)
### API Design
**Public Mutations:**
- `createUser` - Create user with name
- `createChannel` - Create channel with name
- `sendMessage` - Send message and trigger AI response
**Public Queries:**
- `listMessages` - Get 10 most recent messages (descending)
**Internal Functions:**
- `generateResponse` - Call OpenAI API
- `loadContext` - Load message history for AI context
- `writeAgentResponse` - Save AI response to database
### Schema Design
```
users: { name: string }
channels: { name: string }
messages: { channelId, authorId?, content } + index by_channel
```
---
### Implementation
#### package.json
```json
{
"name": "chat-app",
"version": "1.0.0",
"dependencies": {
"convex": "^1.31.2",
"openai": "^4.79.0"
},
"devDependencies": {
"typescript": "^5.7.3"
}
}
```
#### convex/schema.ts
```typescript
import { defineSchema, defineTable } from "convex/server";
import { v } from "convex/values";
export default defineSchema({
channels: defineTable({
name: v.string(),
}),
users: defineTable({
name: v.string(),
}),
messages: defineTable({
channelId: v.id("channels"),
authorId: v.optional(v.id("users")),
content: v.string(),
}).index("by_channel", ["channelId"]),
});
```
#### convex/index.ts
```typescript
import {
query,
mutation,
internalQuery,
internalMutation,
internalAction,
} from "./_generated/server";
import { v } from "convex/values";
import OpenAI from "openai";
import { internal } from "./_generated/api";
/**
* Create a user with a given name.
*/
export const createUser = mutation({
args: {
name: v.string(),
},
returns: v.id("users"),
handler: async (ctx, args) => {
return await ctx.db.insert("users", { name: args.name });
},
});
/**
* Create a channel with a given name.
*/
export const createChannel = mutation({
args: {
name: v.string(),
},
returns: v.id("channels"),
handler: async (ctx, args) => {
return await ctx.db.insert("channels", { name: args.name });
},
});
/**
* List the 10 most recent messages from a channel in descending creation order.
*/
export const listMessages = query({
args: {
channelId: v.id("channels"),
},
returns: v.array(
v.object({
_id: v.id("messages"),
_creationTime: v.number(),
channelId: v.id("channels"),
authorId: v.optional(v.id("users")),
content: v.string(),
}),
),
handler: async (ctx, args) => {
const messages = await ctx.db
.query("messages")
.withIndex("by_channel", (q) => q.eq("channelId", args.channelId))
.order("desc")
.take(10);
return messages;
},
});
/**
* Send a message to a channel and schedule a response from the AI.
*/
export const sendMessage = mutation({
args: {
channelId: v.id("channels"),
authorId: v.id("users"),
content: v.string(),
},
returns: v.null(),
handler: async (ctx, args) => {
const channel = await ctx.db.get(args.channelId);
if (!channel) {
throw new Error("Channel not found");
}
const user = await ctx.db.get(args.authorId);
if (!user) {
throw new Error("User not found");
}
await ctx.db.insert("messages", {
channelId: args.channelId,
authorId: args.authorId,
content: args.content,
});
await ctx.scheduler.runAfter(0, internal.index.generateResponse, {
channelId: args.channelId,
});
return null;
},
});
const openai = new OpenAI();
export const generateResponse = internalAction({
args: {
channelId: v.id("channels"),
},
returns: v.null(),
handler: async (ctx, args) => {
const context = await ctx.runQuery(internal.index.loadContext, {
channelId: args.channelId,
});
const response = await openai.chat.completions.create({
model: "gpt-4o",
messages: context,
});
const content = response.choices[0].message.content;
if (!content) {
throw new Error("No content in response");
}
await ctx.runMutation(internal.index.writeAgentResponse, {
channelId: args.channelId,
content,
});
return null;
},
});
export const loadContext = internalQuery({
args: {
channelId: v.id("channels"),
},
returns: v.array(
v.object({
role: v.union(v.literal("user"), v.literal("assistant")),
content: v.string(),
}),
),
handler: async (ctx, args) => {
const channel = await ctx.db.get(args.channelId);
if (!channel) {
throw new Error("Channel not found");
}
const messages = await ctx.db
.query("messages")
.withIndex("by_channel", (q) => q.eq("channelId", args.channelId))
.order("desc")
.take(10);
const result = [];
for (const message of messages) {
if (message.authorId) {
const user = await ctx.db.get(message.authorId);
if (!user) {
throw new Error("User not found");
}
result.push({
role: "user" as const,
content: `${user.name}: ${message.content}`,
});
} else {
result.push({ role: "assistant" as const, content: message.content });
}
}
return result;
},
});
export const writeAgentResponse = internalMutation({
args: {
channelId: v.id("channels"),
content: v.string(),
},
returns: v.null(),
handler: async (ctx, args) => {
await ctx.db.insert("messages", {
channelId: args.channelId,
content: args.content,
});
return null;
},
});
```
#### convex/tsconfig.json
```json
{
"compilerOptions": {
"allowJs": true,
"strict": true,
"moduleResolution": "Bundler",
"jsx": "react-jsx",
"skipLibCheck": true,
"allowSyntheticDefaultImports": true,
"target": "ESNext",
"lib": ["ES2021", "dom"],
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"isolatedModules": true,
"noEmit": true
},
"include": ["./**/*"],
"exclude": ["./_generated"]
}
```
#### tsconfig.json (root)
```json
{
"compilerOptions": {
"target": "ESNext",
"lib": ["DOM", "DOM.Iterable", "ESNext"],
"skipLibCheck": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"allowImportingTsExtensions": true,
"noEmit": true,
"jsx": "react-jsx"
},
"exclude": ["convex"],
"include": ["**/src/**/*.tsx", "**/src/**/*.ts", "vite.config.ts"]
}
```

254
CONVEX_RULES.md Normal file
View File

@@ -0,0 +1,254 @@
# Convex Framework Guidelines
Generic Convex best practices for building backend applications.
---
## Function Guidelines
### New Function Syntax
ALWAYS use the new function syntax for Convex functions:
```typescript
import { query } from "./_generated/server";
import { v } from "convex/values";
export const f = query({
args: {},
returns: v.null(),
handler: async (ctx, args) => {
// Function body
},
});
```
### HTTP Endpoint Syntax
HTTP endpoints are defined in `convex/http.ts` and require an `httpAction` decorator:
```typescript
import { httpRouter } from "convex/server";
import { httpAction } from "./_generated/server";
const http = httpRouter();
http.route({
path: "/echo",
method: "POST",
handler: httpAction(async (ctx, req) => {
const body = await req.bytes();
return new Response(body, { status: 200 });
}),
});
```
HTTP endpoints are registered at the exact path you specify.
### Validators
Array validator example:
```typescript
import { mutation } from "./_generated/server";
import { v } from "convex/values";
export default mutation({
args: {
simpleArray: v.array(v.union(v.string(), v.number())),
},
handler: async (ctx, args) => {
//...
},
});
```
Discriminated union example:
```typescript
import { defineSchema, defineTable } from "convex/server";
import { v } from "convex/values";
export default defineSchema({
results: defineTable(
v.union(
v.object({
kind: v.literal("error"),
errorMessage: v.string(),
}),
v.object({
kind: v.literal("success"),
value: v.number(),
}),
),
)
});
```
Always use `v.null()` when returning null:
```typescript
export const exampleQuery = query({
args: {},
returns: v.null(),
handler: async (ctx, args) => {
return null;
},
});
```
### Convex Types Reference
| Convex Type | TS/JS type | Example | Validator | Notes |
|-------------|-------------|---------------|----------------------------------|-------|
| Id | string | `doc._id` | `v.id(tableName)` | |
| Null | null | `null` | `v.null()` | Use `null` instead of `undefined` |
| Int64 | bigint | `3n` | `v.int64()` | -2^63 to 2^63-1 |
| Float64 | number | `3.1` | `v.number()` | IEEE-754 double-precision |
| Boolean | boolean | `true` | `v.boolean()` | |
| String | string | `"abc"` | `v.string()` | UTF-8, <1MB |
| Bytes | ArrayBuffer | `new ArrayBuffer(8)` | `v.bytes()` | <1MB |
| Array | Array | `[1, 3.2]` | `v.array(values)` | Max 8192 values |
| Object | Object | `{a: "abc"}` | `v.object({property: value})` | Max 1024 entries |
| Record | Record | `{"a": "1"}` | `v.record(keys, values)` | ASCII keys only |
### Function Registration
- Use `internalQuery`, `internalMutation`, `internalAction` for private functions (only callable by other Convex functions)
- Use `query`, `mutation`, `action` for public API functions
- You CANNOT register functions through the `api` or `internal` objects
- ALWAYS include argument and return validators. Use `returns: v.null()` if no return value
### Function Calling
- `ctx.runQuery` - call a query from query, mutation, or action
- `ctx.runMutation` - call a mutation from mutation or action
- `ctx.runAction` - call an action from action (only for crossing runtimes V8 to Node)
- All calls take a `FunctionReference`, not the function directly
- Minimize calls from actions to queries/mutations (risk of race conditions)
When calling functions in the same file, add type annotation:
```typescript
export const g = query({
args: {},
returns: v.null(),
handler: async (ctx, args) => {
const result: string = await ctx.runQuery(api.example.f, { name: "Bob" });
return null;
},
});
```
### Function References
- Use `api` object for public functions: `api.example.f`
- Use `internal` object for private functions: `internal.example.g`
- File-based routing: `convex/messages/access.ts` -> `api.messages.access.h`
---
## Validator Guidelines
- `v.bigint()` is deprecated - use `v.int64()` instead
- Use `v.record()` for record types. `v.map()` and `v.set()` are not supported
---
## Schema Guidelines
- Define schema in `convex/schema.ts`
- Import schema functions from `convex/server`
- System fields `_creationTime` (v.number()) and `_id` (v.id(tableName)) are automatic
- Name indexes after their fields: `["field1", "field2"]` -> `"by_field1_and_field2"`
- Index fields must be queried in order defined
---
## TypeScript Guidelines
- Use `Id<'tableName'>` from `./_generated/dataModel` for typed IDs
- Record example: `Record<Id<"users">, string>`
- Use `as const` for string literals in discriminated unions
- Always define arrays as `const array: Array<T> = [...]`
- Always define records as `const record: Record<K, V> = {...}`
- Add `@types/node` when using Node.js built-in modules
---
## Query Guidelines
- Do NOT use `filter` - define an index and use `withIndex` instead
- No `.delete()` - use `.collect()` then iterate with `ctx.db.delete(row._id)`
- Use `.unique()` for single document (throws if multiple match)
- For async iteration, use `for await (const row of query)` instead of `.collect()`
### Ordering
- Default: ascending `_creationTime`
- Use `.order('asc')` or `.order('desc')`
- Indexed queries ordered by index columns
### Full Text Search
```typescript
const messages = await ctx.db
.query("messages")
.withSearchIndex("search_body", (q) =>
q.search("body", "hello hi").eq("channel", "#general"),
)
.take(10);
```
---
## Mutation Guidelines
- `ctx.db.replace` - fully replace document (throws if not exists)
- `ctx.db.patch` - shallow merge updates (throws if not exists)
---
## Action Guidelines
- Add `"use node";` at top of files using Node.js modules
- Actions don't have database access (`ctx.db` not available)
```typescript
import { action } from "./_generated/server";
export const exampleAction = action({
args: {},
returns: v.null(),
handler: async (ctx, args) => {
return null;
},
});
```
---
## Pagination
```typescript
import { paginationOptsValidator } from "convex/server";
export const listWithExtraArg = query({
args: { paginationOpts: paginationOptsValidator, author: v.string() },
handler: async (ctx, args) => {
return await ctx.db
.query("messages")
.withIndex("by_author", (q) => q.eq("author", args.author))
.order("desc")
.paginate(args.paginationOpts);
},
});
```
`paginationOpts`: `{ numItems: number, cursor: string | null }`
Returns: `{ page: Doc[], isDone: boolean, continueCursor: string }`
---
## Cron Jobs
- Use `crons.interval` or `crons.cron` only (not hourly/daily/weekly helpers)
- Pass FunctionReference, not the function directly
```typescript
import { cronJobs } from "convex/server";
import { internal } from "./_generated/api";
const crons = cronJobs();
crons.interval("job name", { hours: 2 }, internal.crons.myFunction, {});
export default crons;
```
---
## File Storage
- `ctx.storage.getUrl()` returns signed URL (null if file doesn't exist)
- Query `_storage` system table for metadata (don't use deprecated `ctx.storage.getMetadata`)
```typescript
const metadata = await ctx.db.system.get(args.fileId);
// Returns: { _id, _creationTime, contentType?, sha256, size }
```
- Store items as `Blob` objects

View File

@@ -10,14 +10,14 @@
"dependencies": {
"@livekit/components-react": "^2.9.17",
"@livekit/components-styles": "^1.2.0",
"convex": "^1.31.2",
"livekit-client": "^2.16.1",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-markdown": "^10.1.0",
"react-router-dom": "^7.11.0",
"react-syntax-highlighter": "^16.1.0",
"remark-gfm": "^4.0.1",
"socket.io-client": "^4.8.3"
"remark-gfm": "^4.0.1"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
@@ -2122,12 +2122,6 @@
"url": "https://github.com/sindresorhus/is?sponsor=1"
}
},
"node_modules/@socket.io/component-emitter": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz",
"integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==",
"license": "MIT"
},
"node_modules/@standard-schema/spec": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
@@ -3678,6 +3672,496 @@
"dev": true,
"license": "MIT"
},
"node_modules/convex": {
"version": "1.31.7",
"resolved": "https://registry.npmjs.org/convex/-/convex-1.31.7.tgz",
"integrity": "sha512-PtNMe1mAIOvA8Yz100QTOaIdgt2rIuWqencVXrb4McdhxBHZ8IJ1eXTnrgCC9HydyilGT1pOn+KNqT14mqn9fQ==",
"license": "Apache-2.0",
"dependencies": {
"esbuild": "0.27.0",
"prettier": "^3.0.0"
},
"bin": {
"convex": "bin/main.js"
},
"engines": {
"node": ">=18.0.0",
"npm": ">=7.0.0"
},
"peerDependencies": {
"@auth0/auth0-react": "^2.0.1",
"@clerk/clerk-react": "^4.12.8 || ^5.0.0",
"react": "^18.0.0 || ^19.0.0-0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@auth0/auth0-react": {
"optional": true
},
"@clerk/clerk-react": {
"optional": true
},
"react": {
"optional": true
}
}
},
"node_modules/convex/node_modules/@esbuild/aix-ppc64": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.0.tgz",
"integrity": "sha512-KuZrd2hRjz01y5JK9mEBSD3Vj3mbCvemhT466rSuJYeE/hjuBrHfjjcjMdTm/sz7au+++sdbJZJmuBwQLuw68A==",
"cpu": [
"ppc64"
],
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=18"
}
},
"node_modules/convex/node_modules/@esbuild/android-arm": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.0.tgz",
"integrity": "sha512-j67aezrPNYWJEOHUNLPj9maeJte7uSMM6gMoxfPC9hOg8N02JuQi/T7ewumf4tNvJadFkvLZMlAq73b9uwdMyQ==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/convex/node_modules/@esbuild/android-arm64": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.0.tgz",
"integrity": "sha512-CC3vt4+1xZrs97/PKDkl0yN7w8edvU2vZvAFGD16n9F0Cvniy5qvzRXjfO1l94efczkkQE6g1x0i73Qf5uthOQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/convex/node_modules/@esbuild/android-x64": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.0.tgz",
"integrity": "sha512-wurMkF1nmQajBO1+0CJmcN17U4BP6GqNSROP8t0X/Jiw2ltYGLHpEksp9MpoBqkrFR3kv2/te6Sha26k3+yZ9Q==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/convex/node_modules/@esbuild/darwin-arm64": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.0.tgz",
"integrity": "sha512-uJOQKYCcHhg07DL7i8MzjvS2LaP7W7Pn/7uA0B5S1EnqAirJtbyw4yC5jQ5qcFjHK9l6o/MX9QisBg12kNkdHg==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/convex/node_modules/@esbuild/darwin-x64": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.0.tgz",
"integrity": "sha512-8mG6arH3yB/4ZXiEnXof5MK72dE6zM9cDvUcPtxhUZsDjESl9JipZYW60C3JGreKCEP+p8P/72r69m4AZGJd5g==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/convex/node_modules/@esbuild/freebsd-arm64": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.0.tgz",
"integrity": "sha512-9FHtyO988CwNMMOE3YIeci+UV+x5Zy8fI2qHNpsEtSF83YPBmE8UWmfYAQg6Ux7Gsmd4FejZqnEUZCMGaNQHQw==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/convex/node_modules/@esbuild/freebsd-x64": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.0.tgz",
"integrity": "sha512-zCMeMXI4HS/tXvJz8vWGexpZj2YVtRAihHLk1imZj4efx1BQzN76YFeKqlDr3bUWI26wHwLWPd3rwh6pe4EV7g==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/convex/node_modules/@esbuild/linux-arm": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.0.tgz",
"integrity": "sha512-t76XLQDpxgmq2cNXKTVEB7O7YMb42atj2Re2Haf45HkaUpjM2J0UuJZDuaGbPbamzZ7bawyGFUkodL+zcE+jvQ==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/convex/node_modules/@esbuild/linux-arm64": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.0.tgz",
"integrity": "sha512-AS18v0V+vZiLJyi/4LphvBE+OIX682Pu7ZYNsdUHyUKSoRwdnOsMf6FDekwoAFKej14WAkOef3zAORJgAtXnlQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/convex/node_modules/@esbuild/linux-ia32": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.0.tgz",
"integrity": "sha512-Mz1jxqm/kfgKkc/KLHC5qIujMvnnarD9ra1cEcrs7qshTUSksPihGrWHVG5+osAIQ68577Zpww7SGapmzSt4Nw==",
"cpu": [
"ia32"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/convex/node_modules/@esbuild/linux-loong64": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.0.tgz",
"integrity": "sha512-QbEREjdJeIreIAbdG2hLU1yXm1uu+LTdzoq1KCo4G4pFOLlvIspBm36QrQOar9LFduavoWX2msNFAAAY9j4BDg==",
"cpu": [
"loong64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/convex/node_modules/@esbuild/linux-mips64el": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.0.tgz",
"integrity": "sha512-sJz3zRNe4tO2wxvDpH/HYJilb6+2YJxo/ZNbVdtFiKDufzWq4JmKAiHy9iGoLjAV7r/W32VgaHGkk35cUXlNOg==",
"cpu": [
"mips64el"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/convex/node_modules/@esbuild/linux-ppc64": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.0.tgz",
"integrity": "sha512-z9N10FBD0DCS2dmSABDBb5TLAyF1/ydVb+N4pi88T45efQ/w4ohr/F/QYCkxDPnkhkp6AIpIcQKQ8F0ANoA2JA==",
"cpu": [
"ppc64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/convex/node_modules/@esbuild/linux-riscv64": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.0.tgz",
"integrity": "sha512-pQdyAIZ0BWIC5GyvVFn5awDiO14TkT/19FTmFcPdDec94KJ1uZcmFs21Fo8auMXzD4Tt+diXu1LW1gHus9fhFQ==",
"cpu": [
"riscv64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/convex/node_modules/@esbuild/linux-s390x": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.0.tgz",
"integrity": "sha512-hPlRWR4eIDDEci953RI1BLZitgi5uqcsjKMxwYfmi4LcwyWo2IcRP+lThVnKjNtk90pLS8nKdroXYOqW+QQH+w==",
"cpu": [
"s390x"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/convex/node_modules/@esbuild/linux-x64": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.0.tgz",
"integrity": "sha512-1hBWx4OUJE2cab++aVZ7pObD6s+DK4mPGpemtnAORBvb5l/g5xFGk0vc0PjSkrDs0XaXj9yyob3d14XqvnQ4gw==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/convex/node_modules/@esbuild/netbsd-arm64": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.0.tgz",
"integrity": "sha512-6m0sfQfxfQfy1qRuecMkJlf1cIzTOgyaeXaiVaaki8/v+WB+U4hc6ik15ZW6TAllRlg/WuQXxWj1jx6C+dfy3w==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/convex/node_modules/@esbuild/netbsd-x64": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.0.tgz",
"integrity": "sha512-xbbOdfn06FtcJ9d0ShxxvSn2iUsGd/lgPIO2V3VZIPDbEaIj1/3nBBe1AwuEZKXVXkMmpr6LUAgMkLD/4D2PPA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/convex/node_modules/@esbuild/openbsd-arm64": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.0.tgz",
"integrity": "sha512-fWgqR8uNbCQ/GGv0yhzttj6sU/9Z5/Sv/VGU3F5OuXK6J6SlriONKrQ7tNlwBrJZXRYk5jUhuWvF7GYzGguBZQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/convex/node_modules/@esbuild/openbsd-x64": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.0.tgz",
"integrity": "sha512-aCwlRdSNMNxkGGqQajMUza6uXzR/U0dIl1QmLjPtRbLOx3Gy3otfFu/VjATy4yQzo9yFDGTxYDo1FfAD9oRD2A==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/convex/node_modules/@esbuild/openharmony-arm64": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.0.tgz",
"integrity": "sha512-nyvsBccxNAsNYz2jVFYwEGuRRomqZ149A39SHWk4hV0jWxKM0hjBPm3AmdxcbHiFLbBSwG6SbpIcUbXjgyECfA==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"engines": {
"node": ">=18"
}
},
"node_modules/convex/node_modules/@esbuild/sunos-x64": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.0.tgz",
"integrity": "sha512-Q1KY1iJafM+UX6CFEL+F4HRTgygmEW568YMqDA5UV97AuZSm21b7SXIrRJDwXWPzr8MGr75fUZPV67FdtMHlHA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=18"
}
},
"node_modules/convex/node_modules/@esbuild/win32-arm64": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.0.tgz",
"integrity": "sha512-W1eyGNi6d+8kOmZIwi/EDjrL9nxQIQ0MiGqe/AWc6+IaHloxHSGoeRgDRKHFISThLmsewZ5nHFvGFWdBYlgKPg==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/convex/node_modules/@esbuild/win32-ia32": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.0.tgz",
"integrity": "sha512-30z1aKL9h22kQhilnYkORFYt+3wp7yZsHWus+wSKAJR8JtdfI76LJ4SBdMsCopTR3z/ORqVu5L1vtnHZWVj4cQ==",
"cpu": [
"ia32"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/convex/node_modules/@esbuild/win32-x64": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.0.tgz",
"integrity": "sha512-aIitBcjQeyOhMTImhLZmtxfdOcuNRpwlPNmlFKPcHQYPhEssw75Cl1TSXJXpMkzaua9FUetx/4OQKq7eJul5Cg==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/convex/node_modules/esbuild": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.0.tgz",
"integrity": "sha512-jd0f4NHbD6cALCyGElNpGAOtWxSq46l9X/sWB0Nzd5er4Kz2YTm+Vl0qKFT9KUJvD8+fiO8AvoHhFvEatfVixA==",
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.27.0",
"@esbuild/android-arm": "0.27.0",
"@esbuild/android-arm64": "0.27.0",
"@esbuild/android-x64": "0.27.0",
"@esbuild/darwin-arm64": "0.27.0",
"@esbuild/darwin-x64": "0.27.0",
"@esbuild/freebsd-arm64": "0.27.0",
"@esbuild/freebsd-x64": "0.27.0",
"@esbuild/linux-arm": "0.27.0",
"@esbuild/linux-arm64": "0.27.0",
"@esbuild/linux-ia32": "0.27.0",
"@esbuild/linux-loong64": "0.27.0",
"@esbuild/linux-mips64el": "0.27.0",
"@esbuild/linux-ppc64": "0.27.0",
"@esbuild/linux-riscv64": "0.27.0",
"@esbuild/linux-s390x": "0.27.0",
"@esbuild/linux-x64": "0.27.0",
"@esbuild/netbsd-arm64": "0.27.0",
"@esbuild/netbsd-x64": "0.27.0",
"@esbuild/openbsd-arm64": "0.27.0",
"@esbuild/openbsd-x64": "0.27.0",
"@esbuild/openharmony-arm64": "0.27.0",
"@esbuild/sunos-x64": "0.27.0",
"@esbuild/win32-arm64": "0.27.0",
"@esbuild/win32-ia32": "0.27.0",
"@esbuild/win32-x64": "0.27.0"
}
},
"node_modules/cookie": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
@@ -4341,28 +4825,6 @@
"once": "^1.4.0"
}
},
"node_modules/engine.io-client": {
"version": "6.6.4",
"resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.4.tgz",
"integrity": "sha512-+kjUJnZGwzewFDw951CDWcwj35vMNf2fcj7xQWOctq1F2i1jkDdVvdFG9kM/BEChymCH36KgjnW0NsL58JYRxw==",
"license": "MIT",
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.4.1",
"engine.io-parser": "~5.2.1",
"ws": "~8.18.3",
"xmlhttprequest-ssl": "~2.1.1"
}
},
"node_modules/engine.io-parser": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz",
"integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/env-paths": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz",
@@ -7977,6 +8439,21 @@
"node": ">= 0.8.0"
}
},
"node_modules/prettier": {
"version": "3.8.1",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz",
"integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==",
"license": "MIT",
"bin": {
"prettier": "bin/prettier.cjs"
},
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/prettier/prettier?sponsor=1"
}
},
"node_modules/prismjs": {
"version": "1.30.0",
"resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz",
@@ -8717,34 +9194,6 @@
"npm": ">= 3.0.0"
}
},
"node_modules/socket.io-client": {
"version": "4.8.3",
"resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.3.tgz",
"integrity": "sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g==",
"license": "MIT",
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.4.1",
"engine.io-client": "~6.6.1",
"socket.io-parser": "~4.2.4"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/socket.io-parser": {
"version": "4.2.5",
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.5.tgz",
"integrity": "sha512-bPMmpy/5WWKHea5Y/jYAP6k74A+hvmRCQaJuJB6I/ML5JZq/KfNieUVo/3Mh7SAqn7TyFdIo6wqYHInG1MU1bQ==",
"license": "MIT",
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.4.1"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/socks": {
"version": "2.8.7",
"resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz",
@@ -9682,27 +10131,6 @@
"dev": true,
"license": "ISC"
},
"node_modules/ws": {
"version": "8.18.3",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
"integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/xmlbuilder": {
"version": "15.1.1",
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz",
@@ -9713,14 +10141,6 @@
"node": ">=8.0"
}
},
"node_modules/xmlhttprequest-ssl": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz",
"integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==",
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/y18n": {
"version": "5.0.8",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",

View File

@@ -33,8 +33,8 @@
"react-markdown": "^10.1.0",
"react-router-dom": "^7.11.0",
"react-syntax-highlighter": "^16.1.0",
"remark-gfm": "^4.0.1",
"socket.io-client": "^4.8.3"
"convex": "^1.31.2",
"remark-gfm": "^4.0.1"
},
"devDependencies": {
"@eslint/js": "^9.39.1",

View File

@@ -22,6 +22,7 @@ import EmojiesColored from './emojies_colored.png';
import EmojiesGreyscale from './emojies_greyscale.png';
import TypingIcon from './typing.svg';
import DMIcon from './dm.svg';
import SpoilerIcon from './spoiler.svg';
export {
AddIcon,
@@ -47,7 +48,8 @@ export {
DeleteIcon,
PinIcon,
TypingIcon,
DMIcon
DMIcon,
SpoilerIcon
};
export const Icons = {
@@ -74,5 +76,6 @@ export const Icons = {
Delete: DeleteIcon,
Pin: PinIcon,
Typing: TypingIcon,
DM: DMIcon
DM: DMIcon,
Spoiler: SpoilerIcon
};

View File

@@ -0,0 +1 @@
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24"><path fill="currentColor" d="M12 5C5.648 5 1 12 1 12s4.648 7 11 7 11-7 11-7-4.648-7-11-7m0 12c-2.761 0-5-2.239-5-5s2.239-5 5-5 5 2.239 5 5-2.239 5-5 5"/><circle fill="currentColor" cx="12" cy="12" r="3"/></svg>

After

Width:  |  Height:  |  Size: 325 B

View File

@@ -1,24 +1,21 @@
import React, { useState } from 'react';
import { useConvex } from 'convex/react';
import { api } from '../../../../convex/_generated/api';
const ChannelSettingsModal = ({ channel, onClose, onRename, onDelete }) => {
const [name, setName] = useState(channel.name);
const [activeTab, setActiveTab] = useState('Overview');
const convex = useConvex();
const handleSave = async () => {
try {
const res = await fetch(`http://localhost:3000/api/channels/${channel.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name })
});
if (res.ok) {
onRename(channel.id, name);
onClose();
} else {
alert('Failed to update channel');
}
await convex.mutation(api.channels.rename, { id: channel._id, name });
onRename(channel._id, name);
onClose();
} catch (err) {
console.error(err);
alert('Failed to update channel: ' + err.message);
}
};
@@ -26,17 +23,12 @@ const ChannelSettingsModal = ({ channel, onClose, onRename, onDelete }) => {
if (!confirm('Are you sure you want to delete this channel? This cannot be undone.')) return;
try {
const res = await fetch(`http://localhost:3000/api/channels/${channel.id}`, {
method: 'DELETE'
});
if (res.ok) {
onDelete(channel.id);
onClose();
} else {
alert('Failed to delete channel');
}
await convex.mutation(api.channels.remove, { id: channel._id });
onDelete(channel._id);
onClose();
} catch (err) {
console.error(err);
alert('Failed to delete channel: ' + err.message);
}
};
@@ -90,7 +82,7 @@ const ChannelSettingsModal = ({ channel, onClose, onRename, onDelete }) => {
<div style={{ height: '1px', backgroundColor: '#3f4147', margin: '8px 0' }} />
<div
onClick={() => setActiveTab('Delete')} // Simplify: Just switch content or trigger? UI screenshot implies a tab
onClick={() => setActiveTab('Delete')}
style={{
padding: '6px 10px',
borderRadius: '4px',

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,6 @@
import React, { useState, useEffect, useRef } from 'react';
import { useConvex } from 'convex/react';
import { api } from '../../../../convex/_generated/api';
const DMList = ({ dmChannels, activeDMChannel, onSelectDM, onOpenDM }) => {
const [showUserPicker, setShowUserPicker] = useState(false);
@@ -6,6 +8,8 @@ const DMList = ({ dmChannels, activeDMChannel, onSelectDM, onOpenDM }) => {
const [searchQuery, setSearchQuery] = useState('');
const searchRef = useRef(null);
const convex = useConvex();
const getUserColor = (username) => {
if (!username) return '#5865F2';
const colors = ['#5865F2', '#EBA7CD', '#57F287', '#FEE75C', '#EB459E', '#ED4245'];
@@ -16,17 +20,17 @@ const DMList = ({ dmChannels, activeDMChannel, onSelectDM, onOpenDM }) => {
return colors[Math.abs(hash) % colors.length];
};
const handleOpenUserPicker = () => {
const handleOpenUserPicker = async () => {
setShowUserPicker(true);
setSearchQuery('');
// Fetch all users for the picker
fetch('http://localhost:3000/api/auth/users/public-keys')
.then(res => res.json())
.then(data => {
const myId = localStorage.getItem('userId');
setAllUsers(data.filter(u => u.id !== myId));
})
.catch(err => console.error(err));
// Fetch all users via Convex query
try {
const data = await convex.query(api.auth.getPublicKeys, {});
const myId = localStorage.getItem('userId');
setAllUsers(data.filter(u => u.id !== myId));
} catch (err) {
console.error(err);
}
};
useEffect(() => {

View File

@@ -1,16 +1,15 @@
import React, { useState, useEffect } from 'react';
import React, { useState } from 'react';
import { useQuery } from 'convex/react';
import { api } from '../../../../convex/_generated/api';
const FriendsView = ({ onOpenDM }) => {
const [users, setUsers] = useState([]);
const [activeTab, setActiveTab] = useState('Online');
useEffect(() => {
const myId = localStorage.getItem('userId');
fetch('http://localhost:3000/api/auth/users/public-keys')
.then(res => res.json())
.then(data => setUsers(data.filter(u => u.id !== myId)))
.catch(err => console.error(err));
}, []);
const myId = localStorage.getItem('userId');
// Reactive query for all users' public keys
const allUsers = useQuery(api.auth.getPublicKeys) || [];
const users = allUsers.filter(u => u.id !== myId);
const getUserColor = (username) => {
if (!username) return '#747f8d';
@@ -22,9 +21,7 @@ const FriendsView = ({ onOpenDM }) => {
return colors[Math.abs(hash) % colors.length];
};
// Filter logic
const filteredUsers = users; // For now, assume all are friends.
// In real app, "Online" would filter by status.
const filteredUsers = users;
return (
<div style={{ display: 'flex', flexDirection: 'column', flex: 1, backgroundColor: 'hsl(240 calc(1*7.143%) 10.98% /1)', height: '100vh' }}>

View File

@@ -1,5 +1,7 @@
import CategorizedEmojis, { AllEmojis } from '../assets/emojis';
import React, { useState, useEffect, useRef } from 'react';
import { useConvex } from 'convex/react';
import { api } from '../../../../convex/_generated/api';
const GifPicker = ({ onSelect, onClose, initialTab, currentTab, onTabChange }) => {
const [search, setSearch] = useState('');
@@ -19,11 +21,11 @@ const GifPicker = ({ onSelect, onClose, initialTab, currentTab, onTabChange }) =
const [collapsedCategories, setCollapsedCategories] = useState({});
const inputRef = useRef(null);
const convex = useConvex();
useEffect(() => {
// Fetch categories on mount
fetch('http://localhost:3000/api/gifs/categories')
.then(res => res.json())
// Fetch categories via Convex action
convex.action(api.gifs.categories, {})
.then(data => {
if (data.categories) setCategories(data.categories);
})
@@ -43,14 +45,13 @@ const GifPicker = ({ onSelect, onClose, initialTab, currentTab, onTabChange }) =
useEffect(() => {
const fetchResults = async () => {
if (!search || activeTab !== 'GIFs') { // Only search API for GIFs
if (!search || activeTab !== 'GIFs') {
setResults([]);
return;
}
setLoading(true);
try {
const res = await fetch(`http://localhost:3000/api/gifs/search?q=${encodeURIComponent(search)}`);
const data = await res.json();
const data = await convex.action(api.gifs.search, { q: search });
setResults(data.results || []);
} catch (err) {
console.error(err);
@@ -91,7 +92,7 @@ const GifPicker = ({ onSelect, onClose, initialTab, currentTab, onTabChange }) =
overflow: 'hidden',
zIndex: 1000
}}
onClick={(e) => e.stopPropagation()} // Prevent close when clicking inside
onClick={(e) => e.stopPropagation()}
>
{/* Header / Tabs */}
<div style={{ padding: '16px 16px 8px 16px', display: 'flex', gap: '16px', borderBottom: '1px solid #202225' }}>
@@ -238,12 +239,12 @@ const GifPicker = ({ onSelect, onClose, initialTab, currentTab, onTabChange }) =
// Emoji Search Results
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(8, 1fr)', gap: '4px' }}>
{AllEmojis.filter(e => e.name.toLowerCase().includes(search.toLowerCase().replace(/:/g, '')))
.slice(0, 100) // Optimization: Limit to top 100 results
.slice(0, 100)
.map((emoji, idx) => (
<div
key={idx}
onMouseDown={(e) => e.preventDefault()}
onClick={() => onSelect && onSelect({ type: 'emoji', ...emoji })} // Pass object to distinguish
onClick={() => onSelect && onSelect({ type: 'emoji', ...emoji })}
title={`:${emoji.name}:`}
style={{ cursor: 'pointer', padding: '4px', borderRadius: '4px' }}
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#40444b'}
@@ -254,7 +255,6 @@ const GifPicker = ({ onSelect, onClose, initialTab, currentTab, onTabChange }) =
))}
</div>
) : (
// Emoji Categories
// Emoji Categories
Object.entries(emojiCategories).map(([category, emojis]) => (
<div key={category} style={{ marginBottom: '8px' }}>

View File

@@ -1,91 +1,66 @@
import React, { useState, useEffect } from 'react';
import { useQuery, useConvex } from 'convex/react';
import { api } from '../../../../convex/_generated/api';
const ServerSettingsModal = ({ onClose }) => {
const [activeTab, setActiveTab] = useState('Overview');
const [roles, setRoles] = useState([]);
const [members, setMembers] = useState([]);
const [selectedRole, setSelectedRole] = useState(null);
const [permissions, setPermissions] = useState({
manage_channels: false,
manage_roles: false,
create_invite: false,
embed_links: true,
attach_files: true
});
const [myPermissions, setMyPermissions] = useState({});
const userId = localStorage.getItem('userId');
const convex = useConvex();
useEffect(() => {
if (!userId) return;
fetchRoles();
fetchMembers();
fetchMyPermissions();
}, [userId]);
const getHeaders = () => ({
'Content-Type': 'application/json',
'x-user-id': userId
});
const fetchMyPermissions = async () => {
try {
const res = await fetch('http://localhost:3000/api/roles/permissions', { headers: getHeaders() });
const data = await res.json();
setMyPermissions(data);
} catch (e) { console.error(e); }
};
const fetchRoles = async () => {
const res = await fetch('http://localhost:3000/api/roles', { headers: getHeaders() });
const data = await res.json();
setRoles(data);
};
const fetchMembers = async () => {
const res = await fetch('http://localhost:3000/api/roles/members', { headers: getHeaders() });
const data = await res.json();
setMembers(data);
};
// Reactive queries from Convex
const roles = useQuery(api.roles.list) || [];
const members = useQuery(api.roles.listMembers) || [];
const myPermissions = useQuery(
api.roles.getMyPermissions,
userId ? { userId } : "skip"
) || {};
const handleCreateRole = async () => {
const res = await fetch('http://localhost:3000/api/roles', {
method: 'POST',
headers: getHeaders(),
body: JSON.stringify({ name: 'new role', color: '#99aab5' })
});
const newRole = await res.json();
setRoles([...roles, newRole]);
setSelectedRole(newRole);
try {
const newRole = await convex.mutation(api.roles.create, {
name: 'new role',
color: '#99aab5'
});
setSelectedRole(newRole);
} catch (e) {
console.error('Failed to create role:', e);
}
};
const handleUpdateRole = async (id, updates) => {
const res = await fetch(`http://localhost:3000/api/roles/${id}`, {
method: 'PUT',
headers: getHeaders(),
body: JSON.stringify(updates)
});
const updated = await res.json();
setRoles(roles.map(r => r.id === id ? updated : r));
if (selectedRole && selectedRole.id === id) {
setSelectedRole(updated);
try {
const updated = await convex.mutation(api.roles.update, { id, ...updates });
if (selectedRole && selectedRole._id === id) {
setSelectedRole(updated);
}
} catch (e) {
console.error('Failed to update role:', e);
}
};
const handleDeleteRole = async (id) => {
if (!confirm('Delete this role?')) return;
await fetch(`http://localhost:3000/api/roles/${id}`, { method: 'DELETE', headers: getHeaders() });
setRoles(roles.filter(r => r.id !== id));
if (selectedRole && selectedRole.id === id) setSelectedRole(null);
try {
await convex.mutation(api.roles.remove, { id });
if (selectedRole && selectedRole._id === id) setSelectedRole(null);
} catch (e) {
console.error('Failed to delete role:', e);
}
};
const handleAssignRole = async (roleId, userId, isAdding) => {
const endpoint = isAdding ? 'assign' : 'remove';
await fetch(`http://localhost:3000/api/roles/${roleId}/${endpoint}`, {
method: 'POST',
headers: getHeaders(),
body: JSON.stringify({ userId })
});
fetchMembers(); // Refresh to show
const handleAssignRole = async (roleId, targetUserId, isAdding) => {
try {
if (isAdding) {
await convex.mutation(api.roles.assign, { roleId, userId: targetUserId });
} else {
await convex.mutation(api.roles.unassign, { roleId, userId: targetUserId });
}
// Convex reactive queries auto-update members list
} catch (e) {
console.error('Failed to assign/unassign role:', e);
}
};
// Render Tabs
@@ -129,11 +104,11 @@ const ServerSettingsModal = ({ onClose }) => {
</div>
{roles.filter(r => r.name !== 'Owner').map(r => (
<div
key={r.id}
key={r._id}
onClick={() => setSelectedRole(r)}
style={{
padding: '6px',
backgroundColor: selectedRole?.id === r.id ? '#40444b' : 'transparent',
backgroundColor: selectedRole?._id === r._id ? '#40444b' : 'transparent',
borderRadius: '4px', cursor: 'pointer', color: r.color || '#b9bbbe',
display: 'flex', alignItems: 'center'
}}
@@ -152,7 +127,7 @@ const ServerSettingsModal = ({ onClose }) => {
<label style={{ display: 'block', color: '#b9bbbe', fontSize: '12px', fontWeight: '700', marginBottom: 8 }}>ROLE NAME</label>
<input
value={selectedRole.name}
onChange={(e) => handleUpdateRole(selectedRole.id, { name: e.target.value })}
onChange={(e) => handleUpdateRole(selectedRole._id, { name: e.target.value })}
disabled={!myPermissions.manage_roles}
style={{ width: '100%', padding: 10, background: '#202225', border: 'none', borderRadius: 4, color: 'white', marginBottom: 20, opacity: !myPermissions.manage_roles ? 0.5 : 1 }}
/>
@@ -161,7 +136,7 @@ const ServerSettingsModal = ({ onClose }) => {
<input
type="color"
value={selectedRole.color}
onChange={(e) => handleUpdateRole(selectedRole.id, { color: e.target.value })}
onChange={(e) => handleUpdateRole(selectedRole._id, { color: e.target.value })}
disabled={!myPermissions.manage_roles}
style={{ width: '100%', height: 40, border: 'none', padding: 0, marginBottom: 20, opacity: !myPermissions.manage_roles ? 0.5 : 1 }}
/>
@@ -175,7 +150,7 @@ const ServerSettingsModal = ({ onClose }) => {
checked={selectedRole.permissions?.[perm] || false}
onChange={(e) => {
const newPerms = { ...selectedRole.permissions, [perm]: e.target.checked };
handleUpdateRole(selectedRole.id, { permissions: newPerms });
handleUpdateRole(selectedRole._id, { permissions: newPerms });
}}
disabled={!myPermissions.manage_roles}
style={{ transform: 'scale(1.5)', opacity: !myPermissions.manage_roles ? 0.5 : 1 }}
@@ -185,7 +160,7 @@ const ServerSettingsModal = ({ onClose }) => {
{/* Prevent deleting Default Roles */}
{myPermissions.manage_roles && selectedRole.name !== 'Owner' && selectedRole.name !== '@everyone' && (
<button onClick={() => handleDeleteRole(selectedRole.id)} style={{ color: '#ed4245', background: 'transparent', border: '1px solid #ed4245', padding: '6px 12px', borderRadius: 4, marginTop: 20, cursor: 'pointer' }}>
<button onClick={() => handleDeleteRole(selectedRole._id)} style={{ color: '#ed4245', background: 'transparent', border: '1px solid #ed4245', padding: '6px 12px', borderRadius: 4, marginTop: 20, cursor: 'pointer' }}>
Delete Role
</button>
)}
@@ -208,21 +183,20 @@ const ServerSettingsModal = ({ onClose }) => {
<div style={{ color: 'white', fontWeight: 'bold' }}>{m.username}</div>
<div style={{ display: 'flex', gap: 4 }}>
{m.roles && m.roles.map(r => (
<span key={r.id} style={{ fontSize: 10, background: r.color, color: 'white', padding: '2px 4px', borderRadius: 4 }}>
<span key={r._id} style={{ fontSize: 10, background: r.color, color: 'white', padding: '2px 4px', borderRadius: 4 }}>
{r.name}
</span>
))}
</div>
</div>
{/* Add Role Dropdown/Buttons logic here - simplified for now */}
<div style={{ display: 'flex', gap: 4 }}>
{roles.filter(r => r.name !== 'Owner').map(r => {
const hasRole = m.roles?.some(ur => ur.id === r.id);
const hasRole = m.roles?.some(ur => ur._id === r._id);
return (
myPermissions.manage_roles && (
<button
key={r.id}
onClick={() => handleAssignRole(r.id, m.id, !hasRole)}
key={r._id}
onClick={() => handleAssignRole(r._id, m.id, !hasRole)}
style={{
width: 16, height: 16, borderRadius: '50%',
border: `2px solid ${r.color}`,

View File

@@ -1,9 +1,11 @@
import React, { useState } from 'react';
import { useConvex } from 'convex/react';
import { api } from '../../../../convex/_generated/api';
import { useVoice } from '../contexts/VoiceContext';
import ChannelSettingsModal from './ChannelSettingsModal';
import ServerSettingsModal from './ServerSettingsModal';
import ScreenShareModal from './ScreenShareModal';
import DMList from './DMList'; // Import DMList
import DMList from './DMList';
import { Track } from 'livekit-client';
import muteIcon from '../assets/icons/mute.svg';
import mutedIcon from '../assets/icons/muted.svg';
@@ -24,7 +26,7 @@ const ColoredIcon = ({ src, color, size = '20px' }) => (
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0 // Prevent shrinking in flex containers
flexShrink: 0
}}>
<img
src={src}
@@ -42,8 +44,6 @@ const ColoredIcon = ({ src, color, size = '20px' }) => (
const UserControlPanel = ({ username }) => {
const { isMuted, isDeafened, toggleMute, toggleDeafen, connectionState } = useVoice();
// Check if muted explicitly OR implicitly via deafen
// User requested: "turn the mic icon red to show they are muted also"
const effectiveMute = isMuted || isDeafened;
const getUserColor = (name) => {
@@ -181,31 +181,28 @@ const UserControlPanel = ({ username }) => {
const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKeys, onChannelCreated, view, onViewChange, onOpenDM, activeDMChannel, setActiveDMChannel, dmChannels }) => {
const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKeys, view, onViewChange, onOpenDM, activeDMChannel, setActiveDMChannel, dmChannels }) => {
const [isCreating, setIsCreating] = useState(false);
const [isServerSettingsOpen, setIsServerSettingsOpen] = useState(false); // New State
const [isServerSettingsOpen, setIsServerSettingsOpen] = useState(false);
const [newChannelName, setNewChannelName] = useState('');
const [newChannelType, setNewChannelType] = useState('text'); // 'text' or 'voice'
const [newChannelType, setNewChannelType] = useState('text');
const [editingChannel, setEditingChannel] = useState(null);
const [isScreenShareModalOpen, setIsScreenShareModalOpen] = useState(false);
// Callbacks for Modal
const convex = useConvex();
// Callbacks for Modal - Convex is reactive, no need to manually refresh
const onRenameChannel = (id, newName) => {
if (onChannelCreated) onChannelCreated();
// Convex reactive queries auto-update
};
const onDeleteChannel = (id) => {
if (activeChannel === id) onSelectChannel(null);
if (onChannelCreated) onChannelCreated();
// Convex reactive queries auto-update
};
const { connectToVoice, activeChannelId: voiceChannelId, connectionState, disconnectVoice, activeChannelName: voiceChannelName, voiceStates, room, activeSpeakers, setScreenSharing } = useVoice();
// ... helper for public key ...
const getMyPublicKey = async (userId) => {
return null;
};
const handleStartCreate = () => {
setIsCreating(true);
setNewChannelName('');
@@ -230,27 +227,17 @@ const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKe
}
try {
// 1. Create Channel
const createRes = await fetch('http://localhost:3000/api/channels/create', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, type })
});
const { id: channelId, error } = await createRes.json();
if (error) throw new Error(error);
// 1. Create Channel via Convex
const { id: channelId } = await convex.mutation(api.channels.create, { name, type });
// 2. Generate Key (Only needed for encrypted TEXT channels roughly, but we do it for all to simplify logic?
// Actually, Voice only needs access token. But keeping key logic doesn't hurt for consistent DB.
// Voice channels might use the key for text chat INTside the voice channel later?)
// 2. Generate Key
const keyBytes = new Uint8Array(32);
crypto.getRandomValues(keyBytes);
const keyHex = Array.from(keyBytes).map(b => b.toString(16).padStart(2, '0')).join('');
// 3. Encrypt Key for ALL Users (Group Logic)
// 3. Encrypt Key for ALL Users
try {
// Fetch all public keys
const usersRes = await fetch('http://localhost:3000/api/auth/users/public-keys');
const users = await usersRes.json();
const users = await convex.query(api.auth.getPublicKeys, {});
const batchKeys = [];
@@ -258,7 +245,6 @@ const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKe
if (!u.public_identity_key) continue;
try {
// Correct Format: JSON Stringify { [channelId]: key }
const payload = JSON.stringify({ [channelId]: keyHex });
const encryptedKeyHex = await window.cryptoAPI.publicEncrypt(u.public_identity_key, payload);
@@ -273,24 +259,18 @@ const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKe
}
}
// 4. Upload Keys Batch
await fetch('http://localhost:3000/api/channels/keys', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(batchKeys)
});
// 4. Upload Keys Batch via Convex
await convex.mutation(api.channelKeys.uploadKeys, { keys: batchKeys });
// 5. Notify Everyone (NOW it is safe)
await fetch(`http://localhost:3000/api/channels/${channelId}/notify`, { method: 'POST' });
// No need to notify - Convex queries are reactive!
} catch (keyErr) {
console.error("Critical: Failed to distribute keys", keyErr);
alert("Channel created but key distribution failed.");
}
// 6. Refresh
// 5. Done - Convex reactive queries auto-update the channel list
setIsCreating(false);
if (onChannelCreated) onChannelCreated();
} catch (err) {
console.error(err);
@@ -299,11 +279,6 @@ const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKe
}
};
// ... (rest of logic)
const handleCreateInvite = async () => {
const userId = localStorage.getItem('userId');
if (!userId) {
@@ -320,7 +295,7 @@ const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKe
// 2. Prepare Key Bundle
const generalChannel = channels.find(c => c.name === 'general');
const targetChannelId = generalChannel ? generalChannel.id : activeChannel; // Fallback to active if no general
const targetChannelId = generalChannel ? generalChannel._id : activeChannel;
if (!targetChannelId) {
alert("No channel selected.");
@@ -347,20 +322,14 @@ const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKe
iv: encrypted.iv
});
// 4. Send to Server
const res = await fetch('http://localhost:3000/api/invites/create', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
code: inviteCode,
encryptedPayload: blob,
createdBy: userId,
keyVersion: 1
})
// 4. Create invite via Convex
await convex.mutation(api.invites.create, {
code: inviteCode,
encryptedPayload: blob,
createdBy: userId,
keyVersion: 1
});
if (!res.ok) throw new Error('Server rejected invite creation');
// 5. Show Link
const link = `http://localhost:5173/#/register?code=${inviteCode}&key=${inviteSecret}`;
navigator.clipboard.writeText(link);
@@ -450,12 +419,10 @@ const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKe
cursor: 'pointer'
}}
>
{/* Discord Logo / Home Icon */}
<svg width="28" height="20" viewBox="0 0 28 20">
<path fill="currentColor" d="M23.0212 1.67671C21.3107 0.879656 19.5079 0.318797 17.6584 0C17.4062 0.461742 17.1749 0.934541 16.9708 1.4184C15.0172 1.11817 13.0907 1.11817 11.1372 1.4184C10.9331 0.934541 10.7018 0.461742 10.4496 0C8.59007 0.318797 6.78726 0.879656 5.0768 1.67671C1.65217 6.84883 0.706797 11.8997 1.166 16.858C3.39578 18.5135 5.56066 19.5165 7.6473 20.1417C8.16912 19.4246 8.63212 18.6713 9.02347 17.8863C8.25707 17.5929 7.52187 17.2415 6.82914 16.8374C7.0147 16.6975 7.19515 16.5518 7.36941 16.402C11.66 18.396 16.4523 18.396 20.7186 16.402C20.8953 16.5518 21.0758 16.6975 21.2613 16.8374C20.5663 17.2435 19.8288 17.5947 19.0624 17.8863C19.4537 18.6713 19.9167 19.4246 20.4385 20.1417C22.5276 19.5165 24.6925 18.5135 26.9223 16.858C27.4684 11.2365 25.9961 6.22055 23.0212 1.67671ZM9.68041 13.6383C8.39754 13.6383 7.34085 12.4456 7.34085 10.994C7.34085 9.5424 8.37555 8.34973 9.68041 8.34973C10.9893 8.34973 12.0395 9.5424 12.0175 10.994C12.0175 12.4456 10.9893 13.6383 9.68041 13.6383ZM18.3161 13.6383C17.0332 13.6383 15.9765 12.4456 15.9765 10.994C15.9765 9.5424 17.0112 8.34973 18.3161 8.34973C19.625 8.34973 20.6752 9.5424 20.6532 10.994C20.6532 12.4456 19.625 13.6383 18.3161 13.6383Z"/>
</svg>
</div>
{/* Add separator logic if needed, or just list other servers (currently just 1 hardcoded placeholder in UI) */}
{/* The Server Icon (Secure Chat) */}
<div
@@ -561,18 +528,18 @@ const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKe
)}
{channels.map(channel => (
<React.Fragment key={channel.id}>
<React.Fragment key={channel._id}>
<div
className={`channel-item ${activeChannel === channel.id ? 'active' : ''} ${voiceChannelId === channel.id ? 'voice-active' : ''}`}
className={`channel-item ${activeChannel === channel._id ? 'active' : ''} ${voiceChannelId === channel._id ? 'voice-active' : ''}`}
onClick={() => {
if (channel.type === 'voice') {
if (voiceChannelId === channel.id) {
onSelectChannel(channel.id);
if (voiceChannelId === channel._id) {
onSelectChannel(channel._id);
} else {
connectToVoice(channel.id, channel.name, localStorage.getItem('userId'));
connectToVoice(channel._id, channel.name, localStorage.getItem('userId'));
}
} else {
onSelectChannel(channel.id);
onSelectChannel(channel._id);
}
}}
style={{
@@ -580,7 +547,7 @@ const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKe
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
paddingRight: '8px' // Space for icon
paddingRight: '8px'
}}
>
<div style={{ display: 'flex', alignItems: 'center', overflow: 'hidden', flex: 1 }}>
@@ -589,9 +556,9 @@ const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKe
<ColoredIcon
src={voiceIcon}
size="16px"
color={voiceStates[channel.id]?.length > 0
? "color-mix(in oklab, hsl(132.809 calc(1*34.902%) 50% /1) 100%, #000 0%)" // Active Green
: "#8e9297" // Default Gray
color={voiceStates[channel._id]?.length > 0
? "color-mix(in oklab, hsl(132.809 calc(1*34.902%) 50% /1) 100%, #000 0%)"
: "#8e9297"
}
/>
</div>
@@ -621,12 +588,10 @@ const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKe
>
</button>
{/* Participant List (Only for Voice Channels) */}
</div>
{channel.type === 'voice' && voiceStates[channel.id] && voiceStates[channel.id].length > 0 && (
{channel.type === 'voice' && voiceStates[channel._id] && voiceStates[channel._id].length > 0 && (
<div style={{ marginLeft: 32, marginBottom: 8 }}>
{voiceStates[channel.id].map(user => (
{voiceStates[channel._id].map(user => (
<div key={user.userId} style={{ display: 'flex', alignItems: 'center', marginBottom: 4 }}>
<div style={{
width: 24, height: 24, borderRadius: '50%',
@@ -643,7 +608,7 @@ const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKe
<div style={{ display: 'flex', marginLeft: 'auto', gap: 4, alignItems: 'center' }}>
{user.isScreenSharing && (
<div style={{
backgroundColor: '#ed4245', // var(--red-400) fallback
backgroundColor: '#ed4245',
borderRadius: '8px',
padding: '0 6px',
textOverflow: 'ellipsis',

View File

@@ -4,28 +4,27 @@ import {
VideoConference,
RoomAudioRenderer,
} from '@livekit/components-react';
import { useConvex } from 'convex/react';
import { api } from '../../../../convex/_generated/api';
import '@livekit/components-styles';
import { Track } from 'livekit-client';
const VoiceRoom = ({ channelId, channelName, userId, onDisconnect }) => {
const [token, setToken] = useState('');
const convex = useConvex();
useEffect(() => {
const fetchToken = async () => {
try {
const res = await fetch('http://localhost:3000/api/voice/token', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-user-id': userId
},
body: JSON.stringify({ channelId })
const { token: lkToken } = await convex.action(api.voice.getToken, {
channelId,
userId,
username: localStorage.getItem('username') || 'Unknown'
});
const data = await res.json();
if (data.token) {
setToken(data.token);
if (lkToken) {
setToken(lkToken);
} else {
console.error('Failed to get token:', data);
console.error('Failed to get token');
onDisconnect();
}
} catch (err) {
@@ -41,7 +40,7 @@ const VoiceRoom = ({ channelId, channelName, userId, onDisconnect }) => {
if (!token) return <div style={{ color: 'white', padding: 20 }}>Connecting to Voice...</div>;
const liveKitUrl = 'ws://localhost:7880'; // Should come from env/config
const liveKitUrl = import.meta.env.VITE_LIVEKIT_URL;
return (
<div style={{ height: '100%', display: 'flex', flexDirection: 'column', background: '#000' }}>
@@ -77,7 +76,7 @@ const VoiceRoom = ({ channelId, channelName, userId, onDisconnect }) => {
<div style={{ flex: 1, position: 'relative' }}>
<LiveKitRoom
video={false} // Start with video off? Or let user choose
video={false}
audio={true}
token={token}
serverUrl={liveKitUrl}
@@ -85,15 +84,8 @@ const VoiceRoom = ({ channelId, channelName, userId, onDisconnect }) => {
style={{ height: '100%' }}
onDisconnected={onDisconnect}
>
{/* The VideoConference component provides the default UI grid */}
<VideoConference />
{/* Ensure audio is rendered */}
<RoomAudioRenderer />
{/* Custom Control Bar if needed, but VideoConference includes one by default usually.
Let's verify standard components behavior. VideoConference is a high-level UI.
*/}
</LiveKitRoom>
</div>
</div>

View File

@@ -1,7 +1,8 @@
import React, { createContext, useContext, useState, useEffect, useRef } from 'react';
import { Room, RoomEvent } from 'livekit-client';
import { LiveKitRoom, RoomAudioRenderer } from '@livekit/components-react';
import { io } from 'socket.io-client';
import { useQuery, useConvex } from 'convex/react';
import { api } from '../../../../convex/_generated/api';
import '@livekit/components-styles';
import joinSound from '../assets/sounds/join_call.mp3';
@@ -22,12 +23,14 @@ export const VoiceProvider = ({ children }) => {
const [room, setRoom] = useState(null);
const [token, setToken] = useState(null);
const [voiceStates, setVoiceStates] = useState({}); // { channelId: [users...] }
const [activeSpeakers, setActiveSpeakers] = useState(new Set()); // Set<userId>
const [isMuted, setIsMuted] = useState(false);
const [isDeafened, setIsDeafened] = useState(false);
const socketRef = useRef(null);
const convex = useConvex();
// Reactive voice states from Convex (replaces socket.io)
const voiceStates = useQuery(api.voiceState.getAll) || {};
// Sound Helper
const playSound = (type) => {
@@ -47,50 +50,6 @@ export const VoiceProvider = ({ children }) => {
}
};
// Initialize Socket for Voice States
useEffect(() => {
const socket = io('http://localhost:3000');
socketRef.current = socket;
// ... (Socket logic same as before) ...
socket.on('full_voice_state', (states) => {
setVoiceStates(states);
});
socket.on('voice_state_update', (data) => {
setVoiceStates(prev => {
const newState = { ...prev };
const currentUsers = newState[data.channelId] || [];
if (data.action === 'joined') {
if (!currentUsers.find(u => u.userId === data.userId)) {
currentUsers.push({
userId: data.userId,
username: data.username,
isMuted: data.isMuted,
isDeafened: data.isDeafened
});
}
} else if (data.action === 'left') {
const index = currentUsers.findIndex(u => u.userId === data.userId);
if (index !== -1) currentUsers.splice(index, 1);
} else if (data.action === 'state_update') {
const user = currentUsers.find(u => u.userId === data.userId);
if (user) {
if (data.isMuted !== undefined) user.isMuted = data.isMuted;
if (data.isDeafened !== undefined) user.isDeafened = data.isDeafened;
if (data.isScreenSharing !== undefined) user.isScreenSharing = data.isScreenSharing;
}
}
newState[data.channelId] = [...currentUsers];
return newState;
});
});
socket.emit('request_voice_state');
return () => socket.disconnect();
}, []);
const connectToVoice = async (channelId, channelName, userId) => {
if (activeChannelId === channelId) return;
@@ -101,20 +60,21 @@ export const VoiceProvider = ({ children }) => {
setConnectionState('connecting');
try {
const res = await fetch('http://localhost:3000/api/voice/token', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'x-user-id': userId },
body: JSON.stringify({ channelId })
// Get LiveKit token via Convex action
const { token: lkToken } = await convex.action(api.voice.getToken, {
channelId,
userId,
username: localStorage.getItem('username') || 'Unknown'
});
const data = await res.json();
if (!data.token) throw new Error('Failed to get token');
setToken(data.token);
if (!lkToken) throw new Error('Failed to get token');
setToken(lkToken);
// Disable adaptiveStream to ensure all tracks are available/subscribed immediately
const newRoom = new Room({ adaptiveStream: false, dynacast: false, autoSubscribe: true });
const liveKitUrl = 'ws://localhost:7880';
await newRoom.connect(liveKitUrl, data.token);
const liveKitUrl = import.meta.env.VITE_LIVEKIT_URL;
await newRoom.connect(liveKitUrl, lkToken);
// Auto-enable microphone & Apply Mute/Deafen State
const shouldEnableMic = !isMuted && !isDeafened;
@@ -125,22 +85,17 @@ export const VoiceProvider = ({ children }) => {
window.voiceRoom = newRoom; // For debugging
playSound('join');
// Emit Join Event
if (socketRef.current) {
socketRef.current.emit('voice_state_change', {
channelId,
userId,
username: localStorage.getItem('username') || 'Unknown',
username: localStorage.getItem('username') || 'Unknown',
action: 'joined',
isMuted: isMuted,
isDeafened: isDeafened,
isScreenSharing: false // Initial state is always false
});
}
// Update voice state in Convex
await convex.mutation(api.voiceState.join, {
channelId,
userId,
username: localStorage.getItem('username') || 'Unknown',
isMuted: isMuted,
isDeafened: isDeafened,
});
// Events
newRoom.on(RoomEvent.Disconnected, (reason) => {
newRoom.on(RoomEvent.Disconnected, async (reason) => {
console.warn('Voice Room Disconnected. Reason:', reason);
playSound('leave');
setConnectionState('disconnected');
@@ -148,20 +103,16 @@ export const VoiceProvider = ({ children }) => {
setRoom(null);
setToken(null);
setActiveSpeakers(new Set());
// Emit Leave Event
if (socketRef.current) {
socketRef.current.emit('voice_state_change', {
channelId,
userId,
action: 'left'
});
// Remove voice state in Convex
try {
await convex.mutation(api.voiceState.leave, { userId });
} catch (e) {
console.error('Failed to leave voice state:', e);
}
});
newRoom.on(RoomEvent.ActiveSpeakersChanged, (speakers) => {
// speakers is generic Participant[]
// We need to map to user identities (which we used as userId in token usually)
// LiveKit identity = our userId
const newActive = new Set();
speakers.forEach(p => newActive.add(p.identity));
setActiveSpeakers(newActive);
@@ -179,7 +130,7 @@ export const VoiceProvider = ({ children }) => {
if (room) room.disconnect();
};
const toggleMute = () => {
const toggleMute = async () => {
const nextState = !isMuted;
setIsMuted(nextState);
playSound(nextState ? 'mute' : 'unmute');
@@ -187,80 +138,62 @@ export const VoiceProvider = ({ children }) => {
room.localParticipant.setMicrophoneEnabled(!nextState);
}
if (socketRef.current && activeChannelId) {
socketRef.current.emit('voice_state_change', {
channelId: activeChannelId,
userId: localStorage.getItem('userId'),
username: localStorage.getItem('username'),
action: 'state_update',
isMuted: nextState
});
const userId = localStorage.getItem('userId');
if (userId && activeChannelId) {
try {
await convex.mutation(api.voiceState.updateState, {
userId,
isMuted: nextState,
});
} catch (e) {
console.error('Failed to update mute state:', e);
}
}
};
const toggleDeafen = () => {
const toggleDeafen = async () => {
const nextState = !isDeafened;
setIsDeafened(nextState);
playSound(nextState ? 'deafen' : 'undeafen');
// Logic: if deafened, mute mic too (usually)
if (nextState) {
// If becoming deafened, ensuring mic is muted too is standard discord behavior
if (room && !isMuted) {
room.localParticipant.setMicrophoneEnabled(false);
}
} else {
// If undeafening, restore mic if it wasn't explicitly muted?
// Simplified: If undeafened, and isMuted is false, unmute mic.
if (room && !isMuted) {
room.localParticipant.setMicrophoneEnabled(true);
}
}
if (socketRef.current && activeChannelId) {
socketRef.current.emit('voice_state_change', {
channelId: activeChannelId,
userId: localStorage.getItem('userId'),
username: localStorage.getItem('username'),
action: 'state_update',
isDeafened: nextState // Send the NEW State
const userId = localStorage.getItem('userId');
if (userId && activeChannelId) {
try {
await convex.mutation(api.voiceState.updateState, {
userId,
isDeafened: nextState,
});
} catch (e) {
console.error('Failed to update deafen state:', e);
}
}
};
const [isScreenSharing, setIsScreenSharingLocal] = useState(false);
const setScreenSharing = (active) => {
const setScreenSharing = async (active) => {
setIsScreenSharingLocal(active);
// Optimistic Update for UI
setVoiceStates(prev => {
const newState = { ...prev };
if (activeChannelId) {
const currentUsers = newState[activeChannelId] || [];
// Use map to ensure we create a new array/object reference if needed,
// though mutation in React state setter is risky, strictly we should clone.
// But strictly:
const userId = localStorage.getItem('userId');
const userIndex = currentUsers.findIndex(u => u.userId === userId);
if (userIndex !== -1) {
const updatedUser = { ...currentUsers[userIndex], isScreenSharing: active };
const updatedUsers = [...currentUsers];
updatedUsers[userIndex] = updatedUser;
newState[activeChannelId] = updatedUsers;
}
}
return newState;
});
if (socketRef.current && activeChannelId) {
socketRef.current.emit('voice_state_change', {
channelId: activeChannelId,
userId: localStorage.getItem('userId'),
username: localStorage.getItem('username'),
action: 'state_update',
isScreenSharing: active
});
}
const userId = localStorage.getItem('userId');
if (userId && activeChannelId) {
try {
await convex.mutation(api.voiceState.updateState, {
userId,
isScreenSharing: active,
});
} catch (e) {
console.error('Failed to update screen sharing state:', e);
}
}
};
return (
@@ -281,44 +214,6 @@ export const VoiceProvider = ({ children }) => {
isScreenSharing,
setScreenSharing
}}>
{/* Provide LiveKit Context globally if room exists, or a dummy context?
Actually LiveKitRoom requires a token or room. If room is null, we can't render it.
But we need children to always render.
Solution: Only wrap in LiveKitRoom if room exists.
BUT: If we later mistakenly expect context when room is null, it's fine.
However, if we wrap conditionally, the component tree changes when room logic changes, which might unmount children?
No, children are passed in. If we put conditional wrapper around children:
{room ? <LiveKitRoom>{children}</LiveKitRoom> : children}
This WILL remount children when room connects/disconnects. That is BAD for ChatArea etc.
Better: LiveKitRoom ALWAYS, but pass null room? LiveKitRoom might not like null room.
Documentation says: "If you want to manage the room connection yourself... pass the room instance."
Alternative: We only needed LiveKitRoom for VoiceStage (and audio renderer).
ChatArea doesn't need it.
VoiceStage is only rendered when activeChannel.type is voice, which implies we are likely connected (or clicking it connects).
Wait, if I use the conditional wrapper in VoiceContext to wrap children, `Sidebar` (which is a child) might remount?
Yes, `Sidebar` is inside `VoiceProvider`.
If `VoiceProvider` changes structure, `Sidebar` remounts.
Sidebar holds a lot of state? No, usually lifted. But remounting Sidebar is jarring.
Maybe we DON'T wrap children in VoiceContext.
Instead, we keep `VoiceContext` as is (rendering audio renderer), AND `VoiceStage` wraps itself in `LiveKitRoom`.
BUT `VoiceStage` causing disconnect suggests `LiveKitRoom` cleanup is the problem.
Why did `VoiceStage`'s `LiveKitRoom` cause disconnect?
Because on Unmount, `LiveKitRoom` calls `room.disconnect()`.
When user clicks channel, `VoiceStage` Mounts.
But user said "I click on it again and it disconnects me".
Wait, user clicks "again" to SHOW `VoiceStage`.
So `VoiceStage` MOUNTS.
Why does it disconnect?
Maybe `LiveKitRoom` ON MOUNT does something?
Or maybe `Sidebar` logic caused a re-render/disconnect?
In `Sidebar`: `connectToVoice` calls `if (room) await room.disconnect()`.
*/}
{children}
{room && (
<LiveKitRoom

View File

@@ -1,17 +1,22 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { HashRouter } from 'react-router-dom';
import { ConvexProvider, ConvexReactClient } from 'convex/react';
import App from './App';
import './index.css';
import { VoiceProvider } from './contexts/VoiceContext';
const convex = new ConvexReactClient(import.meta.env.VITE_CONVEX_URL);
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<VoiceProvider>
<HashRouter>
<App />
</HashRouter>
</VoiceProvider>
<ConvexProvider client={convex}>
<VoiceProvider>
<HashRouter>
<App />
</HashRouter>
</VoiceProvider>
</ConvexProvider>
</React.StrictMode>,
);

View File

@@ -1,5 +1,6 @@
import React, { useState, useEffect, useCallback } from 'react';
import { io } from 'socket.io-client';
import { useQuery, useConvex } from 'convex/react';
import { api } from '../../../../convex/_generated/api';
import Sidebar from '../components/Sidebar';
import ChatArea from '../components/ChatArea';
import VoiceStage from '../components/VoiceStage';
@@ -9,66 +10,71 @@ import FriendsView from '../components/FriendsView';
const Chat = () => {
const [view, setView] = useState('server'); // 'server' | 'me'
const [activeChannel, setActiveChannel] = useState(null);
const [channels, setChannels] = useState([]);
const [username, setUsername] = useState('');
const [userId, setUserId] = useState(null);
const [channelKeys, setChannelKeys] = useState({}); // { channelId: key_hex }
// DM state
const [activeDMChannel, setActiveDMChannel] = useState(null); // { channel_id, other_username }
const [dmChannels, setDMChannels] = useState([]);
const refreshData = () => {
const convex = useConvex();
// Reactive channel list from Convex (auto-updates!)
const channels = useQuery(api.channels.list) || [];
// Reactive channel keys from Convex
const rawChannelKeys = useQuery(
api.channelKeys.getKeysForUser,
userId ? { userId } : "skip"
);
// Reactive DM channels from Convex
const dmChannels = useQuery(
api.dms.listDMs,
userId ? { userId } : "skip"
) || [];
// Initialize user from localStorage
useEffect(() => {
const storedUsername = localStorage.getItem('username');
const userId = localStorage.getItem('userId');
const privateKey = sessionStorage.getItem('privateKey');
const storedUserId = localStorage.getItem('userId');
if (storedUsername) setUsername(storedUsername);
if (userId) setUserId(userId);
if (userId && privateKey) {
// Fetch Encrypted Channel Keys
fetch(`http://localhost:3000/api/channels/keys/${userId}`)
.then(res => res.json())
.then(async (data) => {
const keys = {};
for (const item of data) {
try {
const bundleJson = await window.cryptoAPI.privateDecrypt(privateKey, item.encrypted_key_bundle);
const bundle = JSON.parse(bundleJson);
Object.assign(keys, bundle);
} catch (e) {
console.error(`Failed to decrypt keys for channel ${item.channel_id}`, e);
}
}
setChannelKeys(keys);
})
.catch(err => console.error('Error fetching channel keys:', err));
}
fetch('http://localhost:3000/api/channels')
.then(res => res.json())
.then(data => {
setChannels(data);
if (!activeChannel && data.length > 0) {
const firstTextChannel = data.find(c => c.type === 'text');
if (firstTextChannel) {
setActiveChannel(firstTextChannel.id);
}
}
})
.catch(err => console.error('Error fetching channels:', err));
};
const fetchDMChannels = useCallback(() => {
const uid = localStorage.getItem('userId');
if (!uid) return;
fetch(`http://localhost:3000/api/dms/user/${uid}`)
.then(res => res.json())
.then(data => setDMChannels(data))
.catch(err => console.error('Error fetching DM channels:', err));
if (storedUserId) setUserId(storedUserId);
}, []);
// Decrypt channel keys when raw keys change
useEffect(() => {
if (!rawChannelKeys || rawChannelKeys.length === 0) return;
const privateKey = sessionStorage.getItem('privateKey');
if (!privateKey) return;
const decryptKeys = async () => {
const keys = {};
for (const item of rawChannelKeys) {
try {
const bundleJson = await window.cryptoAPI.privateDecrypt(privateKey, item.encrypted_key_bundle);
const bundle = JSON.parse(bundleJson);
Object.assign(keys, bundle);
} catch (e) {
console.error(`Failed to decrypt keys for channel ${item.channel_id}`, e);
}
}
setChannelKeys(keys);
};
decryptKeys();
}, [rawChannelKeys]);
// Auto-select first text channel when channels load
useEffect(() => {
if (!activeChannel && channels.length > 0) {
const firstTextChannel = channels.find(c => c.type === 'text');
if (firstTextChannel) {
setActiveChannel(firstTextChannel._id);
}
}
}, [channels, activeChannel]);
const openDM = useCallback(async (targetUserId, targetUsername) => {
const uid = localStorage.getItem('userId');
const privateKey = sessionStorage.getItem('privateKey');
@@ -76,12 +82,10 @@ const Chat = () => {
try {
// 1. Find or create the DM channel
const res = await fetch('http://localhost:3000/api/dms/open', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ userId: uid, targetUserId })
const { channelId, created } = await convex.mutation(api.dms.openDM, {
userId: uid,
targetUserId
});
const { channelId, created } = await res.json();
// 2. If newly created, generate + distribute an AES key for both users
if (created) {
@@ -90,8 +94,7 @@ const Chat = () => {
const keyHex = Array.from(keyBytes).map(b => b.toString(16).padStart(2, '0')).join('');
// Fetch both users' public keys
const usersRes = await fetch('http://localhost:3000/api/auth/users/public-keys');
const allUsers = await usersRes.json();
const allUsers = await convex.query(api.auth.getPublicKeys, {});
const participants = allUsers.filter(u => u.id === uid || u.id === targetUserId);
const batchKeys = [];
@@ -112,65 +115,22 @@ const Chat = () => {
}
if (batchKeys.length > 0) {
await fetch('http://localhost:3000/api/channels/keys', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(batchKeys)
});
}
// Refresh channel keys so the new DM key is available
if (privateKey) {
const keysRes = await fetch(`http://localhost:3000/api/channels/keys/${uid}`);
const keysData = await keysRes.json();
const keys = {};
for (const item of keysData) {
try {
const bundleJson = await window.cryptoAPI.privateDecrypt(privateKey, item.encrypted_key_bundle);
const bundle = JSON.parse(bundleJson);
Object.assign(keys, bundle);
} catch (e) {
console.error(`Failed to decrypt keys for channel ${item.channel_id}`, e);
}
}
setChannelKeys(keys);
await convex.mutation(api.channelKeys.uploadKeys, { keys: batchKeys });
}
// Channel keys will auto-update via reactive query
}
// 3. Set active DM and switch to me view
setActiveDMChannel({ channel_id: channelId, other_username: targetUsername });
setView('me');
fetchDMChannels();
} catch (err) {
console.error('Error opening DM:', err);
}
}, [fetchDMChannels]);
useEffect(() => {
refreshData();
fetchDMChannels();
// Listen for updates (requires socket connection)
const socket = io('http://localhost:3000');
socket.on('new_channel', (channel) => {
console.log("New Channel Detected:", channel);
refreshData(); // Re-fetch keys/channels
});
socket.on('channel_renamed', () => refreshData());
socket.on('channel_deleted', (id) => {
refreshData();
if (activeChannel === id) setActiveChannel(null);
});
return () => socket.disconnect();
}, []);
}, [convex]);
// Helper to get active channel object
const activeChannelObj = channels.find(c => c.id === activeChannel);
const activeChannelObj = channels.find(c => c._id === activeChannel);
const { room, voiceStates } = useVoice();
@@ -223,13 +183,9 @@ const Chat = () => {
onSelectChannel={setActiveChannel}
username={username}
channelKeys={channelKeys}
onChannelCreated={refreshData}
view={view}
onViewChange={(v) => {
setView(v);
if (v === 'me') {
fetchDMChannels();
}
}}
onOpenDM={openDM}
activeDMChannel={activeDMChannel}

View File

@@ -1,5 +1,7 @@
import React, { useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { useConvex } from 'convex/react';
import { api } from '../../../../convex/_generated/api';
const Login = () => {
const [username, setUsername] = useState('');
@@ -7,6 +9,7 @@ const Login = () => {
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const navigate = useNavigate();
const convex = useConvex();
const handleLogin = async (e) => {
e.preventDefault();
@@ -16,30 +19,19 @@ const Login = () => {
try {
console.log('Starting login for:', username);
// 1. Get Salt
const saltRes = await fetch('http://localhost:3000/api/auth/login/salt', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username })
});
const { salt } = await saltRes.json();
// 1. Get Salt (via Convex query)
const { salt } = await convex.query(api.auth.getSalt, { username });
console.log('Got salt');
// 2. Derive Keys (DEK, DAK)
const { dek, dak } = await window.cryptoAPI.deriveAuthKeys(password, salt);
console.log('Derived keys');
// 3. Verify with Server
const verifyRes = await fetch('http://localhost:3000/api/auth/login/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, dak })
});
// 3. Verify with Convex
const verifyData = await convex.mutation(api.auth.verifyUser, { username, dak });
const verifyData = await verifyRes.json();
if (!verifyRes.ok) {
throw new Error(verifyData.error || 'Login failed');
if (verifyData.error) {
throw new Error(verifyData.error);
}
console.log('Login verified. Response data:', verifyData);
@@ -66,10 +58,10 @@ const Login = () => {
const encryptedPrivateKeysObj = JSON.parse(verifyData.encryptedPrivateKeys);
// Decrypt Ed25519 Signing Key
const edPrivObj = encryptedPrivateKeysObj.ed; // Already an object
const edPrivObj = encryptedPrivateKeysObj.ed;
const signingKey = await window.cryptoAPI.decryptData(
edPrivObj.content,
mkHex, // MK acts as the key
mkHex,
edPrivObj.iv,
edPrivObj.tag
);
@@ -78,14 +70,14 @@ const Login = () => {
const rsaPrivObj = encryptedPrivateKeysObj.rsa;
const rsaPriv = await window.cryptoAPI.decryptData(
rsaPrivObj.content,
mkHex, // MK acts as the key
mkHex,
rsaPrivObj.iv,
rsaPrivObj.tag
);
// Store Keys in Session (Memory-like) storage
sessionStorage.setItem('signingKey', signingKey);
sessionStorage.setItem('privateKey', rsaPriv); // Store RSA Key
sessionStorage.setItem('privateKey', rsaPriv);
console.log('Keys decrypted and stored in session.');
localStorage.setItem('username', username);

View File

@@ -1,5 +1,7 @@
import React, { useState, useEffect } from 'react';
import { Link, useNavigate, useLocation } from 'react-router-dom';
import { useConvex } from 'convex/react';
import { api } from '../../../../convex/_generated/api';
const Register = () => {
const [username, setUsername] = useState('');
@@ -12,6 +14,7 @@ const Register = () => {
const navigate = useNavigate();
const location = useLocation();
const convex = useConvex();
// Helper to process code/key
const processInvite = async (code, secret) => {
@@ -21,10 +24,10 @@ const Register = () => {
}
try {
// Fetch Invite
const res = await fetch(`http://localhost:3000/api/invites/${code}`);
if (!res.ok) throw new Error('Invalid or expired invite');
const { encryptedPayload } = await res.json();
// Fetch Invite via Convex
const result = await convex.query(api.invites.use, { code });
if (result.error) throw new Error(result.error);
const { encryptedPayload } = result;
// Decrypt Payload
const blob = JSON.parse(encryptedPayload);
@@ -33,15 +36,15 @@ const Register = () => {
const keys = JSON.parse(decrypted);
console.log('Invite keys decrypted successfully:', Object.keys(keys).length);
setInviteKeys(keys);
setActiveInviteCode(code); // Store code for backend validation
setError(''); // Clear errors
setActiveInviteCode(code);
setError('');
} catch (err) {
console.error('Invite error:', err);
setError('Invite verification failed: ' + err.message);
}
};
// Handle Invite Link parsing from URL (if somehow navigated)
// Handle Invite Link parsing from URL
useEffect(() => {
const params = new URLSearchParams(location.search);
const code = params.get('code');
@@ -55,14 +58,6 @@ const Register = () => {
const handleManualInvite = () => {
try {
// Support full URL or just code? Full URL is easier for user (copy-paste)
// Format: .../#/register?code=UUID&key=HEX
const urlObj = new URL(inviteLinkInput);
// In HashRouter, params are after #.
// URL: http://.../#/register?code=X&key=Y
// urlObj.hash -> "#/register?code=X&key=Y"
// We can just regex it to be safe
const codeMatch = inviteLinkInput.match(/[?&]code=([^&]+)/);
const keyMatch = inviteLinkInput.match(/[?&]key=([^&]+)/);
@@ -86,7 +81,7 @@ const Register = () => {
// 1. Generate Salt and Master Key (MK)
const salt = await window.cryptoAPI.randomBytes(16);
const mk = await window.cryptoAPI.randomBytes(32); // 256-bit MK for AES-256
const mk = await window.cryptoAPI.randomBytes(32);
console.log('Generated Salt and MK');
@@ -96,7 +91,7 @@ const Register = () => {
// 3. Encrypt MK with DEK
const encryptedMKObj = await window.cryptoAPI.encryptData(mk, dek);
const encryptedMK = JSON.stringify(encryptedMKObj); // Store as JSON string {content, tag, iv}
const encryptedMK = JSON.stringify(encryptedMKObj);
// 4. Hash DAK for Auth Proof
const hak = await window.cryptoAPI.sha256(dak);
@@ -113,8 +108,8 @@ const Register = () => {
ed: encryptedEdPriv
});
// 7. Send to Backend
const payload = {
// 7. Register via Convex
const data = await convex.mutation(api.auth.createUserWithProfile, {
username,
salt,
encryptedMK,
@@ -122,19 +117,11 @@ const Register = () => {
publicKey: keys.rsaPub,
signingKey: keys.edPub,
encryptedPrivateKeys,
inviteCode: activeInviteCode // Enforce Invite
};
const response = await fetch('http://localhost:3000/api/auth/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
inviteCode: activeInviteCode || undefined
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || 'Registration failed');
if (data.error) {
throw new Error(data.error);
}
console.log('Registration successful:', data);
@@ -142,31 +129,27 @@ const Register = () => {
// 8. Upload Invite Keys (If present)
if (inviteKeys && data.userId) {
console.log('Uploading invite keys...');
const batchKeys = [];
for (const [channelId, channelKeyHex] of Object.entries(inviteKeys)) {
// Encrypt Channel Key with User's RSA Public Key
// Hybrid Encrypt? No, for now simplistic: encrypt the 32-byte hex key string (64 chars) with RSA-2048.
// RSA-2048 can encrypt ~200 bytes. 64 chars is fine.
try {
// Match Sidebar.jsx format: payload is JSON string { [channelId]: key }
const payload = JSON.stringify({ [channelId]: channelKeyHex });
const encryptedKeyBundle = await window.cryptoAPI.publicEncrypt(keys.rsaPub, payload);
// Upload
await fetch('http://localhost:3000/api/channels/keys', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
channelId,
userId: data.userId,
encryptedKeyBundle,
keyVersion: 1
})
batchKeys.push({
channelId,
userId: data.userId,
encryptedKeyBundle,
keyVersion: 1
});
console.log(`Uploaded key for channel ${channelId}`);
} catch (keyErr) {
console.error('Failed to upload key for channel:', channelId, keyErr);
console.error('Failed to encrypt key for channel:', channelId, keyErr);
}
}
if (batchKeys.length > 0) {
await convex.mutation(api.channelKeys.uploadKeys, { keys: batchKeys });
console.log('Uploaded invite keys');
}
}
navigate('/');

View File

@@ -5,6 +5,10 @@ import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
base: './',
envDir: '../../', // Pick up .env.local from project root (for VITE_CONVEX_URL)
resolve: {
dedupe: ['react', 'react-dom'],
},
build: {
outDir: 'dist-react',
},

73
convex/_generated/api.d.ts vendored Normal file
View File

@@ -0,0 +1,73 @@
/* eslint-disable */
/**
* Generated `api` utility.
*
* THIS CODE IS AUTOMATICALLY GENERATED.
*
* To regenerate, run `npx convex dev`.
* @module
*/
import type * as auth from "../auth.js";
import type * as channelKeys from "../channelKeys.js";
import type * as channels from "../channels.js";
import type * as dms from "../dms.js";
import type * as files from "../files.js";
import type * as gifs from "../gifs.js";
import type * as invites from "../invites.js";
import type * as messages from "../messages.js";
import type * as reactions from "../reactions.js";
import type * as roles from "../roles.js";
import type * as typing from "../typing.js";
import type * as voice from "../voice.js";
import type * as voiceState from "../voiceState.js";
import type {
ApiFromModules,
FilterApi,
FunctionReference,
} from "convex/server";
declare const fullApi: ApiFromModules<{
auth: typeof auth;
channelKeys: typeof channelKeys;
channels: typeof channels;
dms: typeof dms;
files: typeof files;
gifs: typeof gifs;
invites: typeof invites;
messages: typeof messages;
reactions: typeof reactions;
roles: typeof roles;
typing: typeof typing;
voice: typeof voice;
voiceState: typeof voiceState;
}>;
/**
* A utility for referencing Convex functions in your app's public API.
*
* Usage:
* ```js
* const myFunctionReference = api.myModule.myFunction;
* ```
*/
export declare const api: FilterApi<
typeof fullApi,
FunctionReference<any, "public">
>;
/**
* A utility for referencing Convex functions in your app's internal API.
*
* Usage:
* ```js
* const myFunctionReference = internal.myModule.myFunction;
* ```
*/
export declare const internal: FilterApi<
typeof fullApi,
FunctionReference<any, "internal">
>;
export declare const components: {};

23
convex/_generated/api.js Normal file
View File

@@ -0,0 +1,23 @@
/* eslint-disable */
/**
* Generated `api` utility.
*
* THIS CODE IS AUTOMATICALLY GENERATED.
*
* To regenerate, run `npx convex dev`.
* @module
*/
import { anyApi, componentsGeneric } from "convex/server";
/**
* A utility for referencing Convex functions in your app's API.
*
* Usage:
* ```js
* const myFunctionReference = api.myModule.myFunction;
* ```
*/
export const api = anyApi;
export const internal = anyApi;
export const components = componentsGeneric();

60
convex/_generated/dataModel.d.ts vendored Normal file
View File

@@ -0,0 +1,60 @@
/* eslint-disable */
/**
* Generated data model types.
*
* THIS CODE IS AUTOMATICALLY GENERATED.
*
* To regenerate, run `npx convex dev`.
* @module
*/
import type {
DataModelFromSchemaDefinition,
DocumentByName,
TableNamesInDataModel,
SystemTableNames,
} from "convex/server";
import type { GenericId } from "convex/values";
import schema from "../schema.js";
/**
* The names of all of your Convex tables.
*/
export type TableNames = TableNamesInDataModel<DataModel>;
/**
* The type of a document stored in Convex.
*
* @typeParam TableName - A string literal type of the table name (like "users").
*/
export type Doc<TableName extends TableNames> = DocumentByName<
DataModel,
TableName
>;
/**
* An identifier for a document in Convex.
*
* Convex documents are uniquely identified by their `Id`, which is accessible
* on the `_id` field. To learn more, see [Document IDs](https://docs.convex.dev/using/document-ids).
*
* Documents can be loaded using `db.get(tableName, id)` in query and mutation functions.
*
* IDs are just strings at runtime, but this type can be used to distinguish them from other
* strings when type checking.
*
* @typeParam TableName - A string literal type of the table name (like "users").
*/
export type Id<TableName extends TableNames | SystemTableNames> =
GenericId<TableName>;
/**
* A type describing your Convex data model.
*
* This type includes information about what tables you have, the type of
* documents stored in those tables, and the indexes defined on them.
*
* This type is used to parameterize methods like `queryGeneric` and
* `mutationGeneric` to make them type-safe.
*/
export type DataModel = DataModelFromSchemaDefinition<typeof schema>;

143
convex/_generated/server.d.ts vendored Normal file
View File

@@ -0,0 +1,143 @@
/* eslint-disable */
/**
* Generated utilities for implementing server-side Convex query and mutation functions.
*
* THIS CODE IS AUTOMATICALLY GENERATED.
*
* To regenerate, run `npx convex dev`.
* @module
*/
import {
ActionBuilder,
HttpActionBuilder,
MutationBuilder,
QueryBuilder,
GenericActionCtx,
GenericMutationCtx,
GenericQueryCtx,
GenericDatabaseReader,
GenericDatabaseWriter,
} from "convex/server";
import type { DataModel } from "./dataModel.js";
/**
* Define a query in this Convex app's public API.
*
* This function will be allowed to read your Convex database and will be accessible from the client.
*
* @param func - The query function. It receives a {@link QueryCtx} as its first argument.
* @returns The wrapped query. Include this as an `export` to name it and make it accessible.
*/
export declare const query: QueryBuilder<DataModel, "public">;
/**
* Define a query that is only accessible from other Convex functions (but not from the client).
*
* This function will be allowed to read from your Convex database. It will not be accessible from the client.
*
* @param func - The query function. It receives a {@link QueryCtx} as its first argument.
* @returns The wrapped query. Include this as an `export` to name it and make it accessible.
*/
export declare const internalQuery: QueryBuilder<DataModel, "internal">;
/**
* Define a mutation in this Convex app's public API.
*
* This function will be allowed to modify your Convex database and will be accessible from the client.
*
* @param func - The mutation function. It receives a {@link MutationCtx} as its first argument.
* @returns The wrapped mutation. Include this as an `export` to name it and make it accessible.
*/
export declare const mutation: MutationBuilder<DataModel, "public">;
/**
* Define a mutation that is only accessible from other Convex functions (but not from the client).
*
* This function will be allowed to modify your Convex database. It will not be accessible from the client.
*
* @param func - The mutation function. It receives a {@link MutationCtx} as its first argument.
* @returns The wrapped mutation. Include this as an `export` to name it and make it accessible.
*/
export declare const internalMutation: MutationBuilder<DataModel, "internal">;
/**
* Define an action in this Convex app's public API.
*
* An action is a function which can execute any JavaScript code, including non-deterministic
* code and code with side-effects, like calling third-party services.
* They can be run in Convex's JavaScript environment or in Node.js using the "use node" directive.
* They can interact with the database indirectly by calling queries and mutations using the {@link ActionCtx}.
*
* @param func - The action. It receives an {@link ActionCtx} as its first argument.
* @returns The wrapped action. Include this as an `export` to name it and make it accessible.
*/
export declare const action: ActionBuilder<DataModel, "public">;
/**
* Define an action that is only accessible from other Convex functions (but not from the client).
*
* @param func - The function. It receives an {@link ActionCtx} as its first argument.
* @returns The wrapped function. Include this as an `export` to name it and make it accessible.
*/
export declare const internalAction: ActionBuilder<DataModel, "internal">;
/**
* Define an HTTP action.
*
* The wrapped function will be used to respond to HTTP requests received
* by a Convex deployment if the requests matches the path and method where
* this action is routed. Be sure to route your httpAction in `convex/http.js`.
*
* @param func - The function. It receives an {@link ActionCtx} as its first argument
* and a Fetch API `Request` object as its second.
* @returns The wrapped function. Import this function from `convex/http.js` and route it to hook it up.
*/
export declare const httpAction: HttpActionBuilder;
/**
* A set of services for use within Convex query functions.
*
* The query context is passed as the first argument to any Convex query
* function run on the server.
*
* This differs from the {@link MutationCtx} because all of the services are
* read-only.
*/
export type QueryCtx = GenericQueryCtx<DataModel>;
/**
* A set of services for use within Convex mutation functions.
*
* The mutation context is passed as the first argument to any Convex mutation
* function run on the server.
*/
export type MutationCtx = GenericMutationCtx<DataModel>;
/**
* A set of services for use within Convex action functions.
*
* The action context is passed as the first argument to any Convex action
* function run on the server.
*/
export type ActionCtx = GenericActionCtx<DataModel>;
/**
* An interface to read from the database within Convex query functions.
*
* The two entry points are {@link DatabaseReader.get}, which fetches a single
* document by its {@link Id}, or {@link DatabaseReader.query}, which starts
* building a query.
*/
export type DatabaseReader = GenericDatabaseReader<DataModel>;
/**
* An interface to read from and write to the database within Convex mutation
* functions.
*
* Convex guarantees that all writes within a single mutation are
* executed atomically, so you never have to worry about partial writes leaving
* your data in an inconsistent state. See [the Convex Guide](https://docs.convex.dev/understanding/convex-fundamentals/functions#atomicity-and-optimistic-concurrency-control)
* for the guarantees Convex provides your functions.
*/
export type DatabaseWriter = GenericDatabaseWriter<DataModel>;

View File

@@ -0,0 +1,93 @@
/* eslint-disable */
/**
* Generated utilities for implementing server-side Convex query and mutation functions.
*
* THIS CODE IS AUTOMATICALLY GENERATED.
*
* To regenerate, run `npx convex dev`.
* @module
*/
import {
actionGeneric,
httpActionGeneric,
queryGeneric,
mutationGeneric,
internalActionGeneric,
internalMutationGeneric,
internalQueryGeneric,
} from "convex/server";
/**
* Define a query in this Convex app's public API.
*
* This function will be allowed to read your Convex database and will be accessible from the client.
*
* @param func - The query function. It receives a {@link QueryCtx} as its first argument.
* @returns The wrapped query. Include this as an `export` to name it and make it accessible.
*/
export const query = queryGeneric;
/**
* Define a query that is only accessible from other Convex functions (but not from the client).
*
* This function will be allowed to read from your Convex database. It will not be accessible from the client.
*
* @param func - The query function. It receives a {@link QueryCtx} as its first argument.
* @returns The wrapped query. Include this as an `export` to name it and make it accessible.
*/
export const internalQuery = internalQueryGeneric;
/**
* Define a mutation in this Convex app's public API.
*
* This function will be allowed to modify your Convex database and will be accessible from the client.
*
* @param func - The mutation function. It receives a {@link MutationCtx} as its first argument.
* @returns The wrapped mutation. Include this as an `export` to name it and make it accessible.
*/
export const mutation = mutationGeneric;
/**
* Define a mutation that is only accessible from other Convex functions (but not from the client).
*
* This function will be allowed to modify your Convex database. It will not be accessible from the client.
*
* @param func - The mutation function. It receives a {@link MutationCtx} as its first argument.
* @returns The wrapped mutation. Include this as an `export` to name it and make it accessible.
*/
export const internalMutation = internalMutationGeneric;
/**
* Define an action in this Convex app's public API.
*
* An action is a function which can execute any JavaScript code, including non-deterministic
* code and code with side-effects, like calling third-party services.
* They can be run in Convex's JavaScript environment or in Node.js using the "use node" directive.
* They can interact with the database indirectly by calling queries and mutations using the {@link ActionCtx}.
*
* @param func - The action. It receives an {@link ActionCtx} as its first argument.
* @returns The wrapped action. Include this as an `export` to name it and make it accessible.
*/
export const action = actionGeneric;
/**
* Define an action that is only accessible from other Convex functions (but not from the client).
*
* @param func - The function. It receives an {@link ActionCtx} as its first argument.
* @returns The wrapped function. Include this as an `export` to name it and make it accessible.
*/
export const internalAction = internalActionGeneric;
/**
* Define an HTTP action.
*
* The wrapped function will be used to respond to HTTP requests received
* by a Convex deployment if the requests matches the path and method where
* this action is routed. Be sure to route your httpAction in `convex/http.js`.
*
* @param func - The function. It receives an {@link ActionCtx} as its first argument
* and a Fetch API `Request` object as its second.
* @returns The wrapped function. Import this function from `convex/http.js` and route it to hook it up.
*/
export const httpAction = httpActionGeneric;

226
convex/auth.ts Normal file
View File

@@ -0,0 +1,226 @@
import { query, mutation } from "./_generated/server";
import { v } from "convex/values";
// Get salt for a username (returns fake salt for non-existent users)
export const getSalt = query({
args: { username: v.string() },
returns: v.object({ salt: v.string() }),
handler: async (ctx, args) => {
const user = await ctx.db
.query("userProfiles")
.withIndex("by_username", (q) => q.eq("username", args.username))
.unique();
if (user) {
return { salt: user.clientSalt };
}
// Generate deterministic fake salt for non-existent users (privacy)
// Simple HMAC-like approach using username
const encoder = new TextEncoder();
const data = encoder.encode("SERVER_SECRET_KEY" + args.username);
const hashBuffer = await crypto.subtle.digest("SHA-256", data);
const hashArray = new Uint8Array(hashBuffer);
const fakeSalt = Array.from(hashArray)
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
return { salt: fakeSalt };
},
});
// Verify user credentials (DAK comparison)
export const verifyUser = mutation({
args: {
username: v.string(),
dak: v.string(),
},
returns: v.union(
v.object({
success: v.boolean(),
userId: v.string(),
encryptedMK: v.string(),
encryptedPrivateKeys: v.string(),
publicKey: v.string(),
}),
v.object({ error: v.string() })
),
handler: async (ctx, args) => {
const user = await ctx.db
.query("userProfiles")
.withIndex("by_username", (q) => q.eq("username", args.username))
.unique();
if (!user) {
return { error: "Invalid credentials" };
}
// Hash the DAK with SHA-256 and compare
const encoder = new TextEncoder();
const dakBuffer = encoder.encode(args.dak);
const hashBuffer = await crypto.subtle.digest("SHA-256", dakBuffer);
const hashArray = new Uint8Array(hashBuffer);
const hashedDAK = Array.from(hashArray)
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
if (hashedDAK === user.hashedAuthKey) {
return {
success: true,
userId: user._id,
encryptedMK: user.encryptedMasterKey,
encryptedPrivateKeys: user.encryptedPrivateKeys,
publicKey: user.publicIdentityKey,
};
}
return { error: "Invalid credentials" };
},
});
// Register new user with crypto keys
export const createUserWithProfile = mutation({
args: {
username: v.string(),
salt: v.string(),
encryptedMK: v.string(),
hak: v.string(),
publicKey: v.string(),
signingKey: v.string(),
encryptedPrivateKeys: v.string(),
inviteCode: v.optional(v.string()),
},
returns: v.union(
v.object({ success: v.boolean(), userId: v.string() }),
v.object({ error: v.string() })
),
handler: async (ctx, args) => {
// Check if username is taken
const existing = await ctx.db
.query("userProfiles")
.withIndex("by_username", (q) => q.eq("username", args.username))
.unique();
if (existing) {
return { error: "Username taken" };
}
// Count existing users
const allUsers = await ctx.db.query("userProfiles").collect();
const userCount = allUsers.length;
// Enforce invite code for non-first users
if (userCount > 0) {
if (!args.inviteCode) {
return { error: "Invite code required" };
}
// Validate invite
const invite = await ctx.db
.query("invites")
.withIndex("by_code", (q) => q.eq("code", args.inviteCode))
.unique();
if (!invite) {
return { error: "Invalid invite code" };
}
if (invite.expiresAt && Date.now() > invite.expiresAt) {
return { error: "Invite expired" };
}
if (
invite.maxUses !== undefined &&
invite.maxUses !== null &&
invite.uses >= invite.maxUses
) {
return { error: "Invite max uses reached" };
}
// Increment invite usage
await ctx.db.patch(invite._id, { uses: invite.uses + 1 });
}
// Create user profile
const userId = await ctx.db.insert("userProfiles", {
username: args.username,
clientSalt: args.salt,
encryptedMasterKey: args.encryptedMK,
hashedAuthKey: args.hak,
publicIdentityKey: args.publicKey,
publicSigningKey: args.signingKey,
encryptedPrivateKeys: args.encryptedPrivateKeys,
isAdmin: userCount === 0,
});
// First user bootstrap: create Owner + @everyone roles if they don't exist
if (userCount === 0) {
// Create @everyone role
const everyoneRoleId = await ctx.db.insert("roles", {
name: "@everyone",
color: "#99aab5",
position: 0,
permissions: {
create_invite: true,
embed_links: true,
attach_files: true,
},
isHoist: false,
});
// Create Owner role
const ownerRoleId = await ctx.db.insert("roles", {
name: "Owner",
color: "#e91e63",
position: 100,
permissions: {
manage_channels: true,
manage_roles: true,
create_invite: true,
embed_links: true,
attach_files: true,
},
isHoist: true,
});
// Assign both roles to first user
await ctx.db.insert("userRoles", { userId, roleId: everyoneRoleId });
await ctx.db.insert("userRoles", { userId, roleId: ownerRoleId });
} else {
// Assign @everyone role to new user
const everyoneRole = await ctx.db
.query("roles")
.filter((q) => q.eq(q.field("name"), "@everyone"))
.first();
if (everyoneRole) {
await ctx.db.insert("userRoles", {
userId,
roleId: everyoneRole._id,
});
}
}
return { success: true, userId };
},
});
// Get all users' public keys
export const getPublicKeys = query({
args: {},
returns: v.array(
v.object({
id: v.string(),
username: v.string(),
public_identity_key: v.string(),
})
),
handler: async (ctx) => {
const users = await ctx.db.query("userProfiles").collect();
return users.map((u) => ({
id: u._id,
username: u.username,
public_identity_key: u.publicIdentityKey,
}));
},
});

72
convex/channelKeys.ts Normal file
View File

@@ -0,0 +1,72 @@
import { query, mutation } from "./_generated/server";
import { v } from "convex/values";
// Batch upsert encrypted key bundles
export const uploadKeys = mutation({
args: {
keys: v.array(
v.object({
channelId: v.id("channels"),
userId: v.id("userProfiles"),
encryptedKeyBundle: v.string(),
keyVersion: v.number(),
})
),
},
returns: v.object({ success: v.boolean(), count: v.number() }),
handler: async (ctx, args) => {
for (const keyData of args.keys) {
if (!keyData.channelId || !keyData.userId || !keyData.encryptedKeyBundle) {
continue;
}
// Check if exists (upsert)
const existing = await ctx.db
.query("channelKeys")
.withIndex("by_channel_and_user", (q) =>
q.eq("channelId", keyData.channelId).eq("userId", keyData.userId)
)
.unique();
if (existing) {
await ctx.db.patch(existing._id, {
encryptedKeyBundle: keyData.encryptedKeyBundle,
keyVersion: keyData.keyVersion,
});
} else {
await ctx.db.insert("channelKeys", {
channelId: keyData.channelId,
userId: keyData.userId,
encryptedKeyBundle: keyData.encryptedKeyBundle,
keyVersion: keyData.keyVersion,
});
}
}
return { success: true, count: args.keys.length };
},
});
// Get user's encrypted key bundles (reactive!)
export const getKeysForUser = query({
args: { userId: v.id("userProfiles") },
returns: v.array(
v.object({
channel_id: v.id("channels"),
encrypted_key_bundle: v.string(),
key_version: v.number(),
})
),
handler: async (ctx, args) => {
const keys = await ctx.db
.query("channelKeys")
.withIndex("by_user", (q) => q.eq("userId", args.userId))
.collect();
return keys.map((k) => ({
channel_id: k.channelId,
encrypted_key_bundle: k.encryptedKeyBundle,
key_version: k.keyVersion,
}));
},
});

166
convex/channels.ts Normal file
View File

@@ -0,0 +1,166 @@
import { query, mutation } from "./_generated/server";
import { v } from "convex/values";
// List all non-DM channels
export const list = query({
args: {},
returns: v.array(
v.object({
_id: v.id("channels"),
_creationTime: v.number(),
name: v.string(),
type: v.string(),
})
),
handler: async (ctx) => {
const channels = await ctx.db.query("channels").collect();
return channels
.filter((c) => c.type !== "dm")
.sort((a, b) => a.name.localeCompare(b.name));
},
});
// Get single channel by ID
export const get = query({
args: { id: v.id("channels") },
returns: v.union(
v.object({
_id: v.id("channels"),
_creationTime: v.number(),
name: v.string(),
type: v.string(),
}),
v.null()
),
handler: async (ctx, args) => {
return await ctx.db.get(args.id);
},
});
// Create new channel
export const create = mutation({
args: {
name: v.string(),
type: v.optional(v.string()),
},
returns: v.object({ id: v.id("channels") }),
handler: async (ctx, args) => {
if (!args.name.trim()) {
throw new Error("Channel name required");
}
// Check for duplicate name
const existing = await ctx.db
.query("channels")
.withIndex("by_name", (q) => q.eq("name", args.name))
.unique();
if (existing) {
throw new Error("Channel already exists");
}
const id = await ctx.db.insert("channels", {
name: args.name,
type: args.type || "text",
});
return { id };
},
});
// Rename channel
export const rename = mutation({
args: {
id: v.id("channels"),
name: v.string(),
},
returns: v.object({
_id: v.id("channels"),
_creationTime: v.number(),
name: v.string(),
type: v.string(),
}),
handler: async (ctx, args) => {
if (!args.name.trim()) {
throw new Error("Name required");
}
const channel = await ctx.db.get(args.id);
if (!channel) {
throw new Error("Channel not found");
}
await ctx.db.patch(args.id, { name: args.name });
return { ...channel, name: args.name };
},
});
// Delete channel + cascade messages and keys
export const remove = mutation({
args: { id: v.id("channels") },
returns: v.object({ success: v.boolean() }),
handler: async (ctx, args) => {
const channel = await ctx.db.get(args.id);
if (!channel) {
throw new Error("Channel not found");
}
// Delete messages
const messages = await ctx.db
.query("messages")
.withIndex("by_channel", (q) => q.eq("channelId", args.id))
.collect();
for (const msg of messages) {
// Delete reactions for this message
const reactions = await ctx.db
.query("messageReactions")
.withIndex("by_message", (q) => q.eq("messageId", msg._id))
.collect();
for (const r of reactions) {
await ctx.db.delete(r._id);
}
await ctx.db.delete(msg._id);
}
// Delete channel keys
const keys = await ctx.db
.query("channelKeys")
.withIndex("by_channel", (q) => q.eq("channelId", args.id))
.collect();
for (const key of keys) {
await ctx.db.delete(key._id);
}
// Delete DM participants
const dmParts = await ctx.db
.query("dmParticipants")
.withIndex("by_channel", (q) => q.eq("channelId", args.id))
.collect();
for (const dp of dmParts) {
await ctx.db.delete(dp._id);
}
// Delete typing indicators
const typing = await ctx.db
.query("typingIndicators")
.withIndex("by_channel", (q) => q.eq("channelId", args.id))
.collect();
for (const t of typing) {
await ctx.db.delete(t._id);
}
// Delete voice states
const voiceStates = await ctx.db
.query("voiceStates")
.withIndex("by_channel", (q) => q.eq("channelId", args.id))
.collect();
for (const vs of voiceStates) {
await ctx.db.delete(vs._id);
}
// Delete channel itself
await ctx.db.delete(args.id);
return { success: true };
},
});

104
convex/dms.ts Normal file
View File

@@ -0,0 +1,104 @@
import { query, mutation } from "./_generated/server";
import { v } from "convex/values";
// Find-or-create DM channel between two users
export const openDM = mutation({
args: {
userId: v.id("userProfiles"),
targetUserId: v.id("userProfiles"),
},
returns: v.object({
channelId: v.id("channels"),
created: v.boolean(),
}),
handler: async (ctx, args) => {
if (args.userId === args.targetUserId) {
throw new Error("Cannot DM yourself");
}
// Deterministic channel name
const sorted = [args.userId, args.targetUserId].sort();
const dmName = `dm-${sorted[0]}-${sorted[1]}`;
// Check if already exists
const existing = await ctx.db
.query("channels")
.withIndex("by_name", (q) => q.eq("name", dmName))
.unique();
if (existing) {
return { channelId: existing._id, created: false };
}
// Create DM channel
const channelId = await ctx.db.insert("channels", {
name: dmName,
type: "dm",
});
// Add participants
await ctx.db.insert("dmParticipants", {
channelId,
userId: args.userId,
});
await ctx.db.insert("dmParticipants", {
channelId,
userId: args.targetUserId,
});
return { channelId, created: true };
},
});
// List user's DM channels with other user info
export const listDMs = query({
args: { userId: v.id("userProfiles") },
returns: v.array(
v.object({
channel_id: v.id("channels"),
channel_name: v.string(),
other_user_id: v.string(),
other_username: v.string(),
})
),
handler: async (ctx, args) => {
// Get all DM participations for this user
const myParticipations = await ctx.db
.query("dmParticipants")
.withIndex("by_user", (q) => q.eq("userId", args.userId))
.collect();
const result: Array<{
channel_id: typeof myParticipations[0]["channelId"];
channel_name: string;
other_user_id: string;
other_username: string;
}> = [];
for (const part of myParticipations) {
const channel = await ctx.db.get(part.channelId);
if (!channel || channel.type !== "dm") continue;
// Find other participant
const otherParts = await ctx.db
.query("dmParticipants")
.withIndex("by_channel", (q) => q.eq("channelId", part.channelId))
.collect();
const otherPart = otherParts.find((p) => p.userId !== args.userId);
if (!otherPart) continue;
const otherUser = await ctx.db.get(otherPart.userId);
if (!otherUser) continue;
result.push({
channel_id: part.channelId,
channel_name: channel.name,
other_user_id: otherUser._id,
other_username: otherUser.username,
});
}
return result;
},
});

20
convex/files.ts Normal file
View File

@@ -0,0 +1,20 @@
import { mutation, query } from "./_generated/server";
import { v } from "convex/values";
// Generate upload URL for client-side uploads
export const generateUploadUrl = mutation({
args: {},
returns: v.string(),
handler: async (ctx) => {
return await ctx.storage.generateUploadUrl();
},
});
// Get file URL from storage ID
export const getFileUrl = query({
args: { storageId: v.id("_storage") },
returns: v.union(v.string(), v.null()),
handler: async (ctx, args) => {
return await ctx.storage.getUrl(args.storageId);
},
});

43
convex/gifs.ts Normal file
View File

@@ -0,0 +1,43 @@
"use node";
import { action } from "./_generated/server";
import { v } from "convex/values";
// Search GIFs via Tenor API
export const search = action({
args: {
q: v.string(),
limit: v.optional(v.number()),
},
returns: v.any(),
handler: async (_ctx, args) => {
const apiKey = process.env.TENOR_API_KEY;
if (!apiKey) {
console.warn("TENOR_API_KEY missing");
return { results: [] };
}
const limit = args.limit || 8;
const url = `https://tenor.googleapis.com/v2/search?q=${encodeURIComponent(args.q)}&key=${apiKey}&limit=${limit}`;
const response = await fetch(url);
if (!response.ok) {
console.error("Tenor API Error:", response.statusText);
return { results: [] };
}
return await response.json();
},
});
// Get GIF categories
export const categories = action({
args: {},
returns: v.any(),
handler: async () => {
// Return static categories (same as the JSON file in backend)
// These are loaded from the frontend data file
return { categories: [] };
},
});

85
convex/invites.ts Normal file
View File

@@ -0,0 +1,85 @@
import { query, mutation } from "./_generated/server";
import { v } from "convex/values";
// Create invite with encrypted payload
export const create = mutation({
args: {
code: v.string(),
encryptedPayload: v.string(),
createdBy: v.id("userProfiles"),
maxUses: v.optional(v.number()),
expiresAt: v.optional(v.number()),
keyVersion: v.number(),
},
returns: v.object({ success: v.boolean() }),
handler: async (ctx, args) => {
await ctx.db.insert("invites", {
code: args.code,
encryptedPayload: args.encryptedPayload,
createdBy: args.createdBy,
maxUses: args.maxUses,
uses: 0,
expiresAt: args.expiresAt,
keyVersion: args.keyVersion,
});
return { success: true };
},
});
// Fetch and validate invite (returns encrypted payload)
export const use = query({
args: { code: v.string() },
returns: v.union(
v.object({
encryptedPayload: v.string(),
keyVersion: v.number(),
}),
v.object({ error: v.string() })
),
handler: async (ctx, args) => {
const invite = await ctx.db
.query("invites")
.withIndex("by_code", (q) => q.eq("code", args.code))
.unique();
if (!invite) {
return { error: "Invite not found" };
}
if (invite.expiresAt && Date.now() > invite.expiresAt) {
return { error: "Invite expired" };
}
if (
invite.maxUses !== undefined &&
invite.maxUses !== null &&
invite.uses >= invite.maxUses
) {
return { error: "Invite max uses reached" };
}
return {
encryptedPayload: invite.encryptedPayload,
keyVersion: invite.keyVersion,
};
},
});
// Revoke invite
export const revoke = mutation({
args: { code: v.string() },
returns: v.object({ success: v.boolean() }),
handler: async (ctx, args) => {
const invite = await ctx.db
.query("invites")
.withIndex("by_code", (q) => q.eq("code", args.code))
.unique();
if (invite) {
await ctx.db.delete(invite._id);
}
return { success: true };
},
});

111
convex/messages.ts Normal file
View File

@@ -0,0 +1,111 @@
import { query, mutation } from "./_generated/server";
import { v } from "convex/values";
// List recent messages for a channel with reactions + username
export const list = query({
args: {
channelId: v.id("channels"),
userId: v.optional(v.id("userProfiles")),
},
returns: v.array(v.any()),
handler: async (ctx, args) => {
const messages = await ctx.db
.query("messages")
.withIndex("by_channel", (q) => q.eq("channelId", args.channelId))
.order("desc")
.take(50);
// Reverse to get chronological order
const chronological = messages.reverse();
// Enrich with username, signing key, and reactions
const enriched = await Promise.all(
chronological.map(async (msg) => {
// Get sender info
const sender = await ctx.db.get(msg.senderId);
// Get reactions for this message
const reactionDocs = await ctx.db
.query("messageReactions")
.withIndex("by_message", (q) => q.eq("messageId", msg._id))
.collect();
// Aggregate reactions
const reactions: Record<
string,
{ count: number; me: boolean }
> = {};
for (const r of reactionDocs) {
if (!reactions[r.emoji]) {
reactions[r.emoji] = { count: 0, me: false };
}
reactions[r.emoji].count++;
if (args.userId && r.userId === args.userId) {
reactions[r.emoji].me = true;
}
}
return {
id: msg._id,
channel_id: msg.channelId,
sender_id: msg.senderId,
ciphertext: msg.ciphertext,
nonce: msg.nonce,
signature: msg.signature,
key_version: msg.keyVersion,
created_at: new Date(msg._creationTime).toISOString(),
username: sender?.username || "Unknown",
public_signing_key: sender?.publicSigningKey || "",
reactions:
Object.keys(reactions).length > 0 ? reactions : null,
};
})
);
return enriched;
},
});
// Send encrypted message
export const send = mutation({
args: {
channelId: v.id("channels"),
senderId: v.id("userProfiles"),
ciphertext: v.string(),
nonce: v.string(),
signature: v.string(),
keyVersion: v.number(),
},
returns: v.object({ id: v.id("messages") }),
handler: async (ctx, args) => {
const id = await ctx.db.insert("messages", {
channelId: args.channelId,
senderId: args.senderId,
ciphertext: args.ciphertext,
nonce: args.nonce,
signature: args.signature,
keyVersion: args.keyVersion,
});
return { id };
},
});
// Delete a message
export const remove = mutation({
args: { id: v.id("messages") },
returns: v.null(),
handler: async (ctx, args) => {
// Delete reactions first
const reactions = await ctx.db
.query("messageReactions")
.withIndex("by_message", (q) => q.eq("messageId", args.id))
.collect();
for (const r of reactions) {
await ctx.db.delete(r._id);
}
await ctx.db.delete(args.id);
return null;
},
});

61
convex/reactions.ts Normal file
View File

@@ -0,0 +1,61 @@
import { mutation } from "./_generated/server";
import { v } from "convex/values";
// Add reaction (upsert - no duplicates)
export const add = mutation({
args: {
messageId: v.id("messages"),
userId: v.id("userProfiles"),
emoji: v.string(),
},
returns: v.null(),
handler: async (ctx, args) => {
// Check if already exists
const existing = await ctx.db
.query("messageReactions")
.withIndex("by_message_user_emoji", (q) =>
q
.eq("messageId", args.messageId)
.eq("userId", args.userId)
.eq("emoji", args.emoji)
)
.unique();
if (!existing) {
await ctx.db.insert("messageReactions", {
messageId: args.messageId,
userId: args.userId,
emoji: args.emoji,
});
}
return null;
},
});
// Remove reaction
export const remove = mutation({
args: {
messageId: v.id("messages"),
userId: v.id("userProfiles"),
emoji: v.string(),
},
returns: v.null(),
handler: async (ctx, args) => {
const existing = await ctx.db
.query("messageReactions")
.withIndex("by_message_user_emoji", (q) =>
q
.eq("messageId", args.messageId)
.eq("userId", args.userId)
.eq("emoji", args.emoji)
)
.unique();
if (existing) {
await ctx.db.delete(existing._id);
}
return null;
},
});

210
convex/roles.ts Normal file
View File

@@ -0,0 +1,210 @@
import { query, mutation } from "./_generated/server";
import { v } from "convex/values";
// List all roles
export const list = query({
args: {},
returns: v.array(v.any()),
handler: async (ctx) => {
const roles = await ctx.db.query("roles").collect();
return roles.sort((a, b) => (b.position || 0) - (a.position || 0));
},
});
// Create new role
export const create = mutation({
args: {
name: v.optional(v.string()),
color: v.optional(v.string()),
permissions: v.optional(v.any()),
position: v.optional(v.number()),
isHoist: v.optional(v.boolean()),
},
returns: v.any(),
handler: async (ctx, args) => {
const id = await ctx.db.insert("roles", {
name: args.name || "new role",
color: args.color || "#99aab5",
position: args.position || 0,
permissions: args.permissions || {},
isHoist: args.isHoist || false,
});
return await ctx.db.get(id);
},
});
// Update role properties
export const update = mutation({
args: {
id: v.id("roles"),
name: v.optional(v.string()),
color: v.optional(v.string()),
permissions: v.optional(v.any()),
position: v.optional(v.number()),
isHoist: v.optional(v.boolean()),
},
returns: v.any(),
handler: async (ctx, args) => {
const role = await ctx.db.get(args.id);
if (!role) throw new Error("Role not found");
const updates: Record<string, unknown> = {};
if (args.name !== undefined) updates.name = args.name;
if (args.color !== undefined) updates.color = args.color;
if (args.permissions !== undefined) updates.permissions = args.permissions;
if (args.position !== undefined) updates.position = args.position;
if (args.isHoist !== undefined) updates.isHoist = args.isHoist;
if (Object.keys(updates).length > 0) {
await ctx.db.patch(args.id, updates);
}
return await ctx.db.get(args.id);
},
});
// Delete role
export const remove = mutation({
args: { id: v.id("roles") },
returns: v.object({ success: v.boolean() }),
handler: async (ctx, args) => {
const role = await ctx.db.get(args.id);
if (!role) throw new Error("Role not found");
// Delete user_role assignments
const assignments = await ctx.db
.query("userRoles")
.withIndex("by_role", (q) => q.eq("roleId", args.id))
.collect();
for (const a of assignments) {
await ctx.db.delete(a._id);
}
await ctx.db.delete(args.id);
return { success: true };
},
});
// List members with roles
export const listMembers = query({
args: {},
returns: v.array(v.any()),
handler: async (ctx) => {
const users = await ctx.db.query("userProfiles").collect();
const result = await Promise.all(
users.map(async (user) => {
const userRoleAssignments = await ctx.db
.query("userRoles")
.withIndex("by_user", (q) => q.eq("userId", user._id))
.collect();
const roles = await Promise.all(
userRoleAssignments.map(async (ur) => {
const role = await ctx.db.get(ur.roleId);
return role;
})
);
return {
id: user._id,
username: user.username,
public_identity_key: user.publicIdentityKey,
roles: roles.filter(Boolean),
};
})
);
return result;
},
});
// Assign role to user
export const assign = mutation({
args: {
roleId: v.id("roles"),
userId: v.id("userProfiles"),
},
returns: v.object({ success: v.boolean() }),
handler: async (ctx, args) => {
// Check if already assigned
const existing = await ctx.db
.query("userRoles")
.withIndex("by_user_and_role", (q) =>
q.eq("userId", args.userId).eq("roleId", args.roleId)
)
.unique();
if (!existing) {
await ctx.db.insert("userRoles", {
userId: args.userId,
roleId: args.roleId,
});
}
return { success: true };
},
});
// Remove role from user
export const unassign = mutation({
args: {
roleId: v.id("roles"),
userId: v.id("userProfiles"),
},
returns: v.object({ success: v.boolean() }),
handler: async (ctx, args) => {
const existing = await ctx.db
.query("userRoles")
.withIndex("by_user_and_role", (q) =>
q.eq("userId", args.userId).eq("roleId", args.roleId)
)
.unique();
if (existing) {
await ctx.db.delete(existing._id);
}
return { success: true };
},
});
// Get current user's aggregated permissions
export const getMyPermissions = query({
args: { userId: v.id("userProfiles") },
returns: v.object({
manage_channels: v.boolean(),
manage_roles: v.boolean(),
create_invite: v.boolean(),
embed_links: v.boolean(),
attach_files: v.boolean(),
}),
handler: async (ctx, args) => {
const userRoleAssignments = await ctx.db
.query("userRoles")
.withIndex("by_user", (q) => q.eq("userId", args.userId))
.collect();
const finalPerms = {
manage_channels: false,
manage_roles: false,
create_invite: false,
embed_links: false,
attach_files: false,
};
for (const ur of userRoleAssignments) {
const role = await ctx.db.get(ur.roleId);
if (!role) continue;
const p = (role.permissions || {}) as Record<string, boolean>;
if (p.manage_channels) finalPerms.manage_channels = true;
if (p.manage_roles) finalPerms.manage_roles = true;
if (p.create_invite) finalPerms.create_invite = true;
if (p.embed_links) finalPerms.embed_links = true;
if (p.attach_files) finalPerms.attach_files = true;
}
return finalPerms;
},
});

98
convex/schema.ts Normal file
View File

@@ -0,0 +1,98 @@
import { defineSchema, defineTable } from "convex/server";
import { v } from "convex/values";
export default defineSchema({
userProfiles: defineTable({
username: v.string(),
clientSalt: v.string(),
encryptedMasterKey: v.string(),
hashedAuthKey: v.string(),
publicIdentityKey: v.string(),
publicSigningKey: v.string(),
encryptedPrivateKeys: v.string(),
isAdmin: v.boolean(),
}).index("by_username", ["username"]),
channels: defineTable({
name: v.string(),
type: v.string(), // 'text' | 'voice' | 'dm'
}).index("by_name", ["name"]),
messages: defineTable({
channelId: v.id("channels"),
senderId: v.id("userProfiles"),
ciphertext: v.string(),
nonce: v.string(),
signature: v.string(),
keyVersion: v.number(),
}).index("by_channel", ["channelId"]),
messageReactions: defineTable({
messageId: v.id("messages"),
userId: v.id("userProfiles"),
emoji: v.string(),
})
.index("by_message", ["messageId"])
.index("by_message_user_emoji", ["messageId", "userId", "emoji"]),
channelKeys: defineTable({
channelId: v.id("channels"),
userId: v.id("userProfiles"),
encryptedKeyBundle: v.string(),
keyVersion: v.number(),
})
.index("by_channel", ["channelId"])
.index("by_user", ["userId"])
.index("by_channel_and_user", ["channelId", "userId"]),
roles: defineTable({
name: v.string(),
color: v.string(),
position: v.number(),
permissions: v.any(), // JSON object of permissions
isHoist: v.boolean(),
}),
userRoles: defineTable({
userId: v.id("userProfiles"),
roleId: v.id("roles"),
})
.index("by_user", ["userId"])
.index("by_role", ["roleId"])
.index("by_user_and_role", ["userId", "roleId"]),
invites: defineTable({
code: v.string(),
encryptedPayload: v.string(),
createdBy: v.id("userProfiles"),
maxUses: v.optional(v.number()),
uses: v.number(),
expiresAt: v.optional(v.number()), // timestamp
keyVersion: v.number(),
}).index("by_code", ["code"]),
dmParticipants: defineTable({
channelId: v.id("channels"),
userId: v.id("userProfiles"),
})
.index("by_channel", ["channelId"])
.index("by_user", ["userId"]),
typingIndicators: defineTable({
channelId: v.id("channels"),
userId: v.id("userProfiles"),
username: v.string(),
expiresAt: v.number(), // timestamp
}).index("by_channel", ["channelId"]),
voiceStates: defineTable({
channelId: v.id("channels"),
userId: v.id("userProfiles"),
username: v.string(),
isMuted: v.boolean(),
isDeafened: v.boolean(),
isScreenSharing: v.boolean(),
})
.index("by_channel", ["channelId"])
.index("by_user", ["userId"]),
});

11
convex/tsconfig.json Normal file
View File

@@ -0,0 +1,11 @@
{
"compilerOptions": {
"allowJs": true,
"strict": true,
"moduleResolution": "Bundler",
"jsx": "react-jsx",
"skipLibCheck": true
},
"include": ["./**/*"],
"exclude": ["./_generated"]
}

103
convex/typing.ts Normal file
View File

@@ -0,0 +1,103 @@
import { query, mutation, internalMutation } from "./_generated/server";
import { v } from "convex/values";
import { internal } from "./_generated/api";
// Start typing indicator
export const startTyping = mutation({
args: {
channelId: v.id("channels"),
userId: v.id("userProfiles"),
username: v.string(),
},
returns: v.null(),
handler: async (ctx, args) => {
const expiresAt = Date.now() + 6000; // 6 second TTL
// Upsert: check if already exists
const existing = await ctx.db
.query("typingIndicators")
.withIndex("by_channel", (q) => q.eq("channelId", args.channelId))
.collect();
const userTyping = existing.find((t) => t.userId === args.userId);
if (userTyping) {
await ctx.db.patch(userTyping._id, { expiresAt });
} else {
await ctx.db.insert("typingIndicators", {
channelId: args.channelId,
userId: args.userId,
username: args.username,
expiresAt,
});
}
// Schedule cleanup
await ctx.scheduler.runAfter(6000, internal.typing.cleanExpired, {});
return null;
},
});
// Stop typing indicator
export const stopTyping = mutation({
args: {
channelId: v.id("channels"),
userId: v.id("userProfiles"),
},
returns: v.null(),
handler: async (ctx, args) => {
const indicators = await ctx.db
.query("typingIndicators")
.withIndex("by_channel", (q) => q.eq("channelId", args.channelId))
.collect();
const mine = indicators.find((t) => t.userId === args.userId);
if (mine) {
await ctx.db.delete(mine._id);
}
return null;
},
});
// Get typing users for a channel (reactive!)
export const getTyping = query({
args: { channelId: v.id("channels") },
returns: v.array(
v.object({
userId: v.id("userProfiles"),
username: v.string(),
})
),
handler: async (ctx, args) => {
const now = Date.now();
const indicators = await ctx.db
.query("typingIndicators")
.withIndex("by_channel", (q) => q.eq("channelId", args.channelId))
.collect();
return indicators
.filter((t) => t.expiresAt > now)
.map((t) => ({
userId: t.userId,
username: t.username,
}));
},
});
// Internal: clean expired typing indicators
export const cleanExpired = internalMutation({
args: {},
returns: v.null(),
handler: async (ctx) => {
const now = Date.now();
const all = await ctx.db.query("typingIndicators").collect();
for (const t of all) {
if (t.expiresAt <= now) {
await ctx.db.delete(t._id);
}
}
return null;
},
});

34
convex/voice.ts Normal file
View File

@@ -0,0 +1,34 @@
"use node";
import { action } from "./_generated/server";
import { v } from "convex/values";
import { AccessToken } from "livekit-server-sdk";
// Generate LiveKit token for voice channel
export const getToken = action({
args: {
channelId: v.string(),
userId: v.string(),
username: v.string(),
},
returns: v.object({ token: v.string() }),
handler: async (_ctx, args) => {
const apiKey = process.env.LIVEKIT_API_KEY || "devkey";
const apiSecret = process.env.LIVEKIT_API_SECRET || "secret";
const at = new AccessToken(apiKey, apiSecret, {
identity: args.userId,
name: args.username,
});
at.addGrant({
roomJoin: true,
room: args.channelId,
canPublish: true,
canSubscribe: true,
});
const token = await at.toJwt();
return { token };
},
});

123
convex/voiceState.ts Normal file
View File

@@ -0,0 +1,123 @@
import { query, mutation } from "./_generated/server";
import { v } from "convex/values";
// Join voice channel
export const join = mutation({
args: {
channelId: v.id("channels"),
userId: v.id("userProfiles"),
username: v.string(),
isMuted: v.boolean(),
isDeafened: v.boolean(),
},
returns: v.null(),
handler: async (ctx, args) => {
// Remove from any other voice channel first
const existing = await ctx.db
.query("voiceStates")
.withIndex("by_user", (q) => q.eq("userId", args.userId))
.collect();
for (const vs of existing) {
await ctx.db.delete(vs._id);
}
// Add to new channel
await ctx.db.insert("voiceStates", {
channelId: args.channelId,
userId: args.userId,
username: args.username,
isMuted: args.isMuted,
isDeafened: args.isDeafened,
isScreenSharing: false,
});
return null;
},
});
// Leave voice channel
export const leave = mutation({
args: {
userId: v.id("userProfiles"),
},
returns: v.null(),
handler: async (ctx, args) => {
const existing = await ctx.db
.query("voiceStates")
.withIndex("by_user", (q) => q.eq("userId", args.userId))
.collect();
for (const vs of existing) {
await ctx.db.delete(vs._id);
}
return null;
},
});
// Update mute/deafen/screenshare state
export const updateState = mutation({
args: {
userId: v.id("userProfiles"),
isMuted: v.optional(v.boolean()),
isDeafened: v.optional(v.boolean()),
isScreenSharing: v.optional(v.boolean()),
},
returns: v.null(),
handler: async (ctx, args) => {
const existing = await ctx.db
.query("voiceStates")
.withIndex("by_user", (q) => q.eq("userId", args.userId))
.collect();
if (existing.length > 0) {
const updates: Record<string, boolean> = {};
if (args.isMuted !== undefined) updates.isMuted = args.isMuted;
if (args.isDeafened !== undefined) updates.isDeafened = args.isDeafened;
if (args.isScreenSharing !== undefined)
updates.isScreenSharing = args.isScreenSharing;
await ctx.db.patch(existing[0]._id, updates);
}
return null;
},
});
// Get all voice states (reactive!)
export const getAll = query({
args: {},
returns: v.any(),
handler: async (ctx) => {
const states = await ctx.db.query("voiceStates").collect();
// Group by channel
const grouped: Record<
string,
Array<{
userId: string;
username: string;
isMuted: boolean;
isDeafened: boolean;
isScreenSharing: boolean;
}>
> = {};
for (const s of states) {
const channelId = s.channelId;
if (!grouped[channelId]) {
grouped[channelId] = [];
}
grouped[channelId].push({
userId: s.userId,
username: s.username,
isMuted: s.isMuted,
isDeafened: s.isDeafened,
isScreenSharing: s.isScreenSharing,
});
}
return grouped;
},
});

30
livekit.yaml Normal file
View File

@@ -0,0 +1,30 @@
# LiveKit Server Configuration (local development)
#
# Usage:
# livekit-server --config livekit.yaml
#
# Or with Docker:
# docker run --rm -p 7880:7880 -p 50000-60000:50000-60000/udp \
# -v $(pwd)/livekit.yaml:/etc/livekit.yaml \
# livekit/livekit-server --config /etc/livekit.yaml
# Ports
port: 7880 # WebSocket/HTTP (matches VITE_LIVEKIT_URL=ws://localhost:7880)
rtc:
port_range_start: 50000
port_range_end: 50100
use_external_ip: false # false for local dev; set true for production
# API credentials (must match LIVEKIT_API_KEY / LIVEKIT_API_SECRET env vars)
keys:
devkey: oEygk6X4iSnDLr4HbjIY21ewcvLH492qtj
prod_key: oEygk6X4iSnDLr4HbjIY21ewcvLH492qtj
# Logging
logging:
level: info
# Room defaults
room:
empty_timeout: 60 # seconds before empty room is destroyed
max_participants: 50

616
package-lock.json generated
View File

@@ -4,7 +4,621 @@
"requires": true,
"packages": {
"": {
"name": "discord-clone"
"name": "discord-clone",
"dependencies": {
"convex": "^1.31.2",
"livekit-server-sdk": "^2.15.0"
}
},
"node_modules/@bufbuild/protobuf": {
"version": "1.10.1",
"resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-1.10.1.tgz",
"integrity": "sha512-wJ8ReQbHxsAfXhrf9ixl0aYbZorRuOWpBNzm8pL8ftmSxQx/wnJD5Eg861NwJU/czy2VXFIebCeZnZrI9rktIQ==",
"license": "(Apache-2.0 AND BSD-3-Clause)"
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.0.tgz",
"integrity": "sha512-KuZrd2hRjz01y5JK9mEBSD3Vj3mbCvemhT466rSuJYeE/hjuBrHfjjcjMdTm/sz7au+++sdbJZJmuBwQLuw68A==",
"cpu": [
"ppc64"
],
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.0.tgz",
"integrity": "sha512-j67aezrPNYWJEOHUNLPj9maeJte7uSMM6gMoxfPC9hOg8N02JuQi/T7ewumf4tNvJadFkvLZMlAq73b9uwdMyQ==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.0.tgz",
"integrity": "sha512-CC3vt4+1xZrs97/PKDkl0yN7w8edvU2vZvAFGD16n9F0Cvniy5qvzRXjfO1l94efczkkQE6g1x0i73Qf5uthOQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.0.tgz",
"integrity": "sha512-wurMkF1nmQajBO1+0CJmcN17U4BP6GqNSROP8t0X/Jiw2ltYGLHpEksp9MpoBqkrFR3kv2/te6Sha26k3+yZ9Q==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.0.tgz",
"integrity": "sha512-uJOQKYCcHhg07DL7i8MzjvS2LaP7W7Pn/7uA0B5S1EnqAirJtbyw4yC5jQ5qcFjHK9l6o/MX9QisBg12kNkdHg==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.0.tgz",
"integrity": "sha512-8mG6arH3yB/4ZXiEnXof5MK72dE6zM9cDvUcPtxhUZsDjESl9JipZYW60C3JGreKCEP+p8P/72r69m4AZGJd5g==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.0.tgz",
"integrity": "sha512-9FHtyO988CwNMMOE3YIeci+UV+x5Zy8fI2qHNpsEtSF83YPBmE8UWmfYAQg6Ux7Gsmd4FejZqnEUZCMGaNQHQw==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.0.tgz",
"integrity": "sha512-zCMeMXI4HS/tXvJz8vWGexpZj2YVtRAihHLk1imZj4efx1BQzN76YFeKqlDr3bUWI26wHwLWPd3rwh6pe4EV7g==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.0.tgz",
"integrity": "sha512-t76XLQDpxgmq2cNXKTVEB7O7YMb42atj2Re2Haf45HkaUpjM2J0UuJZDuaGbPbamzZ7bawyGFUkodL+zcE+jvQ==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.0.tgz",
"integrity": "sha512-AS18v0V+vZiLJyi/4LphvBE+OIX682Pu7ZYNsdUHyUKSoRwdnOsMf6FDekwoAFKej14WAkOef3zAORJgAtXnlQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.0.tgz",
"integrity": "sha512-Mz1jxqm/kfgKkc/KLHC5qIujMvnnarD9ra1cEcrs7qshTUSksPihGrWHVG5+osAIQ68577Zpww7SGapmzSt4Nw==",
"cpu": [
"ia32"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.0.tgz",
"integrity": "sha512-QbEREjdJeIreIAbdG2hLU1yXm1uu+LTdzoq1KCo4G4pFOLlvIspBm36QrQOar9LFduavoWX2msNFAAAY9j4BDg==",
"cpu": [
"loong64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.0.tgz",
"integrity": "sha512-sJz3zRNe4tO2wxvDpH/HYJilb6+2YJxo/ZNbVdtFiKDufzWq4JmKAiHy9iGoLjAV7r/W32VgaHGkk35cUXlNOg==",
"cpu": [
"mips64el"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.0.tgz",
"integrity": "sha512-z9N10FBD0DCS2dmSABDBb5TLAyF1/ydVb+N4pi88T45efQ/w4ohr/F/QYCkxDPnkhkp6AIpIcQKQ8F0ANoA2JA==",
"cpu": [
"ppc64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.0.tgz",
"integrity": "sha512-pQdyAIZ0BWIC5GyvVFn5awDiO14TkT/19FTmFcPdDec94KJ1uZcmFs21Fo8auMXzD4Tt+diXu1LW1gHus9fhFQ==",
"cpu": [
"riscv64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.0.tgz",
"integrity": "sha512-hPlRWR4eIDDEci953RI1BLZitgi5uqcsjKMxwYfmi4LcwyWo2IcRP+lThVnKjNtk90pLS8nKdroXYOqW+QQH+w==",
"cpu": [
"s390x"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.0.tgz",
"integrity": "sha512-1hBWx4OUJE2cab++aVZ7pObD6s+DK4mPGpemtnAORBvb5l/g5xFGk0vc0PjSkrDs0XaXj9yyob3d14XqvnQ4gw==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.0.tgz",
"integrity": "sha512-6m0sfQfxfQfy1qRuecMkJlf1cIzTOgyaeXaiVaaki8/v+WB+U4hc6ik15ZW6TAllRlg/WuQXxWj1jx6C+dfy3w==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.0.tgz",
"integrity": "sha512-xbbOdfn06FtcJ9d0ShxxvSn2iUsGd/lgPIO2V3VZIPDbEaIj1/3nBBe1AwuEZKXVXkMmpr6LUAgMkLD/4D2PPA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.0.tgz",
"integrity": "sha512-fWgqR8uNbCQ/GGv0yhzttj6sU/9Z5/Sv/VGU3F5OuXK6J6SlriONKrQ7tNlwBrJZXRYk5jUhuWvF7GYzGguBZQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.0.tgz",
"integrity": "sha512-aCwlRdSNMNxkGGqQajMUza6uXzR/U0dIl1QmLjPtRbLOx3Gy3otfFu/VjATy4yQzo9yFDGTxYDo1FfAD9oRD2A==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openharmony-arm64": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.0.tgz",
"integrity": "sha512-nyvsBccxNAsNYz2jVFYwEGuRRomqZ149A39SHWk4hV0jWxKM0hjBPm3AmdxcbHiFLbBSwG6SbpIcUbXjgyECfA==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.0.tgz",
"integrity": "sha512-Q1KY1iJafM+UX6CFEL+F4HRTgygmEW568YMqDA5UV97AuZSm21b7SXIrRJDwXWPzr8MGr75fUZPV67FdtMHlHA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.0.tgz",
"integrity": "sha512-W1eyGNi6d+8kOmZIwi/EDjrL9nxQIQ0MiGqe/AWc6+IaHloxHSGoeRgDRKHFISThLmsewZ5nHFvGFWdBYlgKPg==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.0.tgz",
"integrity": "sha512-30z1aKL9h22kQhilnYkORFYt+3wp7yZsHWus+wSKAJR8JtdfI76LJ4SBdMsCopTR3z/ORqVu5L1vtnHZWVj4cQ==",
"cpu": [
"ia32"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.0.tgz",
"integrity": "sha512-aIitBcjQeyOhMTImhLZmtxfdOcuNRpwlPNmlFKPcHQYPhEssw75Cl1TSXJXpMkzaua9FUetx/4OQKq7eJul5Cg==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@livekit/protocol": {
"version": "1.44.0",
"resolved": "https://registry.npmjs.org/@livekit/protocol/-/protocol-1.44.0.tgz",
"integrity": "sha512-/vfhDUGcUKO8Q43r6i+5FrDhl5oZjm/X3U4x2Iciqvgn5C8qbj+57YPcWSJ1kyIZm5Cm6AV2nAPjMm3ETD/iyg==",
"license": "Apache-2.0",
"dependencies": {
"@bufbuild/protobuf": "^1.10.0"
}
},
"node_modules/camelcase": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-8.0.0.tgz",
"integrity": "sha512-8WB3Jcas3swSvjIeA2yvCJ+Miyz5l1ZmB6HFb9R1317dt9LCQoswg/BGrmAmkWVEszSrrg4RwmO46qIm2OEnSA==",
"license": "MIT",
"engines": {
"node": ">=16"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/camelcase-keys": {
"version": "9.1.3",
"resolved": "https://registry.npmjs.org/camelcase-keys/-/camelcase-keys-9.1.3.tgz",
"integrity": "sha512-Rircqi9ch8AnZscQcsA1C47NFdaO3wukpmIRzYcDOrmvgt78hM/sj5pZhZNec2NM12uk5vTwRHZ4anGcrC4ZTg==",
"license": "MIT",
"dependencies": {
"camelcase": "^8.0.0",
"map-obj": "5.0.0",
"quick-lru": "^6.1.1",
"type-fest": "^4.3.2"
},
"engines": {
"node": ">=16"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/convex": {
"version": "1.31.7",
"resolved": "https://registry.npmjs.org/convex/-/convex-1.31.7.tgz",
"integrity": "sha512-PtNMe1mAIOvA8Yz100QTOaIdgt2rIuWqencVXrb4McdhxBHZ8IJ1eXTnrgCC9HydyilGT1pOn+KNqT14mqn9fQ==",
"license": "Apache-2.0",
"dependencies": {
"esbuild": "0.27.0",
"prettier": "^3.0.0"
},
"bin": {
"convex": "bin/main.js"
},
"engines": {
"node": ">=18.0.0",
"npm": ">=7.0.0"
},
"peerDependencies": {
"@auth0/auth0-react": "^2.0.1",
"@clerk/clerk-react": "^4.12.8 || ^5.0.0",
"react": "^18.0.0 || ^19.0.0-0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@auth0/auth0-react": {
"optional": true
},
"@clerk/clerk-react": {
"optional": true
},
"react": {
"optional": true
}
}
},
"node_modules/esbuild": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.0.tgz",
"integrity": "sha512-jd0f4NHbD6cALCyGElNpGAOtWxSq46l9X/sWB0Nzd5er4Kz2YTm+Vl0qKFT9KUJvD8+fiO8AvoHhFvEatfVixA==",
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.27.0",
"@esbuild/android-arm": "0.27.0",
"@esbuild/android-arm64": "0.27.0",
"@esbuild/android-x64": "0.27.0",
"@esbuild/darwin-arm64": "0.27.0",
"@esbuild/darwin-x64": "0.27.0",
"@esbuild/freebsd-arm64": "0.27.0",
"@esbuild/freebsd-x64": "0.27.0",
"@esbuild/linux-arm": "0.27.0",
"@esbuild/linux-arm64": "0.27.0",
"@esbuild/linux-ia32": "0.27.0",
"@esbuild/linux-loong64": "0.27.0",
"@esbuild/linux-mips64el": "0.27.0",
"@esbuild/linux-ppc64": "0.27.0",
"@esbuild/linux-riscv64": "0.27.0",
"@esbuild/linux-s390x": "0.27.0",
"@esbuild/linux-x64": "0.27.0",
"@esbuild/netbsd-arm64": "0.27.0",
"@esbuild/netbsd-x64": "0.27.0",
"@esbuild/openbsd-arm64": "0.27.0",
"@esbuild/openbsd-x64": "0.27.0",
"@esbuild/openharmony-arm64": "0.27.0",
"@esbuild/sunos-x64": "0.27.0",
"@esbuild/win32-arm64": "0.27.0",
"@esbuild/win32-ia32": "0.27.0",
"@esbuild/win32-x64": "0.27.0"
}
},
"node_modules/jose": {
"version": "5.10.0",
"resolved": "https://registry.npmjs.org/jose/-/jose-5.10.0.tgz",
"integrity": "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/livekit-server-sdk": {
"version": "2.15.0",
"resolved": "https://registry.npmjs.org/livekit-server-sdk/-/livekit-server-sdk-2.15.0.tgz",
"integrity": "sha512-HmzjWnwEwwShu8yUf7VGFXdc+BuMJR5pnIY4qsdlhqI9d9wDgq+4cdTEHg0NEBaiGnc6PCOBiaTYgmIyVJ0S9w==",
"license": "Apache-2.0",
"dependencies": {
"@bufbuild/protobuf": "^1.10.1",
"@livekit/protocol": "^1.43.1",
"camelcase-keys": "^9.0.0",
"jose": "^5.1.2"
},
"engines": {
"node": ">=18"
}
},
"node_modules/map-obj": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/map-obj/-/map-obj-5.0.0.tgz",
"integrity": "sha512-2L3MIgJynYrZ3TYMriLDLWocz15okFakV6J12HXvMXDHui2x/zgChzg1u9mFFGbbGWE+GsLpQByt4POb9Or+uA==",
"license": "MIT",
"engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/prettier": {
"version": "3.8.1",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz",
"integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==",
"license": "MIT",
"bin": {
"prettier": "bin/prettier.cjs"
},
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/prettier/prettier?sponsor=1"
}
},
"node_modules/quick-lru": {
"version": "6.1.2",
"resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-6.1.2.tgz",
"integrity": "sha512-AAFUA5O1d83pIHEhJwWCq/RQcRukCkn/NSm2QsTEMle5f2hP0ChI2+3Xb051PZCkLryI/Ir1MVKviT2FIloaTQ==",
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/type-fest": {
"version": "4.41.0",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz",
"integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==",
"license": "(MIT OR CC0-1.0)",
"engines": {
"node": ">=16"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
}
}
}

View File

@@ -2,12 +2,16 @@
"name": "discord-clone",
"private": true,
"scripts": {
"backend": "node Backend/server.js",
"frontend": "cd Frontend/Electron && npm run dev",
"electron": "cd Frontend/Electron && npm run electron:dev",
"electron:build": "cd Frontend/Electron && npm run electron:build",
"install:backend": "cd Backend && npm install",
"install:frontend": "cd Frontend/Electron && npm install",
"install:all": "cd Backend && npm install && cd ../../Frontend/Electron && npm install"
"dev": "npx convex dev",
"backend": "npx convex dev",
"frontend": "cd FrontEnd/Electron && npm run dev",
"electron": "cd FrontEnd/Electron && npm run electron:dev",
"electron:build": "cd FrontEnd/Electron && npm run electron:build",
"install:frontend": "cd FrontEnd/Electron && npm install",
"install:all": "npm install && cd FrontEnd/Electron && npm install"
},
"dependencies": {
"convex": "^1.31.2",
"livekit-server-sdk": "^2.15.0"
}
}