All checks were successful
Build and Release / build-and-release (push) Successful in 13m55s
368 lines
10 KiB
TypeScript
368 lines
10 KiB
TypeScript
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";
|
|
|
|
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"),
|
|
userId: v.id("userProfiles"),
|
|
username: v.string(),
|
|
isMuted: v.boolean(),
|
|
isDeafened: v.boolean(),
|
|
},
|
|
returns: v.null(),
|
|
handler: async (ctx, args) => {
|
|
await removeUserVoiceStates(ctx, args.userId);
|
|
|
|
await ctx.db.insert("voiceStates", {
|
|
channelId: args.channelId,
|
|
userId: args.userId,
|
|
username: args.username,
|
|
isMuted: args.isMuted,
|
|
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;
|
|
},
|
|
});
|
|
|
|
export const leave = mutation({
|
|
args: {
|
|
userId: v.id("userProfiles"),
|
|
},
|
|
returns: v.null(),
|
|
handler: async (ctx, args) => {
|
|
await removeUserVoiceStates(ctx, args.userId);
|
|
return null;
|
|
},
|
|
});
|
|
|
|
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))
|
|
.first();
|
|
|
|
if (existing) {
|
|
const { userId: _, ...updates } = args;
|
|
const filtered = Object.fromEntries(
|
|
Object.entries(updates).filter(([, val]) => val !== undefined)
|
|
);
|
|
await ctx.db.patch(existing._id, filtered);
|
|
|
|
// When a user stops screen sharing, clear all viewers watching their stream
|
|
if (args.isScreenSharing === false) {
|
|
const allStates = await ctx.db.query("voiceStates").collect();
|
|
for (const s of allStates) {
|
|
if (s.watchingStream === args.userId) {
|
|
await ctx.db.patch(s._id, { watchingStream: undefined });
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return null;
|
|
},
|
|
});
|
|
|
|
export const serverMute = mutation({
|
|
args: {
|
|
actorUserId: v.id("userProfiles"),
|
|
targetUserId: v.id("userProfiles"),
|
|
isServerMuted: v.boolean(),
|
|
},
|
|
returns: v.null(),
|
|
handler: async (ctx, args) => {
|
|
const roles = await getRolesForUser(ctx, args.actorUserId);
|
|
const canMute = roles.some(
|
|
(role) => (role.permissions as Record<string, boolean>)?.["mute_members"]
|
|
);
|
|
if (!canMute) {
|
|
throw new Error("You don't have permission to server mute members");
|
|
}
|
|
|
|
const existing = await ctx.db
|
|
.query("voiceStates")
|
|
.withIndex("by_user", (q: any) => q.eq("userId", args.targetUserId))
|
|
.first();
|
|
|
|
if (!existing) throw new Error("Target user is not in a voice channel");
|
|
|
|
await ctx.db.patch(existing._id, { isServerMuted: args.isServerMuted });
|
|
|
|
return null;
|
|
},
|
|
});
|
|
|
|
export const setWatchingStream = mutation({
|
|
args: {
|
|
userId: v.id("userProfiles"),
|
|
watchingStream: v.optional(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))
|
|
.first();
|
|
|
|
if (existing) {
|
|
await ctx.db.patch(existing._id, {
|
|
watchingStream: args.watchingStream ?? undefined,
|
|
});
|
|
}
|
|
|
|
return null;
|
|
},
|
|
});
|
|
|
|
export const getAll = query({
|
|
args: {},
|
|
returns: v.any(),
|
|
handler: async (ctx) => {
|
|
const states = await ctx.db.query("voiceStates").collect();
|
|
|
|
const grouped: Record<string, Array<{
|
|
userId: string;
|
|
username: string;
|
|
displayName: string | null;
|
|
isMuted: boolean;
|
|
isDeafened: boolean;
|
|
isScreenSharing: boolean;
|
|
isServerMuted: boolean;
|
|
avatarUrl: string | null;
|
|
joinSoundUrl: string | null;
|
|
watchingStream: string | null;
|
|
}>> = {};
|
|
|
|
for (const s of states) {
|
|
const user = await ctx.db.get(s.userId);
|
|
let avatarUrl: string | null = null;
|
|
if (user?.avatarStorageId) {
|
|
avatarUrl = await getPublicStorageUrl(ctx, user.avatarStorageId);
|
|
}
|
|
let joinSoundUrl: string | null = null;
|
|
if (user?.joinSoundStorageId) {
|
|
joinSoundUrl = await getPublicStorageUrl(ctx, user.joinSoundStorageId);
|
|
}
|
|
|
|
(grouped[s.channelId] ??= []).push({
|
|
userId: s.userId,
|
|
username: s.username,
|
|
displayName: user?.displayName || null,
|
|
isMuted: s.isMuted,
|
|
isDeafened: s.isDeafened,
|
|
isScreenSharing: s.isScreenSharing,
|
|
isServerMuted: s.isServerMuted,
|
|
avatarUrl,
|
|
joinSoundUrl,
|
|
watchingStream: s.watchingStream ?? null,
|
|
});
|
|
}
|
|
|
|
return grouped;
|
|
},
|
|
});
|
|
|
|
export const afkMove = mutation({
|
|
args: {
|
|
userId: v.id("userProfiles"),
|
|
afkChannelId: v.id("channels"),
|
|
},
|
|
returns: v.null(),
|
|
handler: async (ctx, args) => {
|
|
// Validate afkChannelId matches server settings
|
|
const settings = await ctx.db.query("serverSettings").first();
|
|
if (!settings || settings.afkChannelId !== args.afkChannelId) {
|
|
throw new Error("Invalid AFK channel");
|
|
}
|
|
|
|
// Get current voice state
|
|
const currentState = await ctx.db
|
|
.query("voiceStates")
|
|
.withIndex("by_user", (q: any) => q.eq("userId", args.userId))
|
|
.first();
|
|
|
|
// No-op if not in voice or already in AFK channel
|
|
if (!currentState || currentState.channelId === args.afkChannelId) return null;
|
|
|
|
// Move to AFK channel: delete old state, insert new one muted
|
|
await ctx.db.delete(currentState._id);
|
|
await ctx.db.insert("voiceStates", {
|
|
channelId: args.afkChannelId,
|
|
userId: args.userId,
|
|
username: currentState.username,
|
|
isMuted: true,
|
|
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)
|
|
const allStates = await ctx.db.query("voiceStates").collect();
|
|
for (const s of allStates) {
|
|
if (s.watchingStream === args.userId) {
|
|
await ctx.db.patch(s._id, { watchingStream: undefined });
|
|
}
|
|
}
|
|
|
|
return null;
|
|
},
|
|
});
|
|
|
|
export const disconnectUser = mutation({
|
|
args: {
|
|
actorUserId: v.id("userProfiles"),
|
|
targetUserId: v.id("userProfiles"),
|
|
},
|
|
returns: v.null(),
|
|
handler: async (ctx, args) => {
|
|
const roles = await getRolesForUser(ctx, args.actorUserId);
|
|
const canMove = roles.some(
|
|
(role) => (role.permissions as Record<string, boolean>)?.["move_members"]
|
|
);
|
|
if (!canMove) {
|
|
throw new Error("You don't have permission to disconnect members");
|
|
}
|
|
|
|
// Clear viewers watching the target user's stream
|
|
const allStates = await ctx.db.query("voiceStates").collect();
|
|
for (const s of allStates) {
|
|
if (s.watchingStream === args.targetUserId) {
|
|
await ctx.db.patch(s._id, { watchingStream: undefined });
|
|
}
|
|
}
|
|
|
|
await removeUserVoiceStates(ctx, args.targetUserId);
|
|
|
|
return null;
|
|
},
|
|
});
|
|
|
|
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"),
|
|
targetUserId: v.id("userProfiles"),
|
|
targetChannelId: v.id("channels"),
|
|
},
|
|
returns: v.null(),
|
|
handler: async (ctx, args) => {
|
|
// Check actor has move_members permission
|
|
const roles = await getRolesForUser(ctx, args.actorUserId);
|
|
const canMove = roles.some(
|
|
(role) => (role.permissions as Record<string, boolean>)?.["move_members"]
|
|
);
|
|
if (!canMove) {
|
|
throw new Error("You don't have permission to move members");
|
|
}
|
|
|
|
// Validate target channel exists and is voice
|
|
const targetChannel = await ctx.db.get(args.targetChannelId);
|
|
if (!targetChannel) throw new Error("Target channel not found");
|
|
if (targetChannel.type !== "voice") throw new Error("Target channel is not a voice channel");
|
|
|
|
// Get target user's current voice state
|
|
const currentState = await ctx.db
|
|
.query("voiceStates")
|
|
.withIndex("by_user", (q: any) => q.eq("userId", args.targetUserId))
|
|
.first();
|
|
|
|
if (!currentState) throw new Error("Target user is not in a voice channel");
|
|
|
|
// No-op if already in the target channel
|
|
if (currentState.channelId === args.targetChannelId) return null;
|
|
|
|
// Delete old voice state and insert new one preserving mute/deaf/screenshare
|
|
await ctx.db.delete(currentState._id);
|
|
await ctx.db.insert("voiceStates", {
|
|
channelId: args.targetChannelId,
|
|
userId: args.targetUserId,
|
|
username: currentState.username,
|
|
isMuted: currentState.isMuted,
|
|
isDeafened: currentState.isDeafened,
|
|
isScreenSharing: currentState.isScreenSharing,
|
|
isServerMuted: currentState.isServerMuted,
|
|
lastHeartbeat: Date.now(),
|
|
});
|
|
|
|
return null;
|
|
},
|
|
});
|