feat: Implement core chat page with channel navigation, direct messages, and voice chat integration.
All checks were successful
Build and Release / build-and-release (push) Successful in 9m12s

This commit is contained in:
Bryan1029384756
2026-02-11 17:44:50 -06:00
parent b0acf93059
commit b0f889cb68
17 changed files with 1075 additions and 142 deletions

View File

@@ -9,6 +9,7 @@
*/
import type * as auth from "../auth.js";
import type * as categories from "../categories.js";
import type * as channelKeys from "../channelKeys.js";
import type * as channels from "../channels.js";
import type * as dms from "../dms.js";
@@ -33,6 +34,7 @@ import type {
declare const fullApi: ApiFromModules<{
auth: typeof auth;
categories: typeof categories;
channelKeys: typeof channelKeys;
channels: typeof channels;
dms: typeof dms;

99
convex/categories.ts Normal file
View File

@@ -0,0 +1,99 @@
import { query, mutation } from "./_generated/server";
import { v } from "convex/values";
// List all categories ordered by position
export const list = query({
args: {},
returns: v.array(
v.object({
_id: v.id("categories"),
_creationTime: v.number(),
name: v.string(),
position: v.number(),
})
),
handler: async (ctx) => {
return await ctx.db
.query("categories")
.withIndex("by_position")
.collect();
},
});
// Create a new category
export const create = mutation({
args: { name: v.string() },
returns: v.object({ id: v.id("categories") }),
handler: async (ctx, args) => {
const name = args.name.trim();
if (!name) throw new Error("Category name required");
// Auto-assign position at end
const all = await ctx.db.query("categories").collect();
const maxPos = all.reduce((max, c) => Math.max(max, c.position), -1000);
const id = await ctx.db.insert("categories", {
name,
position: maxPos + 1000,
});
return { id };
},
});
// Rename a category
export const rename = mutation({
args: {
id: v.id("categories"),
name: v.string(),
},
returns: v.null(),
handler: async (ctx, args) => {
const name = args.name.trim();
if (!name) throw new Error("Category name required");
const cat = await ctx.db.get(args.id);
if (!cat) throw new Error("Category not found");
await ctx.db.patch(args.id, { name });
return null;
},
});
// Delete a category (moves its channels to uncategorized)
export const remove = mutation({
args: { id: v.id("categories") },
returns: v.null(),
handler: async (ctx, args) => {
const cat = await ctx.db.get(args.id);
if (!cat) throw new Error("Category not found");
// Move channels to uncategorized
const channels = await ctx.db
.query("channels")
.withIndex("by_category", (q) => q.eq("categoryId", args.id))
.collect();
for (const ch of channels) {
await ctx.db.patch(ch._id, { categoryId: undefined });
}
await ctx.db.delete(args.id);
return null;
},
});
// Batch reorder categories
export const reorder = mutation({
args: {
updates: v.array(
v.object({
id: v.id("categories"),
position: v.number(),
})
),
},
returns: v.null(),
handler: async (ctx, args) => {
for (const u of args.updates) {
await ctx.db.patch(u.id, { position: u.position });
}
return null;
},
});

View File

@@ -32,7 +32,7 @@ export const list = query({
_creationTime: v.number(),
name: v.string(),
type: v.string(),
category: v.optional(v.string()),
categoryId: v.optional(v.id("categories")),
topic: v.optional(v.string()),
position: v.optional(v.number()),
})
@@ -54,7 +54,7 @@ export const get = query({
_creationTime: v.number(),
name: v.string(),
type: v.string(),
category: v.optional(v.string()),
categoryId: v.optional(v.id("categories")),
topic: v.optional(v.string()),
position: v.optional(v.number()),
}),
@@ -70,7 +70,7 @@ export const create = mutation({
args: {
name: v.string(),
type: v.optional(v.string()),
category: v.optional(v.string()),
categoryId: v.optional(v.id("categories")),
topic: v.optional(v.string()),
position: v.optional(v.number()),
},
@@ -89,12 +89,26 @@ export const create = mutation({
throw new Error("Channel already exists");
}
// Auto-calculate position if not provided
let position = args.position;
if (position === undefined) {
const allChannels = await ctx.db.query("channels").collect();
const sameCategory = allChannels.filter(
(c) => c.categoryId === args.categoryId && c.type !== "dm"
);
const maxPos = sameCategory.reduce(
(max, c) => Math.max(max, c.position ?? 0),
-1000
);
position = maxPos + 1000;
}
const id = await ctx.db.insert("channels", {
name: args.name,
type: args.type || "text",
category: args.category,
categoryId: args.categoryId,
topic: args.topic,
position: args.position,
position,
});
return { id };
@@ -127,7 +141,7 @@ export const rename = mutation({
_creationTime: v.number(),
name: v.string(),
type: v.string(),
category: v.optional(v.string()),
categoryId: v.optional(v.id("categories")),
topic: v.optional(v.string()),
position: v.optional(v.number()),
}),
@@ -146,6 +160,48 @@ export const rename = mutation({
},
});
// Move a channel to a different category with a new position
export const moveChannel = mutation({
args: {
id: v.id("channels"),
categoryId: v.optional(v.id("categories")),
position: v.number(),
},
returns: v.null(),
handler: async (ctx, args) => {
const channel = await ctx.db.get(args.id);
if (!channel) throw new Error("Channel not found");
await ctx.db.patch(args.id, {
categoryId: args.categoryId,
position: args.position,
});
return null;
},
});
// Batch reorder channels
export const reorderChannels = mutation({
args: {
updates: v.array(
v.object({
id: v.id("channels"),
categoryId: v.optional(v.id("categories")),
position: v.number(),
})
),
},
returns: v.null(),
handler: async (ctx, args) => {
for (const u of args.updates) {
await ctx.db.patch(u.id, {
categoryId: u.categoryId,
position: u.position,
});
}
return null;
},
});
// Delete channel + cascade messages and keys
export const remove = mutation({
args: { id: v.id("channels") },

View File

@@ -18,13 +18,19 @@ export default defineSchema({
customStatus: v.optional(v.string()),
}).index("by_username", ["username"]),
categories: defineTable({
name: v.string(),
position: v.number(),
}).index("by_position", ["position"]),
channels: defineTable({
name: v.string(),
type: v.string(), // 'text' | 'voice' | 'dm'
category: v.optional(v.string()),
categoryId: v.optional(v.id("categories")),
topic: v.optional(v.string()),
position: v.optional(v.number()),
}).index("by_name", ["name"]),
}).index("by_name", ["name"])
.index("by_category", ["categoryId"]),
messages: defineTable({
channelId: v.id("channels"),