feat: Introduce comprehensive user settings, voice, chat, and screen sharing features with new components, contexts, icons, and Convex backend integrations.
All checks were successful
Build and Release / build-and-release (push) Successful in 13m55s

This commit is contained in:
Bryan1029384756
2026-02-18 14:48:57 -06:00
parent a9490f7bd4
commit bdc16b9d3f
22 changed files with 755 additions and 126 deletions

View File

@@ -224,6 +224,109 @@ export const fetchBulkPage = query({
},
});
export const listAround = query({
args: {
channelId: v.id("channels"),
messageId: v.id("messages"),
userId: v.optional(v.id("userProfiles")),
},
returns: v.any(),
handler: async (ctx, args) => {
const target = await ctx.db.get(args.messageId);
if (!target || target.channelId !== args.channelId) {
return { messages: [], hasOlder: false, hasNewer: false, targetFound: false };
}
const targetTime = target._creationTime;
const before = await ctx.db
.query("messages")
.withIndex("by_channel", (q) =>
q.eq("channelId", args.channelId).lt("_creationTime", targetTime)
)
.order("desc")
.take(26);
const after = await ctx.db
.query("messages")
.withIndex("by_channel", (q) =>
q.eq("channelId", args.channelId).gt("_creationTime", targetTime)
)
.order("asc")
.take(26);
const hasOlder = before.length > 25;
const hasNewer = after.length > 25;
const olderMessages = before.slice(0, 25).reverse();
const newerMessages = after.slice(0, 25);
const allRaw = [...olderMessages, target, ...newerMessages];
const messages = await Promise.all(
allRaw.map((msg) => enrichMessage(ctx, msg, args.userId))
);
return { messages, hasOlder, hasNewer, targetFound: true };
},
});
export const listBefore = query({
args: {
channelId: v.id("channels"),
beforeTimestamp: v.number(),
userId: v.optional(v.id("userProfiles")),
limit: v.optional(v.number()),
},
returns: v.any(),
handler: async (ctx, args) => {
const limit = args.limit ?? 50;
const rows = await ctx.db
.query("messages")
.withIndex("by_channel", (q) =>
q.eq("channelId", args.channelId).lt("_creationTime", args.beforeTimestamp)
)
.order("desc")
.take(limit + 1);
const hasMore = rows.length > limit;
const page = rows.slice(0, limit);
const messages = await Promise.all(
page.reverse().map((msg) => enrichMessage(ctx, msg, args.userId))
);
return { messages, hasMore };
},
});
export const listAfter = query({
args: {
channelId: v.id("channels"),
afterTimestamp: v.number(),
userId: v.optional(v.id("userProfiles")),
limit: v.optional(v.number()),
},
returns: v.any(),
handler: async (ctx, args) => {
const limit = args.limit ?? 50;
const rows = await ctx.db
.query("messages")
.withIndex("by_channel", (q) =>
q.eq("channelId", args.channelId).gt("_creationTime", args.afterTimestamp)
)
.order("asc")
.take(limit + 1);
const hasMore = rows.length > limit;
const page = rows.slice(0, limit);
const messages = await Promise.all(
page.map((msg) => enrichMessage(ctx, msg, args.userId))
);
return { messages, hasMore };
},
});
export const remove = mutation({
args: { id: v.id("messages"), userId: v.id("userProfiles") },
returns: v.null(),

View File

@@ -113,6 +113,7 @@ export default defineSchema({
isScreenSharing: v.boolean(),
isServerMuted: v.boolean(),
watchingStream: v.optional(v.id("userProfiles")),
lastHeartbeat: v.optional(v.number()),
})
.index("by_channel", ["channelId"])
.index("by_user", ["userId"]),

View File

@@ -19,6 +19,7 @@ export const getToken = action({
const at = new AccessToken(apiKey, apiSecret, {
identity: args.userId,
name: args.username,
ttl: "24h",
});
at.addGrant({
@@ -26,6 +27,7 @@ export const getToken = action({
room: args.channelId,
canPublish: true,
canSubscribe: true,
canPublishData: true,
});
const token = await at.toJwt();

View File

@@ -1,5 +1,6 @@
import { query, mutation } from "./_generated/server";
import { query, mutation, internalMutation } from "./_generated/server";
import { v } from "convex/values";
import { internal } from "./_generated/api";
import { getPublicStorageUrl } from "./storageUrl";
import { getRolesForUser } from "./roles";
@@ -33,8 +34,12 @@ export const join = mutation({
isDeafened: args.isDeafened,
isScreenSharing: false,
isServerMuted: false,
lastHeartbeat: Date.now(),
});
// Schedule stale cleanup to run in 90 seconds
await ctx.scheduler.runAfter(90000, internal.voiceState.cleanStaleStates, {});
return null;
},
});
@@ -217,6 +222,7 @@ export const afkMove = mutation({
isDeafened: currentState.isDeafened,
isScreenSharing: false,
isServerMuted: currentState.isServerMuted,
lastHeartbeat: Date.now(),
});
// Clear viewers watching the moved user's stream (screen sharing stops on AFK move)
@@ -260,6 +266,56 @@ export const disconnectUser = mutation({
},
});
export const heartbeat = mutation({
args: {
userId: v.id("userProfiles"),
},
returns: v.null(),
handler: async (ctx, args) => {
const existing = await ctx.db
.query("voiceStates")
.withIndex("by_user", (q: any) => q.eq("userId", args.userId))
.first();
if (existing) {
await ctx.db.patch(existing._id, { lastHeartbeat: Date.now() });
}
return null;
},
});
export const cleanStaleStates = internalMutation({
args: {},
returns: v.null(),
handler: async (ctx) => {
const states = await ctx.db.query("voiceStates").collect();
const staleThreshold = Date.now() - 90_000; // 90 seconds
let hasActiveStates = false;
for (const s of states) {
if (s.lastHeartbeat && s.lastHeartbeat < staleThreshold) {
// Clear viewers watching this user's stream
for (const other of states) {
if (other.watchingStream === s.userId && other._id !== s._id) {
await ctx.db.patch(other._id, { watchingStream: undefined });
}
}
await ctx.db.delete(s._id);
} else {
hasActiveStates = true;
}
}
// Re-schedule if there are still active voice states
if (hasActiveStates) {
await ctx.scheduler.runAfter(90000, internal.voiceState.cleanStaleStates, {});
}
return null;
},
});
export const moveUser = mutation({
args: {
actorUserId: v.id("userProfiles"),
@@ -303,6 +359,7 @@ export const moveUser = mutation({
isDeafened: currentState.isDeafened,
isScreenSharing: currentState.isScreenSharing,
isServerMuted: currentState.isServerMuted,
lastHeartbeat: Date.now(),
});
return null;