feat: Implement core Discord clone functionality including Convex backend services for authentication, channels, messages, roles, and voice state, alongside new Electron frontend components for chat, voice, server settings, and user interface.
All checks were successful
Build and Release / build-and-release (push) Successful in 14m19s

This commit is contained in:
Bryan1029384756
2026-02-12 04:52:28 -06:00
parent e790db7029
commit 7a5b789ece
30 changed files with 1339 additions and 162 deletions

View File

@@ -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

View File

@@ -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);
});

View File

@@ -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'),
});

View File

@@ -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
};

View File

@@ -0,0 +1 @@
<svg class="icon__07f91" aria-describedby="«rs2f»" aria-hidden="true" role="img" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24"><path fill="currentColor" fill-rule="evenodd" d="M21.76.83a5.02 5.02 0 0 1 .78 7.7 5 5 0 0 1-7.07 0 5.02 5.02 0 0 1 0-7.07 5 5 0 0 1 6.29-.63Zm-4.88 2.05a3 3 0 0 1 3.41-.59l-4 4a3 3 0 0 1 .59-3.41Zm4.83.83-4 4a3 3 0 0 0 4-4Z" clip-rule="evenodd" class=""></path><path fill="currentColor" d="M12 2c.33 0 .51.35.4.66a6.99 6.99 0 0 0 3.04 8.37c.2.12.31.37.21.6A4 4 0 0 1 8 10V6a4 4 0 0 1 4-4Z" class=""></path><path fill="currentColor" d="M17.55 12.29c.1-.23.33-.37.58-.34.29.03.58.05.87.05h.04c.35 0 .63.32.51.65A8 8 0 0 1 13 17.94V20h2a1 1 0 1 1 0 2H9a1 1 0 1 1 0-2h2v-2.06A8 8 0 0 1 4 10a1 1 0 0 1 2 0 6 6 0 0 0 11.55 2.29Z" class=""></path></svg>

After

Width:  |  Height:  |  Size: 820 B

View File

@@ -0,0 +1 @@
<svg class="icon__07f91 iconServer__07f91" aria-describedby="«rs2f»" aria-hidden="true" role="img" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24"><path fill="currentColor" fill-rule="evenodd" d="M21.76.83a5.02 5.02 0 0 1 .78 7.7 5 5 0 0 1-7.07 0 5.02 5.02 0 0 1 0-7.07 5 5 0 0 1 6.29-.63Zm-4.88 2.05a3 3 0 0 1 3.41-.59l-4 4a3 3 0 0 1 .59-3.41Zm4.83.83-4 4a3 3 0 0 0 4-4Z" clip-rule="evenodd" class=""></path><path fill="currentColor" d="M12 2c.33 0 .51.35.4.66a6.99 6.99 0 0 0 3.04 8.37c.2.12.31.37.21.6A4 4 0 0 1 8 10V6a4 4 0 0 1 4-4Z" class=""></path><path fill="currentColor" d="M17.55 12.29c.1-.23.33-.37.58-.34.29.03.58.05.87.05h.04c.35 0 .63.32.51.65A8 8 0 0 1 13 17.94V20h2a1 1 0 1 1 0 2H9a1 1 0 1 1 0-2h2v-2.06A8 8 0 0 1 4 10a1 1 0 0 1 2 0 6 6 0 0 0 11.55 2.29Z" class=""></path></svg>

After

Width:  |  Height:  |  Size: 838 B

View File

@@ -0,0 +1 @@
<svg class="icon_c9d15c" aria-hidden="true" role="img" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#45a366" d="M4 3a3 3 0 0 0-3 3v9a3 3 0 0 0 3 3h16a3 3 0 0 0 3-3V6a3 3 0 0 0-3-3H4ZM6 20a1 1 0 1 0 0 2h12a1 1 0 1 0 0-2H6Z" class=""></path></svg>

After

Width:  |  Height:  |  Size: 279 B

View File

