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
All checks were successful
Build and Release / build-and-release (push) Successful in 13m55s
This commit is contained in:
@@ -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(),
|
||||
|
||||
@@ -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"]),
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user