feat: Implement core Discord clone functionality including Convex backend services for authentication, channels, messages, roles, and voice state, alongside new Electron frontend components for chat, voice, server settings, and user interface.
All checks were successful
Build and Release / build-and-release (push) Successful in 14m19s
All checks were successful
Build and Release / build-and-release (push) Successful in 14m19s
This commit is contained in:
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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'),
|
||||
});
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
1
Frontend/Electron/src/assets/icons/personal_mute.svg
Normal file
1
Frontend/Electron/src/assets/icons/personal_mute.svg
Normal 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 |
1
Frontend/Electron/src/assets/icons/server_mute.svg
Normal file
1
Frontend/Electron/src/assets/icons/server_mute.svg
Normal 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 |
1
Frontend/Electron/src/assets/icons/sharing.svg
Normal file
1
Frontend/Electron/src/assets/icons/sharing.svg
Normal 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 |
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user