feat: Implement core Discord clone functionality including Convex backend, Electron frontend, and UI components for chat, voice, and settings.
This commit is contained in:
@@ -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
3
.gitignore
vendored
@@ -1,4 +1,5 @@
|
||||
node_modules
|
||||
.env
|
||||
.env.local
|
||||
.vscode
|
||||
./backend/uploads
|
||||
./backend/uploads/
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
1735
Backend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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)
|
||||
);
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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}`);
|
||||
});
|
||||
75
CLAUDE.md
75
CLAUDE.md
@@ -1 +1,74 @@
|
||||
**Update this file when making significant changes.**
|
||||
**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
307
CONVEX_EXAMPLES.md
Normal 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
254
CONVEX_RULES.md
Normal 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
|
||||
594
Frontend/Electron/package-lock.json
generated
594
Frontend/Electron/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
1
Frontend/Electron/src/assets/icons/spoiler.svg
Normal file
1
Frontend/Electron/src/assets/icons/spoiler.svg
Normal 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 |
@@ -1,49 +1,41 @@
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
@@ -71,8 +63,8 @@ const ChannelSettingsModal = ({ channel, onClose, onRename, onDelete }) => {
|
||||
}}>
|
||||
{channel.name} Text Channels
|
||||
</div>
|
||||
|
||||
<div
|
||||
|
||||
<div
|
||||
onClick={() => setActiveTab('Overview')}
|
||||
style={{
|
||||
padding: '6px 10px',
|
||||
@@ -86,11 +78,11 @@ const ChannelSettingsModal = ({ channel, onClose, onRename, onDelete }) => {
|
||||
>
|
||||
Overview
|
||||
</div>
|
||||
|
||||
|
||||
<div style={{ height: '1px', backgroundColor: '#3f4147', margin: '8px 0' }} />
|
||||
|
||||
<div
|
||||
onClick={() => setActiveTab('Delete')} // Simplify: Just switch content or trigger? UI screenshot implies a tab
|
||||
<div
|
||||
onClick={() => setActiveTab('Delete')}
|
||||
style={{
|
||||
padding: '6px 10px',
|
||||
borderRadius: '4px',
|
||||
@@ -113,7 +105,7 @@ const ChannelSettingsModal = ({ channel, onClose, onRename, onDelete }) => {
|
||||
<h2 style={{ color: 'white', margin: 0 }}>
|
||||
{activeTab === 'Delete' ? 'Delete Channel' : 'Overview'}
|
||||
</h2>
|
||||
<button
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
background: 'transparent',
|
||||
@@ -143,8 +135,8 @@ const ChannelSettingsModal = ({ channel, onClose, onRename, onDelete }) => {
|
||||
}}>
|
||||
Channel Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
style={{
|
||||
@@ -205,8 +197,8 @@ const ChannelSettingsModal = ({ channel, onClose, onRename, onDelete }) => {
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 0.5 }}>
|
||||
|
||||
<div style={{ flex: 0.5 }}>
|
||||
{/* Right side spacer like real Discord */}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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(() => {
|
||||
|
||||
@@ -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' }}>
|
||||
@@ -46,7 +43,7 @@ const FriendsView = ({ onOpenDM }) => {
|
||||
</svg>
|
||||
Friends
|
||||
</div>
|
||||
|
||||
|
||||
<div style={{ display: 'flex', gap: '16px' }}>
|
||||
{['Online', 'All'].map(tab => (
|
||||
<div
|
||||
@@ -81,7 +78,7 @@ const FriendsView = ({ onOpenDM }) => {
|
||||
{/* Friends List */}
|
||||
<div style={{ flex: 1, overflowY: 'auto', padding: '0 20px' }}>
|
||||
{filteredUsers.map(user => (
|
||||
<div
|
||||
<div
|
||||
key={user.id}
|
||||
style={{
|
||||
display: 'flex',
|
||||
@@ -121,7 +118,7 @@ const FriendsView = ({ onOpenDM }) => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{/* Actions */}
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<div
|
||||
|
||||
@@ -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('');
|
||||
@@ -7,7 +9,7 @@ const GifPicker = ({ onSelect, onClose, initialTab, currentTab, onTabChange }) =
|
||||
const [results, setResults] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [internalActiveTab, setInternalActiveTab] = useState(initialTab || 'GIFs');
|
||||
|
||||
|
||||
// Resolve effective active tab
|
||||
const activeTab = currentTab !== undefined ? currentTab : internalActiveTab;
|
||||
const setActiveTab = (tab) => {
|
||||
@@ -19,22 +21,22 @@ 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);
|
||||
})
|
||||
.catch(err => console.error('Failed to load categories', err));
|
||||
|
||||
|
||||
// Auto focus
|
||||
if(inputRef.current) inputRef.current.focus();
|
||||
|
||||
// Load Emoji categories
|
||||
setEmojiCategories(CategorizedEmojis);
|
||||
|
||||
|
||||
// Initialize collapsed state (all true)
|
||||
const initialCollapsed = {};
|
||||
Object.keys(CategorizedEmojis).forEach(cat => initialCollapsed[cat] = true);
|
||||
@@ -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);
|
||||
@@ -75,7 +76,7 @@ const GifPicker = ({ onSelect, onClose, initialTab, currentTab, onTabChange }) =
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
<div
|
||||
className="gif-picker"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
@@ -91,12 +92,12 @@ 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' }}>
|
||||
{['GIFs', 'Stickers', 'Emoji'].map(tab => (
|
||||
<button
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => setActiveTab(tab)}
|
||||
style={{
|
||||
@@ -164,9 +165,9 @@ const GifPicker = ({ onSelect, onClose, initialTab, currentTab, onTabChange }) =
|
||||
(search || results.length > 0) ? (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '8px' }}>
|
||||
{results.map(gif => (
|
||||
<img
|
||||
key={gif.id}
|
||||
src={gif.media_formats?.tinygif?.url || gif.media_formats?.gif?.url}
|
||||
<img
|
||||
key={gif.id}
|
||||
src={gif.media_formats?.tinygif?.url || gif.media_formats?.gif?.url}
|
||||
alt={gif.title}
|
||||
style={{ width: '100%', borderRadius: '4px', cursor: 'pointer' }}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
@@ -178,10 +179,10 @@ const GifPicker = ({ onSelect, onClose, initialTab, currentTab, onTabChange }) =
|
||||
) : (
|
||||
// GIF Categories
|
||||
<div>
|
||||
<div style={{
|
||||
backgroundImage: 'linear-gradient(to right, #4a90e2, #9013fe)',
|
||||
borderRadius: '4px',
|
||||
padding: '20px',
|
||||
<div style={{
|
||||
backgroundImage: 'linear-gradient(to right, #4a90e2, #9013fe)',
|
||||
borderRadius: '4px',
|
||||
padding: '20px',
|
||||
marginBottom: '12px',
|
||||
color: '#fff',
|
||||
fontWeight: 'bold',
|
||||
@@ -194,7 +195,7 @@ const GifPicker = ({ onSelect, onClose, initialTab, currentTab, onTabChange }) =
|
||||
{/* Grid of Categories */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '8px' }}>
|
||||
{categories.map(cat => (
|
||||
<div
|
||||
<div
|
||||
key={cat.name}
|
||||
onClick={() => handleCategoryClick(cat.name)}
|
||||
style={{
|
||||
@@ -206,11 +207,11 @@ const GifPicker = ({ onSelect, onClose, initialTab, currentTab, onTabChange }) =
|
||||
backgroundColor: '#202225'
|
||||
}}
|
||||
>
|
||||
<video
|
||||
src={cat.src}
|
||||
autoPlay
|
||||
loop
|
||||
muted
|
||||
<video
|
||||
src={cat.src}
|
||||
autoPlay
|
||||
loop
|
||||
muted
|
||||
style={{ width: '100%', height: '100%', objectFit: 'cover', opacity: 0.6 }}
|
||||
/>
|
||||
<div style={{
|
||||
@@ -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
|
||||
<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,13 +255,12 @@ const GifPicker = ({ onSelect, onClose, initialTab, currentTab, onTabChange }) =
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
// Emoji Categories
|
||||
// Emoji Categories
|
||||
Object.entries(emojiCategories).map(([category, emojis]) => (
|
||||
<div key={category} style={{ marginBottom: '8px' }}>
|
||||
<div
|
||||
<div
|
||||
onClick={() => toggleCategory(category)}
|
||||
style={{
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
cursor: 'pointer',
|
||||
@@ -271,17 +271,17 @@ const GifPicker = ({ onSelect, onClose, initialTab, currentTab, onTabChange }) =
|
||||
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#40444b'}
|
||||
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="#b9bbbe"
|
||||
strokeWidth="3"
|
||||
strokeLinecap="round"
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="#b9bbbe"
|
||||
strokeWidth="3"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
style={{
|
||||
style={{
|
||||
marginRight: '8px',
|
||||
transform: collapsedCategories[category] ? 'rotate(-90deg)' : 'rotate(0deg)',
|
||||
transition: 'transform 0.2s'
|
||||
@@ -289,10 +289,10 @@ const GifPicker = ({ onSelect, onClose, initialTab, currentTab, onTabChange }) =
|
||||
>
|
||||
<polyline points="6 9 12 15 18 9"></polyline>
|
||||
</svg>
|
||||
<h3 style={{
|
||||
color: '#b9bbbe',
|
||||
fontSize: '12px',
|
||||
textTransform: 'uppercase',
|
||||
<h3 style={{
|
||||
color: '#b9bbbe',
|
||||
fontSize: '12px',
|
||||
textTransform: 'uppercase',
|
||||
fontWeight: 700,
|
||||
margin: 0
|
||||
}}>
|
||||
@@ -302,7 +302,7 @@ const GifPicker = ({ onSelect, onClose, initialTab, currentTab, onTabChange }) =
|
||||
{!collapsedCategories[category] && (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(8, 1fr)', gap: '4px' }}>
|
||||
{emojis.map((emoji, idx) => (
|
||||
<div
|
||||
<div
|
||||
key={idx}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => onSelect && onSelect({ type: 'emoji', ...emoji })}
|
||||
@@ -311,10 +311,10 @@ const GifPicker = ({ onSelect, onClose, initialTab, currentTab, onTabChange }) =
|
||||
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#40444b'}
|
||||
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}
|
||||
>
|
||||
<img
|
||||
src={emoji.src}
|
||||
alt={emoji.name}
|
||||
style={{ width: '32px', height: '32px' }}
|
||||
<img
|
||||
src={emoji.src}
|
||||
alt={emoji.name}
|
||||
style={{ width: '32px', height: '32px' }}
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -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
|
||||
@@ -100,7 +75,7 @@ const ServerSettingsModal = ({ onClose }) => {
|
||||
Server Settings
|
||||
</div>
|
||||
{['Overview', 'Roles', 'Members'].map(tab => (
|
||||
<div
|
||||
<div
|
||||
key={tab}
|
||||
onClick={() => setActiveTab(tab)}
|
||||
style={{
|
||||
@@ -128,12 +103,12 @@ const ServerSettingsModal = ({ onClose }) => {
|
||||
)}
|
||||
</div>
|
||||
{roles.filter(r => r.name !== 'Owner').map(r => (
|
||||
<div
|
||||
key={r.id}
|
||||
<div
|
||||
key={r._id}
|
||||
onClick={() => setSelectedRole(r)}
|
||||
style={{
|
||||
padding: '6px',
|
||||
backgroundColor: selectedRole?.id === r.id ? '#40444b' : 'transparent',
|
||||
style={{
|
||||
padding: '6px',
|
||||
backgroundColor: selectedRole?._id === r._id ? '#40444b' : 'transparent',
|
||||
borderRadius: '4px', cursor: 'pointer', color: r.color || '#b9bbbe',
|
||||
display: 'flex', alignItems: 'center'
|
||||
}}
|
||||
@@ -148,20 +123,20 @@ const ServerSettingsModal = ({ onClose }) => {
|
||||
{selectedRole ? (
|
||||
<div className="no-scrollbar" style={{ flex: 1, overflowY: 'auto' }}>
|
||||
<h2 style={{ color: 'white', marginTop: 0 }}>Edit Role - {selectedRole.name}</h2>
|
||||
|
||||
|
||||
<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 })}
|
||||
<input
|
||||
value={selectedRole.name}
|
||||
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 }}
|
||||
/>
|
||||
|
||||
<label style={{ display: 'block', color: '#b9bbbe', fontSize: '12px', fontWeight: '700', marginBottom: 8 }}>ROLE COLOR</label>
|
||||
<input
|
||||
<input
|
||||
type="color"
|
||||
value={selectedRole.color}
|
||||
onChange={(e) => handleUpdateRole(selectedRole.id, { color: e.target.value })}
|
||||
value={selectedRole.color}
|
||||
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 }}
|
||||
/>
|
||||
@@ -170,12 +145,12 @@ const ServerSettingsModal = ({ onClose }) => {
|
||||
{['manage_channels', 'manage_roles', 'create_invite', 'embed_links', 'attach_files'].map(perm => (
|
||||
<div key={perm} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10, paddingBottom: 10, borderBottom: '1px solid #3f4147' }}>
|
||||
<span style={{ color: 'white', textTransform: 'capitalize' }}>{perm.replace('_', ' ')}</span>
|
||||
<input
|
||||
<input
|
||||
type="checkbox"
|
||||
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,23 +183,22 @@ 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)}
|
||||
style={{
|
||||
width: 16, height: 16, borderRadius: '50%',
|
||||
<button
|
||||
key={r._id}
|
||||
onClick={() => handleAssignRole(r._id, m.id, !hasRole)}
|
||||
style={{
|
||||
width: 16, height: 16, borderRadius: '50%',
|
||||
border: `2px solid ${r.color}`,
|
||||
background: hasRole ? r.color : 'transparent',
|
||||
cursor: 'pointer'
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
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 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';
|
||||
import defeanIcon from '../assets/icons/defean.svg';
|
||||
import defeanedIcon from '../assets/icons/defeaned.svg';
|
||||
import settingsIcon from '../assets/icons/settings.svg';
|
||||
import voiceIcon from '../assets/icons/voice.svg';
|
||||
import voiceIcon from '../assets/icons/voice.svg';
|
||||
import disconnectIcon from '../assets/icons/disconnect.svg';
|
||||
import cameraIcon from '../assets/icons/camera.svg';
|
||||
import screenIcon from '../assets/icons/screen.svg';
|
||||
@@ -24,28 +26,26 @@ const ColoredIcon = ({ src, color, size = '20px' }) => (
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0 // Prevent shrinking in flex containers
|
||||
flexShrink: 0
|
||||
}}>
|
||||
<img
|
||||
src={src}
|
||||
alt=""
|
||||
<img
|
||||
src={src}
|
||||
alt=""
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
transform: 'translateX(-1000px)',
|
||||
filter: `drop-shadow(1000px 0 0 ${color})`
|
||||
}}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
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) => {
|
||||
const colors = ['#5865F2', '#EBA7CD', '#57F287', '#FEE75C', '#EB459E', '#ED4245'];
|
||||
let hash = 0;
|
||||
@@ -70,14 +70,14 @@ const UserControlPanel = ({ username }) => {
|
||||
flexShrink: 0
|
||||
}}>
|
||||
{/* User Info */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
marginRight: 'auto',
|
||||
padding: '4px',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
':hover': { backgroundColor: 'rgba(255,255,255,0.05)' }
|
||||
':hover': { backgroundColor: 'rgba(255,255,255,0.05)' }
|
||||
}}>
|
||||
<div style={{ position: 'relative', marginRight: '8px' }}>
|
||||
<div style={{
|
||||
@@ -118,7 +118,7 @@ const UserControlPanel = ({ username }) => {
|
||||
|
||||
{/* Controls */}
|
||||
<div style={{ display: 'flex' }}>
|
||||
<button
|
||||
<button
|
||||
onClick={toggleMute}
|
||||
title={effectiveMute ? "Unmute" : "Mute"}
|
||||
style={{
|
||||
@@ -132,12 +132,12 @@ const UserControlPanel = ({ username }) => {
|
||||
justifyContent: 'center'
|
||||
}}
|
||||
>
|
||||
<ColoredIcon
|
||||
src={effectiveMute ? mutedIcon : muteIcon}
|
||||
color={effectiveMute ? ICON_COLOR_ACTIVE : ICON_COLOR_DEFAULT}
|
||||
<ColoredIcon
|
||||
src={effectiveMute ? mutedIcon : muteIcon}
|
||||
color={effectiveMute ? ICON_COLOR_ACTIVE : ICON_COLOR_DEFAULT}
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
<button
|
||||
onClick={toggleDeafen}
|
||||
title={isDeafened ? "Undeafen" : "Deafen"}
|
||||
style={{
|
||||
@@ -151,12 +151,12 @@ const UserControlPanel = ({ username }) => {
|
||||
justifyContent: 'center'
|
||||
}}
|
||||
>
|
||||
<ColoredIcon
|
||||
src={isDeafened ? defeanedIcon : defeanIcon}
|
||||
color={isDeafened ? ICON_COLOR_ACTIVE : ICON_COLOR_DEFAULT}
|
||||
<ColoredIcon
|
||||
src={isDeafened ? defeanedIcon : defeanIcon}
|
||||
color={isDeafened ? ICON_COLOR_ACTIVE : ICON_COLOR_DEFAULT}
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
<button
|
||||
title="User Settings"
|
||||
style={{
|
||||
background: 'transparent',
|
||||
@@ -169,9 +169,9 @@ const UserControlPanel = ({ username }) => {
|
||||
justifyContent: 'center'
|
||||
}}
|
||||
>
|
||||
<ColoredIcon
|
||||
src={settingsIcon}
|
||||
color={ICON_COLOR_DEFAULT}
|
||||
<ColoredIcon
|
||||
src={settingsIcon}
|
||||
color={ICON_COLOR_DEFAULT}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
@@ -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('');
|
||||
@@ -222,7 +219,7 @@ const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKe
|
||||
const name = newChannelName.trim();
|
||||
const type = newChannelType;
|
||||
const userId = localStorage.getItem('userId');
|
||||
|
||||
|
||||
if (!userId) {
|
||||
alert("Please login first.");
|
||||
setIsCreating(false);
|
||||
@@ -230,38 +227,27 @@ 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 = [];
|
||||
|
||||
for (const u of users) {
|
||||
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);
|
||||
|
||||
|
||||
batchKeys.push({
|
||||
channelId,
|
||||
userId: u.id,
|
||||
@@ -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)
|
||||
});
|
||||
|
||||
// 5. Notify Everyone (NOW it is safe)
|
||||
await fetch(`http://localhost:3000/api/channels/${channelId}/notify`, { method: 'POST' });
|
||||
// 4. Upload Keys Batch via Convex
|
||||
await convex.mutation(api.channelKeys.uploadKeys, { keys: batchKeys });
|
||||
|
||||
// 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,8 +295,8 @@ 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.");
|
||||
return;
|
||||
@@ -334,33 +309,27 @@ const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKe
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = JSON.stringify({
|
||||
[targetChannelId]: targetKey
|
||||
const payload = JSON.stringify({
|
||||
[targetChannelId]: targetKey
|
||||
});
|
||||
|
||||
// 3. Encrypt Payload
|
||||
const encrypted = await window.cryptoAPI.encryptData(payload, inviteSecret);
|
||||
|
||||
const blob = JSON.stringify({
|
||||
c: encrypted.content,
|
||||
t: encrypted.tag,
|
||||
iv: encrypted.iv
|
||||
|
||||
const blob = JSON.stringify({
|
||||
c: encrypted.content,
|
||||
t: encrypted.tag,
|
||||
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);
|
||||
@@ -375,7 +344,7 @@ const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKe
|
||||
// Screen Share Handler
|
||||
const handleScreenShareSelect = async (selection) => {
|
||||
if (!room) return;
|
||||
|
||||
|
||||
try {
|
||||
// Unpublish existing screen share if any
|
||||
if (room.localParticipant.isScreenShareEnabled) {
|
||||
@@ -409,9 +378,9 @@ const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKe
|
||||
name: 'screen_share',
|
||||
source: Track.Source.ScreenShare
|
||||
});
|
||||
|
||||
|
||||
setScreenSharing(true);
|
||||
|
||||
|
||||
track.onended = () => {
|
||||
setScreenSharing(false);
|
||||
room.localParticipant.setScreenShareEnabled(false).catch(console.error);
|
||||
@@ -423,7 +392,7 @@ const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKe
|
||||
alert("Failed to share screen: " + err.message);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// Toggle Modal instead of direct toggle
|
||||
const handleScreenShareClick = () => {
|
||||
if (room?.localParticipant.isScreenShareEnabled) {
|
||||
@@ -440,31 +409,29 @@ const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKe
|
||||
<div style={{ display: 'flex', flex: 1, minHeight: 0 }}>
|
||||
<div className="server-list">
|
||||
{/* Home Button */}
|
||||
<div
|
||||
<div
|
||||
className={`server-icon ${view === 'me' ? 'active' : ''}`}
|
||||
onClick={() => onViewChange('me')}
|
||||
style={{
|
||||
style={{
|
||||
backgroundColor: view === 'me' ? '#5865F2' : '#36393f',
|
||||
color: view === 'me' ? '#fff' : '#dcddde',
|
||||
marginBottom: '8px',
|
||||
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
|
||||
<div
|
||||
className={`server-icon ${view === 'server' ? 'active' : ''}`}
|
||||
onClick={() => onViewChange('server')}
|
||||
style={{ cursor: 'pointer' }}
|
||||
>Sc</div>
|
||||
</div>
|
||||
|
||||
|
||||
{/* Channel List Area */}
|
||||
{view === 'me' ? (
|
||||
<div className="channel-list" style={{ flex: 1, overflowY: 'auto', display: 'flex', flexDirection: 'column' }}>
|
||||
@@ -484,7 +451,7 @@ const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKe
|
||||
) : (
|
||||
<div className="channel-list" style={{ flex: 1, overflowY: 'auto' }}>
|
||||
<div className="channel-header" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<span
|
||||
<span
|
||||
style={{ cursor: 'pointer', maxWidth: '120px', overflow: 'hidden', textOverflow: 'ellipsis' }}
|
||||
onClick={() => setIsServerSettingsOpen(true)}
|
||||
title="Server Settings"
|
||||
@@ -492,7 +459,7 @@ const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKe
|
||||
Secure Chat ▾
|
||||
</span>
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<button
|
||||
<button
|
||||
onClick={handleStartCreate}
|
||||
title="Create New Channel"
|
||||
style={{
|
||||
@@ -507,7 +474,7 @@ const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKe
|
||||
>
|
||||
+
|
||||
</button>
|
||||
<button
|
||||
<button
|
||||
onClick={handleCreateInvite}
|
||||
title="Create Invite Link"
|
||||
style={{
|
||||
@@ -523,7 +490,7 @@ const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKe
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{/* Inline Create Channel Input */}
|
||||
{isCreating && (
|
||||
<div style={{ padding: '0 8px', marginBottom: '4px' }}>
|
||||
@@ -561,37 +528,37 @@ 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={{
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
style={{
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
paddingRight: '8px' // Space for icon
|
||||
paddingRight: '8px'
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', overflow: 'hidden', flex: 1 }}>
|
||||
{channel.type === 'voice' ? (
|
||||
<div style={{ marginRight: 6 }}>
|
||||
<ColoredIcon
|
||||
src={voiceIcon}
|
||||
<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>
|
||||
@@ -602,7 +569,7 @@ const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKe
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="channel-settings-icon"
|
||||
className="channel-settings-icon"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setEditingChannel(channel);
|
||||
@@ -621,20 +588,18 @@ 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%',
|
||||
backgroundColor: '#5865F2',
|
||||
backgroundColor: '#5865F2',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
marginRight: 8, fontSize: 10, color: 'white',
|
||||
boxShadow: activeSpeakers.has(user.userId)
|
||||
? '0 0 0 0px hsl(134.526 calc(1*41.485%) 44.902% /1), inset 0 0 0 2px hsl(134.526 calc(1*41.485%) 44.902% /1), inset 0 0 0 3px color-mix(in oklab, hsl(240 calc(1*7.143%) 10.98% /1) 100%, #000 0%)'
|
||||
boxShadow: activeSpeakers.has(user.userId)
|
||||
? '0 0 0 0px hsl(134.526 calc(1*41.485%) 44.902% /1), inset 0 0 0 2px hsl(134.526 calc(1*41.485%) 44.902% /1), inset 0 0 0 3px color-mix(in oklab, hsl(240 calc(1*7.143%) 10.98% /1) 100%, #000 0%)'
|
||||
: 'none'
|
||||
}}>
|
||||
{user.username.substring(0, 1).toUpperCase()}
|
||||
@@ -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',
|
||||
@@ -695,7 +660,7 @@ const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKe
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 4 }}>
|
||||
<div style={{ color: '#43b581', fontWeight: 'bold', fontSize: 13 }}>Voice Connected</div>
|
||||
<button
|
||||
<button
|
||||
onClick={disconnectVoice}
|
||||
title="Disconnect"
|
||||
style={{
|
||||
@@ -707,7 +672,7 @@ const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKe
|
||||
</div>
|
||||
<div style={{ color: '#dcddde', fontSize: 12, marginBottom: 8 }}>{voiceChannelName} / Secure Chat</div>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
<button
|
||||
<button
|
||||
onClick={() => room?.localParticipant.setCameraEnabled(!room.localParticipant.isCameraEnabled)}
|
||||
title="Turn On Camera"
|
||||
style={{
|
||||
@@ -716,7 +681,7 @@ const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKe
|
||||
>
|
||||
<ColoredIcon src={cameraIcon} color="#b9bbbe" size="20px" />
|
||||
</button>
|
||||
<button
|
||||
<button
|
||||
onClick={handleScreenShareClick}
|
||||
title="Share Screen"
|
||||
style={{
|
||||
@@ -734,8 +699,8 @@ const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKe
|
||||
|
||||
{/* Modals */}
|
||||
{editingChannel && (
|
||||
<ChannelSettingsModal
|
||||
channel={editingChannel}
|
||||
<ChannelSettingsModal
|
||||
channel={editingChannel}
|
||||
onClose={() => setEditingChannel(null)}
|
||||
onRename={onRenameChannel}
|
||||
onDelete={onDeleteChannel}
|
||||
@@ -745,7 +710,7 @@ const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKe
|
||||
<ServerSettingsModal onClose={() => setIsServerSettingsOpen(false)} />
|
||||
)}
|
||||
{isScreenShareModalOpen && (
|
||||
<ScreenShareModal
|
||||
<ScreenShareModal
|
||||
onClose={() => setIsScreenShareModalOpen(false)}
|
||||
onSelectSource={handleScreenShareSelect}
|
||||
/>
|
||||
|
||||
@@ -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,15 +40,15 @@ 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' }}>
|
||||
<div style={{
|
||||
padding: '10px 20px',
|
||||
background: '#1a1b1e',
|
||||
color: 'white',
|
||||
display: 'flex',
|
||||
<div style={{
|
||||
padding: '10px 20px',
|
||||
background: '#1a1b1e',
|
||||
color: 'white',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
borderBottom: '1px solid #2f3136'
|
||||
@@ -59,7 +58,7 @@ const VoiceRoom = ({ channelId, channelName, userId, onDisconnect }) => {
|
||||
<span style={{ fontWeight: 'bold' }}>{channelName}</span>
|
||||
<span style={{ fontSize: 12, color: '#43b581', marginLeft: 8 }}>Connected</span>
|
||||
</div>
|
||||
<button
|
||||
<button
|
||||
onClick={onDisconnect}
|
||||
style={{
|
||||
background: '#ed4245',
|
||||
@@ -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 */}
|
||||
<VideoConference />
|
||||
<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>
|
||||
|
||||
@@ -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';
|
||||
@@ -21,13 +22,15 @@ export const VoiceProvider = ({ children }) => {
|
||||
const [connectionState, setConnectionState] = useState('disconnected');
|
||||
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,52 +50,8 @@ 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;
|
||||
if (activeChannelId === channelId) return;
|
||||
|
||||
if (room) await room.disconnect();
|
||||
|
||||
@@ -101,21 +60,22 @@ 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;
|
||||
await newRoom.localParticipant.setMicrophoneEnabled(shouldEnableMic);
|
||||
@@ -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,88 +130,70 @@ export const VoiceProvider = ({ children }) => {
|
||||
if (room) room.disconnect();
|
||||
};
|
||||
|
||||
const toggleMute = () => {
|
||||
const toggleMute = async () => {
|
||||
const nextState = !isMuted;
|
||||
setIsMuted(nextState);
|
||||
playSound(nextState ? 'mute' : 'unmute');
|
||||
if (room) {
|
||||
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
|
||||
|
||||
@@ -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>,
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,32 +24,32 @@ 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);
|
||||
const decrypted = await window.cryptoAPI.decryptData(blob.c, secret, blob.iv, blob.t);
|
||||
|
||||
|
||||
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');
|
||||
const secret = params.get('key');
|
||||
|
||||
const secret = params.get('key');
|
||||
|
||||
if (code && secret) {
|
||||
console.log('Invite detected in URL');
|
||||
processInvite(code, secret);
|
||||
@@ -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('/');
|
||||
@@ -186,14 +169,14 @@ const Register = () => {
|
||||
<p>Join the secure chat! {inviteKeys ? '(Invite Active)' : ''}</p>
|
||||
</div>
|
||||
{error && <div style={{ color: 'red', marginBottom: 10, textAlign: 'center' }}>{error}</div>}
|
||||
|
||||
|
||||
{/* Manual Invite Input - Fallback for Desktop App */}
|
||||
{!inviteKeys && (
|
||||
<div style={{ marginBottom: '15px' }}>
|
||||
<div style={{ display: 'flex' }}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Paste Invite Link Here..."
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Paste Invite Link Here..."
|
||||
value={inviteLinkInput}
|
||||
onChange={(e) => setInviteLinkInput(e.target.value)}
|
||||
style={{ flex: 1, marginRight: '8px' }}
|
||||
@@ -237,14 +220,14 @@ const Register = () => {
|
||||
<div style={{ textAlign: 'center', marginTop: '20px', color: '#b9bbbe' }}>
|
||||
<p>Registration is Invite-Only.</p>
|
||||
<p style={{ fontSize: '0.9em' }}>Please paste a valid invite link above to proceed.</p>
|
||||
|
||||
|
||||
{/* Backdoor for First User */}
|
||||
<p style={{ marginTop: '20px', fontSize: '0.8em', cursor: 'pointer', color: '#7289da' }} onClick={() => setInviteKeys({})}>
|
||||
(First User / Verify Setup)
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
<div className="auth-footer">
|
||||
Already have an account? <Link to="/">Log In</Link>
|
||||
</div>
|
||||
|
||||
@@ -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
73
convex/_generated/api.d.ts
vendored
Normal 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
23
convex/_generated/api.js
Normal 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
60
convex/_generated/dataModel.d.ts
vendored
Normal 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
143
convex/_generated/server.d.ts
vendored
Normal 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>;
|
||||
93
convex/_generated/server.js
Normal file
93
convex/_generated/server.js
Normal 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
226
convex/auth.ts
Normal 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
72
convex/channelKeys.ts
Normal 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
166
convex/channels.ts
Normal 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
104
convex/dms.ts
Normal 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
20
convex/files.ts
Normal 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
43
convex/gifs.ts
Normal 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
85
convex/invites.ts
Normal 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
111
convex/messages.ts
Normal 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
61
convex/reactions.ts
Normal 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
210
convex/roles.ts
Normal 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
98
convex/schema.ts
Normal 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
11
convex/tsconfig.json
Normal 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
103
convex/typing.ts
Normal 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
34
convex/voice.ts
Normal 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
123
convex/voiceState.ts
Normal 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
30
livekit.yaml
Normal 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
616
package-lock.json
generated
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
18
package.json
18
package.json
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user