@@ -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 }) => {
<div className="context-menu-separator" />
<MenuItem label="Pin Message" iconSrc={PinIcon} iconColor={ICON_COLOR_DEFAULT} onClick={() => onInteract('pin')} />
<div className="context-menu-separator" />
{isOwner && <MenuItem label="Delete Message" iconSrc={DeleteIcon} iconColor={ICON_COLOR_DANGER} danger onClick={() => onInteract('delete')} />}
{canDelete && <MenuItem label="Delete Message" iconSrc={DeleteIcon} iconColor={ICON_COLOR_DANGER} danger onClick={() => onInteract('delete')} />}
</div>
);
};
@@ -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
<div ref={messagesEndRef} />
</div>
</div>
{contextMenu && <MessageContextMenu x={contextMenu.x} y={contextMenu.y} isOwner={contextMenu.isOwner} onClose={() => setContextMenu(null)} onInteract={(action) => handleContextInteract(action, contextMenu.messageId)} />}
{contextMenu && <MessageContextMenu x={contextMenu.x} y={contextMenu.y} isOwner={contextMenu.isOwner} canDelete={contextMenu.canDelete} onClose={() => setContextMenu(null)} onInteract={(action) => handleContextInteract(action, contextMenu.messageId)} />}
<form className="chat-input-form" onSubmit={handleSend} style={{ position: 'relative' }}>
{mentionQuery !== null && filteredMentionMembers.length > 0 && (
{mentionQuery !== null && mentionItems.length > 0 && (
<MentionMenu
members={filteredMentionMembers}
items={mentionItems}
selectedIndex={mentionIndex}
onSelect={insertMention}
onHover={setMentionIndex}
@@ -1266,6 +1303,17 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
onMouseUp={saveSelection}
onKeyUp={saveSelection}
onPaste={(e) => {
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);

View File

@@ -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 && <ColoredIcon src={CrownIcon} color="var(--text-feedback-warning)" size="14px" />}
</span>
{member.customStatus && (
{usersScreenSharing.has(member.id) ? (
<div className="member-screen-sharing-indicator">
<img src={SharingIcon} alt="" />
Sharing their screen
</div>
) : usersInVoice.has(member.id) ? (
<div className="member-voice-indicator">
<svg viewBox="0 0 24 24" fill="#3ba55c">
<path d="M12 3a1 1 0 0 0-1-1h-.06a1 1 0 0 0-.74.32L5.92 7H3a1 1 0 0 0-1 1v8a1 1 0 0 0 1 1h2.92l4.28 4.68a1 1 0 0 0 .74.32H11a1 1 0 0 0 1-1zm3.1 17.75c-.58.14-1.1-.33-1.1-.92v-.03c0-.5.37-.92.85-1.05a7 7 0 0 0 0-13.5A1.11 1.11 0 0 1 14 4.2v-.03c0-.6.52-1.06 1.1-.92a9 9 0 0 1 0 17.5"/>
<path d="M15.16 16.51c-.57.28-1.16-.2-1.16-.83v-.14c0-.43.28-.8.63-1.02a3 3 0 0 0 0-5.04c-.35-.23-.63-.6-.63-1.02v-.14c0-.63.59-1.1 1.16-.83a5 5 0 0 1 0 9.02"/>
</svg>
In Voice
</div>
) : member.customStatus ? (
<div style={{ fontSize: '12px', color: 'var(--text-muted)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
{member.customStatus}
</div>
)}
) : null}
</div>
</div>
);

View File

@@ -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 (
<div className="mention-menu">
<div className="mention-menu-header">Members</div>
<div className="mention-menu-scroller" ref={scrollerRef}>
{members.map((member, i) => {
const topRole = member.roles && member.roles.length > 0 ? member.roles[0] : null;
const nameColor = topRole?.color || undefined;
return (
<div
key={member.id}
className={`mention-menu-row${i === selectedIndex ? ' selected' : ''}`}
onMouseDown={(e) => e.preventDefault()}
onClick={() => onSelect(member)}
onMouseEnter={() => onHover(i)}
>
<Avatar username={member.username} avatarUrl={member.avatarUrl} size={24} />
<span className="mention-menu-row-primary" style={nameColor ? { color: nameColor } : undefined}>
{member.username}
</span>
<span className="mention-menu-row-secondary">{member.username}</span>
</div>
);
})}
{roleItems.length > 0 && (
<>
<div className="mention-menu-section-header">Roles</div>
{roleItems.map((role) => {
const idx = globalIndex++;
const displayName = role.name.startsWith('@') ? role.name : `@${role.name}`;
return (
<div
key={`role-${role._id}`}
className={`mention-menu-row${idx === selectedIndex ? ' selected' : ''}`}
onMouseDown={(e) => e.preventDefault()}
onClick={() => onSelect(role)}
onMouseEnter={() => onHover(idx)}
>
<div
className="mention-menu-role-icon"
style={{ backgroundColor: role.color || '#99aab5' }}
>
@
</div>
<span
className="mention-menu-row-primary"
style={role.color ? { color: role.color } : undefined}
>
{displayName}
</span>
</div>
);
})}
</>
)}
{memberItems.length > 0 && (
<>
<div className="mention-menu-section-header">Members</div>
{memberItems.map((member) => {
const idx = globalIndex++;
const topRole = member.roles && member.roles.length > 0 ? member.roles[0] : null;
const nameColor = topRole?.color || undefined;
return (
<div
key={member.id}
className={`mention-menu-row${idx === selectedIndex ? ' selected' : ''}`}
onMouseDown={(e) => e.preventDefault()}
onClick={() => onSelect(member)}
onMouseEnter={() => onHover(idx)}
>
<Avatar username={member.username} avatarUrl={member.avatarUrl} size={24} />
<span className="mention-menu-row-primary" style={nameColor ? { color: nameColor } : undefined}>
{member.username}
</span>
<span className="mention-menu-row-secondary">{member.username}</span>
</div>
);
})}
</>
)}
</div>
</div>
);

View File

@@ -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(`(?<!\\[)${escaped}\\b`, 'g');
const color = role.color || '#99aab5';
result = result.replace(re, `[${role.name}](rolemention://${encodeURIComponent(role.name)}?color=${encodeURIComponent(color)})`);
}
}
}
// Third pass: replace @username with user mention links (skip already-linked @)
result = result.replace(/(?<!\[)@(\w+)/g, '[@$1](mention://$1)');
return result;
};
export const formatEmojis = (text) => {
@@ -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 <span style={{ background: `${color}26`, borderRadius: '3px', color, fontWeight: 500, padding: '0 2px', cursor: 'default' }} title={props.children}>{props.children}</span>;
} catch {
return <span>{props.children}</span>;
}
}
if (props.href && props.href.startsWith('mention://')) return <span style={{ background: 'color-mix(in oklab,hsl(234.935 calc(1*85.556%) 64.706% /0.23921568627450981) 100%,hsl(0 0% 0% /0.23921568627450981) 0%)', borderRadius: '3px', color: 'color-mix(in oklab, hsl(228.14 calc(1*100%) 83.137% /1) 100%, #000 0%)', fontWeight: 500, padding: '0 2px', cursor: 'default' }} title={props.children}>{props.children}</span>;
return <a {...props} onClick={(e) => { 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 && (
<ReactMarkdown remarkPlugins={[remarkGfm]} urlTransform={(url) => url} components={markdownComponents}>
{formatEmojis(formatMentions(msg.content))}
{formatEmojis(formatMentions(msg.content, roles))}
</ReactMarkdown>
)}
{isDirectVideo && <DirectVideo src={urls[0]} marginTop={4} />}
@@ -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
);
});

