From 7a5b789ece7b0c3f787d9c1790becd2dcc54df13 Mon Sep 17 00:00:00 2001 From: Bryan1029384756 <23323626+Bryan1029384756@users.noreply.github.com> Date: Thu, 12 Feb 2026 04:52:28 -0600 Subject: [PATCH] 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. --- CLAUDE.md | 8 +- Frontend/Electron/main.cjs | 20 +- Frontend/Electron/preload.cjs | 6 + Frontend/Electron/src/assets/icons/index.js | 13 +- .../src/assets/icons/personal_mute.svg | 1 + .../Electron/src/assets/icons/server_mute.svg | 1 + .../Electron/src/assets/icons/sharing.svg | 1 + Frontend/Electron/src/components/ChatArea.jsx | 82 +++- .../Electron/src/components/MembersList.jsx | 30 +- .../Electron/src/components/MentionMenu.jsx | 85 +++- .../Electron/src/components/MessageItem.jsx | 39 +- .../src/components/ScreenShareModal.jsx | 36 +- .../src/components/ServerSettingsModal.jsx | 97 ++++- Frontend/Electron/src/components/Sidebar.jsx | 378 ++++++++++++++++-- Frontend/Electron/src/components/TitleBar.jsx | 22 +- .../Electron/src/components/VoiceStage.jsx | 136 ++++++- .../Electron/src/contexts/VoiceContext.jsx | 151 ++++++- Frontend/Electron/src/index.css | 130 +++++- TODO.md | 30 +- convex/_generated/api.d.ts | 2 + convex/auth.ts | 1 + convex/channels.ts | 4 + convex/messages.ts | 17 +- convex/roles.ts | 11 +- convex/schema.ts | 6 + convex/serverSettings.ts | 69 ++++ convex/voiceState.ts | 120 ++++++ .../Settings Panel/settings snippit.txt | 3 - .../mention menu/mention snippit.txt | 1 - discord-html-copy/reply snippit.txt | 1 - 30 files changed, 1339 insertions(+), 162 deletions(-) create mode 100644 Frontend/Electron/src/assets/icons/personal_mute.svg create mode 100644 Frontend/Electron/src/assets/icons/server_mute.svg create mode 100644 Frontend/Electron/src/assets/icons/sharing.svg create mode 100644 convex/serverSettings.ts delete mode 100644 discord-html-copy/Settings Panel/settings snippit.txt delete mode 100644 discord-html-copy/mention menu/mention snippit.txt delete mode 100644 discord-html-copy/reply snippit.txt diff --git a/CLAUDE.md b/CLAUDE.md index 3783fa7..9a05213 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -14,19 +14,20 @@ See also: [CONVEX_RULES.md](./CONVEX_RULES.md) | [CONVEX_EXAMPLES.md](./CONVEX_E ## Key Convex Files (convex/) -- `schema.ts` - Full schema: userProfiles (with avatarStorageId, aboutMe, customStatus), categories (name, position), channels (with categoryId, topic, position), messages, messageReactions, channelKeys, roles, userRoles, invites, dmParticipants, typingIndicators, voiceStates, channelReadState +- `schema.ts` - Full schema: userProfiles (with avatarStorageId, aboutMe, customStatus), categories (name, position), channels (with categoryId, topic, position), messages, messageReactions, channelKeys, roles, userRoles, invites, dmParticipants, typingIndicators, voiceStates, channelReadState, serverSettings (afkChannelId, afkTimeout) - `auth.ts` - getSalt, verifyUser, createUserWithProfile, getPublicKeys (includes avatarUrl, aboutMe, customStatus), updateProfile, updateStatus - `categories.ts` - list, create, rename, remove, reorder - `channels.ts` - list, get, create (with categoryId/topic/position), rename, remove (cascade), updateTopic, moveChannel, reorderChannels - `members.ts` - getChannelMembers (includes isHoist on roles, avatarUrl, aboutMe, customStatus) - `channelKeys.ts` - uploadKeys, getKeysForUser -- `messages.ts` - list (with reactions + username), send, remove +- `messages.ts` - list (with reactions + username), send, edit, pin, listPinned, remove (with manage_messages permission check) - `reactions.ts` - add, remove +- `serverSettings.ts` - get, update (manage_channels permission), clearAfkChannel (internal) - `typing.ts` - startTyping, stopTyping, getTyping, cleanExpired (scheduled) - `dms.ts` - openDM, listDMs - `invites.ts` - create, use, revoke - `roles.ts` - list, create, update, remove, listMembers, assign, unassign, getMyPermissions -- `voiceState.ts` - join, leave, updateState, getAll +- `voiceState.ts` - join, leave, updateState, getAll, afkMove (self-move to AFK channel) - `voice.ts` - getToken (Node action, livekit-server-sdk) - `files.ts` - generateUploadUrl, getFileUrl - `gifs.ts` - search, categories (Node actions, Tenor API) @@ -72,6 +73,7 @@ See also: [CONVEX_RULES.md](./CONVEX_RULES.md) | [CONVEX_EXAMPLES.md](./CONVEX_E - Voice connected panel includes elapsed time timer - Keyboard shortcuts: Ctrl+K (quick switcher), Ctrl+Shift+M (mute toggle) - Unread tracking: `channelReadState` table stores last-read timestamp per user/channel. ChatArea shows red "NEW" divider, Sidebar shows white dot on unread channels +- AFK voice channel: `serverSettings` singleton table stores `afkChannelId` + `afkTimeout`. VoiceContext polls `idleAPI.getSystemIdleTime()` every 15s; auto-moves idle users to AFK channel via `voiceState.afkMove`. Users in AFK channel are force-muted and can't unmute. Sidebar shows "(AFK)" label. Server Settings Overview tab has AFK config UI. ## Environment Variables diff --git a/Frontend/Electron/main.cjs b/Frontend/Electron/main.cjs index 054764f..775a9b4 100644 --- a/Frontend/Electron/main.cjs +++ b/Frontend/Electron/main.cjs @@ -1,4 +1,4 @@ -const { app, BrowserWindow, ipcMain, shell, screen, safeStorage } = require('electron'); +const { app, BrowserWindow, ipcMain, shell, screen, safeStorage, powerMonitor } = require('electron'); const path = require('path'); const fs = require('fs'); @@ -613,4 +613,22 @@ app.whenReady().then(async () => { } }); }); + + // AFK voice channel: expose system idle time to renderer + ipcMain.handle('get-system-idle-time', () => powerMonitor.getSystemIdleTime()); + + // --- Auto-idle detection --- + const IDLE_THRESHOLD_SECONDS = 300; // 5 minutes + let wasIdle = false; + setInterval(() => { + if (!mainWindow || mainWindow.isDestroyed()) return; + const idleTime = powerMonitor.getSystemIdleTime(); + if (!wasIdle && idleTime >= IDLE_THRESHOLD_SECONDS) { + wasIdle = true; + mainWindow.webContents.send('idle-state-changed', { isIdle: true }); + } else if (wasIdle && idleTime < IDLE_THRESHOLD_SECONDS) { + wasIdle = false; + mainWindow.webContents.send('idle-state-changed', { isIdle: false }); + } + }, 15000); }); diff --git a/Frontend/Electron/preload.cjs b/Frontend/Electron/preload.cjs index 3f11439..25035b1 100644 --- a/Frontend/Electron/preload.cjs +++ b/Frontend/Electron/preload.cjs @@ -41,3 +41,9 @@ contextBridge.exposeInMainWorld('sessionPersistence', { load: () => ipcRenderer.invoke('load-session'), clear: () => ipcRenderer.invoke('clear-session'), }); + +contextBridge.exposeInMainWorld('idleAPI', { + onIdleStateChanged: (callback) => ipcRenderer.on('idle-state-changed', (_event, data) => callback(data)), + removeIdleStateListener: () => ipcRenderer.removeAllListeners('idle-state-changed'), + getSystemIdleTime: () => ipcRenderer.invoke('get-system-idle-time'), +}); diff --git a/Frontend/Electron/src/assets/icons/index.js b/Frontend/Electron/src/assets/icons/index.js index 90e4042..7608eb0 100644 --- a/Frontend/Electron/src/assets/icons/index.js +++ b/Frontend/Electron/src/assets/icons/index.js @@ -25,6 +25,9 @@ import DMIcon from './dm.svg'; import SpoilerIcon from './spoiler.svg'; import CrownIcon from './crown.svg'; import FriendsIcon from './friends.svg'; +import SharingIcon from './sharing.svg'; +import PersonalMuteIcon from './personal_mute.svg'; +import ServerMuteIcon from './server_mute.svg'; export { AddIcon, @@ -53,7 +56,10 @@ export { DMIcon, SpoilerIcon, CrownIcon, - FriendsIcon + FriendsIcon, + SharingIcon, + PersonalMuteIcon, + ServerMuteIcon }; export const Icons = { @@ -83,5 +89,8 @@ export const Icons = { DM: DMIcon, Spoiler: SpoilerIcon, Crown: CrownIcon, - Friends: FriendsIcon + Friends: FriendsIcon, + Sharing: SharingIcon, + PersonalMute: PersonalMuteIcon, + ServerMute: ServerMuteIcon }; diff --git a/Frontend/Electron/src/assets/icons/personal_mute.svg b/Frontend/Electron/src/assets/icons/personal_mute.svg new file mode 100644 index 0000000..d24d7df --- /dev/null +++ b/Frontend/Electron/src/assets/icons/personal_mute.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Frontend/Electron/src/assets/icons/server_mute.svg b/Frontend/Electron/src/assets/icons/server_mute.svg new file mode 100644 index 0000000..00c1e97 --- /dev/null +++ b/Frontend/Electron/src/assets/icons/server_mute.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Frontend/Electron/src/assets/icons/sharing.svg b/Frontend/Electron/src/assets/icons/sharing.svg new file mode 100644 index 0000000..aa393ae --- /dev/null +++ b/Frontend/Electron/src/assets/icons/sharing.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Frontend/Electron/src/components/ChatArea.jsx b/Frontend/Electron/src/components/ChatArea.jsx index f941351..c639728 100644 --- a/Frontend/Electron/src/components/ChatArea.jsx +++ b/Frontend/Electron/src/components/ChatArea.jsx @@ -115,6 +115,20 @@ const filterMembersForMention = (members, query) => { return [...prefix, ...substring]; }; +const filterRolesForMention = (roles, query) => { + if (!roles) return []; + const q = query.toLowerCase(); + if (!q) return roles; + const prefix = []; + const substring = []; + for (const r of roles) { + const name = r.name.replace(/^@/, '').toLowerCase(); + if (name.startsWith(q)) prefix.push(r); + else if (name.includes(q)) substring.push(r); + } + return [...prefix, ...substring]; +}; + const isNewDay = (current, previous) => { if (!previous) return true; return current.getDate() !== previous.getDate() @@ -411,7 +425,7 @@ const EmojiButton = ({ onClick, active }) => { ); }; -const MessageContextMenu = ({ x, y, onInteract, onClose, isOwner }) => { +const MessageContextMenu = ({ x, y, onInteract, onClose, isOwner, canDelete }) => { const menuRef = useRef(null); const [pos, setPos] = useState({ top: y, left: x }); useEffect(() => { const h = () => onClose(); window.addEventListener('click', h); return () => window.removeEventListener('click', h); }, [onClose]); @@ -441,7 +455,7 @@ const MessageContextMenu = ({ x, y, onInteract, onClose, isOwner }) => {
onInteract('pin')} />
- {isOwner && onInteract('delete')} />} + {canDelete && onInteract('delete')} />}
); }; @@ -488,6 +502,8 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u const convex = useConvex(); const members = useQuery(api.members.getChannelMembers, channelId ? { channelId } : "skip") || []; + const roles = useQuery(api.roles.list, channelType !== 'dm' ? {} : "skip") || []; + const myPermissions = useQuery(api.roles.getMyPermissions, currentUserId ? { userId: currentUserId } : "skip"); const { results: rawMessages, status, loadMore } = usePaginatedQuery( api.messages.list, @@ -713,7 +729,16 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u markChannelAsReadRef.current = markChannelAsRead; const typingUsers = typingData.filter(t => t.username !== username); - const filteredMentionMembers = mentionQuery !== null ? filterMembersForMention(members, mentionQuery) : []; + const mentionableRoles = roles.filter(r => r.name !== 'Owner'); + const filteredMentionRoles = mentionQuery !== null && channelType !== 'dm' + ? filterRolesForMention(mentionableRoles, mentionQuery) : []; + const filteredMentionMembers = mentionQuery !== null + ? filterMembersForMention(members, mentionQuery) : []; + const mentionItems = [ + ...filteredMentionRoles.map(r => ({ type: 'role', ...r })), + ...filteredMentionMembers.map(m => ({ type: 'member', ...m })), + ]; + const myRoleNames = members?.find(m => m.username === username)?.roles?.map(r => r.name) || []; const scrollToBottom = useCallback((force = false) => { const container = messagesContainerRef.current; @@ -876,7 +901,7 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u } }; - const insertMention = (member) => { + const insertMention = (item) => { if (!inputDivRef.current) return; const selection = window.getSelection(); if (!selection.rangeCount) return; @@ -889,8 +914,11 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u const matchStart = match.index + (match[0].startsWith(' ') ? 1 : 0); const before = node.textContent.substring(0, matchStart); const after = node.textContent.substring(range.startOffset); - node.textContent = before + '@' + member.username + ' ' + after; - const newOffset = before.length + 1 + member.username.length + 1; + const insertText = item.type === 'role' + ? (item.name.startsWith('@') ? `${item.name} ` : `@role:${item.name} `) + : `@${item.username} `; + node.textContent = before + insertText + after; + const newOffset = before.length + insertText.length; const newRange = document.createRange(); newRange.setStart(node, newOffset); newRange.collapse(true); @@ -1035,10 +1063,10 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u }; const handleKeyDown = (e) => { - if (mentionQuery !== null && filteredMentionMembers.length > 0) { - if (e.key === 'ArrowDown') { e.preventDefault(); setMentionIndex(i => (i + 1) % filteredMentionMembers.length); return; } - if (e.key === 'ArrowUp') { e.preventDefault(); setMentionIndex(i => (i - 1 + filteredMentionMembers.length) % filteredMentionMembers.length); return; } - if ((e.key === 'Enter' && !e.shiftKey) || e.key === 'Tab') { e.preventDefault(); insertMention(filteredMentionMembers[mentionIndex]); return; } + if (mentionQuery !== null && mentionItems.length > 0) { + if (e.key === 'ArrowDown') { e.preventDefault(); setMentionIndex(i => (i + 1) % mentionItems.length); return; } + if (e.key === 'ArrowUp') { e.preventDefault(); setMentionIndex(i => (i - 1 + mentionItems.length) % mentionItems.length); return; } + if ((e.key === 'Enter' && !e.shiftKey) || e.key === 'Tab') { e.preventDefault(); insertMention(mentionItems[mentionIndex]); return; } if (e.key === 'Escape') { e.preventDefault(); setMentionQuery(null); return; } } if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSend(e); } @@ -1098,7 +1126,7 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u pinMessageMutation({ id: msg.id, pinned: !msg.pinned }); break; case 'delete': - deleteMessageMutation({ id: msg.id }); + deleteMessageMutation({ id: msg.id, userId: currentUserId }); break; case 'reaction': addReaction({ messageId: msg.id, userId: currentUserId, emoji: 'heart' }); @@ -1166,8 +1194,16 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u {decryptedMessages.map((msg, idx) => { const currentDate = new Date(msg.created_at); const previousDate = idx > 0 ? new Date(decryptedMessages[idx - 1].created_at) : null; - const isMentioned = msg.content && msg.content.includes(`@${username}`); + const isMentioned = msg.content && ( + msg.content.includes(`@${username}`) || + myRoleNames.some(rn => + rn.startsWith('@') + ? msg.content.includes(rn) + : msg.content.includes(`@role:${rn}`) + ) + ); const isOwner = msg.username === username; + const canDelete = isOwner || !!myPermissions?.manage_messages; const prevMsg = idx > 0 ? decryptedMessages[idx - 1] : null; const isGrouped = prevMsg @@ -1194,17 +1230,18 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u dateLabel={dateLabel} isMentioned={isMentioned} isOwner={isOwner} + roles={roles} isEditing={editingMessage?.id === msg.id} isHovered={hoveredMessageId === msg.id} editInput={editInput} username={username} onHover={() => setHoveredMessageId(msg.id)} onLeave={() => setHoveredMessageId(null)} - onContextMenu={(e) => { e.preventDefault(); setContextMenu({ x: e.clientX, y: e.clientY, messageId: msg.id, isOwner }); }} + onContextMenu={(e) => { e.preventDefault(); setContextMenu({ x: e.clientX, y: e.clientY, messageId: msg.id, isOwner, canDelete }); }} onAddReaction={(emoji) => { addReaction({ messageId: msg.id, userId: currentUserId, emoji: emoji || 'heart' }); }} onEdit={() => { setEditingMessage({ id: msg.id, content: msg.content }); setEditInput(msg.content); }} onReply={() => setReplyingTo({ messageId: msg.id, username: msg.username, content: msg.content?.substring(0, 100) })} - onMore={(e) => { const rect = e.target.getBoundingClientRect(); setContextMenu({ x: rect.left, y: rect.bottom, messageId: msg.id, isOwner }); }} + onMore={(e) => { const rect = e.target.getBoundingClientRect(); setContextMenu({ x: rect.left, y: rect.bottom, messageId: msg.id, isOwner, canDelete }); }} onEditInputChange={(e) => setEditInput(e.target.value)} onEditKeyDown={handleEditKeyDown} onEditSave={handleEditSave} @@ -1223,12 +1260,12 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
- {contextMenu && setContextMenu(null)} onInteract={(action) => handleContextInteract(action, contextMenu.messageId)} />} + {contextMenu && setContextMenu(null)} onInteract={(action) => handleContextInteract(action, contextMenu.messageId)} />}
- {mentionQuery !== null && filteredMentionMembers.length > 0 && ( + {mentionQuery !== null && mentionItems.length > 0 && ( { + const items = e.clipboardData?.items; + if (items) { + for (const item of items) { + if (item.type.startsWith('image/')) { + e.preventDefault(); + const file = item.getAsFile(); + if (file) processFile(file); + return; + } + } + } e.preventDefault(); const text = e.clipboardData.getData('text/plain'); document.execCommand('insertText', false, text); diff --git a/Frontend/Electron/src/components/MembersList.jsx b/Frontend/Electron/src/components/MembersList.jsx index 153440f..a826261 100644 --- a/Frontend/Electron/src/components/MembersList.jsx +++ b/Frontend/Electron/src/components/MembersList.jsx @@ -2,7 +2,8 @@ import React from 'react'; import { useQuery } from 'convex/react'; import { api } from '../../../../convex/_generated/api'; import { useOnlineUsers } from '../contexts/PresenceContext'; -import { CrownIcon } from '../assets/icons'; +import { useVoice } from '../contexts/VoiceContext'; +import { CrownIcon, SharingIcon } from '../assets/icons'; const USER_COLORS = ['#5865F2', '#EBA7CD', '#57F287', '#FEE75C', '#EB459E', '#ED4245']; @@ -34,6 +35,16 @@ const MembersList = ({ channelId, visible, onMemberClick }) => { channelId ? { channelId } : "skip" ) || []; const { resolveStatus } = useOnlineUsers(); + const { voiceStates } = useVoice(); + + const usersInVoice = new Set(); + const usersScreenSharing = new Set(); + Object.values(voiceStates).forEach(users => { + users.forEach(u => { + usersInVoice.add(u.userId); + if (u.isScreenSharing) usersScreenSharing.add(u.userId); + }); + }); if (!visible) return null; @@ -99,11 +110,24 @@ const MembersList = ({ channelId, visible, onMemberClick }) => { {member.username} {isOwner && } - {member.customStatus && ( + {usersScreenSharing.has(member.id) ? ( +
+ + Sharing their screen +
+ ) : usersInVoice.has(member.id) ? ( +
+ + + + + In Voice +
+ ) : member.customStatus ? (
{member.customStatus}
- )} + ) : null} ); diff --git a/Frontend/Electron/src/components/MentionMenu.jsx b/Frontend/Electron/src/components/MentionMenu.jsx index 14ef953..8679fed 100644 --- a/Frontend/Electron/src/components/MentionMenu.jsx +++ b/Frontend/Electron/src/components/MentionMenu.jsx @@ -1,7 +1,7 @@ import React, { useEffect, useRef } from 'react'; import Avatar from './Avatar'; -const MentionMenu = ({ members, selectedIndex, onSelect, onHover }) => { +const MentionMenu = ({ items, selectedIndex, onSelect, onHover }) => { const scrollerRef = useRef(null); useEffect(() => { @@ -10,31 +10,72 @@ const MentionMenu = ({ members, selectedIndex, onSelect, onHover }) => { if (selected) selected.scrollIntoView({ block: 'nearest' }); }, [selectedIndex]); - if (!members || members.length === 0) return null; + if (!items || items.length === 0) return null; + + const roleItems = items.filter(i => i.type === 'role'); + const memberItems = items.filter(i => i.type === 'member'); + + let globalIndex = 0; return (
-
Members
- {members.map((member, i) => { - const topRole = member.roles && member.roles.length > 0 ? member.roles[0] : null; - const nameColor = topRole?.color || undefined; - return ( -
e.preventDefault()} - onClick={() => onSelect(member)} - onMouseEnter={() => onHover(i)} - > - - - {member.username} - - {member.username} -
- ); - })} + {roleItems.length > 0 && ( + <> +
Roles
+ {roleItems.map((role) => { + const idx = globalIndex++; + const displayName = role.name.startsWith('@') ? role.name : `@${role.name}`; + return ( +
e.preventDefault()} + onClick={() => onSelect(role)} + onMouseEnter={() => onHover(idx)} + > +
+ @ +
+ + {displayName} + +
+ ); + })} + + )} + {memberItems.length > 0 && ( + <> +
Members
+ {memberItems.map((member) => { + const idx = globalIndex++; + const topRole = member.roles && member.roles.length > 0 ? member.roles[0] : null; + const nameColor = topRole?.color || undefined; + return ( +
e.preventDefault()} + onClick={() => onSelect(member)} + onMouseEnter={() => onHover(idx)} + > + + + {member.username} + + {member.username} +
+ ); + })} + + )}
); diff --git a/Frontend/Electron/src/components/MessageItem.jsx b/Frontend/Electron/src/components/MessageItem.jsx index a41e618..99ecdcc 100644 --- a/Frontend/Electron/src/components/MessageItem.jsx +++ b/Frontend/Electron/src/components/MessageItem.jsx @@ -33,9 +33,29 @@ export const extractUrls = (text) => { return text.match(urlRegex) || []; }; -export const formatMentions = (text) => { +export const formatMentions = (text, roles) => { if (!text) return ''; - return text.replace(/@(\w+)/g, '[@$1](mention://$1)'); + // First pass: replace @role:Name with role mention links + let result = text.replace(/@role:([^\s]+)/g, (match, name) => { + const role = roles?.find(r => r.name === name); + const color = role?.color || '#99aab5'; + const displayName = name.startsWith('@') ? name : `@${name}`; + return `[${displayName}](rolemention://${encodeURIComponent(name)}?color=${encodeURIComponent(color)})`; + }); + // Second pass: replace @-prefixed role names (like @everyone) directly + if (roles) { + for (const role of roles) { + if (role.name.startsWith('@')) { + const escaped = role.name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const re = new RegExp(`(? { @@ -93,6 +113,15 @@ const isNewDay = (current, previous) => { const markdownComponents = { a: ({ node, ...props }) => { + if (props.href && props.href.startsWith('rolemention://')) { + try { + const url = new URL(props.href); + const color = url.searchParams.get('color') || '#99aab5'; + return {props.children}; + } catch { + return {props.children}; + } + } if (props.href && props.href.startsWith('mention://')) return {props.children}; return { e.preventDefault(); window.cryptoAPI.openExternal(props.href); }} style={{ color: '#00b0f4', cursor: 'pointer', textDecoration: 'none' }} onMouseOver={(e) => e.target.style.textDecoration = 'underline'} onMouseOut={(e) => e.target.style.textDecoration = 'none'} />; }, @@ -163,6 +192,7 @@ const MessageItem = React.memo(({ isHovered, editInput, username, + roles, onHover, onLeave, onContextMenu, @@ -213,7 +243,7 @@ const MessageItem = React.memo(({ <> {!isGif && !isDirectVideo && ( url} components={markdownComponents}> - {formatEmojis(formatMentions(msg.content))} + {formatEmojis(formatMentions(msg.content, roles))} )} {isDirectVideo && } @@ -349,7 +379,8 @@ const MessageItem = React.memo(({ prevProps.isGrouped === nextProps.isGrouped && prevProps.showDateDivider === nextProps.showDateDivider && prevProps.showUnreadDivider === nextProps.showUnreadDivider && - prevProps.isMentioned === nextProps.isMentioned + prevProps.isMentioned === nextProps.isMentioned && + prevProps.roles === nextProps.roles ); }); diff --git a/Frontend/Electron/src/components/ScreenShareModal.jsx b/Frontend/Electron/src/components/ScreenShareModal.jsx index 0d16c10..948a657 100644 --- a/Frontend/Electron/src/components/ScreenShareModal.jsx +++ b/Frontend/Electron/src/components/ScreenShareModal.jsx @@ -4,6 +4,7 @@ const ScreenShareModal = ({ onClose, onSelectSource }) => { const [activeTab, setActiveTab] = useState('applications'); // applications | screens | devices const [sources, setSources] = useState([]); const [loading, setLoading] = useState(true); + const [shareAudio, setShareAudio] = useState(true); useEffect(() => { loadSources(); @@ -43,11 +44,11 @@ const ScreenShareModal = ({ onClose, onSelectSource }) => { }; const handleSelect = (source) => { - // If device, pass constraints differently + // If device, pass constraints differently (webcams don't have loopback audio) if (source.isDevice) { - onSelectSource({ deviceId: source.id, type: 'device' }); + onSelectSource({ deviceId: source.id, type: 'device', shareAudio: false }); } else { - onSelectSource({ sourceId: source.id, type: 'screen' }); + onSelectSource({ sourceId: source.id, type: 'screen', shareAudio }); } onClose(); }; @@ -210,6 +211,35 @@ const ScreenShareModal = ({ onClose, onSelectSource }) => { renderGrid(sources[activeTab]) )} + + {/* Audio sharing footer — hidden for device sources (webcams) */} + {activeTab !== 'devices' && ( +
+ +
+ )} ); diff --git a/Frontend/Electron/src/components/ServerSettingsModal.jsx b/Frontend/Electron/src/components/ServerSettingsModal.jsx index 2ae0ef1..57a5686 100644 --- a/Frontend/Electron/src/components/ServerSettingsModal.jsx +++ b/Frontend/Electron/src/components/ServerSettingsModal.jsx @@ -2,6 +2,14 @@ import React, { useState } from 'react'; import { useQuery, useConvex } from 'convex/react'; import { api } from '../../../../convex/_generated/api'; +const TIMEOUT_OPTIONS = [ + { value: 60, label: '1 min' }, + { value: 300, label: '5 min' }, + { value: 900, label: '15 min' }, + { value: 1800, label: '30 min' }, + { value: 3600, label: '1 hour' }, +]; + const ServerSettingsModal = ({ onClose }) => { const [activeTab, setActiveTab] = useState('Overview'); const [selectedRole, setSelectedRole] = useState(null); @@ -17,6 +25,37 @@ const ServerSettingsModal = ({ onClose }) => { userId ? { userId } : "skip" ) || {}; + // AFK settings + const serverSettings = useQuery(api.serverSettings.get); + const channels = useQuery(api.channels.list) || []; + const voiceChannels = channels.filter(c => c.type === 'voice'); + const [afkChannelId, setAfkChannelId] = useState(''); + const [afkTimeout, setAfkTimeout] = useState(300); + const [afkDirty, setAfkDirty] = useState(false); + + React.useEffect(() => { + if (serverSettings) { + setAfkChannelId(serverSettings.afkChannelId || ''); + setAfkTimeout(serverSettings.afkTimeout || 300); + setAfkDirty(false); + } + }, [serverSettings]); + + const handleSaveAfkSettings = async () => { + if (!userId) return; + try { + await convex.mutation(api.serverSettings.update, { + userId, + afkChannelId: afkChannelId || undefined, + afkTimeout, + }); + setAfkDirty(false); + } catch (e) { + console.error('Failed to update server settings:', e); + alert('Failed to save settings: ' + e.message); + } + }; + const handleCreateRole = async () => { try { const newRole = await convex.mutation(api.roles.create, { @@ -63,7 +102,7 @@ const ServerSettingsModal = ({ onClose }) => { const renderSidebar = () => (
@@ -141,7 +180,7 @@ const ServerSettingsModal = ({ onClose }) => { /> - {['manage_channels', 'manage_roles', 'create_invite', 'embed_links', 'attach_files'].map(perm => ( + {['manage_channels', 'manage_roles', 'create_invite', 'embed_links', 'attach_files', 'move_members', 'mute_members'].map(perm => (
{perm.replace('_', ' ')} { switch (activeTab) { case 'Roles': return renderRolesTab(); case 'Members': return renderMembersTab(); - default: return
Server Name: Secure Chat
Region: US-East
; + default: return ( +
+
Server Name: Secure Chat
Region: US-East
+ + + + + + + + {afkDirty && myPermissions.manage_channels && ( + + )} +
+ ); } }; diff --git a/Frontend/Electron/src/components/Sidebar.jsx b/Frontend/Electron/src/components/Sidebar.jsx index 964a545..3298721 100644 --- a/Frontend/Electron/src/components/Sidebar.jsx +++ b/Frontend/Electron/src/components/Sidebar.jsx @@ -11,7 +11,7 @@ import DMList from './DMList'; import Avatar from './Avatar'; import UserSettings from './UserSettings'; import { Track } from 'livekit-client'; -import { DndContext, closestCenter, PointerSensor, useSensor, useSensors, DragOverlay } from '@dnd-kit/core'; +import { DndContext, closestCenter, PointerSensor, useSensor, useSensors, DragOverlay, useDraggable } from '@dnd-kit/core'; import { SortableContext, verticalListSortingStrategy, useSortable } from '@dnd-kit/sortable'; import { CSS } from '@dnd-kit/utilities'; import muteIcon from '../assets/icons/mute.svg'; @@ -24,13 +24,18 @@ import disconnectIcon from '../assets/icons/disconnect.svg'; import cameraIcon from '../assets/icons/camera.svg'; import screenIcon from '../assets/icons/screen.svg'; import inviteUserIcon from '../assets/icons/invite_user.svg'; +import personalMuteIcon from '../assets/icons/personal_mute.svg'; +import serverMuteIcon from '../assets/icons/server_mute.svg'; import categoryCollapsedIcon from '../assets/icons/category_collapsed_icon.svg'; import PingSound from '../assets/sounds/ping.mp3'; +import screenShareStartSound from '../assets/sounds/screenshare_start.mp3'; +import screenShareStopSound from '../assets/sounds/screenshare_stop.mp3'; const USER_COLORS = ['#5865F2', '#EBA7CD', '#57F287', '#FEE75C', '#EB459E', '#ED4245']; const ICON_COLOR_DEFAULT = 'color-mix(in oklab, hsl(240 4.294% 68.039% / 1) 100%, #000 0%)'; const ICON_COLOR_ACTIVE = 'hsl(357.692 67.826% 54.902% / 1)'; +const SERVER_MUTE_RED = 'color-mix(in oklab, hsl(1.343 calc(1*84.81%) 69.02% /1) 100%, #000 0%)'; const controlButtonStyle = { background: 'transparent', @@ -114,6 +119,8 @@ const UserControlPanel = ({ username, userId }) => { const [currentStatus, setCurrentStatus] = useState('online'); const updateStatusMutation = useMutation(api.auth.updateStatus); const navigate = useNavigate(); + const manualStatusRef = useRef(false); + const preIdleStatusRef = useRef('online'); // Fetch stored status preference from server and sync local state const allUsers = useQuery(api.auth.getPublicKeys) || []; @@ -122,9 +129,12 @@ const UserControlPanel = ({ username, userId }) => { if (myUser) { if (myUser.status && myUser.status !== 'offline') { setCurrentStatus(myUser.status); + // dnd/invisible are manual overrides; idle is auto-set so don't count it + manualStatusRef.current = (myUser.status === 'dnd' || myUser.status === 'invisible'); } else if (!myUser.status || myUser.status === 'offline') { // First login or no preference set yet — default to "online" setCurrentStatus('online'); + manualStatusRef.current = false; if (userId) { updateStatusMutation({ userId, status: 'online' }).catch(() => {}); } @@ -153,6 +163,7 @@ const UserControlPanel = ({ username, userId }) => { const statusColor = STATUS_OPTIONS.find(s => s.value === currentStatus)?.color || '#3ba55c'; const handleStatusChange = async (status) => { + manualStatusRef.current = (status !== 'online'); setCurrentStatus(status); setShowStatusMenu(false); if (userId) { @@ -164,6 +175,25 @@ const UserControlPanel = ({ username, userId }) => { } }; + // Auto-idle detection via Electron powerMonitor + useEffect(() => { + if (!window.idleAPI || !userId) return; + const handleIdleChange = (data) => { + if (manualStatusRef.current) return; + if (data.isIdle) { + preIdleStatusRef.current = currentStatus; + setCurrentStatus('idle'); + updateStatusMutation({ userId, status: 'idle' }).catch(() => {}); + } else { + const restoreTo = preIdleStatusRef.current || 'online'; + setCurrentStatus(restoreTo); + updateStatusMutation({ userId, status: restoreTo }).catch(() => {}); + } + }; + window.idleAPI.onIdleStateChanged(handleIdleChange); + return () => window.idleAPI.removeIdleStateListener(); + }, [userId]); + return (
{ + const menuRef = useRef(null); + const [pos, setPos] = useState({ top: y, left: x }); + + useEffect(() => { + const h = () => onClose(); + window.addEventListener('click', h); + return () => window.removeEventListener('click', h); + }, [onClose]); + + useLayoutEffect(() => { + if (!menuRef.current) return; + const rect = menuRef.current.getBoundingClientRect(); + let newTop = y, newLeft = x; + if (x + rect.width > window.innerWidth) newLeft = x - rect.width; + if (y + rect.height > window.innerHeight) newTop = y - rect.height; + if (newLeft < 0) newLeft = 10; + if (newTop < 0) newTop = 10; + setPos({ top: newTop, left: newLeft }); + }, [x, y]); + + return ( +
e.stopPropagation()}> +
{ e.stopPropagation(); onMute(); }} + > + Mute +
+
+ {isMuted ? ( + + + + ) : ( + + + )} +
+
+
+ {hasPermission && ( +
{ e.stopPropagation(); onServerMute(); }} + > + Server Mute +
+
+ {isServerMuted ? ( + + + + ) : ( + + + )} +
+
+
+ )} +
+
{ e.stopPropagation(); onMessage(); onClose(); }}> + Message +
+
+ ); +}; + const ChannelListContextMenu = ({ x, y, onClose, onCreateChannel, onCreateCategory }) => { const menuRef = useRef(null); const [pos, setPos] = useState({ top: y, left: x }); @@ -580,7 +691,29 @@ const SortableChannel = ({ id, children }) => { }; return ( -
+
+ {typeof children === 'function' ? children(listeners) : children} +
+ ); +}; + +const DraggableVoiceUser = ({ userId, channelId, disabled, children }) => { + const { attributes, listeners, setNodeRef, isDragging } = useDraggable({ + id: `voice-user-${userId}`, + data: { type: 'voice-user', userId, channelId }, + disabled, + }); + + return ( +
{children}
); @@ -595,13 +728,21 @@ const Sidebar = ({ channels, categories, activeChannel, onSelectChannel, usernam const [isScreenShareModalOpen, setIsScreenShareModalOpen] = useState(false); const [collapsedCategories, setCollapsedCategories] = useState({}); const [channelListContextMenu, setChannelListContextMenu] = useState(null); + const [voiceUserMenu, setVoiceUserMenu] = useState(null); const [showCreateChannelModal, setShowCreateChannelModal] = useState(false); const [showCreateCategoryModal, setShowCreateCategoryModal] = useState(false); const [createChannelCategoryId, setCreateChannelCategoryId] = useState(null); const [activeDragItem, setActiveDragItem] = useState(null); + const [dragOverChannelId, setDragOverChannelId] = useState(null); const convex = useConvex(); + // Permissions for move_members gating + const myPermissions = useQuery( + api.roles.getMyPermissions, + userId ? { userId } : "skip" + ) || {}; + // DnD sensors const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 5 } }) @@ -674,7 +815,7 @@ const Sidebar = ({ channels, categories, activeChannel, onSelectChannel, usernam if (activeChannel === id) onSelectChannel(null); }; - const { connectToVoice, activeChannelId: voiceChannelId, connectionState, disconnectVoice, activeChannelName: voiceChannelName, voiceStates, room, activeSpeakers, setScreenSharing } = useVoice(); + const { connectToVoice, activeChannelId: voiceChannelId, connectionState, disconnectVoice, activeChannelName: voiceChannelName, voiceStates, room, activeSpeakers, setScreenSharing, isPersonallyMuted, togglePersonalMute, isMuted: selfMuted, toggleMute, serverMute, isServerMuted, serverSettings } = useVoice(); const handleStartCreate = () => { setIsCreating(true); @@ -772,7 +913,19 @@ const Sidebar = ({ channels, categories, activeChannel, onSelectChannel, usernam await room.localParticipant.setScreenShareEnabled(false); } - const stream = await navigator.mediaDevices.getUserMedia(getScreenCaptureConstraints(selection)); + let stream; + try { + stream = await navigator.mediaDevices.getUserMedia(getScreenCaptureConstraints(selection)); + } catch (audioErr) { + // Audio capture may fail (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(getScreenCaptureConstraints({ ...selection, shareAudio: false })); + } else { + throw audioErr; + } + } + const track = stream.getVideoTracks()[0]; if (!track) return; @@ -781,9 +934,24 @@ const Sidebar = ({ channels, categories, activeChannel, onSelectChannel, usernam 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 + }); + } + + new Audio(screenShareStartSound).play(); setScreenSharing(true); track.onended = () => { + // Clean up audio track when video track ends + if (audioTrack) { + audioTrack.stop(); + room.localParticipant.unpublishTrack(audioTrack); + } setScreenSharing(false); room.localParticipant.setScreenShareEnabled(false).catch(console.error); }; @@ -795,7 +963,17 @@ const Sidebar = ({ channels, categories, activeChannel, onSelectChannel, usernam const handleScreenShareClick = () => { if (room?.localParticipant.isScreenShareEnabled) { + // Clean up any screen share audio tracks before stopping + for (const pub of room.localParticipant.trackPublications.values()) { + const source = pub.source ? pub.source.toString().toLowerCase() : ''; + const name = pub.trackName ? pub.trackName.toLowerCase() : ''; + if (source === 'screen_share_audio' || name === 'screen_share_audio') { + if (pub.track) pub.track.stop(); + room.localParticipant.unpublishTrack(pub.track); + } + } room.localParticipant.setScreenShareEnabled(false); + new Audio(screenShareStopSound).play(); setScreenSharing(false); } else { setIsScreenShareModalOpen(true); @@ -828,31 +1006,83 @@ const Sidebar = ({ channels, categories, activeChannel, onSelectChannel, usernam return (
{users.map(user => ( -
- +
{ + e.preventDefault(); + e.stopPropagation(); + setVoiceUserMenu({ x: e.clientX, y: e.clientY, user }); }} - /> - {user.username} -
- {user.isScreenSharing &&
Live
} - {(user.isMuted || user.isDeafened) && ( - - )} - {user.isDeafened && ( - - )} + > + + {user.username} +
+ {user.isScreenSharing &&
Live
} + {user.isServerMuted ? ( + + ) : isPersonallyMuted(user.userId) ? ( + + ) : (user.isMuted || user.isDeafened) ? ( + + ) : null} + {user.isDeafened && ( + + )} +
-
+ ))}
); }; + const renderCollapsedVoiceUsers = (channel) => { + const users = voiceStates[channel._id]; + if (channel.type !== 'voice' || !users?.length) return null; + + return ( +
handleChannelClick(channel)} + style={{ position: 'relative', display: 'flex', alignItems: 'center', paddingRight: '8px' }} + > +
+ +
+
+ {users.map(user => ( +
+ +
+ ))} +
+
+ ); + }; + const toggleCategory = (cat) => { setCollapsedCategories(prev => ({ ...prev, [cat]: !prev[cat] })); }; @@ -901,17 +1131,65 @@ const Sidebar = ({ channels, categories, activeChannel, onSelectChannel, usernam const chId = active.id.replace('channel-', ''); const ch = channels.find(c => c._id === chId); setActiveDragItem({ type: 'channel', channel: ch }); + } else if (activeType === 'voice-user') { + const targetUserId = active.data.current.userId; + const sourceChannelId = active.data.current.channelId; + const users = voiceStates[sourceChannelId]; + const user = users?.find(u => u.userId === targetUserId); + setActiveDragItem({ type: 'voice-user', user, sourceChannelId }); } }; + const handleDragOver = (event) => { + const { active, over } = event; + if (!active?.data.current || active.data.current.type !== 'voice-user') { + setDragOverChannelId(null); + return; + } + if (over) { + // Check if hovering over a voice channel (channel item or its DnD wrapper) + const overType = over.data.current?.type; + if (overType === 'channel') { + const chId = over.id.replace('channel-', ''); + const ch = channels.find(c => c._id === chId); + if (ch?.type === 'voice') { + setDragOverChannelId(ch._id); + return; + } + } + } + setDragOverChannelId(null); + }; + const handleDragEnd = async (event) => { setActiveDragItem(null); + setDragOverChannelId(null); const { active, over } = event; if (!over || active.id === over.id) return; const activeType = active.data.current?.type; const overType = over.data.current?.type; + // Handle voice-user drag + if (activeType === 'voice-user') { + if (overType !== 'channel') return; + const targetChId = over.id.replace('channel-', ''); + const targetChannel = channels.find(c => c._id === targetChId); + if (!targetChannel || targetChannel.type !== 'voice') return; + const sourceChannelId = active.data.current.channelId; + if (sourceChannelId === targetChId) return; + try { + await convex.mutation(api.voiceState.moveUser, { + actorUserId: userId, + targetUserId: active.data.current.userId, + targetChannelId: targetChId, + }); + } catch (e) { + console.error('Failed to move voice user:', e); + } + return; + } + if (activeType === 'category' && overType === 'category') { // Reorder categories const oldIndex = groupedChannels.findIndex(g => `category-${g.id}` === active.id); @@ -1043,6 +1321,7 @@ const Sidebar = ({ channels, categories, activeChannel, onSelectChannel, usernam sensors={sensors} collisionDetection={closestCenter} onDragStart={handleDragStart} + onDragOver={handleDragOver} onDragEnd={handleDragEnd} > @@ -1060,8 +1339,12 @@ const Sidebar = ({ channels, categories, activeChannel, onSelectChannel, usernam }} /> {(() => { - const visibleChannels = collapsedCategories[group.id] - ? group.channels.filter(ch => ch._id === activeChannel) + const isCollapsed = collapsedCategories[group.id]; + const visibleChannels = isCollapsed + ? group.channels.filter(ch => + ch._id === activeChannel || + (ch.type === 'voice' && voiceStates[ch._id]?.length > 0) + ) : group.channels; if (visibleChannels.length === 0) return null; const visibleDndIds = visibleChannels.map(ch => `channel-${ch._id}`); @@ -1071,10 +1354,12 @@ const Sidebar = ({ channels, categories, activeChannel, onSelectChannel, usernam const isUnread = activeChannel !== channel._id && unreadChannels.has(channel._id); return ( + {(channelDragListeners) => ( -
0) &&
handleChannelClick(channel)} + {...channelDragListeners} style={{ position: 'relative', display: 'flex', @@ -1096,7 +1381,9 @@ const Sidebar = ({ channels, categories, activeChannel, onSelectChannel, usernam ) : ( # )} - {channel.name} + + {channel.name}{serverSettings?.afkChannelId === channel._id ? ' (AFK)' : ''} +
-
- {renderVoiceUsers(channel)} +
} + {isCollapsed + ? renderCollapsedVoiceUsers(channel) + : renderVoiceUsers(channel)} + )} ); })} @@ -1145,6 +1435,17 @@ const Sidebar = ({ channels, categories, activeChannel, onSelectChannel, usernam {activeDragItem.name}
)} + {activeDragItem?.type === 'voice-user' && activeDragItem.user && ( +
+ + {activeDragItem.user.username} +
+ )}
@@ -1283,6 +1584,23 @@ const Sidebar = ({ channels, categories, activeChannel, onSelectChannel, usernam onCreateCategory={() => setShowCreateCategoryModal(true)} /> )} + {voiceUserMenu && ( + setVoiceUserMenu(null)} + isMuted={voiceUserMenu.user.userId === userId ? selfMuted : isPersonallyMuted(voiceUserMenu.user.userId)} + onMute={() => voiceUserMenu.user.userId === userId ? toggleMute() : togglePersonalMute(voiceUserMenu.user.userId)} + isServerMuted={isServerMuted(voiceUserMenu.user.userId)} + onServerMute={() => serverMute(voiceUserMenu.user.userId, !isServerMuted(voiceUserMenu.user.userId))} + hasPermission={!!myPermissions.mute_members} + onMessage={() => { + onOpenDM(voiceUserMenu.user.userId, voiceUserMenu.user.username); + onViewChange('me'); + }} + /> + )} {showCreateChannelModal && ( { return (
-
- - -
-
Discord Clone
+
Brycord
@@ -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 @@ -

Members

Moyettes
moyettes
MoyMusic
MoyMusic#2029
MoyTalk
MoyTalk#8753
@time
new
Refer to a time dynamically in the viewer's time zone
@Banned
Notify users with this role who have permission to view this channel.
@Midjourney Bot
Notify users with this role who have permission to view this channel.
@MoyMagic
Notify users with this role who have permission to view this channel.
@MoyMine
Notify users with this role who have permission to view this channel.
@MoyMusic
Notify users with this role who have permission to view this channel.
@MoyTalk
Notify users with this role who have permission to view this channel.
\ 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 @@ -
  • i never uninstalled it

    dꚙbySaturday, February 7, 2026 7:19 PM

    Speaking about uninstaling, khans?
    :thumbsup:
    Click to react
    :fire:
    Click to react
    :heart:
    Click to react
    Add Reaction
    Reply
    ForwardMore
  • \ No newline at end of file