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

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