feat: Implement core Discord clone functionality including Convex backend services for authentication, channels, messages, roles, and voice state, alongside new Electron frontend components for chat, voice, server settings, and user interface.
All checks were successful
Build and Release / build-and-release (push) Successful in 14m19s
All checks were successful
Build and Release / build-and-release (push) Successful in 14m19s
This commit is contained in:
2
convex/_generated/api.d.ts
vendored
2
convex/_generated/api.d.ts
vendored
@@ -22,6 +22,7 @@ import type * as presence from "../presence.js";
|
||||
import type * as reactions from "../reactions.js";
|
||||
import type * as readState from "../readState.js";
|
||||
import type * as roles from "../roles.js";
|
||||
import type * as serverSettings from "../serverSettings.js";
|
||||
import type * as storageUrl from "../storageUrl.js";
|
||||
import type * as typing from "../typing.js";
|
||||
import type * as voice from "../voice.js";
|
||||
@@ -48,6 +49,7 @@ declare const fullApi: ApiFromModules<{
|
||||
reactions: typeof reactions;
|
||||
readState: typeof readState;
|
||||
roles: typeof roles;
|
||||
serverSettings: typeof serverSettings;
|
||||
storageUrl: typeof storageUrl;
|
||||
typing: typeof typing;
|
||||
voice: typeof voice;
|
||||
|
||||
@@ -163,6 +163,7 @@ export const createUserWithProfile = mutation({
|
||||
permissions: {
|
||||
manage_channels: true,
|
||||
manage_roles: true,
|
||||
manage_messages: true,
|
||||
create_invite: true,
|
||||
embed_links: true,
|
||||
attach_files: true,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { query, mutation } from "./_generated/server";
|
||||
import { v } from "convex/values";
|
||||
import { GenericMutationCtx } from "convex/server";
|
||||
import { DataModel, Id } from "./_generated/dataModel";
|
||||
import { internal } from "./_generated/api";
|
||||
|
||||
type TableWithChannelIndex =
|
||||
| "channelKeys"
|
||||
@@ -234,6 +235,9 @@ export const remove = mutation({
|
||||
await deleteByChannel(ctx, "voiceStates", args.id);
|
||||
await deleteByChannel(ctx, "channelReadState", args.id);
|
||||
|
||||
// Clear AFK setting if this channel was the AFK channel
|
||||
await ctx.runMutation(internal.serverSettings.clearAfkChannel, { channelId: args.id });
|
||||
|
||||
await ctx.db.delete(args.id);
|
||||
|
||||
return { success: true };
|
||||
|
||||
@@ -2,6 +2,7 @@ import { query, mutation } from "./_generated/server";
|
||||
import { paginationOptsValidator } from "convex/server";
|
||||
import { v } from "convex/values";
|
||||
import { getPublicStorageUrl } from "./storageUrl";
|
||||
import { getRolesForUser } from "./roles";
|
||||
|
||||
export const list = query({
|
||||
args: {
|
||||
@@ -173,9 +174,23 @@ export const listPinned = query({
|
||||
});
|
||||
|
||||
export const remove = mutation({
|
||||
args: { id: v.id("messages") },
|
||||
args: { id: v.id("messages"), userId: v.id("userProfiles") },
|
||||
returns: v.null(),
|
||||
handler: async (ctx, args) => {
|
||||
const message = await ctx.db.get(args.id);
|
||||
if (!message) throw new Error("Message not found");
|
||||
|
||||
const isSender = message.senderId === args.userId;
|
||||
if (!isSender) {
|
||||
const roles = await getRolesForUser(ctx, args.userId);
|
||||
const canManage = roles.some(
|
||||
(role) => (role.permissions as Record<string, boolean>)?.manage_messages
|
||||
);
|
||||
if (!canManage) {
|
||||
throw new Error("Not authorized to delete this message");
|
||||
}
|
||||
}
|
||||
|
||||
const reactions = await ctx.db
|
||||
.query("messageReactions")
|
||||
.withIndex("by_message", (q) => q.eq("messageId", args.id))
|
||||
|
||||
@@ -6,12 +6,15 @@ import { DataModel, Id, Doc } from "./_generated/dataModel";
|
||||
const PERMISSION_KEYS = [
|
||||
"manage_channels",
|
||||
"manage_roles",
|
||||
"manage_messages",
|
||||
"create_invite",
|
||||
"embed_links",
|
||||
"attach_files",
|
||||
"move_members",
|
||||
"mute_members",
|
||||
] as const;
|
||||
|
||||
async function getRolesForUser(
|
||||
export async function getRolesForUser(
|
||||
ctx: GenericQueryCtx<DataModel>,
|
||||
userId: Id<"userProfiles">
|
||||
): Promise<Doc<"roles">[]> {
|
||||
@@ -182,9 +185,12 @@ export const getMyPermissions = query({
|
||||
returns: v.object({
|
||||
manage_channels: v.boolean(),
|
||||
manage_roles: v.boolean(),
|
||||
manage_messages: v.boolean(),
|
||||
create_invite: v.boolean(),
|
||||
embed_links: v.boolean(),
|
||||
attach_files: v.boolean(),
|
||||
move_members: v.boolean(),
|
||||
mute_members: v.boolean(),
|
||||
}),
|
||||
handler: async (ctx, args) => {
|
||||
const roles = await getRolesForUser(ctx, args.userId);
|
||||
@@ -199,9 +205,12 @@ export const getMyPermissions = query({
|
||||
return finalPerms as {
|
||||
manage_channels: boolean;
|
||||
manage_roles: boolean;
|
||||
manage_messages: boolean;
|
||||
create_invite: boolean;
|
||||
embed_links: boolean;
|
||||
attach_files: boolean;
|
||||
move_members: boolean;
|
||||
mute_members: boolean;
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
@@ -109,6 +109,7 @@ export default defineSchema({
|
||||
isMuted: v.boolean(),
|
||||
isDeafened: v.boolean(),
|
||||
isScreenSharing: v.boolean(),
|
||||
isServerMuted: v.boolean(),
|
||||
})
|
||||
.index("by_channel", ["channelId"])
|
||||
.index("by_user", ["userId"]),
|
||||
@@ -121,4 +122,9 @@ export default defineSchema({
|
||||
.index("by_user", ["userId"])
|
||||
.index("by_channel", ["channelId"])
|
||||
.index("by_user_and_channel", ["userId", "channelId"]),
|
||||
|
||||
serverSettings: defineTable({
|
||||
afkChannelId: v.optional(v.id("channels")),
|
||||
afkTimeout: v.number(), // seconds (default 300 = 5 min)
|
||||
}),
|
||||
});
|
||||
|
||||
69
convex/serverSettings.ts
Normal file
69
convex/serverSettings.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { query, mutation, internalMutation } from "./_generated/server";
|
||||
import { v } from "convex/values";
|
||||
import { getRolesForUser } from "./roles";
|
||||
|
||||
export const get = query({
|
||||
args: {},
|
||||
returns: v.any(),
|
||||
handler: async (ctx) => {
|
||||
return await ctx.db.query("serverSettings").first();
|
||||
},
|
||||
});
|
||||
|
||||
export const update = mutation({
|
||||
args: {
|
||||
userId: v.id("userProfiles"),
|
||||
afkChannelId: v.optional(v.id("channels")),
|
||||
afkTimeout: v.number(),
|
||||
},
|
||||
returns: v.null(),
|
||||
handler: async (ctx, args) => {
|
||||
// Permission check
|
||||
const roles = await getRolesForUser(ctx, args.userId);
|
||||
const canManage = roles.some(
|
||||
(role) => (role.permissions as Record<string, boolean>)?.["manage_channels"]
|
||||
);
|
||||
if (!canManage) {
|
||||
throw new Error("You don't have permission to manage server settings");
|
||||
}
|
||||
|
||||
// Validate timeout range
|
||||
if (args.afkTimeout < 60 || args.afkTimeout > 3600) {
|
||||
throw new Error("AFK timeout must be between 60 and 3600 seconds");
|
||||
}
|
||||
|
||||
// Validate AFK channel is a voice channel if provided
|
||||
if (args.afkChannelId) {
|
||||
const channel = await ctx.db.get(args.afkChannelId);
|
||||
if (!channel) throw new Error("AFK channel not found");
|
||||
if (channel.type !== "voice") throw new Error("AFK channel must be a voice channel");
|
||||
}
|
||||
|
||||
const existing = await ctx.db.query("serverSettings").first();
|
||||
if (existing) {
|
||||
await ctx.db.patch(existing._id, {
|
||||
afkChannelId: args.afkChannelId,
|
||||
afkTimeout: args.afkTimeout,
|
||||
});
|
||||
} else {
|
||||
await ctx.db.insert("serverSettings", {
|
||||
afkChannelId: args.afkChannelId,
|
||||
afkTimeout: args.afkTimeout,
|
||||
});
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
});
|
||||
|
||||
export const clearAfkChannel = internalMutation({
|
||||
args: { channelId: v.id("channels") },
|
||||
returns: v.null(),
|
||||
handler: async (ctx, args) => {
|
||||
const settings = await ctx.db.query("serverSettings").first();
|
||||
if (settings && settings.afkChannelId === args.channelId) {
|
||||
await ctx.db.patch(settings._id, { afkChannelId: undefined });
|
||||
}
|
||||
return null;
|
||||
},
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
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
|
||||
@@ -31,6 +32,7 @@ export const join = mutation({
|
||||
isMuted: args.isMuted,
|
||||
isDeafened: args.isDeafened,
|
||||
isScreenSharing: false,
|
||||
isServerMuted: false,
|
||||
});
|
||||
|
||||
return null;
|
||||
@@ -74,6 +76,35 @@ export const updateState = mutation({
|
||||
},
|
||||
});
|
||||
|
||||
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 getAll = query({
|
||||
args: {},
|
||||
returns: v.any(),
|
||||
@@ -86,6 +117,7 @@ export const getAll = query({
|
||||
isMuted: boolean;
|
||||
isDeafened: boolean;
|
||||
isScreenSharing: boolean;
|
||||
isServerMuted: boolean;
|
||||
avatarUrl: string | null;
|
||||
}>> = {};
|
||||
|
||||
@@ -102,6 +134,7 @@ export const getAll = query({
|
||||
isMuted: s.isMuted,
|
||||
isDeafened: s.isDeafened,
|
||||
isScreenSharing: s.isScreenSharing,
|
||||
isServerMuted: s.isServerMuted,
|
||||
avatarUrl,
|
||||
});
|
||||
}
|
||||
@@ -109,3 +142,90 @@ export const getAll = query({
|
||||
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,
|
||||
});
|
||||
|
||||
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;
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user