View File

@@ -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])
)}
</div>
{/* Audio sharing footer — hidden for device sources (webcams) */}
{activeTab !== 'devices' && (
<div style={{
borderTop: '1px solid #2f3136',
padding: '12px 16px',
display: 'flex',
alignItems: 'center',
flexShrink: 0,
}}>
<label style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
cursor: 'pointer',
color: '#dcddde',
fontSize: '14px',
userSelect: 'none',
}}>
<input
type="checkbox"
checked={shareAudio}
onChange={(e) => setShareAudio(e.target.checked)}
style={{ accentColor: '#5865F2', width: '16px', height: '16px', cursor: 'pointer' }}
/>
Also share computer audio
</label>
</div>
)}
</div>
</div>
);

View File

@@ -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 = () => (
<div style={{
width: '218px',
backgroundColor: 'var(--bg-secondary)',
backgroundColor: 'var(--bg-tertiary)',
display: 'flex', flexDirection: 'column', alignItems: 'flex-end', padding: '60px 6px 60px 20px'
}}>
<div style={{ width: '100%', padding: '0 10px' }}>
@@ -141,7 +180,7 @@ const ServerSettingsModal = ({ onClose }) => {
/>
<label style={labelStyle}>PERMISSIONS</label>
{['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 => (
<div key={perm} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10, paddingBottom: 10, borderBottom: '1px solid var(--border-subtle)' }}>
<span style={{ color: 'var(--header-primary)', textTransform: 'capitalize' }}>{perm.replace('_', ' ')}</span>
<input
@@ -217,7 +256,59 @@ const ServerSettingsModal = ({ onClose }) => {
switch (activeTab) {
case 'Roles': return renderRolesTab();
case 'Members': return renderMembersTab();
default: return <div style={{ color: 'var(--header-secondary)' }}>Server Name: Secure Chat<br/>Region: US-East</div>;
default: return (
<div>
<div style={{ color: 'var(--header-secondary)', marginBottom: 30 }}>Server Name: Secure Chat<br/>Region: US-East</div>
<label style={labelStyle}>INACTIVE CHANNEL</label>
<select
value={afkChannelId}
onChange={(e) => { setAfkChannelId(e.target.value); setAfkDirty(true); }}
disabled={!myPermissions.manage_channels}
style={{
width: '100%', padding: 10, background: 'var(--bg-tertiary)', border: 'none',
borderRadius: 4, color: 'var(--header-primary)', marginBottom: 20,
opacity: myPermissions.manage_channels ? 1 : 0.5, cursor: 'pointer',
appearance: 'auto',
}}
>
<option value="">No Inactive Channel</option>
{voiceChannels.map(ch => (
<option key={ch._id} value={ch._id}>{ch.name}</option>
))}
</select>
<label style={labelStyle}>INACTIVE TIMEOUT</label>
<select
value={afkTimeout}
onChange={(e) => { setAfkTimeout(Number(e.target.value)); setAfkDirty(true); }}
disabled={!myPermissions.manage_channels}
style={{
width: '100%', padding: 10, background: 'var(--bg-tertiary)', border: 'none',
borderRadius: 4, color: 'var(--header-primary)', marginBottom: 20,
opacity: myPermissions.manage_channels ? 1 : 0.5, cursor: 'pointer',
appearance: 'auto',
}}
>
{TIMEOUT_OPTIONS.map(opt => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
{afkDirty && myPermissions.manage_channels && (
<button
onClick={handleSaveAfkSettings}
style={{
backgroundColor: '#5865F2', color: '#fff', border: 'none',
borderRadius: 4, padding: '8px 16px', cursor: 'pointer',
fontWeight: 600, fontSize: 14,
}}
>
Save Changes
</button>
)}
</div>
);
}
};

View File

@@ -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 (
<div style={{
height: '64px',
@@ -331,7 +361,12 @@ function getScreenCaptureConstraints(selection) {
return { video: { deviceId: { exact: selection.deviceId } }, audio: false };
}
return {
audio: false,
audio: selection.shareAudio ? {
mandatory: {
chromeMediaSource: 'desktop',
chromeMediaSourceId: selection.sourceId
}
} : false,
video: {
mandatory: {
chromeMediaSource: 'desktop',
@@ -341,6 +376,82 @@ function getScreenCaptureConstraints(selection) {
};
}
const VoiceUserContextMenu = ({ x, y, onClose, user, onMute, isMuted, onServerMute, isServerMuted, hasPermission, onMessage }) => {
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 (
<div ref={menuRef} className="context-menu" style={{ top: pos.top, left: pos.left }} onClick={(e) => e.stopPropagation()}>
<div
className="context-menu-item context-menu-checkbox-item"
role="menuitemcheckbox"
aria-checked={isMuted}
onClick={(e) => { e.stopPropagation(); onMute(); }}
>
<span>Mute</span>
<div className="context-menu-checkbox">
<div className={`context-menu-checkbox-indicator ${isMuted ? 'checked' : ''}`}>
{isMuted ? (
<svg width="18" height="18" viewBox="0 0 24 24">
<path fill="white" d="M8.99991 16.17L4.82991 12L3.40991 13.41L8.99991 19L20.9999 7L19.5899 5.59L8.99991 16.17Z" />
</svg>
) : (
<svg width="10" height="10" viewBox="0 0 10 10">
</svg>
)}
</div>
</div>
</div>
{hasPermission && (
<div
className="context-menu-item context-menu-checkbox-item"
role="menuitemcheckbox"
aria-checked={isServerMuted}
onClick={(e) => { e.stopPropagation(); onServerMute(); }}
>
<span style={{ color: SERVER_MUTE_RED }}>Server Mute</span>
<div className="context-menu-checkbox">
<div
className={`context-menu-checkbox-indicator ${isServerMuted ? 'checked' : ''}`}
style={isServerMuted ? { backgroundColor: SERVER_MUTE_RED, borderColor: SERVER_MUTE_RED } : {}}
>
{isServerMuted ? (
<svg width="18" height="18" viewBox="0 0 24 24">
<path fill="white" d="M8.99991 16.17L4.82991 12L3.40991 13.41L8.99991 19L20.9999 7L19.5899 5.59L8.99991 16.17Z" />
</svg>
) : (
<svg width="10" height="10" viewBox="0 0 10 10">
</svg>
)}
</div>
</div>
</div>
)}
<div className="context-menu-separator" />
<div className="context-menu-item" onClick={(e) => { e.stopPropagation(); onMessage(); onClose(); }}>
<span>Message</span>
</div>
</div>
);
};
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 (
<div ref={setNodeRef} style={style} {...attributes} {...listeners}>
<div ref={setNodeRef} style={style} {...attributes}>
{typeof children === 'function' ? children(listeners) : children}
</div>
);
};
const DraggableVoiceUser = ({ userId, channelId, disabled, children }) => {
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
id: `voice-user-${userId}`,
data: { type: 'voice-user', userId, channelId },
disabled,
});
return (
<div
ref={setNodeRef}
{...attributes}
{...listeners}
style={{
opacity: isDragging ? 0.4 : 1,
cursor: disabled ? 'default' : 'grab',
}}
>
{children}
</div>
);
@@ -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 (
<div style={{ marginLeft: 32, marginBottom: 8 }}>
{users.map(user => (
<div key={user.userId} style={{ display: 'flex', alignItems: 'center', marginBottom: 8 }}>
<Avatar
username={user.username}
size={24}
style={{
marginRight: 8,
boxShadow: activeSpeakers.has(user.userId) ? ACTIVE_SPEAKER_SHADOW : 'none'
<DraggableVoiceUser
key={user.userId}
userId={user.userId}
channelId={channel._id}
disabled={!myPermissions.move_members}
>
<div
className="voice-user-item"
style={{ display: 'flex', alignItems: 'center', marginBottom: 2 }}
onContextMenu={(e) => {
e.preventDefault();
e.stopPropagation();
setVoiceUserMenu({ x: e.clientX, y: e.clientY, user });
}}
/>
<span style={{ color: 'var(--header-secondary)', fontSize: 14 }}>{user.username}</span>
<div style={{ display: 'flex', marginLeft: 'auto', gap: 4, alignItems: 'center', marginRight: "16px" }}>
{user.isScreenSharing && <div style={liveBadgeStyle}>Live</div>}
{(user.isMuted || user.isDeafened) && (
<ColoredIcon src={mutedIcon} color="var(--header-secondary)" size="14px" />
)}
{user.isDeafened && (
<ColoredIcon src={defeanedIcon} color="var(--header-secondary)" size="14px" />
)}
>
<Avatar
username={user.username}
avatarUrl={user.avatarUrl}
size={24}
style={{
marginRight: 8,
boxShadow: activeSpeakers.has(user.userId) ? ACTIVE_SPEAKER_SHADOW : 'none'
}}
/>
<span style={{ color: 'var(--header-secondary)', fontSize: 14 }}>{user.username}</span>
<div style={{ display: 'flex', marginLeft: 'auto', gap: 4, alignItems: 'center', marginRight: "16px" }}>
{user.isScreenSharing && <div style={liveBadgeStyle}>Live</div>}
{user.isServerMuted ? (
<ColoredIcon src={serverMuteIcon} color={SERVER_MUTE_RED} size="14px" />
) : isPersonallyMuted(user.userId) ? (
<ColoredIcon src={personalMuteIcon} color="var(--header-secondary)" size="14px" />
) : (user.isMuted || user.isDeafened) ? (
<ColoredIcon src={mutedIcon} color="var(--header-secondary)" size="14px" />
) : null}
{user.isDeafened && (
<ColoredIcon src={defeanedIcon} color="var(--header-secondary)" size="14px" />
)}
</div>
</div>
</div>
</DraggableVoiceUser>
))}
</div>
);
};
const renderCollapsedVoiceUsers = (channel) => {
const users = voiceStates[channel._id];
if (channel.type !== 'voice' || !users?.length) return null;
return (
<div
className={`channel-item ${voiceChannelId === channel._id ? 'voice-active' : ''}`}
onClick={() => handleChannelClick(channel)}
style={{ position: 'relative', display: 'flex', alignItems: 'center', paddingRight: '8px' }}
>
<div style={{ marginRight: 6 }}>
<ColoredIcon src={voiceIcon} size="16px" color={VOICE_ACTIVE_COLOR} />
</div>
<div style={{ display: 'flex', alignItems: 'center' }}>
{users.map(user => (
<div key={user.userId} style={{ marginRight: -6, position: 'relative', zIndex: 1 }}>
<Avatar
username={user.username}
avatarUrl={user.avatarUrl}
size={24}
style={{
boxShadow: activeSpeakers.has(user.userId) ? ACTIVE_SPEAKER_SHADOW : 'none',
borderRadius: '50%',
}}
/>
</div>
))}
</div>
</div>
);
};
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}
>
<SortableContext items={categoryDndIds} strategy={verticalListSortingStrategy}>
@@ -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 (
<SortableChannel key={channel._id} id={`channel-${channel._id}`}>
{(channelDragListeners) => (
<React.Fragment>
<div
className={`channel-item ${activeChannel === channel._id ? 'active' : ''} ${voiceChannelId === channel._id ? 'voice-active' : ''}`}
{!(isCollapsed && channel.type === 'voice' && voiceStates[channel._id]?.length > 0) && <div
className={`channel-item ${activeChannel === channel._id ? 'active' : ''} ${voiceChannelId === channel._id ? 'voice-active' : ''} ${dragOverChannelId === channel._id ? 'voice-drop-target' : ''}`}
onClick={() => handleChannelClick(channel)}
{...channelDragListeners}
style={{
position: 'relative',
display: 'flex',
@@ -1096,7 +1381,9 @@ const Sidebar = ({ channels, categories, activeChannel, onSelectChannel, usernam
) : (
<span style={{ color: 'var(--interactive-normal)', marginRight: '6px', flexShrink: 0 }}>#</span>
)}
<span style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', ...(isUnread ? { color: 'var(--header-primary)', fontWeight: 600 } : {}) }}>{channel.name}</span>
<span style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', ...(isUnread ? { color: 'var(--header-primary)', fontWeight: 600 } : {}) }}>
{channel.name}{serverSettings?.afkChannelId === channel._id ? ' (AFK)' : ''}
</span>
</div>
<button
@@ -1115,9 +1402,12 @@ const Sidebar = ({ channels, categories, activeChannel, onSelectChannel, usernam
>
<ColoredIcon src={settingsIcon} color="var(--interactive-normal)" size="14px" />
</button>
</div>
{renderVoiceUsers(channel)}
</div>}
{isCollapsed
? renderCollapsedVoiceUsers(channel)
: renderVoiceUsers(channel)}
</React.Fragment>
)}
</SortableChannel>
);
})}
@@ -1145,6 +1435,17 @@ const Sidebar = ({ channels, categories, activeChannel, onSelectChannel, usernam
{activeDragItem.name}
</div>
)}
{activeDragItem?.type === 'voice-user' && activeDragItem.user && (
<div className="drag-overlay-voice-user">
<Avatar
username={activeDragItem.user.username}
avatarUrl={activeDragItem.user.avatarUrl}
size={24}
style={{ marginRight: 8 }}
/>
<span>{activeDragItem.user.username}</span>
</div>
)}
</DragOverlay>
</DndContext>
</div>
@@ -1283,6 +1584,23 @@ const Sidebar = ({ channels, categories, activeChannel, onSelectChannel, usernam
onCreateCategory={() => setShowCreateCategoryModal(true)}
/>
)}
{voiceUserMenu && (
<VoiceUserContextMenu
x={voiceUserMenu.x}
y={voiceUserMenu.y}
user={voiceUserMenu.user}
onClose={() => 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 && (
<CreateChannelModal
categoryId={createChannelCategoryId}

View File

@@ -5,27 +5,7 @@ const TitleBar = () => {
return (
<div className="titlebar">
<div className="titlebar-drag-region" />
<div className="titlebar-nav">
<button
className="titlebar-nav-btn"
onClick={() => window.history.back()}
aria-label="Go Back"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
<path d="M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z"/>
</svg>
</button>
<button
className="titlebar-nav-btn"
onClick={() => window.history.forward()}
aria-label="Go Forward"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
<path d="M8.59 16.59L10 18l6-6-6-6-1.41 1.41L13.17 12z"/>
</svg>
</button>
</div>
<div className="titlebar-title">Discord Clone</div>
<div className="titlebar-title">Brycord</div>
<div className="titlebar-buttons">
<TitleBarUpdateIcon />
<button

View File

@@ -10,9 +10,13 @@ import mutedIcon from '../assets/icons/muted.svg';
import cameraIcon from '../assets/icons/camera.svg';
import screenIcon from '../assets/icons/screen.svg';
import disconnectIcon from '../assets/icons/disconnect.svg';
import personalMuteIcon from '../assets/icons/personal_mute.svg';
import serverMuteIcon from '../assets/icons/server_mute.svg';
import screenShareStartSound from '../assets/sounds/screenshare_start.mp3';
import screenShareStopSound from '../assets/sounds/screenshare_stop.mp3';
const SERVER_MUTE_RED = 'color-mix(in oklab, hsl(1.343 calc(1*84.81%) 69.02% /1) 100%, #000 0%)';
const getInitials = (name) => (name || '?').substring(0, 1).toUpperCase();
const getUserColor = (username) => {
@@ -88,6 +92,7 @@ const VideoRenderer = ({ track, style }) => {
function findTrackPubs(participant) {
let cameraPub = null;
let screenSharePub = null;
let screenShareAudioPub = null;
const trackMap = participant.tracks || participant.trackPublications;
if (trackMap) {
@@ -95,7 +100,9 @@ function findTrackPubs(participant) {
const source = pub.source ? pub.source.toString().toLowerCase() : '';
const name = pub.trackName ? pub.trackName.toLowerCase() : '';
if (source === 'screenshare' || source === 'screen_share' || name.includes('screen')) {
if (source === 'screen_share_audio' || name === 'screen_share_audio') {
screenShareAudioPub = pub;
} else if (source === 'screenshare' || source === 'screen_share' || name.includes('screen')) {
screenSharePub = pub;
} else if (source === 'camera' || name.includes('camera')) {
cameraPub = pub;
@@ -111,7 +118,9 @@ function findTrackPubs(participant) {
for (const pub of participant.getTracks()) {
const source = pub.source ? pub.source.toString().toLowerCase() : '';
const name = pub.trackName ? pub.trackName.toLowerCase() : '';
if (source === 'screenshare' || source === 'screen_share' || name.includes('screen')) {
if (source === 'screen_share_audio' || name === 'screen_share_audio') {
screenShareAudioPub = pub;
} else if (source === 'screenshare' || source === 'screen_share' || name.includes('screen')) {
screenSharePub = pub;
} else if (source === 'camera' || name.includes('camera')) {
cameraPub = pub;
@@ -122,7 +131,7 @@ function findTrackPubs(participant) {
} catch (e) { /* ignore */ }
}
return { cameraPub, screenSharePub };
return { cameraPub, screenSharePub, screenShareAudioPub };
}
function useParticipantTrack(participant, source) {
@@ -179,9 +188,18 @@ function useParticipantTrack(participant, source) {
const ParticipantTile = ({ participant, username, avatarUrl }) => {
const cameraTrack = useParticipantTrack(participant, 'camera');
const { isPersonallyMuted, voiceStates } = useVoice();
const isMicEnabled = participant.isMicrophoneEnabled;
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 (
<div style={{
backgroundColor: '#202225',
@@ -227,7 +245,11 @@ const ParticipantTile = ({ participant, username, avatarUrl }) => {
alignItems: 'center',
gap: '6px'
}}>
{isMicEnabled ? '\u{1F3A4}' : '\u{1F507}'}
{isServerMutedUser ? (
<ColoredIcon src={serverMuteIcon} color={SERVER_MUTE_RED} size="16px" />
) : isPersonalMuted ? (
<ColoredIcon src={personalMuteIcon} color="white" size="16px" />
) : isMicEnabled ? '\u{1F3A4}' : '\u{1F507}'}
{displayName}
</div>
</div>
@@ -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 (
<div style={{
width: THUMBNAIL_SIZE.width,
@@ -337,7 +368,13 @@ const ParticipantThumbnail = ({ participant, username, avatarUrl, isStreamer, is
maxWidth: '100%', overflow: 'hidden', textOverflow: 'ellipsis',
whiteSpace: 'nowrap', display: 'flex', alignItems: 'center', gap: '3px',
}}>
{isMuted && <span style={{ fontSize: '9px' }}>{'\u{1F507}'}</span>}
{isServerMutedUser ? (
<ColoredIcon src={serverMuteIcon} color={SERVER_MUTE_RED} size="12px" />
) : isPersonalMuted ? (
<ColoredIcon src={personalMuteIcon} color="white" size="12px" />
) : isMuted ? (
<span style={{ fontSize: '9px' }}>{'\u{1F507}'}</span>
) : null}
{displayName}
{isStreamer && <span style={{ ...LIVE_BADGE_STYLE, fontSize: '8px', padding: '1px 3px' }}>LIVE</span>}
</span>
@@ -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 {

View File

@@ -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 && (

View File

@@ -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;
}

30
TODO.md
View File

@@ -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
<!-- - 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.
<!-- - 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.
<!-- - Owners should be able to delete anyones message in the server. -->
<!-- - When i share my screen using the Share Screen button thats in our side bar with the disconnect button i dont hear the sharing screen sound like i started sharing. I only hear it when i use the screenshare button in the voice stage modal.
- When i click on my voice channel i dont join it anymore right away.
- Add audio to screenshare
- Add audio to screenshare -->
<!-- - Figure out why audio is shit. -->
- 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.
<!-- - 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 idel channel -->
- If you go afk for 5min switch to channel and to idle.
<!-- - Add server muting. Forcing user to mute. -->
- Add server muting. Forcing user to mute.
- Allow users to mute other users for themself only.
<!-- - Allow users to mute other users for themself only. I want to be able to allow users to mute other users for themself only and no one else. So if we click the button button in the popup that we get for when we right click on a user and click mute we will mute their voice audio. Can we also update that menu i have a snippit server mute setting snippit.txt inside the discord-html-copy folder. Where they have a checkbox that shows when that mute is on or off. Also when we mute someone we put the personal_mute.svg icon on them. If they are muted themself we show this icon rather than the mute.svg icon. -->
- Independient voice volumes per user.
<!-- - We have it so if a user is in a voice channel on the memebers list it shows a status as "In voice" with a icon. Can we do the same when they are streaming. Where its the streaming icon and says "Sharing their screen" We will use the sharing.svg icon. -->
# Future
- Allow users to add custom join sounds.
- Allow users to add custom join sounds.

View File

@@ -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;

View File

@@ -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,

View File

@@ -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 };

View File

@@ -2,6 +2,7 @@ import { query, mutation } from "./_generated/server";
import { paginationOptsValidator } from "convex/server";
import { v } from "convex/values";
import { getPublicStorageUrl } from "./storageUrl";
import { getRolesForUser } from "./roles";
export const list = query({
args: {
@@ -173,9 +174,23 @@ export const listPinned = query({
});
export const remove = mutation({
args: { id: v.id("messages") },
args: { id: v.id("messages"), userId: v.id("userProfiles") },
returns: v.null(),
handler: async (ctx, args) => {
const message = await ctx.db.get(args.id);
if (!message) throw new Error("Message not found");
const isSender = message.senderId === args.userId;
if (!isSender) {
const roles = await getRolesForUser(ctx, args.userId);
const canManage = roles.some(
(role) => (role.permissions as Record<string, boolean>)?.manage_messages
);
if (!canManage) {
throw new Error("Not authorized to delete this message");
}
}
const reactions = await ctx.db
.query("messageReactions")
.withIndex("by_message", (q) => q.eq("messageId", args.id))

View File

@@ -6,12 +6,15 @@ import { DataModel, Id, Doc } from "./_generated/dataModel";
const PERMISSION_KEYS = [
"manage_channels",
"manage_roles",
"manage_messages",
"create_invite",
"embed_links",
"attach_files",
"move_members",
"mute_members",
] as const;
async function getRolesForUser(
export async function getRolesForUser(
ctx: GenericQueryCtx<DataModel>,
userId: Id<"userProfiles">
): Promise<Doc<"roles">[]> {
@@ -182,9 +185,12 @@ export const getMyPermissions = query({
returns: v.object({
manage_channels: v.boolean(),
manage_roles: v.boolean(),
manage_messages: v.boolean(),
create_invite: v.boolean(),
embed_links: v.boolean(),
attach_files: v.boolean(),
move_members: v.boolean(),
mute_members: v.boolean(),
}),
handler: async (ctx, args) => {
const roles = await getRolesForUser(ctx, args.userId);
@@ -199,9 +205,12 @@ export const getMyPermissions = query({
return finalPerms as {
manage_channels: boolean;
manage_roles: boolean;
manage_messages: boolean;
create_invite: boolean;
embed_links: boolean;
attach_files: boolean;
move_members: boolean;
mute_members: boolean;
};
},
});

View File

@@ -109,6 +109,7 @@ export default defineSchema({
isMuted: v.boolean(),
isDeafened: v.boolean(),
isScreenSharing: v.boolean(),
isServerMuted: v.boolean(),
})
.index("by_channel", ["channelId"])
.index("by_user", ["userId"]),
@@ -121,4 +122,9 @@ export default defineSchema({
.index("by_user", ["userId"])
.index("by_channel", ["channelId"])
.index("by_user_and_channel", ["userId", "channelId"]),
serverSettings: defineTable({
afkChannelId: v.optional(v.id("channels")),
afkTimeout: v.number(), // seconds (default 300 = 5 min)
}),
});

69
convex/serverSettings.ts Normal file
View File

@@ -0,0 +1,69 @@
import { query, mutation, internalMutation } from "./_generated/server";
import { v } from "convex/values";
import { getRolesForUser } from "./roles";
export const get = query({
args: {},
returns: v.any(),
handler: async (ctx) => {
return await ctx.db.query("serverSettings").first();
},
});
export const update = mutation({
args: {
userId: v.id("userProfiles"),
afkChannelId: v.optional(v.id("channels")),
afkTimeout: v.number(),
},
returns: v.null(),
handler: async (ctx, args) => {
// Permission check
const roles = await getRolesForUser(ctx, args.userId);
const canManage = roles.some(
(role) => (role.permissions as Record<string, boolean>)?.["manage_channels"]
);
if (!canManage) {
throw new Error("You don't have permission to manage server settings");
}
// Validate timeout range
if (args.afkTimeout < 60 || args.afkTimeout > 3600) {
throw new Error("AFK timeout must be between 60 and 3600 seconds");
}
// Validate AFK channel is a voice channel if provided
if (args.afkChannelId) {
const channel = await ctx.db.get(args.afkChannelId);
if (!channel) throw new Error("AFK channel not found");
if (channel.type !== "voice") throw new Error("AFK channel must be a voice channel");
}
const existing = await ctx.db.query("serverSettings").first();
if (existing) {
await ctx.db.patch(existing._id, {
afkChannelId: args.afkChannelId,
afkTimeout: args.afkTimeout,
});
} else {
await ctx.db.insert("serverSettings", {
afkChannelId: args.afkChannelId,
afkTimeout: args.afkTimeout,
});
}
return null;
},
});
export const clearAfkChannel = internalMutation({
args: { channelId: v.id("channels") },
returns: v.null(),
handler: async (ctx, args) => {
const settings = await ctx.db.query("serverSettings").first();
if (settings && settings.afkChannelId === args.channelId) {
await ctx.db.patch(settings._id, { afkChannelId: undefined });
}
return null;
},
});

View File

@@ -1,6 +1,7 @@
import { query, mutation } from "./_generated/server";
import { v } from "convex/values";
import { getPublicStorageUrl } from "./storageUrl";
import { getRolesForUser } from "./roles";
async function removeUserVoiceStates(ctx: any, userId: any) {
const existing = await ctx.db
@@ -31,6 +32,7 @@ export const join = mutation({
isMuted: args.isMuted,
isDeafened: args.isDeafened,
isScreenSharing: false,
isServerMuted: false,
});
return null;
@@ -74,6 +76,35 @@ export const updateState = mutation({
},
});
export const serverMute = mutation({
args: {
actorUserId: v.id("userProfiles"),
targetUserId: v.id("userProfiles"),
isServerMuted: v.boolean(),
},
returns: v.null(),
handler: async (ctx, args) => {
const roles = await getRolesForUser(ctx, args.actorUserId);
const canMute = roles.some(
(role) => (role.permissions as Record<string, boolean>)?.["mute_members"]
);
if (!canMute) {
throw new Error("You don't have permission to server mute members");
}
const existing = await ctx.db
.query("voiceStates")
.withIndex("by_user", (q: any) => q.eq("userId", args.targetUserId))
.first();
if (!existing) throw new Error("Target user is not in a voice channel");
await ctx.db.patch(existing._id, { isServerMuted: args.isServerMuted });
return null;
},
});
export const getAll = query({
args: {},
returns: v.any(),
@@ -86,6 +117,7 @@ export const getAll = query({
isMuted: boolean;
isDeafened: boolean;
isScreenSharing: boolean;
isServerMuted: boolean;
avatarUrl: string | null;
}>> = {};
@@ -102,6 +134,7 @@ export const getAll = query({
isMuted: s.isMuted,
isDeafened: s.isDeafened,
isScreenSharing: s.isScreenSharing,
isServerMuted: s.isServerMuted,
avatarUrl,
});
}
@@ -109,3 +142,90 @@ export const getAll = query({
return grouped;
},
});
export const afkMove = mutation({
args: {
userId: v.id("userProfiles"),
afkChannelId: v.id("channels"),
},
returns: v.null(),
handler: async (ctx, args) => {
// Validate afkChannelId matches server settings
const settings = await ctx.db.query("serverSettings").first();
if (!settings || settings.afkChannelId !== args.afkChannelId) {
throw new Error("Invalid AFK channel");
}
// Get current voice state
const currentState = await ctx.db
.query("voiceStates")
.withIndex("by_user", (q: any) => q.eq("userId", args.userId))
.first();
// No-op if not in voice or already in AFK channel
if (!currentState || currentState.channelId === args.afkChannelId) return null;
// Move to AFK channel: delete old state, insert new one muted
await ctx.db.delete(currentState._id);
await ctx.db.insert("voiceStates", {
channelId: args.afkChannelId,
userId: args.userId,
username: currentState.username,
isMuted: true,
isDeafened: currentState.isDeafened,
isScreenSharing: false,
isServerMuted: currentState.isServerMuted,
});
return null;
},
});
export const moveUser = mutation({
args: {
actorUserId: v.id("userProfiles"),
targetUserId: v.id("userProfiles"),
targetChannelId: v.id("channels"),
},
returns: v.null(),
handler: async (ctx, args) => {
// Check actor has move_members permission
const roles = await getRolesForUser(ctx, args.actorUserId);
const canMove = roles.some(
(role) => (role.permissions as Record<string, boolean>)?.["move_members"]
);
if (!canMove) {
throw new Error("You don't have permission to move members");
}
// Validate target channel exists and is voice
const targetChannel = await ctx.db.get(args.targetChannelId);
if (!targetChannel) throw new Error("Target channel not found");
if (targetChannel.type !== "voice") throw new Error("Target channel is not a voice channel");
// Get target user's current voice state
const currentState = await ctx.db
.query("voiceStates")
.withIndex("by_user", (q: any) => q.eq("userId", args.targetUserId))
.first();
if (!currentState) throw new Error("Target user is not in a voice channel");
// No-op if already in the target channel
if (currentState.channelId === args.targetChannelId) return null;
// Delete old voice state and insert new one preserving mute/deaf/screenshare
await ctx.db.delete(currentState._id);
await ctx.db.insert("voiceStates", {
channelId: args.targetChannelId,
userId: args.targetUserId,
username: currentState.username,
isMuted: currentState.isMuted,
isDeafened: currentState.isDeafened,
isScreenSharing: currentState.isScreenSharing,
isServerMuted: currentState.isServerMuted,
});
return null;
},
});

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long