@@ -303,8 +325,17 @@ const StreamPreviewTile = ({ participant, username, onWatchStream }) => {
const ParticipantThumbnail = ({ participant, username, avatarUrl, isStreamer, isMuted }) => {
const cameraTrack = useParticipantTrack(participant, 'camera');
+ const { isPersonallyMuted, voiceStates } = useVoice();
+ const isPersonalMuted = isPersonallyMuted(participant.identity);
const displayName = username || participant.identity;
+ // Look up server mute from voiceStates
+ let isServerMutedUser = false;
+ for (const users of Object.values(voiceStates)) {
+ const u = users.find(u => u.userId === participant.identity);
+ if (u) { isServerMutedUser = !!u.isServerMuted; break; }
+ }
+
return (
- {isMuted &&
{'\u{1F507}'}}
+ {isServerMutedUser ? (
+
+ ) : isPersonalMuted ? (
+
+ ) : isMuted ? (
+
{'\u{1F507}'}
+ ) : null}
{displayName}
{isStreamer &&
LIVE}
@@ -558,6 +595,7 @@ const VoiceStage = ({ room, channelId, voiceStates, channelName }) => {
const { isMuted, toggleMute, disconnectVoice, setScreenSharing, connectToVoice } = useVoice();
const [isScreenShareModalOpen, setIsScreenShareModalOpen] = useState(false);
const [isScreenShareActive, setIsScreenShareActive] = useState(false);
+ const screenShareAudioTrackRef = useRef(null);
// Stream viewing state
const [watchingStreamOf, setWatchingStreamOf] = useState(null);
@@ -607,13 +645,16 @@ const VoiceStage = ({ room, channelId, voiceStates, channelName }) => {
const manageSubscriptions = () => {
for (const p of room.remoteParticipants.values()) {
- const { screenSharePub } = findTrackPubs(p);
- if (!screenSharePub) continue;
+ const { screenSharePub, screenShareAudioPub } = findTrackPubs(p);
const shouldSubscribe = watchingStreamOf === p.identity;
- if (screenSharePub.isSubscribed !== shouldSubscribe) {
+
+ if (screenSharePub && screenSharePub.isSubscribed !== shouldSubscribe) {
screenSharePub.setSubscribed(shouldSubscribe);
}
+ if (screenShareAudioPub && screenShareAudioPub.isSubscribed !== shouldSubscribe) {
+ screenShareAudioPub.setSubscribed(shouldSubscribe);
+ }
}
};
@@ -669,20 +710,50 @@ const VoiceStage = ({ room, channelId, voiceStates, channelName }) => {
audio: false
});
} else {
- stream = await navigator.mediaDevices.getUserMedia({
- audio: false,
- video: {
- mandatory: {
- chromeMediaSource: 'desktop',
- chromeMediaSourceId: selection.sourceId,
- minWidth: 1280,
- maxWidth: 1920,
- minHeight: 720,
- maxHeight: 1080,
- maxFrameRate: 30
- }
+ // Try with audio if requested, fall back to video-only if it fails
+ const audioConstraint = selection.shareAudio ? {
+ mandatory: {
+ chromeMediaSource: 'desktop',
+ chromeMediaSourceId: selection.sourceId
}
- });
+ } : false;
+ try {
+ stream = await navigator.mediaDevices.getUserMedia({
+ audio: audioConstraint,
+ video: {
+ mandatory: {
+ chromeMediaSource: 'desktop',
+ chromeMediaSourceId: selection.sourceId,
+ minWidth: 1280,
+ maxWidth: 1920,
+ minHeight: 720,
+ maxHeight: 1080,
+ maxFrameRate: 30
+ }
+ }
+ });
+ } catch (audioErr) {
+ // Audio capture failed (e.g. macOS/Linux) — retry video-only
+ if (selection.shareAudio) {
+ console.warn("Audio capture failed, falling back to video-only:", audioErr.message);
+ stream = await navigator.mediaDevices.getUserMedia({
+ audio: false,
+ video: {
+ mandatory: {
+ chromeMediaSource: 'desktop',
+ chromeMediaSourceId: selection.sourceId,
+ minWidth: 1280,
+ maxWidth: 1920,
+ minHeight: 720,
+ maxHeight: 1080,
+ maxFrameRate: 30
+ }
+ }
+ });
+ } else {
+ throw audioErr;
+ }
+ }
}
const track = stream.getVideoTracks()[0];
if (track) {
@@ -691,9 +762,26 @@ const VoiceStage = ({ room, channelId, voiceStates, channelName }) => {
name: 'screen_share',
source: Track.Source.ScreenShare
});
+
+ // Publish audio track if present (system audio from desktop capture)
+ const audioTrack = stream.getAudioTracks()[0];
+ if (audioTrack) {
+ await room.localParticipant.publishTrack(audioTrack, {
+ name: 'screen_share_audio',
+ source: Track.Source.ScreenShareAudio
+ });
+ screenShareAudioTrackRef.current = audioTrack;
+ }
+
setScreenSharing(true);
track.onended = () => {
+ // Clean up audio track when video track ends
+ if (screenShareAudioTrackRef.current) {
+ screenShareAudioTrackRef.current.stop();
+ room.localParticipant.unpublishTrack(screenShareAudioTrackRef.current);
+ screenShareAudioTrackRef.current = null;
+ }
setScreenSharing(false);
room.localParticipant.setScreenShareEnabled(false).catch(console.error);
};
@@ -706,6 +794,12 @@ const VoiceStage = ({ room, channelId, voiceStates, channelName }) => {
const handleScreenShareClick = () => {
if (isScreenShareActive) {
+ // Clean up audio track before stopping screen share
+ if (screenShareAudioTrackRef.current) {
+ screenShareAudioTrackRef.current.stop();
+ room.localParticipant.unpublishTrack(screenShareAudioTrackRef.current);
+ screenShareAudioTrackRef.current = null;
+ }
room.localParticipant.setScreenShareEnabled(false);
setScreenSharing(false);
} else {
diff --git a/Frontend/Electron/src/contexts/VoiceContext.jsx b/Frontend/Electron/src/contexts/VoiceContext.jsx
index b0a3323..83c9a2e 100644
--- a/Frontend/Electron/src/contexts/VoiceContext.jsx
+++ b/Frontend/Electron/src/contexts/VoiceContext.jsx
@@ -43,10 +43,51 @@ export const VoiceProvider = ({ children }) => {
const [isMuted, setIsMuted] = useState(false);
const [isDeafened, setIsDeafened] = useState(false);
const [isScreenSharing, setIsScreenSharingLocal] = useState(false);
+ const isMovingRef = useRef(false);
+
+ // Personal mute state (persisted to localStorage)
+ const [personallyMutedUsers, setPersonallyMutedUsers] = useState(() => {
+ const saved = localStorage.getItem('personallyMutedUsers');
+ return new Set(saved ? JSON.parse(saved) : []);
+ });
+
+ const togglePersonalMute = (userId) => {
+ setPersonallyMutedUsers(prev => {
+ const next = new Set(prev);
+ if (next.has(userId)) next.delete(userId);
+ else next.add(userId);
+ localStorage.setItem('personallyMutedUsers', JSON.stringify([...next]));
+ const participant = room?.remoteParticipants?.get(userId);
+ if (participant) participant.setVolume(next.has(userId) ? 0 : 1);
+ return next;
+ });
+ };
+
+ const isPersonallyMuted = (userId) => personallyMutedUsers.has(userId);
const convex = useConvex();
+ const serverMute = async (targetUserId, isServerMuted) => {
+ const actorUserId = localStorage.getItem('userId');
+ if (!actorUserId) return;
+ try {
+ await convex.mutation(api.voiceState.serverMute, { actorUserId, targetUserId, isServerMuted });
+ } catch (e) {
+ console.error('Failed to server mute:', e);
+ }
+ };
+
+ const isServerMuted = (userId) => {
+ for (const users of Object.values(voiceStates)) {
+ const user = users.find(u => u.userId === userId);
+ if (user) return !!user.isServerMuted;
+ }
+ return false;
+ };
+
const voiceStates = useQuery(api.voiceState.getAll) || {};
+ const serverSettings = useQuery(api.serverSettings.get);
+ const isInAfkChannel = !!(activeChannelId && serverSettings?.afkChannelId === activeChannelId);
async function updateVoiceState(fields) {
const userId = localStorage.getItem('userId');
@@ -117,8 +158,22 @@ export const VoiceProvider = ({ children }) => {
isDeafened,
});
+ // Auto-mute when joining AFK channel
+ if (serverSettings?.afkChannelId === channelId) {
+ setIsMuted(true);
+ await newRoom.localParticipant.setMicrophoneEnabled(false);
+ await convex.mutation(api.voiceState.updateState, { userId, isMuted: true });
+ }
+
newRoom.on(RoomEvent.Disconnected, async (reason) => {
console.warn('Voice Room Disconnected. Reason:', reason);
+ // If we're being moved, skip leave mutation — we'll reconnect shortly
+ if (isMovingRef.current) {
+ setRoom(null);
+ setToken(null);
+ setActiveSpeakers(new Set());
+ return;
+ }
playSound('leave');
setConnectionState('disconnected');
setActiveChannelId(null);
@@ -144,12 +199,99 @@ export const VoiceProvider = ({ children }) => {
}
};
+ // Detect when another user moves us to a different voice channel
+ useEffect(() => {
+ const myUserId = localStorage.getItem('userId');
+ if (!myUserId || !activeChannelId || isMovingRef.current) return;
+
+ // Find which channel the server says we're in
+ let serverChannelId = null;
+ for (const [chId, users] of Object.entries(voiceStates)) {
+ if (users.some(u => u.userId === myUserId)) {
+ serverChannelId = chId;
+ break;
+ }
+ }
+
+ // If server says we're in a different channel, reconnect
+ if (serverChannelId && serverChannelId !== activeChannelId) {
+ isMovingRef.current = true;
+ (async () => {
+ try {
+ const channel = await convex.query(api.channels.get, { id: serverChannelId });
+ if (room) await room.disconnect();
+ await connectToVoice(serverChannelId, channel?.name || 'Voice', myUserId);
+ } catch (e) {
+ console.error('Failed to reconnect after move:', e);
+ } finally {
+ isMovingRef.current = false;
+ }
+ })();
+ }
+ }, [voiceStates, activeChannelId]);
+
+ // Enforce server mute: force-disable mic when server muted, restore when lifted
+ useEffect(() => {
+ const myUserId = localStorage.getItem('userId');
+ if (!myUserId || !room) return;
+ if (isServerMuted(myUserId)) {
+ room.localParticipant.setMicrophoneEnabled(false);
+ } else if (!isMuted && !isDeafened) {
+ room.localParticipant.setMicrophoneEnabled(true);
+ }
+ }, [voiceStates, room]);
+
+ // Re-apply personal mutes when room or participants change
+ useEffect(() => {
+ if (!room) return;
+ const applyMutes = () => {
+ for (const [identity, participant] of room.remoteParticipants) {
+ participant.setVolume(personallyMutedUsers.has(identity) ? 0 : 1);
+ }
+ };
+ applyMutes();
+ room.on(RoomEvent.ParticipantConnected, applyMutes);
+ return () => room.off(RoomEvent.ParticipantConnected, applyMutes);
+ }, [room, personallyMutedUsers]);
+
+ // AFK idle polling: move user to AFK channel when idle exceeds timeout
+ useEffect(() => {
+ if (!activeChannelId || !serverSettings?.afkChannelId || isInAfkChannel) return;
+ if (!window.idleAPI?.getSystemIdleTime) return;
+
+ const afkTimeout = serverSettings.afkTimeout || 300;
+ const interval = setInterval(async () => {
+ try {
+ const idleSeconds = await window.idleAPI.getSystemIdleTime();
+ if (idleSeconds >= afkTimeout) {
+ const userId = localStorage.getItem('userId');
+ if (!userId) return;
+ await convex.mutation(api.voiceState.afkMove, {
+ userId,
+ afkChannelId: serverSettings.afkChannelId,
+ });
+ // After server-side move, locally mute
+ setIsMuted(true);
+ if (room) room.localParticipant.setMicrophoneEnabled(false);
+ }
+ } catch (e) {
+ console.error('AFK check failed:', e);
+ }
+ }, 15000);
+
+ return () => clearInterval(interval);
+ }, [activeChannelId, serverSettings?.afkChannelId, serverSettings?.afkTimeout, isInAfkChannel]);
+
const disconnectVoice = () => {
console.log('User manually disconnected voice');
if (room) room.disconnect();
};
const toggleMute = async () => {
+ const myUserId = localStorage.getItem('userId');
+ // Block unmute if server muted or in AFK channel
+ if (isMuted && myUserId && isServerMuted(myUserId)) return;
+ if (isMuted && isInAfkChannel) return;
const nextState = !isMuted;
setIsMuted(nextState);
playSound(nextState ? 'mute' : 'unmute');
@@ -190,7 +332,14 @@ export const VoiceProvider = ({ children }) => {
toggleMute,
toggleDeafen,
isScreenSharing,
- setScreenSharing
+ setScreenSharing,
+ personallyMutedUsers,
+ togglePersonalMute,
+ isPersonallyMuted,
+ serverMute,
+ isServerMuted,
+ isInAfkChannel,
+ serverSettings
}}>
{children}
{room && (
diff --git a/Frontend/Electron/src/index.css b/Frontend/Electron/src/index.css
index 180dfe4..1affac8 100644
--- a/Frontend/Electron/src/index.css
+++ b/Frontend/Electron/src/index.css
@@ -195,8 +195,8 @@ body {
.channel-item {
padding: 8px;
- margin-bottom: 2px;
- border-radius: 4px;
+ margin: 4px;
+ border-radius: 8px;
color: var(--interactive-normal);
font-weight: 500;
cursor: pointer;
@@ -986,6 +986,36 @@ body {
text-overflow: ellipsis;
}
+.member-voice-indicator {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ color: #3ba55c;
+ font-size: 12px;
+ font-weight: 500;
+}
+
+.member-voice-indicator svg {
+ width: 14px;
+ height: 14px;
+ flex-shrink: 0;
+}
+
+.member-screen-sharing-indicator {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ color: #3ba55c;
+ font-size: 12px;
+ font-weight: 500;
+}
+
+.member-screen-sharing-indicator img {
+ width: 14px;
+ height: 14px;
+ flex-shrink: 0;
+}
+
/* ============================================
REPLY SYSTEM
============================================ */
@@ -1158,8 +1188,9 @@ body {
============================================ */
.context-menu {
position: fixed;
- background-color: var(--background-base-lowest);
- border-radius: 4px;
+ background-color: var(--panel-bg);
+ border-radius: 8px;
+ border: 1px solid var(--app-frame-border);
box-shadow: 0 8px 16px rgba(0,0,0,0.24);
z-index: 9999;
min-width: 188px;
@@ -1184,7 +1215,7 @@ body {
color: var(--text-normal);
justify-content: space-between;
white-space: nowrap;
- border-radius: 2px;
+ border-radius: 8px;
transition: background-color 0.1s;
}
@@ -1200,6 +1231,34 @@ body {
background-color: color-mix(in oklab,hsl(355.636 calc(1*64.706%) 50% /0.0784313725490196) 100%,hsl(0 0% 0% /0.0784313725490196) 0%);
}
+.context-menu-checkbox-item {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+}
+
+.context-menu-checkbox {
+ display: flex;
+ align-items: center;
+ margin-left: 8px;
+}
+
+.context-menu-checkbox-indicator {
+ width: 20px;
+ height: 20px;
+ border-radius: 4px;
+ border: 2px solid var(--header-secondary);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ transition: all 0.15s;
+}
+
+.context-menu-checkbox-indicator.checked {
+ background-color: hsl(235 86% 65%);
+ border-color: hsl(235 86% 65%);
+}
+
.context-menu-separator {
height: 1px;
background-color: var(--bg-primary);
@@ -1216,8 +1275,9 @@ body {
right: 0;
background-color: var(--background-surface-high, var(--embed-background));
border-radius: 5px;
- box-shadow: 0 8px 16px rgba(0,0,0,0.24);
+ box-shadow: 0 10px 16px rgba(0,0,0,0.24);
z-index: 100;
+ margin: 8px;
}
.mention-menu-header {
@@ -1228,6 +1288,27 @@ body {
color: var(--header-secondary);
}
+.mention-menu-section-header {
+ padding: 8px 12px 4px;
+ font-size: 12px;
+ font-weight: 600;
+ text-transform: uppercase;
+ color: var(--header-secondary);
+}
+
+.mention-menu-role-icon {
+ width: 24px;
+ height: 24px;
+ border-radius: 50%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: white;
+ font-weight: 700;
+ font-size: 14px;
+ flex-shrink: 0;
+}
+
.mention-menu-scroller {
max-height: 490px;
overflow-y: auto;
@@ -2873,6 +2954,21 @@ body {
background-color: var(--brand-experiment-hover);
}
+/* ============================================
+ VOICE USER ITEM (sidebar)
+ ============================================ */
+.voice-user-item {
+ padding: 6px 8px;
+ border-radius: 8px;
+ cursor: pointer;
+ transition: background-color 0.1s ease;
+ margin-right: 4px;
+}
+
+.voice-user-item:hover {
+ background-color: var(--background-modifier-hover);
+}
+
.drag-overlay-category {
padding: 8px 12px;
background-color: var(--bg-secondary);
@@ -2886,4 +2982,26 @@ body {
cursor: grabbing;
opacity: 0.9;
width: 200px;
+}
+
+/* ============================================
+ VOICE USER DRAG & DROP
+ ============================================ */
+.drag-overlay-voice-user {
+ display: flex;
+ align-items: center;
+ padding: 6px 10px;
+ background: var(--background-modifier-selected);
+ border-radius: 8px;
+ color: var(--interactive-active);
+ font-weight: 500;
+ font-size: 14px;
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
+ cursor: grabbing;
+}
+
+.voice-drop-target {
+ background-color: rgba(88, 101, 242, 0.15) !important;
+ outline: 2px dashed var(--brand-experiment);
+ border-radius: 4px;
}
\ No newline at end of file
diff --git a/TODO.md b/TODO.md
index 261a8e4..0384e7e 100644
--- a/TODO.md
+++ b/TODO.md
@@ -1,37 +1,31 @@
-- 955px
-
-
- I want to give users the choice to update the app. Can we make updating work 2 ways. One, optional updates, i want to somehow make some updates marked as optional, where techinically older versions will still work so we dont care if they are on a older version. And some updates non optional, where we will require users to update to the latest version to continue using the app. So for example when they launch the app we will check if their is a update but we dont update the app right away. We will show a download icon like discord in the header, that will be the update.svg icon. Make the icon use this color "hsl(138.353 calc(1*38.117%) 56.275% /1);"
-- When a user messages you, you should get a notification. On the server list that user profile picture should be their above all servers. right under the discord and above the server-separator. With a red dot next to it. If you get a private dm you should hear the ping sound also
+
- We should play a sound when a user mentions you also in the main server.
-- In the mention list we should also have roles, so if people do @everyone they should mention everyone in the server, or they can @ a certain role they want to mention from the server. This does not apply to private messages.
+
-- Owners should be able to delete anyones message in the server.
+
+
- Fix green status not updating correctly
-- Move people between voice channels.
-- Allow copy paste of images using CTRL + V in the message box to attach an iamge.
+
+
-- When you collapse a category that has a voice channel lets still show the users in their.
+
-- If you go afk for 5min switch to channel and to idle.
+
-- Add server muting. Forcing user to mute.
-- Allow users to mute other users for themself only.
+
- Independient voice volumes per user.
+
# Future
-- Allow users to add custom join sounds.
\ No newline at end of file
+- Allow users to add custom join sounds.
diff --git a/convex/_generated/api.d.ts b/convex/_generated/api.d.ts
index deed1f3..1cd3e0a 100644
--- a/convex/_generated/api.d.ts
+++ b/convex/_generated/api.d.ts
@@ -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;
diff --git a/convex/auth.ts b/convex/auth.ts
index ff653b1..68479f8 100644
--- a/convex/auth.ts
+++ b/convex/auth.ts
@@ -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,
diff --git a/convex/channels.ts b/convex/channels.ts
index 311cf2c..da8a2d0 100644
--- a/convex/channels.ts
+++ b/convex/channels.ts
@@ -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 };
diff --git a/convex/messages.ts b/convex/messages.ts
index 3dbba3c..e2a3370 100644
--- a/convex/messages.ts
+++ b/convex/messages.ts
@@ -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
)?.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))
diff --git a/convex/roles.ts b/convex/roles.ts
index 4558b2c..0bf02fa 100644
--- a/convex/roles.ts
+++ b/convex/roles.ts
@@ -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,
userId: Id<"userProfiles">
): Promise[]> {
@@ -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;
};
},
});
diff --git a/convex/schema.ts b/convex/schema.ts
index 3556aa4..7677fc3 100644
--- a/convex/schema.ts
+++ b/convex/schema.ts
@@ -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)
+ }),
});
diff --git a/convex/serverSettings.ts b/convex/serverSettings.ts
new file mode 100644
index 0000000..ec64e5d
--- /dev/null
+++ b/convex/serverSettings.ts
@@ -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)?.["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;
+ },
+});
diff --git a/convex/voiceState.ts b/convex/voiceState.ts
index b4f4632..2042f14 100644
--- a/convex/voiceState.ts
+++ b/convex/voiceState.ts
@@ -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)?.["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)?.["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;
+ },
+});
diff --git a/discord-html-copy/Settings Panel/settings snippit.txt b/discord-html-copy/Settings Panel/settings snippit.txt
deleted file mode 100644
index 7e70f7e..0000000
--- a/discord-html-copy/Settings Panel/settings snippit.txt
+++ /dev/null
@@ -1,3 +0,0 @@
-
\ No newline at end of file
diff --git a/discord-html-copy/mention menu/mention snippit.txt b/discord-html-copy/mention menu/mention snippit.txt
deleted file mode 100644
index 1c85273..0000000
--- a/discord-html-copy/mention menu/mention snippit.txt
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/discord-html-copy/reply snippit.txt b/discord-html-copy/reply snippit.txt
deleted file mode 100644
index 2b5b1b8..0000000
--- a/discord-html-copy/reply snippit.txt
+++ /dev/null
@@ -1 +0,0 @@
-
@Brian With A Y

Speaking about uninstaling, khans?
\ No newline at end of file