feat: Add initial Discord clone application with Convex backend services and Electron React frontend components.

This commit is contained in:
Bryan1029384756
2026-02-10 05:27:10 -06:00
parent 47f173c79b
commit 34e9790db9
29 changed files with 3254 additions and 1398 deletions

View File

@@ -1,6 +1,16 @@
import { query, mutation } from "./_generated/server";
import { v } from "convex/values";
async function sha256Hex(input: string): Promise<string> {
const buffer = await crypto.subtle.digest(
"SHA-256",
new TextEncoder().encode(input)
);
return Array.from(new Uint8Array(buffer))
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
}
// Get salt for a username (returns fake salt for non-existent users)
export const getSalt = query({
args: { username: v.string() },
@@ -16,15 +26,7 @@ export const getSalt = query({
}
// 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("");
const fakeSalt = await sha256Hex("SERVER_SECRET_KEY" + args.username);
return { salt: fakeSalt };
},
});
@@ -55,14 +57,7 @@ export const verifyUser = mutation({
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("");
const hashedDAK = await sha256Hex(args.dak);
if (hashedDAK === user.hashedAuthKey) {
return {
@@ -95,7 +90,6 @@ export const createUserWithProfile = mutation({
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))
@@ -105,17 +99,14 @@ export const createUserWithProfile = mutation({
return { error: "Username taken" };
}
// Count existing users
const allUsers = await ctx.db.query("userProfiles").collect();
const userCount = allUsers.length;
const isFirstUser =
(await ctx.db.query("userProfiles").first()) === null;
// Enforce invite code for non-first users
if (userCount > 0) {
if (!isFirstUser) {
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))
@@ -137,11 +128,9 @@ export const createUserWithProfile = mutation({
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,
@@ -150,12 +139,10 @@ export const createUserWithProfile = mutation({
publicIdentityKey: args.publicKey,
publicSigningKey: args.signingKey,
encryptedPrivateKeys: args.encryptedPrivateKeys,
isAdmin: userCount === 0,
isAdmin: isFirstUser,
});
// First user bootstrap: create Owner + @everyone roles if they don't exist
if (userCount === 0) {
// Create @everyone role
if (isFirstUser) {
const everyoneRoleId = await ctx.db.insert("roles", {
name: "@everyone",
color: "#99aab5",
@@ -168,7 +155,6 @@ export const createUserWithProfile = mutation({
isHoist: false,
});
// Create Owner role
const ownerRoleId = await ctx.db.insert("roles", {
name: "Owner",
color: "#e91e63",
@@ -183,11 +169,9 @@ export const createUserWithProfile = mutation({
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"))

View File

@@ -1,5 +1,26 @@
import { query, mutation } from "./_generated/server";
import { v } from "convex/values";
import { GenericMutationCtx } from "convex/server";
import { DataModel, Id } from "./_generated/dataModel";
type TableWithChannelIndex =
| "channelKeys"
| "dmParticipants"
| "typingIndicators"
| "voiceStates";
async function deleteByChannel(
ctx: GenericMutationCtx<DataModel>,
table: TableWithChannelIndex,
channelId: Id<"channels">
) {
const docs = await (ctx.db.query(table) as any)
.withIndex("by_channel", (q: any) => q.eq("channelId", channelId))
.collect();
for (const doc of docs) {
await ctx.db.delete(doc._id);
}
}
// List all non-DM channels
export const list = query({
@@ -49,7 +70,6 @@ export const create = mutation({
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))
@@ -105,13 +125,12 @@ export const remove = mutation({
throw new Error("Channel not found");
}
// Delete messages
// Delete reactions for all messages in this channel
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))
@@ -122,43 +141,11 @@ export const remove = mutation({
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);
}
await deleteByChannel(ctx, "channelKeys", args.id);
await deleteByChannel(ctx, "dmParticipants", args.id);
await deleteByChannel(ctx, "typingIndicators", args.id);
await deleteByChannel(ctx, "voiceStates", args.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 };

View File

@@ -1,7 +1,6 @@
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"),
@@ -16,11 +15,9 @@ export const openDM = mutation({
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))
@@ -30,27 +27,20 @@ export const openDM = mutation({
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,
});
await Promise.all([
ctx.db.insert("dmParticipants", { channelId, userId: args.userId }),
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(
@@ -62,43 +52,36 @@ export const listDMs = query({
})
),
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;
}> = [];
const results = await Promise.all(
myParticipations.map(async (part) => {
const channel = await ctx.db.get(part.channelId);
if (!channel || channel.type !== "dm") return null;
for (const part of myParticipations) {
const channel = await ctx.db.get(part.channelId);
if (!channel || channel.type !== "dm") continue;
const otherParts = await ctx.db
.query("dmParticipants")
.withIndex("by_channel", (q) => q.eq("channelId", part.channelId))
.collect();
// 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) return null;
const otherPart = otherParts.find((p) => p.userId !== args.userId);
if (!otherPart) continue;
const otherUser = await ctx.db.get(otherPart.userId);
if (!otherUser) return null;
const otherUser = await ctx.db.get(otherPart.userId);
if (!otherUser) continue;
return {
channel_id: part.channelId,
channel_name: channel.name,
other_user_id: otherUser._id as string,
other_username: otherUser.username,
};
})
);
result.push({
channel_id: part.channelId,
channel_name: channel.name,
other_user_id: otherUser._id,
other_username: otherUser.username,
});
}
return result;
return results.filter((r): r is NonNullable<typeof r> => r !== null);
},
});

View File

@@ -1,47 +1,36 @@
import { query, mutation } from "./_generated/server";
import { paginationOptsValidator } from "convex/server";
import { v } from "convex/values";
// List recent messages for a channel with reactions + username
export const list = query({
args: {
paginationOpts: paginationOptsValidator,
channelId: v.id("channels"),
userId: v.optional(v.id("userProfiles")),
},
returns: v.array(v.any()),
returns: v.any(),
handler: async (ctx, args) => {
const messages = await ctx.db
const result = await ctx.db
.query("messages")
.withIndex("by_channel", (q) => q.eq("channelId", args.channelId))
.order("desc")
.take(50);
.paginate(args.paginationOpts);
// 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 enrichedPage = await Promise.all(
result.page.map(async (msg) => {
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 }
> = {};
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++;
const entry = (reactions[r.emoji] ??= { count: 0, me: false });
entry.count++;
if (args.userId && r.userId === args.userId) {
reactions[r.emoji].me = true;
entry.me = true;
}
}
@@ -56,17 +45,15 @@ export const list = query({
created_at: new Date(msg._creationTime).toISOString(),
username: sender?.username || "Unknown",
public_signing_key: sender?.publicSigningKey || "",
reactions:
Object.keys(reactions).length > 0 ? reactions : null,
reactions: Object.keys(reactions).length > 0 ? reactions : null,
};
})
);
return enriched;
return { ...result, page: enrichedPage };
},
});
// Send encrypted message
export const send = mutation({
args: {
channelId: v.id("channels"),
@@ -86,17 +73,14 @@ export const send = mutation({
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))

View File

@@ -1,5 +1,30 @@
import { query, mutation } from "./_generated/server";
import { v } from "convex/values";
import { GenericQueryCtx } from "convex/server";
import { DataModel, Id, Doc } from "./_generated/dataModel";
const PERMISSION_KEYS = [
"manage_channels",
"manage_roles",
"create_invite",
"embed_links",
"attach_files",
] as const;
async function getRolesForUser(
ctx: GenericQueryCtx<DataModel>,
userId: Id<"userProfiles">
): Promise<Doc<"roles">[]> {
const assignments = await ctx.db
.query("userRoles")
.withIndex("by_user", (q) => q.eq("userId", userId))
.collect();
const roles = await Promise.all(
assignments.map((ur) => ctx.db.get(ur.roleId))
);
return roles.filter((r): r is Doc<"roles"> => r !== null);
}
// List all roles
export const list = query({
@@ -49,18 +74,17 @@ export const update = mutation({
const role = await ctx.db.get(args.id);
if (!role) throw new Error("Role not found");
const { id, ...fields } = args;
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);
for (const [key, value] of Object.entries(fields)) {
if (value !== undefined) updates[key] = value;
}
return await ctx.db.get(args.id);
if (Object.keys(updates).length > 0) {
await ctx.db.patch(id, updates);
}
return await ctx.db.get(id);
},
});
@@ -72,7 +96,6 @@ export const remove = mutation({
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))
@@ -93,30 +116,14 @@ export const listMembers = query({
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 await Promise.all(
users.map(async (user) => ({
id: user._id,
username: user.username,
public_identity_key: user.publicIdentityKey,
roles: await getRolesForUser(ctx, user._id),
}))
);
return result;
},
});
@@ -128,7 +135,6 @@ export const assign = mutation({
},
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) =>
@@ -181,30 +187,21 @@ export const getMyPermissions = query({
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 roles = await getRolesForUser(ctx, args.userId);
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;
const finalPerms: Record<string, boolean> = {};
for (const key of PERMISSION_KEYS) {
finalPerms[key] = roles.some(
(role) => (role.permissions as Record<string, boolean>)?.[key]
);
}
return finalPerms;
return finalPerms as {
manage_channels: boolean;
manage_roles: boolean;
create_invite: boolean;
embed_links: boolean;
attach_files: boolean;
};
},
});

View File

@@ -2,7 +2,8 @@ import { query, mutation, internalMutation } from "./_generated/server";
import { v } from "convex/values";
import { internal } from "./_generated/api";
// Start typing indicator
const TYPING_TTL_MS = 6000;
export const startTyping = mutation({
args: {
channelId: v.id("channels"),
@@ -11,9 +12,8 @@ export const startTyping = mutation({
},
returns: v.null(),
handler: async (ctx, args) => {
const expiresAt = Date.now() + 6000; // 6 second TTL
const expiresAt = Date.now() + TYPING_TTL_MS;
// Upsert: check if already exists
const existing = await ctx.db
.query("typingIndicators")
.withIndex("by_channel", (q) => q.eq("channelId", args.channelId))
@@ -32,14 +32,11 @@ export const startTyping = mutation({
});
}
// Schedule cleanup
await ctx.scheduler.runAfter(6000, internal.typing.cleanExpired, {});
await ctx.scheduler.runAfter(TYPING_TTL_MS, internal.typing.cleanExpired, {});
return null;
},
});
// Stop typing indicator
export const stopTyping = mutation({
args: {
channelId: v.id("channels"),
@@ -61,7 +58,6 @@ export const stopTyping = mutation({
},
});
// Get typing users for a channel (reactive!)
export const getTyping = query({
args: { channelId: v.id("channels") },
returns: v.array(
@@ -79,21 +75,17 @@ export const getTyping = query({
return indicators
.filter((t) => t.expiresAt > now)
.map((t) => ({
userId: t.userId,
username: t.username,
}));
.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) {
const expired = await ctx.db.query("typingIndicators").collect();
for (const t of expired) {
if (t.expiresAt <= now) {
await ctx.db.delete(t._id);
}

View File

@@ -1,7 +1,16 @@
import { query, mutation } from "./_generated/server";
import { v } from "convex/values";
// Join voice channel
async function removeUserVoiceStates(ctx: any, userId: any) {
const existing = await ctx.db
.query("voiceStates")
.withIndex("by_user", (q: any) => q.eq("userId", userId))
.collect();
for (const vs of existing) {
await ctx.db.delete(vs._id);
}
}
export const join = mutation({
args: {
channelId: v.id("channels"),
@@ -12,17 +21,8 @@ export const join = mutation({
},
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();
await removeUserVoiceStates(ctx, args.userId);
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,
@@ -36,27 +36,17 @@ export const join = mutation({
},
});
// 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);
}
await removeUserVoiceStates(ctx, args.userId);
return null;
},
});
// Update mute/deafen/screenshare state
export const updateState = mutation({
args: {
userId: v.id("userProfiles"),
@@ -69,47 +59,36 @@ export const updateState = mutation({
const existing = await ctx.db
.query("voiceStates")
.withIndex("by_user", (q) => q.eq("userId", args.userId))
.collect();
.first();
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);
if (existing) {
const { userId: _, ...updates } = args;
const filtered = Object.fromEntries(
Object.entries(updates).filter(([, val]) => val !== undefined)
);
await ctx.db.patch(existing._id, filtered);
}
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;
}>
> = {};
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({
(grouped[s.channelId] ??= []).push({
userId: s.userId,
username: s.username,
isMuted: s.isMuted,