Files
DiscordClone/convex/voiceState.ts
2026-02-13 10:29:24 -06:00

280 lines
8.0 KiB
TypeScript

import { query, mutation } from "./_generated/server";
import { v } from "convex/values";
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,
});
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;
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,
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,
});
// 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 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,
});
return null;
},
});