feat: Implement core Discord clone functionality including Convex backend, Electron frontend, and UI components for chat, voice, and settings.
This commit is contained in:
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;
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user