feat: Add initial Discord clone application with Convex backend services and Electron React frontend components.
This commit is contained in:
@@ -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"))
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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))
|
||||
|
||||
109
convex/roles.ts
109
convex/roles.ts
@@ -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;
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user