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:
@@ -14,19 +14,20 @@ See also: [CONVEX_RULES.md](./CONVEX_RULES.md) | [CONVEX_EXAMPLES.md](./CONVEX_E
|
|||||||
|
|
||||||
## Key Convex Files (convex/)
|
## Key Convex Files (convex/)
|
||||||
|
|
||||||
- `schema.ts` - Full schema: userProfiles (with avatarStorageId, aboutMe, customStatus), categories (name, position), channels (with categoryId, topic, position), messages, messageReactions, channelKeys, roles, userRoles, invites, dmParticipants, typingIndicators, voiceStates, channelReadState
|
- `schema.ts` - Full schema: userProfiles (with avatarStorageId, aboutMe, customStatus), categories (name, position), channels (with categoryId, topic, position), messages, messageReactions, channelKeys, roles, userRoles, invites, dmParticipants, typingIndicators, voiceStates, channelReadState, serverSettings (afkChannelId, afkTimeout)
|
||||||
- `auth.ts` - getSalt, verifyUser, createUserWithProfile, getPublicKeys (includes avatarUrl, aboutMe, customStatus), updateProfile, updateStatus
|
- `auth.ts` - getSalt, verifyUser, createUserWithProfile, getPublicKeys (includes avatarUrl, aboutMe, customStatus), updateProfile, updateStatus
|
||||||
- `categories.ts` - list, create, rename, remove, reorder
|
- `categories.ts` - list, create, rename, remove, reorder
|
||||||
- `channels.ts` - list, get, create (with categoryId/topic/position), rename, remove (cascade), updateTopic, moveChannel, reorderChannels
|
- `channels.ts` - list, get, create (with categoryId/topic/position), rename, remove (cascade), updateTopic, moveChannel, reorderChannels
|
||||||
- `members.ts` - getChannelMembers (includes isHoist on roles, avatarUrl, aboutMe, customStatus)
|
- `members.ts` - getChannelMembers (includes isHoist on roles, avatarUrl, aboutMe, customStatus)
|
||||||
- `channelKeys.ts` - uploadKeys, getKeysForUser
|
- `channelKeys.ts` - uploadKeys, getKeysForUser
|
||||||
- `messages.ts` - list (with reactions + username), send, remove
|
- `messages.ts` - list (with reactions + username), send, edit, pin, listPinned, remove (with manage_messages permission check)
|
||||||
- `reactions.ts` - add, remove
|
- `reactions.ts` - add, remove
|
||||||
|
- `serverSettings.ts` - get, update (manage_channels permission), clearAfkChannel (internal)
|
||||||
- `typing.ts` - startTyping, stopTyping, getTyping, cleanExpired (scheduled)
|
- `typing.ts` - startTyping, stopTyping, getTyping, cleanExpired (scheduled)
|
||||||
- `dms.ts` - openDM, listDMs
|
- `dms.ts` - openDM, listDMs
|
||||||
- `invites.ts` - create, use, revoke
|
- `invites.ts` - create, use, revoke
|
||||||
- `roles.ts` - list, create, update, remove, listMembers, assign, unassign, getMyPermissions
|
- `roles.ts` - list, create, update, remove, listMembers, assign, unassign, getMyPermissions
|
||||||
- `voiceState.ts` - join, leave, updateState, getAll
|
- `voiceState.ts` - join, leave, updateState, getAll, afkMove (self-move to AFK channel)
|
||||||
- `voice.ts` - getToken (Node action, livekit-server-sdk)
|
- `voice.ts` - getToken (Node action, livekit-server-sdk)
|
||||||
- `files.ts` - generateUploadUrl, getFileUrl
|
- `files.ts` - generateUploadUrl, getFileUrl
|
||||||
- `gifs.ts` - search, categories (Node actions, Tenor API)
|
- `gifs.ts` - search, categories (Node actions, Tenor API)
|
||||||
@@ -72,6 +73,7 @@ See also: [CONVEX_RULES.md](./CONVEX_RULES.md) | [CONVEX_EXAMPLES.md](./CONVEX_E
|
|||||||
- Voice connected panel includes elapsed time timer
|
- Voice connected panel includes elapsed time timer
|
||||||
- Keyboard shortcuts: Ctrl+K (quick switcher), Ctrl+Shift+M (mute toggle)
|
- Keyboard shortcuts: Ctrl+K (quick switcher), Ctrl+Shift+M (mute toggle)
|
||||||
- Unread tracking: `channelReadState` table stores last-read timestamp per user/channel. ChatArea shows red "NEW" divider, Sidebar shows white dot on unread channels
|
- Unread tracking: `channelReadState` table stores last-read timestamp per user/channel. ChatArea shows red "NEW" divider, Sidebar shows white dot on unread channels
|
||||||
|
- AFK voice channel: `serverSettings` singleton table stores `afkChannelId` + `afkTimeout`. VoiceContext polls `idleAPI.getSystemIdleTime()` every 15s; auto-moves idle users to AFK channel via `voiceState.afkMove`. Users in AFK channel are force-muted and can't unmute. Sidebar shows "(AFK)" label. Server Settings Overview tab has AFK config UI.
|
||||||
|
|
||||||
## Environment Variables
|
## Environment Variables
|
||||||
|
|
||||||
|
|||||||
@@ -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 path = require('path');
|
||||||
const fs = require('fs');
|
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'),
|
load: () => ipcRenderer.invoke('load-session'),
|
||||||
clear: () => ipcRenderer.invoke('clear-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 SpoilerIcon from './spoiler.svg';
|
||||||
import CrownIcon from './crown.svg';
|
import CrownIcon from './crown.svg';
|
||||||
import FriendsIcon from './friends.svg';
|
import FriendsIcon from './friends.svg';
|
||||||
|
import SharingIcon from './sharing.svg';
|
||||||
|
import PersonalMuteIcon from './personal_mute.svg';
|
||||||
|
import ServerMuteIcon from './server_mute.svg';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
AddIcon,
|
AddIcon,
|
||||||
@@ -53,7 +56,10 @@ export {
|
|||||||
DMIcon,
|
DMIcon,
|
||||||
SpoilerIcon,
|
SpoilerIcon,
|
||||||
CrownIcon,
|
CrownIcon,
|
||||||
FriendsIcon
|
FriendsIcon,
|
||||||
|
SharingIcon,
|
||||||
|
PersonalMuteIcon,
|
||||||
|
ServerMuteIcon
|
||||||
};
|
};
|
||||||
|
|
||||||
export const Icons = {
|
export const Icons = {
|
||||||
@@ -83,5 +89,8 @@ export const Icons = {
|
|||||||
DM: DMIcon,
|
DM: DMIcon,
|
||||||
Spoiler: SpoilerIcon,
|
Spoiler: SpoilerIcon,
|
||||||
Crown: CrownIcon,
|
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];
|
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) => {
|
const isNewDay = (current, previous) => {
|
||||||
if (!previous) return true;
|
if (!previous) return true;
|
||||||
return current.getDate() !== previous.getDate()
|
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 menuRef = useRef(null);
|
||||||
const [pos, setPos] = useState({ top: y, left: x });
|
const [pos, setPos] = useState({ top: y, left: x });
|
||||||
useEffect(() => { const h = () => onClose(); window.addEventListener('click', h); return () => window.removeEventListener('click', h); }, [onClose]);
|
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" />
|
<div className="context-menu-separator" />
|
||||||
<MenuItem label="Pin Message" iconSrc={PinIcon} iconColor={ICON_COLOR_DEFAULT} onClick={() => onInteract('pin')} />
|
<MenuItem label="Pin Message" iconSrc={PinIcon} iconColor={ICON_COLOR_DEFAULT} onClick={() => onInteract('pin')} />
|
||||||
<div className="context-menu-separator" />
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -488,6 +502,8 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
|
|||||||
const convex = useConvex();
|
const convex = useConvex();
|
||||||
|
|
||||||
const members = useQuery(api.members.getChannelMembers, channelId ? { channelId } : "skip") || [];
|
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(
|
const { results: rawMessages, status, loadMore } = usePaginatedQuery(
|
||||||
api.messages.list,
|
api.messages.list,
|
||||||
@@ -713,7 +729,16 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
|
|||||||
markChannelAsReadRef.current = markChannelAsRead;
|
markChannelAsReadRef.current = markChannelAsRead;
|
||||||
|
|
||||||
const typingUsers = typingData.filter(t => t.username !== username);
|
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 scrollToBottom = useCallback((force = false) => {
|
||||||
const container = messagesContainerRef.current;
|
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;
|
if (!inputDivRef.current) return;
|
||||||
const selection = window.getSelection();
|
const selection = window.getSelection();
|
||||||
if (!selection.rangeCount) return;
|
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 matchStart = match.index + (match[0].startsWith(' ') ? 1 : 0);
|
||||||
const before = node.textContent.substring(0, matchStart);
|
const before = node.textContent.substring(0, matchStart);
|
||||||
const after = node.textContent.substring(range.startOffset);
|
const after = node.textContent.substring(range.startOffset);
|
||||||
node.textContent = before + '@' + member.username + ' ' + after;
|
const insertText = item.type === 'role'
|
||||||
const newOffset = before.length + 1 + member.username.length + 1;
|
? (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();
|
const newRange = document.createRange();
|
||||||
newRange.setStart(node, newOffset);
|
newRange.setStart(node, newOffset);
|
||||||
newRange.collapse(true);
|
newRange.collapse(true);
|
||||||
@@ -1035,10 +1063,10 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleKeyDown = (e) => {
|
const handleKeyDown = (e) => {
|
||||||
if (mentionQuery !== null && filteredMentionMembers.length > 0) {
|
if (mentionQuery !== null && mentionItems.length > 0) {
|
||||||
if (e.key === 'ArrowDown') { e.preventDefault(); setMentionIndex(i => (i + 1) % filteredMentionMembers.length); return; }
|
if (e.key === 'ArrowDown') { e.preventDefault(); setMentionIndex(i => (i + 1) % mentionItems.length); return; }
|
||||||
if (e.key === 'ArrowUp') { e.preventDefault(); setMentionIndex(i => (i - 1 + filteredMentionMembers.length) % filteredMentionMembers.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(filteredMentionMembers[mentionIndex]); 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 === 'Escape') { e.preventDefault(); setMentionQuery(null); return; }
|
||||||
}
|
}
|
||||||
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSend(e); }
|
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 });
|
pinMessageMutation({ id: msg.id, pinned: !msg.pinned });
|
||||||
break;
|
break;
|
||||||
case 'delete':
|
case 'delete':
|
||||||
deleteMessageMutation({ id: msg.id });
|
deleteMessageMutation({ id: msg.id, userId: currentUserId });
|
||||||
break;
|
break;
|
||||||
case 'reaction':
|
case 'reaction':
|
||||||
addReaction({ messageId: msg.id, userId: currentUserId, emoji: 'heart' });
|
addReaction({ messageId: msg.id, userId: currentUserId, emoji: 'heart' });
|
||||||
@@ -1166,8 +1194,16 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
|
|||||||
{decryptedMessages.map((msg, idx) => {
|
{decryptedMessages.map((msg, idx) => {
|
||||||
const currentDate = new Date(msg.created_at);
|
const currentDate = new Date(msg.created_at);
|
||||||
const previousDate = idx > 0 ? new Date(decryptedMessages[idx - 1].created_at) : null;
|
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 isOwner = msg.username === username;
|
||||||
|
const canDelete = isOwner || !!myPermissions?.manage_messages;
|
||||||
|
|
||||||
const prevMsg = idx > 0 ? decryptedMessages[idx - 1] : null;
|
const prevMsg = idx > 0 ? decryptedMessages[idx - 1] : null;
|
||||||
const isGrouped = prevMsg
|
const isGrouped = prevMsg
|
||||||
@@ -1194,17 +1230,18 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
|
|||||||
dateLabel={dateLabel}
|
dateLabel={dateLabel}
|
||||||
isMentioned={isMentioned}
|
isMentioned={isMentioned}
|
||||||
isOwner={isOwner}
|
isOwner={isOwner}
|
||||||
|
roles={roles}
|
||||||
isEditing={editingMessage?.id === msg.id}
|
isEditing={editingMessage?.id === msg.id}
|
||||||
isHovered={hoveredMessageId === msg.id}
|
isHovered={hoveredMessageId === msg.id}
|
||||||
editInput={editInput}
|
editInput={editInput}
|
||||||
username={username}
|
username={username}
|
||||||
onHover={() => setHoveredMessageId(msg.id)}
|
onHover={() => setHoveredMessageId(msg.id)}
|
||||||
onLeave={() => setHoveredMessageId(null)}
|
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' }); }}
|
onAddReaction={(emoji) => { addReaction({ messageId: msg.id, userId: currentUserId, emoji: emoji || 'heart' }); }}
|
||||||
onEdit={() => { setEditingMessage({ id: msg.id, content: msg.content }); setEditInput(msg.content); }}
|
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) })}
|
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)}
|
onEditInputChange={(e) => setEditInput(e.target.value)}
|
||||||
onEditKeyDown={handleEditKeyDown}
|
onEditKeyDown={handleEditKeyDown}
|
||||||
onEditSave={handleEditSave}
|
onEditSave={handleEditSave}
|
||||||
@@ -1223,12 +1260,12 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
|
|||||||
<div ref={messagesEndRef} />
|
<div ref={messagesEndRef} />
|
||||||
</div>
|
</div>
|
||||||
</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' }}>
|
<form className="chat-input-form" onSubmit={handleSend} style={{ position: 'relative' }}>
|
||||||
{mentionQuery !== null && filteredMentionMembers.length > 0 && (
|
{mentionQuery !== null && mentionItems.length > 0 && (
|
||||||
<MentionMenu
|
<MentionMenu
|
||||||
members={filteredMentionMembers}
|
items={mentionItems}
|
||||||
selectedIndex={mentionIndex}
|
selectedIndex={mentionIndex}
|
||||||
onSelect={insertMention}
|
onSelect={insertMention}
|
||||||
onHover={setMentionIndex}
|
onHover={setMentionIndex}
|
||||||
@@ -1266,6 +1303,17 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
|
|||||||
onMouseUp={saveSelection}
|
onMouseUp={saveSelection}
|
||||||
onKeyUp={saveSelection}
|
onKeyUp={saveSelection}
|
||||||
onPaste={(e) => {
|
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();
|
e.preventDefault();
|
||||||
const text = e.clipboardData.getData('text/plain');
|
const text = e.clipboardData.getData('text/plain');
|
||||||
document.execCommand('insertText', false, text);
|
document.execCommand('insertText', false, text);
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import React from 'react';
|
|||||||
import { useQuery } from 'convex/react';
|
import { useQuery } from 'convex/react';
|
||||||
import { api } from '../../../../convex/_generated/api';
|
import { api } from '../../../../convex/_generated/api';
|
||||||
import { useOnlineUsers } from '../contexts/PresenceContext';
|
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'];
|
const USER_COLORS = ['#5865F2', '#EBA7CD', '#57F287', '#FEE75C', '#EB459E', '#ED4245'];
|
||||||
|
|
||||||
@@ -34,6 +35,16 @@ const MembersList = ({ channelId, visible, onMemberClick }) => {
|
|||||||
channelId ? { channelId } : "skip"
|
channelId ? { channelId } : "skip"
|
||||||
) || [];
|
) || [];
|
||||||
const { resolveStatus } = useOnlineUsers();
|
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;
|
if (!visible) return null;
|
||||||
|
|
||||||
@@ -99,11 +110,24 @@ const MembersList = ({ channelId, visible, onMemberClick }) => {
|
|||||||
{member.username}
|
{member.username}
|
||||||
{isOwner && <ColoredIcon src={CrownIcon} color="var(--text-feedback-warning)" size="14px" />}
|
{isOwner && <ColoredIcon src={CrownIcon} color="var(--text-feedback-warning)" size="14px" />}
|
||||||
</span>
|
</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' }}>
|
<div style={{ fontSize: '12px', color: 'var(--text-muted)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||||
{member.customStatus}
|
{member.customStatus}
|
||||||
</div>
|
</div>
|
||||||
)}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React, { useEffect, useRef } from 'react';
|
import React, { useEffect, useRef } from 'react';
|
||||||
import Avatar from './Avatar';
|
import Avatar from './Avatar';
|
||||||
|
|
||||||
const MentionMenu = ({ members, selectedIndex, onSelect, onHover }) => {
|
const MentionMenu = ({ items, selectedIndex, onSelect, onHover }) => {
|
||||||
const scrollerRef = useRef(null);
|
const scrollerRef = useRef(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -10,31 +10,72 @@ const MentionMenu = ({ members, selectedIndex, onSelect, onHover }) => {
|
|||||||
if (selected) selected.scrollIntoView({ block: 'nearest' });
|
if (selected) selected.scrollIntoView({ block: 'nearest' });
|
||||||
}, [selectedIndex]);
|
}, [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 (
|
return (
|
||||||
<div className="mention-menu">
|
<div className="mention-menu">
|
||||||
<div className="mention-menu-header">Members</div>
|
|
||||||
<div className="mention-menu-scroller" ref={scrollerRef}>
|
<div className="mention-menu-scroller" ref={scrollerRef}>
|
||||||
{members.map((member, i) => {
|
{roleItems.length > 0 && (
|
||||||
const topRole = member.roles && member.roles.length > 0 ? member.roles[0] : null;
|
<>
|
||||||
const nameColor = topRole?.color || undefined;
|
<div className="mention-menu-section-header">Roles</div>
|
||||||
return (
|
{roleItems.map((role) => {
|
||||||
<div
|
const idx = globalIndex++;
|
||||||
key={member.id}
|
const displayName = role.name.startsWith('@') ? role.name : `@${role.name}`;
|
||||||
className={`mention-menu-row${i === selectedIndex ? ' selected' : ''}`}
|
return (
|
||||||
onMouseDown={(e) => e.preventDefault()}
|
<div
|
||||||
onClick={() => onSelect(member)}
|
key={`role-${role._id}`}
|
||||||
onMouseEnter={() => onHover(i)}
|
className={`mention-menu-row${idx === selectedIndex ? ' selected' : ''}`}
|
||||||
>
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
<Avatar username={member.username} avatarUrl={member.avatarUrl} size={24} />
|
onClick={() => onSelect(role)}
|
||||||
<span className="mention-menu-row-primary" style={nameColor ? { color: nameColor } : undefined}>
|
onMouseEnter={() => onHover(idx)}
|
||||||
{member.username}
|
>
|
||||||
</span>
|
<div
|
||||||
<span className="mention-menu-row-secondary">{member.username}</span>
|
className="mention-menu-role-icon"
|
||||||
</div>
|
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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -33,9 +33,29 @@ export const extractUrls = (text) => {
|
|||||||
return text.match(urlRegex) || [];
|
return text.match(urlRegex) || [];
|
||||||
};
|
};
|
||||||
|
|
||||||
export const formatMentions = (text) => {
|
export const formatMentions = (text, roles) => {
|
||||||
if (!text) return '';
|
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) => {
|
export const formatEmojis = (text) => {
|
||||||
@@ -93,6 +113,15 @@ const isNewDay = (current, previous) => {
|
|||||||
|
|
||||||
const markdownComponents = {
|
const markdownComponents = {
|
||||||
a: ({ node, ...props }) => {
|
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>;
|
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'} />;
|
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,
|
isHovered,
|
||||||
editInput,
|
editInput,
|
||||||
username,
|
username,
|
||||||
|
roles,
|
||||||
onHover,
|
onHover,
|
||||||
onLeave,
|
onLeave,
|
||||||
onContextMenu,
|
onContextMenu,
|
||||||
@@ -213,7 +243,7 @@ const MessageItem = React.memo(({
|
|||||||
<>
|
<>
|
||||||
{!isGif && !isDirectVideo && (
|
{!isGif && !isDirectVideo && (
|
||||||
<ReactMarkdown remarkPlugins={[remarkGfm]} urlTransform={(url) => url} components={markdownComponents}>
|
<ReactMarkdown remarkPlugins={[remarkGfm]} urlTransform={(url) => url} components={markdownComponents}>
|
||||||
{formatEmojis(formatMentions(msg.content))}
|
{formatEmojis(formatMentions(msg.content, roles))}
|
||||||
</ReactMarkdown>
|
</ReactMarkdown>
|
||||||
)}
|
)}
|
||||||
{isDirectVideo && <DirectVideo src={urls[0]} marginTop={4} />}
|
{isDirectVideo && <DirectVideo src={urls[0]} marginTop={4} />}
|
||||||
@@ -349,7 +379,8 @@ const MessageItem = React.memo(({
|
|||||||
prevProps.isGrouped === nextProps.isGrouped &&
|
prevProps.isGrouped === nextProps.isGrouped &&
|
||||||
prevProps.showDateDivider === nextProps.showDateDivider &&
|
prevProps.showDateDivider === nextProps.showDateDivider &&
|
||||||
prevProps.showUnreadDivider === nextProps.showUnreadDivider &&
|
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 [activeTab, setActiveTab] = useState('applications'); // applications | screens | devices
|
||||||
const [sources, setSources] = useState([]);
|
const [sources, setSources] = useState([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [shareAudio, setShareAudio] = useState(true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadSources();
|
loadSources();
|
||||||
@@ -43,11 +44,11 @@ const ScreenShareModal = ({ onClose, onSelectSource }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleSelect = (source) => {
|
const handleSelect = (source) => {
|
||||||
// If device, pass constraints differently
|
// If device, pass constraints differently (webcams don't have loopback audio)
|
||||||
if (source.isDevice) {
|
if (source.isDevice) {
|
||||||
onSelectSource({ deviceId: source.id, type: 'device' });
|
onSelectSource({ deviceId: source.id, type: 'device', shareAudio: false });
|
||||||
} else {
|
} else {
|
||||||
onSelectSource({ sourceId: source.id, type: 'screen' });
|
onSelectSource({ sourceId: source.id, type: 'screen', shareAudio });
|
||||||
}
|
}
|
||||||
onClose();
|
onClose();
|
||||||
};
|
};
|
||||||
@@ -210,6 +211,35 @@ const ScreenShareModal = ({ onClose, onSelectSource }) => {
|
|||||||
renderGrid(sources[activeTab])
|
renderGrid(sources[activeTab])
|
||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,6 +2,14 @@ import React, { useState } from 'react';
|
|||||||
import { useQuery, useConvex } from 'convex/react';
|
import { useQuery, useConvex } from 'convex/react';
|
||||||
import { api } from '../../../../convex/_generated/api';
|
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 ServerSettingsModal = ({ onClose }) => {
|
||||||
const [activeTab, setActiveTab] = useState('Overview');
|
const [activeTab, setActiveTab] = useState('Overview');
|
||||||
const [selectedRole, setSelectedRole] = useState(null);
|
const [selectedRole, setSelectedRole] = useState(null);
|
||||||
@@ -17,6 +25,37 @@ const ServerSettingsModal = ({ onClose }) => {
|
|||||||
userId ? { userId } : "skip"
|
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 () => {
|
const handleCreateRole = async () => {
|
||||||
try {
|
try {
|
||||||
const newRole = await convex.mutation(api.roles.create, {
|
const newRole = await convex.mutation(api.roles.create, {
|
||||||
@@ -63,7 +102,7 @@ const ServerSettingsModal = ({ onClose }) => {
|
|||||||
const renderSidebar = () => (
|
const renderSidebar = () => (
|
||||||
<div style={{
|
<div style={{
|
||||||
width: '218px',
|
width: '218px',
|
||||||
backgroundColor: 'var(--bg-secondary)',
|
backgroundColor: 'var(--bg-tertiary)',
|
||||||
display: 'flex', flexDirection: 'column', alignItems: 'flex-end', padding: '60px 6px 60px 20px'
|
display: 'flex', flexDirection: 'column', alignItems: 'flex-end', padding: '60px 6px 60px 20px'
|
||||||
}}>
|
}}>
|
||||||
<div style={{ width: '100%', padding: '0 10px' }}>
|
<div style={{ width: '100%', padding: '0 10px' }}>
|
||||||
@@ -141,7 +180,7 @@ const ServerSettingsModal = ({ onClose }) => {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<label style={labelStyle}>PERMISSIONS</label>
|
<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)' }}>
|
<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>
|
<span style={{ color: 'var(--header-primary)', textTransform: 'capitalize' }}>{perm.replace('_', ' ')}</span>
|
||||||
<input
|
<input
|
||||||
@@ -217,7 +256,59 @@ const ServerSettingsModal = ({ onClose }) => {
|
|||||||
switch (activeTab) {
|
switch (activeTab) {
|
||||||
case 'Roles': return renderRolesTab();
|
case 'Roles': return renderRolesTab();
|
||||||
case 'Members': return renderMembersTab();
|
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 Avatar from './Avatar';
|
||||||
import UserSettings from './UserSettings';
|
import UserSettings from './UserSettings';
|
||||||
import { Track } from 'livekit-client';
|
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 { SortableContext, verticalListSortingStrategy, useSortable } from '@dnd-kit/sortable';
|
||||||
import { CSS } from '@dnd-kit/utilities';
|
import { CSS } from '@dnd-kit/utilities';
|
||||||
import muteIcon from '../assets/icons/mute.svg';
|
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 cameraIcon from '../assets/icons/camera.svg';
|
||||||
import screenIcon from '../assets/icons/screen.svg';
|
import screenIcon from '../assets/icons/screen.svg';
|
||||||
import inviteUserIcon from '../assets/icons/invite_user.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 categoryCollapsedIcon from '../assets/icons/category_collapsed_icon.svg';
|
||||||
import PingSound from '../assets/sounds/ping.mp3';
|
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 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_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 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 = {
|
const controlButtonStyle = {
|
||||||
background: 'transparent',
|
background: 'transparent',
|
||||||
@@ -114,6 +119,8 @@ const UserControlPanel = ({ username, userId }) => {
|
|||||||
const [currentStatus, setCurrentStatus] = useState('online');
|
const [currentStatus, setCurrentStatus] = useState('online');
|
||||||
const updateStatusMutation = useMutation(api.auth.updateStatus);
|
const updateStatusMutation = useMutation(api.auth.updateStatus);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const manualStatusRef = useRef(false);
|
||||||
|
const preIdleStatusRef = useRef('online');
|
||||||
|
|
||||||
// Fetch stored status preference from server and sync local state
|
// Fetch stored status preference from server and sync local state
|
||||||
const allUsers = useQuery(api.auth.getPublicKeys) || [];
|
const allUsers = useQuery(api.auth.getPublicKeys) || [];
|
||||||
@@ -122,9 +129,12 @@ const UserControlPanel = ({ username, userId }) => {
|
|||||||
if (myUser) {
|
if (myUser) {
|
||||||
if (myUser.status && myUser.status !== 'offline') {
|
if (myUser.status && myUser.status !== 'offline') {
|
||||||
setCurrentStatus(myUser.status);
|
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') {
|
} else if (!myUser.status || myUser.status === 'offline') {
|
||||||
// First login or no preference set yet — default to "online"
|
// First login or no preference set yet — default to "online"
|
||||||
setCurrentStatus('online');
|
setCurrentStatus('online');
|
||||||
|
manualStatusRef.current = false;
|
||||||
if (userId) {
|
if (userId) {
|
||||||
updateStatusMutation({ userId, status: 'online' }).catch(() => {});
|
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 statusColor = STATUS_OPTIONS.find(s => s.value === currentStatus)?.color || '#3ba55c';
|
||||||
|
|
||||||
const handleStatusChange = async (status) => {
|
const handleStatusChange = async (status) => {
|
||||||
|
manualStatusRef.current = (status !== 'online');
|
||||||
setCurrentStatus(status);
|
setCurrentStatus(status);
|
||||||
setShowStatusMenu(false);
|
setShowStatusMenu(false);
|
||||||
if (userId) {
|
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 (
|
return (
|
||||||
<div style={{
|
<div style={{
|
||||||
height: '64px',
|
height: '64px',
|
||||||
@@ -331,7 +361,12 @@ function getScreenCaptureConstraints(selection) {
|
|||||||
return { video: { deviceId: { exact: selection.deviceId } }, audio: false };
|
return { video: { deviceId: { exact: selection.deviceId } }, audio: false };
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
audio: false,
|
audio: selection.shareAudio ? {
|
||||||
|
mandatory: {
|
||||||
|
chromeMediaSource: 'desktop',
|
||||||
|
chromeMediaSourceId: selection.sourceId
|
||||||
|
}
|
||||||
|
} : false,
|
||||||
video: {
|
video: {
|
||||||
mandatory: {
|
mandatory: {
|
||||||
chromeMediaSource: 'desktop',
|
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 ChannelListContextMenu = ({ x, y, onClose, onCreateChannel, onCreateCategory }) => {
|
||||||
const menuRef = useRef(null);
|
const menuRef = useRef(null);
|
||||||
const [pos, setPos] = useState({ top: y, left: x });
|
const [pos, setPos] = useState({ top: y, left: x });
|
||||||
@@ -580,7 +691,29 @@ const SortableChannel = ({ id, children }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -595,13 +728,21 @@ const Sidebar = ({ channels, categories, activeChannel, onSelectChannel, usernam
|
|||||||
const [isScreenShareModalOpen, setIsScreenShareModalOpen] = useState(false);
|
const [isScreenShareModalOpen, setIsScreenShareModalOpen] = useState(false);
|
||||||
const [collapsedCategories, setCollapsedCategories] = useState({});
|
const [collapsedCategories, setCollapsedCategories] = useState({});
|
||||||
const [channelListContextMenu, setChannelListContextMenu] = useState(null);
|
const [channelListContextMenu, setChannelListContextMenu] = useState(null);
|
||||||
|
const [voiceUserMenu, setVoiceUserMenu] = useState(null);
|
||||||
const [showCreateChannelModal, setShowCreateChannelModal] = useState(false);
|
const [showCreateChannelModal, setShowCreateChannelModal] = useState(false);
|
||||||
const [showCreateCategoryModal, setShowCreateCategoryModal] = useState(false);
|
const [showCreateCategoryModal, setShowCreateCategoryModal] = useState(false);
|
||||||
const [createChannelCategoryId, setCreateChannelCategoryId] = useState(null);
|
const [createChannelCategoryId, setCreateChannelCategoryId] = useState(null);
|
||||||
const [activeDragItem, setActiveDragItem] = useState(null);
|
const [activeDragItem, setActiveDragItem] = useState(null);
|
||||||
|
const [dragOverChannelId, setDragOverChannelId] = useState(null);
|
||||||
|
|
||||||
const convex = useConvex();
|
const convex = useConvex();
|
||||||
|
|
||||||
|
// Permissions for move_members gating
|
||||||
|
const myPermissions = useQuery(
|
||||||
|
api.roles.getMyPermissions,
|
||||||
|
userId ? { userId } : "skip"
|
||||||
|
) || {};
|
||||||
|
|
||||||
// DnD sensors
|
// DnD sensors
|
||||||
const sensors = useSensors(
|
const sensors = useSensors(
|
||||||
useSensor(PointerSensor, { activationConstraint: { distance: 5 } })
|
useSensor(PointerSensor, { activationConstraint: { distance: 5 } })
|
||||||
@@ -674,7 +815,7 @@ const Sidebar = ({ channels, categories, activeChannel, onSelectChannel, usernam
|
|||||||
if (activeChannel === id) onSelectChannel(null);
|
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 = () => {
|
const handleStartCreate = () => {
|
||||||
setIsCreating(true);
|
setIsCreating(true);
|
||||||
@@ -772,7 +913,19 @@ const Sidebar = ({ channels, categories, activeChannel, onSelectChannel, usernam
|
|||||||
await room.localParticipant.setScreenShareEnabled(false);
|
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];
|
const track = stream.getVideoTracks()[0];
|
||||||
if (!track) return;
|
if (!track) return;
|
||||||
|
|
||||||
@@ -781,9 +934,24 @@ const Sidebar = ({ channels, categories, activeChannel, onSelectChannel, usernam
|
|||||||
source: Track.Source.ScreenShare
|
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);
|
setScreenSharing(true);
|
||||||
|
|
||||||
track.onended = () => {
|
track.onended = () => {
|
||||||
|
// Clean up audio track when video track ends
|
||||||
|
if (audioTrack) {
|
||||||
|
audioTrack.stop();
|
||||||
|
room.localParticipant.unpublishTrack(audioTrack);
|
||||||
|
}
|
||||||
setScreenSharing(false);
|
setScreenSharing(false);
|
||||||
room.localParticipant.setScreenShareEnabled(false).catch(console.error);
|
room.localParticipant.setScreenShareEnabled(false).catch(console.error);
|
||||||
};
|
};
|
||||||
@@ -795,7 +963,17 @@ const Sidebar = ({ channels, categories, activeChannel, onSelectChannel, usernam
|
|||||||
|
|
||||||
const handleScreenShareClick = () => {
|
const handleScreenShareClick = () => {
|
||||||
if (room?.localParticipant.isScreenShareEnabled) {
|
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);
|
room.localParticipant.setScreenShareEnabled(false);
|
||||||
|
new Audio(screenShareStopSound).play();
|
||||||
setScreenSharing(false);
|
setScreenSharing(false);
|
||||||
} else {
|
} else {
|
||||||
setIsScreenShareModalOpen(true);
|
setIsScreenShareModalOpen(true);
|
||||||
@@ -828,31 +1006,83 @@ const Sidebar = ({ channels, categories, activeChannel, onSelectChannel, usernam
|
|||||||
return (
|
return (
|
||||||
<div style={{ marginLeft: 32, marginBottom: 8 }}>
|
<div style={{ marginLeft: 32, marginBottom: 8 }}>
|
||||||
{users.map(user => (
|
{users.map(user => (
|
||||||
<div key={user.userId} style={{ display: 'flex', alignItems: 'center', marginBottom: 8 }}>
|
<DraggableVoiceUser
|
||||||
<Avatar
|
key={user.userId}
|
||||||
username={user.username}
|
userId={user.userId}
|
||||||
size={24}
|
channelId={channel._id}
|
||||||
style={{
|
disabled={!myPermissions.move_members}
|
||||||
marginRight: 8,
|
>
|
||||||
boxShadow: activeSpeakers.has(user.userId) ? ACTIVE_SPEAKER_SHADOW : 'none'
|
<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>
|
<Avatar
|
||||||
<div style={{ display: 'flex', marginLeft: 'auto', gap: 4, alignItems: 'center', marginRight: "16px" }}>
|
username={user.username}
|
||||||
{user.isScreenSharing && <div style={liveBadgeStyle}>Live</div>}
|
avatarUrl={user.avatarUrl}
|
||||||
{(user.isMuted || user.isDeafened) && (
|
size={24}
|
||||||
<ColoredIcon src={mutedIcon} color="var(--header-secondary)" size="14px" />
|
style={{
|
||||||
)}
|
marginRight: 8,
|
||||||
{user.isDeafened && (
|
boxShadow: activeSpeakers.has(user.userId) ? ACTIVE_SPEAKER_SHADOW : 'none'
|
||||||
<ColoredIcon src={defeanedIcon} color="var(--header-secondary)" size="14px" />
|
}}
|
||||||
)}
|
/>
|
||||||
|
<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>
|
||||||
</div>
|
</DraggableVoiceUser>
|
||||||
))}
|
))}
|
||||||
</div>
|
</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) => {
|
const toggleCategory = (cat) => {
|
||||||
setCollapsedCategories(prev => ({ ...prev, [cat]: !prev[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 chId = active.id.replace('channel-', '');
|
||||||
const ch = channels.find(c => c._id === chId);
|
const ch = channels.find(c => c._id === chId);
|
||||||
setActiveDragItem({ type: 'channel', channel: ch });
|
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) => {
|
const handleDragEnd = async (event) => {
|
||||||
setActiveDragItem(null);
|
setActiveDragItem(null);
|
||||||
|
setDragOverChannelId(null);
|
||||||
const { active, over } = event;
|
const { active, over } = event;
|
||||||
if (!over || active.id === over.id) return;
|
if (!over || active.id === over.id) return;
|
||||||
|
|
||||||
const activeType = active.data.current?.type;
|
const activeType = active.data.current?.type;
|
||||||
const overType = over.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') {
|
if (activeType === 'category' && overType === 'category') {
|
||||||
// Reorder categories
|
// Reorder categories
|
||||||
const oldIndex = groupedChannels.findIndex(g => `category-${g.id}` === active.id);
|
const oldIndex = groupedChannels.findIndex(g => `category-${g.id}` === active.id);
|
||||||
@@ -1043,6 +1321,7 @@ const Sidebar = ({ channels, categories, activeChannel, onSelectChannel, usernam
|
|||||||
sensors={sensors}
|
sensors={sensors}
|
||||||
collisionDetection={closestCenter}
|
collisionDetection={closestCenter}
|
||||||
onDragStart={handleDragStart}
|
onDragStart={handleDragStart}
|
||||||
|
onDragOver={handleDragOver}
|
||||||
onDragEnd={handleDragEnd}
|
onDragEnd={handleDragEnd}
|
||||||
>
|
>
|
||||||
<SortableContext items={categoryDndIds} strategy={verticalListSortingStrategy}>
|
<SortableContext items={categoryDndIds} strategy={verticalListSortingStrategy}>
|
||||||
@@ -1060,8 +1339,12 @@ const Sidebar = ({ channels, categories, activeChannel, onSelectChannel, usernam
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{(() => {
|
{(() => {
|
||||||
const visibleChannels = collapsedCategories[group.id]
|
const isCollapsed = collapsedCategories[group.id];
|
||||||
? group.channels.filter(ch => ch._id === activeChannel)
|
const visibleChannels = isCollapsed
|
||||||
|
? group.channels.filter(ch =>
|
||||||
|
ch._id === activeChannel ||
|
||||||
|
(ch.type === 'voice' && voiceStates[ch._id]?.length > 0)
|
||||||
|
)
|
||||||
: group.channels;
|
: group.channels;
|
||||||
if (visibleChannels.length === 0) return null;
|
if (visibleChannels.length === 0) return null;
|
||||||
const visibleDndIds = visibleChannels.map(ch => `channel-${ch._id}`);
|
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);
|
const isUnread = activeChannel !== channel._id && unreadChannels.has(channel._id);
|
||||||
return (
|
return (
|
||||||
<SortableChannel key={channel._id} id={`channel-${channel._id}`}>
|
<SortableChannel key={channel._id} id={`channel-${channel._id}`}>
|
||||||
|
{(channelDragListeners) => (
|
||||||
<React.Fragment>
|
<React.Fragment>
|
||||||
<div
|
{!(isCollapsed && channel.type === 'voice' && voiceStates[channel._id]?.length > 0) && <div
|
||||||
className={`channel-item ${activeChannel === channel._id ? 'active' : ''} ${voiceChannelId === channel._id ? 'voice-active' : ''}`}
|
className={`channel-item ${activeChannel === channel._id ? 'active' : ''} ${voiceChannelId === channel._id ? 'voice-active' : ''} ${dragOverChannelId === channel._id ? 'voice-drop-target' : ''}`}
|
||||||
onClick={() => handleChannelClick(channel)}
|
onClick={() => handleChannelClick(channel)}
|
||||||
|
{...channelDragListeners}
|
||||||
style={{
|
style={{
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
display: 'flex',
|
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={{ 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>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
@@ -1115,9 +1402,12 @@ const Sidebar = ({ channels, categories, activeChannel, onSelectChannel, usernam
|
|||||||
>
|
>
|
||||||
<ColoredIcon src={settingsIcon} color="var(--interactive-normal)" size="14px" />
|
<ColoredIcon src={settingsIcon} color="var(--interactive-normal)" size="14px" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>}
|
||||||
{renderVoiceUsers(channel)}
|
{isCollapsed
|
||||||
|
? renderCollapsedVoiceUsers(channel)
|
||||||
|
: renderVoiceUsers(channel)}
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
|
)}
|
||||||
</SortableChannel>
|
</SortableChannel>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -1145,6 +1435,17 @@ const Sidebar = ({ channels, categories, activeChannel, onSelectChannel, usernam
|
|||||||
{activeDragItem.name}
|
{activeDragItem.name}
|
||||||
</div>
|
</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>
|
</DragOverlay>
|
||||||
</DndContext>
|
</DndContext>
|
||||||
</div>
|
</div>
|
||||||
@@ -1283,6 +1584,23 @@ const Sidebar = ({ channels, categories, activeChannel, onSelectChannel, usernam
|
|||||||
onCreateCategory={() => setShowCreateCategoryModal(true)}
|
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 && (
|
{showCreateChannelModal && (
|
||||||
<CreateChannelModal
|
<CreateChannelModal
|
||||||
categoryId={createChannelCategoryId}
|
categoryId={createChannelCategoryId}
|
||||||
|
|||||||
@@ -5,27 +5,7 @@ const TitleBar = () => {
|
|||||||
return (
|
return (
|
||||||
<div className="titlebar">
|
<div className="titlebar">
|
||||||
<div className="titlebar-drag-region" />
|
<div className="titlebar-drag-region" />
|
||||||
<div className="titlebar-nav">
|
<div className="titlebar-title">Brycord</div>
|
||||||
<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-buttons">
|
<div className="titlebar-buttons">
|
||||||
<TitleBarUpdateIcon />
|
<TitleBarUpdateIcon />
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -10,9 +10,13 @@ import mutedIcon from '../assets/icons/muted.svg';
|
|||||||
import cameraIcon from '../assets/icons/camera.svg';
|
import cameraIcon from '../assets/icons/camera.svg';
|
||||||
import screenIcon from '../assets/icons/screen.svg';
|
import screenIcon from '../assets/icons/screen.svg';
|
||||||
import disconnectIcon from '../assets/icons/disconnect.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 screenShareStartSound from '../assets/sounds/screenshare_start.mp3';
|
||||||
import screenShareStopSound from '../assets/sounds/screenshare_stop.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 getInitials = (name) => (name || '?').substring(0, 1).toUpperCase();
|
||||||
|
|
||||||
const getUserColor = (username) => {
|
const getUserColor = (username) => {
|
||||||
@@ -88,6 +92,7 @@ const VideoRenderer = ({ track, style }) => {
|
|||||||
function findTrackPubs(participant) {
|
function findTrackPubs(participant) {
|
||||||
let cameraPub = null;
|
let cameraPub = null;
|
||||||
let screenSharePub = null;
|
let screenSharePub = null;
|
||||||
|
let screenShareAudioPub = null;
|
||||||
|
|
||||||
const trackMap = participant.tracks || participant.trackPublications;
|
const trackMap = participant.tracks || participant.trackPublications;
|
||||||
if (trackMap) {
|
if (trackMap) {
|
||||||
@@ -95,7 +100,9 @@ function findTrackPubs(participant) {
|
|||||||
const source = pub.source ? pub.source.toString().toLowerCase() : '';
|
const source = pub.source ? pub.source.toString().toLowerCase() : '';
|
||||||
const name = pub.trackName ? pub.trackName.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;
|
screenSharePub = pub;
|
||||||
} else if (source === 'camera' || name.includes('camera')) {
|
} else if (source === 'camera' || name.includes('camera')) {
|
||||||
cameraPub = pub;
|
cameraPub = pub;
|
||||||
@@ -111,7 +118,9 @@ function findTrackPubs(participant) {
|
|||||||
for (const pub of participant.getTracks()) {
|
for (const pub of participant.getTracks()) {
|
||||||
const source = pub.source ? pub.source.toString().toLowerCase() : '';
|
const source = pub.source ? pub.source.toString().toLowerCase() : '';
|
||||||
const name = pub.trackName ? pub.trackName.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;
|
screenSharePub = pub;
|
||||||
} else if (source === 'camera' || name.includes('camera')) {
|
} else if (source === 'camera' || name.includes('camera')) {
|
||||||
cameraPub = pub;
|
cameraPub = pub;
|
||||||
@@ -122,7 +131,7 @@ function findTrackPubs(participant) {
|
|||||||
} catch (e) { /* ignore */ }
|
} catch (e) { /* ignore */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
return { cameraPub, screenSharePub };
|
return { cameraPub, screenSharePub, screenShareAudioPub };
|
||||||
}
|
}
|
||||||
|
|
||||||
function useParticipantTrack(participant, source) {
|
function useParticipantTrack(participant, source) {
|
||||||
@@ -179,9 +188,18 @@ function useParticipantTrack(participant, source) {
|
|||||||
|
|
||||||
const ParticipantTile = ({ participant, username, avatarUrl }) => {
|
const ParticipantTile = ({ participant, username, avatarUrl }) => {
|
||||||
const cameraTrack = useParticipantTrack(participant, 'camera');
|
const cameraTrack = useParticipantTrack(participant, 'camera');
|
||||||
|
const { isPersonallyMuted, voiceStates } = useVoice();
|
||||||
const isMicEnabled = participant.isMicrophoneEnabled;
|
const isMicEnabled = participant.isMicrophoneEnabled;
|
||||||
|
const isPersonalMuted = isPersonallyMuted(participant.identity);
|
||||||
const displayName = username || 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 (
|
return (
|
||||||
<div style={{
|
<div style={{
|
||||||
backgroundColor: '#202225',
|
backgroundColor: '#202225',
|
||||||
@@ -227,7 +245,11 @@ const ParticipantTile = ({ participant, username, avatarUrl }) => {
|
|||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: '6px'
|
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}
|
{displayName}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -303,8 +325,17 @@ const StreamPreviewTile = ({ participant, username, onWatchStream }) => {
|
|||||||
|
|
||||||
const ParticipantThumbnail = ({ participant, username, avatarUrl, isStreamer, isMuted }) => {
|
const ParticipantThumbnail = ({ participant, username, avatarUrl, isStreamer, isMuted }) => {
|
||||||
const cameraTrack = useParticipantTrack(participant, 'camera');
|
const cameraTrack = useParticipantTrack(participant, 'camera');
|
||||||
|
const { isPersonallyMuted, voiceStates } = useVoice();
|
||||||
|
const isPersonalMuted = isPersonallyMuted(participant.identity);
|
||||||
const displayName = username || 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 (
|
return (
|
||||||
<div style={{
|
<div style={{
|
||||||
width: THUMBNAIL_SIZE.width,
|
width: THUMBNAIL_SIZE.width,
|
||||||
@@ -337,7 +368,13 @@ const ParticipantThumbnail = ({ participant, username, avatarUrl, isStreamer, is
|
|||||||
maxWidth: '100%', overflow: 'hidden', textOverflow: 'ellipsis',
|
maxWidth: '100%', overflow: 'hidden', textOverflow: 'ellipsis',
|
||||||
whiteSpace: 'nowrap', display: 'flex', alignItems: 'center', gap: '3px',
|
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}
|
{displayName}
|
||||||
{isStreamer && <span style={{ ...LIVE_BADGE_STYLE, fontSize: '8px', padding: '1px 3px' }}>LIVE</span>}
|
{isStreamer && <span style={{ ...LIVE_BADGE_STYLE, fontSize: '8px', padding: '1px 3px' }}>LIVE</span>}
|
||||||
</span>
|
</span>
|
||||||
@@ -558,6 +595,7 @@ const VoiceStage = ({ room, channelId, voiceStates, channelName }) => {
|
|||||||
const { isMuted, toggleMute, disconnectVoice, setScreenSharing, connectToVoice } = useVoice();
|
const { isMuted, toggleMute, disconnectVoice, setScreenSharing, connectToVoice } = useVoice();
|
||||||
const [isScreenShareModalOpen, setIsScreenShareModalOpen] = useState(false);
|
const [isScreenShareModalOpen, setIsScreenShareModalOpen] = useState(false);
|
||||||
const [isScreenShareActive, setIsScreenShareActive] = useState(false);
|
const [isScreenShareActive, setIsScreenShareActive] = useState(false);
|
||||||
|
const screenShareAudioTrackRef = useRef(null);
|
||||||
|
|
||||||
// Stream viewing state
|
// Stream viewing state
|
||||||
const [watchingStreamOf, setWatchingStreamOf] = useState(null);
|
const [watchingStreamOf, setWatchingStreamOf] = useState(null);
|
||||||
@@ -607,13 +645,16 @@ const VoiceStage = ({ room, channelId, voiceStates, channelName }) => {
|
|||||||
|
|
||||||
const manageSubscriptions = () => {
|
const manageSubscriptions = () => {
|
||||||
for (const p of room.remoteParticipants.values()) {
|
for (const p of room.remoteParticipants.values()) {
|
||||||
const { screenSharePub } = findTrackPubs(p);
|
const { screenSharePub, screenShareAudioPub } = findTrackPubs(p);
|
||||||
if (!screenSharePub) continue;
|
|
||||||
|
|
||||||
const shouldSubscribe = watchingStreamOf === p.identity;
|
const shouldSubscribe = watchingStreamOf === p.identity;
|
||||||
if (screenSharePub.isSubscribed !== shouldSubscribe) {
|
|
||||||
|
if (screenSharePub && screenSharePub.isSubscribed !== shouldSubscribe) {
|
||||||
screenSharePub.setSubscribed(shouldSubscribe);
|
screenSharePub.setSubscribed(shouldSubscribe);
|
||||||
}
|
}
|
||||||
|
if (screenShareAudioPub && screenShareAudioPub.isSubscribed !== shouldSubscribe) {
|
||||||
|
screenShareAudioPub.setSubscribed(shouldSubscribe);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -669,20 +710,50 @@ const VoiceStage = ({ room, channelId, voiceStates, channelName }) => {
|
|||||||
audio: false
|
audio: false
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
stream = await navigator.mediaDevices.getUserMedia({
|
// Try with audio if requested, fall back to video-only if it fails
|
||||||
audio: false,
|
const audioConstraint = selection.shareAudio ? {
|
||||||
video: {
|
mandatory: {
|
||||||
mandatory: {
|
chromeMediaSource: 'desktop',
|
||||||
chromeMediaSource: 'desktop',
|
chromeMediaSourceId: selection.sourceId
|
||||||
chromeMediaSourceId: selection.sourceId,
|
|
||||||
minWidth: 1280,
|
|
||||||
maxWidth: 1920,
|
|
||||||
minHeight: 720,
|
|
||||||
maxHeight: 1080,
|
|
||||||
maxFrameRate: 30
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
} : 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];
|
const track = stream.getVideoTracks()[0];
|
||||||
if (track) {
|
if (track) {
|
||||||
@@ -691,9 +762,26 @@ const VoiceStage = ({ room, channelId, voiceStates, channelName }) => {
|
|||||||
name: 'screen_share',
|
name: 'screen_share',
|
||||||
source: Track.Source.ScreenShare
|
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);
|
setScreenSharing(true);
|
||||||
|
|
||||||
track.onended = () => {
|
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);
|
setScreenSharing(false);
|
||||||
room.localParticipant.setScreenShareEnabled(false).catch(console.error);
|
room.localParticipant.setScreenShareEnabled(false).catch(console.error);
|
||||||
};
|
};
|
||||||
@@ -706,6 +794,12 @@ const VoiceStage = ({ room, channelId, voiceStates, channelName }) => {
|
|||||||
|
|
||||||
const handleScreenShareClick = () => {
|
const handleScreenShareClick = () => {
|
||||||
if (isScreenShareActive) {
|
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);
|
room.localParticipant.setScreenShareEnabled(false);
|
||||||
setScreenSharing(false);
|
setScreenSharing(false);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -43,10 +43,51 @@ export const VoiceProvider = ({ children }) => {
|
|||||||
const [isMuted, setIsMuted] = useState(false);
|
const [isMuted, setIsMuted] = useState(false);
|
||||||
const [isDeafened, setIsDeafened] = useState(false);
|
const [isDeafened, setIsDeafened] = useState(false);
|
||||||
const [isScreenSharing, setIsScreenSharingLocal] = 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 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 voiceStates = useQuery(api.voiceState.getAll) || {};
|
||||||
|
const serverSettings = useQuery(api.serverSettings.get);
|
||||||
|
const isInAfkChannel = !!(activeChannelId && serverSettings?.afkChannelId === activeChannelId);
|
||||||
|
|
||||||
async function updateVoiceState(fields) {
|
async function updateVoiceState(fields) {
|
||||||
const userId = localStorage.getItem('userId');
|
const userId = localStorage.getItem('userId');
|
||||||
@@ -117,8 +158,22 @@ export const VoiceProvider = ({ children }) => {
|
|||||||
isDeafened,
|
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) => {
|
newRoom.on(RoomEvent.Disconnected, async (reason) => {
|
||||||
console.warn('Voice Room Disconnected. Reason:', 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');
|
playSound('leave');
|
||||||
setConnectionState('disconnected');
|
setConnectionState('disconnected');
|
||||||
setActiveChannelId(null);
|
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 = () => {
|
const disconnectVoice = () => {
|
||||||
console.log('User manually disconnected voice');
|
console.log('User manually disconnected voice');
|
||||||
if (room) room.disconnect();
|
if (room) room.disconnect();
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleMute = async () => {
|
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;
|
const nextState = !isMuted;
|
||||||
setIsMuted(nextState);
|
setIsMuted(nextState);
|
||||||
playSound(nextState ? 'mute' : 'unmute');
|
playSound(nextState ? 'mute' : 'unmute');
|
||||||
@@ -190,7 +332,14 @@ export const VoiceProvider = ({ children }) => {
|
|||||||
toggleMute,
|
toggleMute,
|
||||||
toggleDeafen,
|
toggleDeafen,
|
||||||
isScreenSharing,
|
isScreenSharing,
|
||||||
setScreenSharing
|
setScreenSharing,
|
||||||
|
personallyMutedUsers,
|
||||||
|
togglePersonalMute,
|
||||||
|
isPersonallyMuted,
|
||||||
|
serverMute,
|
||||||
|
isServerMuted,
|
||||||
|
isInAfkChannel,
|
||||||
|
serverSettings
|
||||||
}}>
|
}}>
|
||||||
{children}
|
{children}
|
||||||
{room && (
|
{room && (
|
||||||
|
|||||||
@@ -195,8 +195,8 @@ body {
|
|||||||
|
|
||||||
.channel-item {
|
.channel-item {
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
margin-bottom: 2px;
|
margin: 4px;
|
||||||
border-radius: 4px;
|
border-radius: 8px;
|
||||||
color: var(--interactive-normal);
|
color: var(--interactive-normal);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -986,6 +986,36 @@ body {
|
|||||||
text-overflow: ellipsis;
|
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
|
REPLY SYSTEM
|
||||||
============================================ */
|
============================================ */
|
||||||
@@ -1158,8 +1188,9 @@ body {
|
|||||||
============================================ */
|
============================================ */
|
||||||
.context-menu {
|
.context-menu {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
background-color: var(--background-base-lowest);
|
background-color: var(--panel-bg);
|
||||||
border-radius: 4px;
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--app-frame-border);
|
||||||
box-shadow: 0 8px 16px rgba(0,0,0,0.24);
|
box-shadow: 0 8px 16px rgba(0,0,0,0.24);
|
||||||
z-index: 9999;
|
z-index: 9999;
|
||||||
min-width: 188px;
|
min-width: 188px;
|
||||||
@@ -1184,7 +1215,7 @@ body {
|
|||||||
color: var(--text-normal);
|
color: var(--text-normal);
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
border-radius: 2px;
|
border-radius: 8px;
|
||||||
transition: background-color 0.1s;
|
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%);
|
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 {
|
.context-menu-separator {
|
||||||
height: 1px;
|
height: 1px;
|
||||||
background-color: var(--bg-primary);
|
background-color: var(--bg-primary);
|
||||||
@@ -1216,8 +1275,9 @@ body {
|
|||||||
right: 0;
|
right: 0;
|
||||||
background-color: var(--background-surface-high, var(--embed-background));
|
background-color: var(--background-surface-high, var(--embed-background));
|
||||||
border-radius: 5px;
|
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;
|
z-index: 100;
|
||||||
|
margin: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mention-menu-header {
|
.mention-menu-header {
|
||||||
@@ -1228,6 +1288,27 @@ body {
|
|||||||
color: var(--header-secondary);
|
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 {
|
.mention-menu-scroller {
|
||||||
max-height: 490px;
|
max-height: 490px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
@@ -2873,6 +2954,21 @@ body {
|
|||||||
background-color: var(--brand-experiment-hover);
|
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 {
|
.drag-overlay-category {
|
||||||
padding: 8px 12px;
|
padding: 8px 12px;
|
||||||
background-color: var(--bg-secondary);
|
background-color: var(--bg-secondary);
|
||||||
@@ -2887,3 +2983,25 @@ body {
|
|||||||
opacity: 0.9;
|
opacity: 0.9;
|
||||||
width: 200px;
|
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;
|
||||||
|
}
|
||||||
28
TODO.md
28
TODO.md
@@ -1,36 +1,30 @@
|
|||||||
- 955px
|
|
||||||
|
|
||||||
|
|
||||||
- I want to give users the choice to update the app. Can we make updating work 2 ways. One, optional updates, i want to somehow make some updates marked as optional, where techinically older versions will still work so we dont care if they are on a older version. And some updates non optional, where we will require users to update to the latest version to continue using the app. So for example when they launch the app we will check if their is a update but we dont update the app right away. We will show a download icon like discord in the header, that will be the update.svg icon. Make the icon use this color "hsl(138.353 calc(1*38.117%) 56.275% /1);"
|
- I want to give users the choice to update the app. Can we make updating work 2 ways. One, optional updates, i want to somehow make some updates marked as optional, where techinically older versions will still work so we dont care if they are on a older version. And some updates non optional, where we will require users to update to the latest version to continue using the app. So for example when they launch the app we will check if their is a update but we dont update the app right away. We will show a download icon like discord in the header, that will be the update.svg icon. Make the icon use this color "hsl(138.353 calc(1*38.117%) 56.275% /1);"
|
||||||
|
|
||||||
- When a user messages you, you should get a notification. On the server list that user profile picture should be their above all servers. right under the discord and above the server-separator. With a red dot next to it. If you get a private dm you should hear the ping sound also
|
<!-- - When a user messages you, you should get a notification. On the server list that user profile picture should be their above all servers. right under the discord and above the server-separator. With a red dot next to it. If you get a private dm you should hear the ping sound also -->
|
||||||
|
|
||||||
- We should play a sound when a user mentions you also in the main server.
|
- We should play a sound when a user mentions you also in the main server.
|
||||||
|
|
||||||
- In the mention list we should also have roles, so if people do @everyone they should mention everyone in the server, or they can @ a certain role they want to mention from the server. This does not apply to private messages.
|
<!-- - In the mention list we should also have roles, so if people do @everyone they should mention everyone in the server, or they can @ a certain role they want to mention from the server. This does not apply to private messages. -->
|
||||||
|
|
||||||
- Owners should be able to delete anyones message in the server.
|
<!-- - Owners should be able to delete anyones message in the server. -->
|
||||||
|
|
||||||
|
<!-- - When i share my screen using the Share Screen button thats in our side bar with the disconnect button i dont hear the sharing screen sound like i started sharing. I only hear it when i use the screenshare button in the voice stage modal.
|
||||||
|
|
||||||
- When i click on my voice channel i dont join it anymore right away.
|
- Add audio to screenshare -->
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
- Add audio to screenshare
|
|
||||||
<!-- - Figure out why audio is shit. -->
|
<!-- - Figure out why audio is shit. -->
|
||||||
- Fix green status not updating correctly
|
- Fix green status not updating correctly
|
||||||
- Move people between voice channels.
|
<!-- - Move people between voice channels. -->
|
||||||
- Allow copy paste of images using CTRL + V in the message box to attach an iamge.
|
<!-- - Allow copy paste of images using CTRL + V in the message box to attach an iamge. -->
|
||||||
|
|
||||||
- When you collapse a category that has a voice channel lets still show the users in their.
|
<!-- - If you go afk for 5min switch to idel channel -->
|
||||||
|
|
||||||
- If you go afk for 5min switch to channel and to idle.
|
<!-- - Add server muting. Forcing user to mute. -->
|
||||||
|
|
||||||
- Add server muting. Forcing user to mute.
|
<!-- - Allow users to mute other users for themself only. I want to be able to allow users to mute other users for themself only and no one else. So if we click the button button in the popup that we get for when we right click on a user and click mute we will mute their voice audio. Can we also update that menu i have a snippit server mute setting snippit.txt inside the discord-html-copy folder. Where they have a checkbox that shows when that mute is on or off. Also when we mute someone we put the personal_mute.svg icon on them. If they are muted themself we show this icon rather than the mute.svg icon. -->
|
||||||
- Allow users to mute other users for themself only.
|
|
||||||
|
|
||||||
- Independient voice volumes per user.
|
- Independient voice volumes per user.
|
||||||
|
|
||||||
|
<!-- - We have it so if a user is in a voice channel on the memebers list it shows a status as "In voice" with a icon. Can we do the same when they are streaming. Where its the streaming icon and says "Sharing their screen" We will use the sharing.svg icon. -->
|
||||||
|
|
||||||
# Future
|
# Future
|
||||||
|
|
||||||
|
|||||||
2
convex/_generated/api.d.ts
vendored
2
convex/_generated/api.d.ts
vendored
@@ -22,6 +22,7 @@ import type * as presence from "../presence.js";
|
|||||||
import type * as reactions from "../reactions.js";
|
import type * as reactions from "../reactions.js";
|
||||||
import type * as readState from "../readState.js";
|
import type * as readState from "../readState.js";
|
||||||
import type * as roles from "../roles.js";
|
import type * as roles from "../roles.js";
|
||||||
|
import type * as serverSettings from "../serverSettings.js";
|
||||||
import type * as storageUrl from "../storageUrl.js";
|
import type * as storageUrl from "../storageUrl.js";
|
||||||
import type * as typing from "../typing.js";
|
import type * as typing from "../typing.js";
|
||||||
import type * as voice from "../voice.js";
|
import type * as voice from "../voice.js";
|
||||||
@@ -48,6 +49,7 @@ declare const fullApi: ApiFromModules<{
|
|||||||
reactions: typeof reactions;
|
reactions: typeof reactions;
|
||||||
readState: typeof readState;
|
readState: typeof readState;
|
||||||
roles: typeof roles;
|
roles: typeof roles;
|
||||||
|
serverSettings: typeof serverSettings;
|
||||||
storageUrl: typeof storageUrl;
|
storageUrl: typeof storageUrl;
|
||||||
typing: typeof typing;
|
typing: typeof typing;
|
||||||
voice: typeof voice;
|
voice: typeof voice;
|
||||||
|
|||||||
@@ -163,6 +163,7 @@ export const createUserWithProfile = mutation({
|
|||||||
permissions: {
|
permissions: {
|
||||||
manage_channels: true,
|
manage_channels: true,
|
||||||
manage_roles: true,
|
manage_roles: true,
|
||||||
|
manage_messages: true,
|
||||||
create_invite: true,
|
create_invite: true,
|
||||||
embed_links: true,
|
embed_links: true,
|
||||||
attach_files: true,
|
attach_files: true,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { query, mutation } from "./_generated/server";
|
|||||||
import { v } from "convex/values";
|
import { v } from "convex/values";
|
||||||
import { GenericMutationCtx } from "convex/server";
|
import { GenericMutationCtx } from "convex/server";
|
||||||
import { DataModel, Id } from "./_generated/dataModel";
|
import { DataModel, Id } from "./_generated/dataModel";
|
||||||
|
import { internal } from "./_generated/api";
|
||||||
|
|
||||||
type TableWithChannelIndex =
|
type TableWithChannelIndex =
|
||||||
| "channelKeys"
|
| "channelKeys"
|
||||||
@@ -234,6 +235,9 @@ export const remove = mutation({
|
|||||||
await deleteByChannel(ctx, "voiceStates", args.id);
|
await deleteByChannel(ctx, "voiceStates", args.id);
|
||||||
await deleteByChannel(ctx, "channelReadState", args.id);
|
await deleteByChannel(ctx, "channelReadState", args.id);
|
||||||
|
|
||||||
|
// Clear AFK setting if this channel was the AFK channel
|
||||||
|
await ctx.runMutation(internal.serverSettings.clearAfkChannel, { channelId: args.id });
|
||||||
|
|
||||||
await ctx.db.delete(args.id);
|
await ctx.db.delete(args.id);
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { query, mutation } from "./_generated/server";
|
|||||||
import { paginationOptsValidator } from "convex/server";
|
import { paginationOptsValidator } from "convex/server";
|
||||||
import { v } from "convex/values";
|
import { v } from "convex/values";
|
||||||
import { getPublicStorageUrl } from "./storageUrl";
|
import { getPublicStorageUrl } from "./storageUrl";
|
||||||
|
import { getRolesForUser } from "./roles";
|
||||||
|
|
||||||
export const list = query({
|
export const list = query({
|
||||||
args: {
|
args: {
|
||||||
@@ -173,9 +174,23 @@ export const listPinned = query({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const remove = mutation({
|
export const remove = mutation({
|
||||||
args: { id: v.id("messages") },
|
args: { id: v.id("messages"), userId: v.id("userProfiles") },
|
||||||
returns: v.null(),
|
returns: v.null(),
|
||||||
handler: async (ctx, args) => {
|
handler: async (ctx, args) => {
|
||||||
|
const message = await ctx.db.get(args.id);
|
||||||
|
if (!message) throw new Error("Message not found");
|
||||||
|
|
||||||
|
const isSender = message.senderId === args.userId;
|
||||||
|
if (!isSender) {
|
||||||
|
const roles = await getRolesForUser(ctx, args.userId);
|
||||||
|
const canManage = roles.some(
|
||||||
|
(role) => (role.permissions as Record<string, boolean>)?.manage_messages
|
||||||
|
);
|
||||||
|
if (!canManage) {
|
||||||
|
throw new Error("Not authorized to delete this message");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const reactions = await ctx.db
|
const reactions = await ctx.db
|
||||||
.query("messageReactions")
|
.query("messageReactions")
|
||||||
.withIndex("by_message", (q) => q.eq("messageId", args.id))
|
.withIndex("by_message", (q) => q.eq("messageId", args.id))
|
||||||
|
|||||||
@@ -6,12 +6,15 @@ import { DataModel, Id, Doc } from "./_generated/dataModel";
|
|||||||
const PERMISSION_KEYS = [
|
const PERMISSION_KEYS = [
|
||||||
"manage_channels",
|
"manage_channels",
|
||||||
"manage_roles",
|
"manage_roles",
|
||||||
|
"manage_messages",
|
||||||
"create_invite",
|
"create_invite",
|
||||||
"embed_links",
|
"embed_links",
|
||||||
"attach_files",
|
"attach_files",
|
||||||
|
"move_members",
|
||||||
|
"mute_members",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
async function getRolesForUser(
|
export async function getRolesForUser(
|
||||||
ctx: GenericQueryCtx<DataModel>,
|
ctx: GenericQueryCtx<DataModel>,
|
||||||
userId: Id<"userProfiles">
|
userId: Id<"userProfiles">
|
||||||
): Promise<Doc<"roles">[]> {
|
): Promise<Doc<"roles">[]> {
|
||||||
@@ -182,9 +185,12 @@ export const getMyPermissions = query({
|
|||||||
returns: v.object({
|
returns: v.object({
|
||||||
manage_channels: v.boolean(),
|
manage_channels: v.boolean(),
|
||||||
manage_roles: v.boolean(),
|
manage_roles: v.boolean(),
|
||||||
|
manage_messages: v.boolean(),
|
||||||
create_invite: v.boolean(),
|
create_invite: v.boolean(),
|
||||||
embed_links: v.boolean(),
|
embed_links: v.boolean(),
|
||||||
attach_files: v.boolean(),
|
attach_files: v.boolean(),
|
||||||
|
move_members: v.boolean(),
|
||||||
|
mute_members: v.boolean(),
|
||||||
}),
|
}),
|
||||||
handler: async (ctx, args) => {
|
handler: async (ctx, args) => {
|
||||||
const roles = await getRolesForUser(ctx, args.userId);
|
const roles = await getRolesForUser(ctx, args.userId);
|
||||||
@@ -199,9 +205,12 @@ export const getMyPermissions = query({
|
|||||||
return finalPerms as {
|
return finalPerms as {
|
||||||
manage_channels: boolean;
|
manage_channels: boolean;
|
||||||
manage_roles: boolean;
|
manage_roles: boolean;
|
||||||
|
manage_messages: boolean;
|
||||||
create_invite: boolean;
|
create_invite: boolean;
|
||||||
embed_links: boolean;
|
embed_links: boolean;
|
||||||
attach_files: boolean;
|
attach_files: boolean;
|
||||||
|
move_members: boolean;
|
||||||
|
mute_members: boolean;
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -109,6 +109,7 @@ export default defineSchema({
|
|||||||
isMuted: v.boolean(),
|
isMuted: v.boolean(),
|
||||||
isDeafened: v.boolean(),
|
isDeafened: v.boolean(),
|
||||||
isScreenSharing: v.boolean(),
|
isScreenSharing: v.boolean(),
|
||||||
|
isServerMuted: v.boolean(),
|
||||||
})
|
})
|
||||||
.index("by_channel", ["channelId"])
|
.index("by_channel", ["channelId"])
|
||||||
.index("by_user", ["userId"]),
|
.index("by_user", ["userId"]),
|
||||||
@@ -121,4 +122,9 @@ export default defineSchema({
|
|||||||
.index("by_user", ["userId"])
|
.index("by_user", ["userId"])
|
||||||
.index("by_channel", ["channelId"])
|
.index("by_channel", ["channelId"])
|
||||||
.index("by_user_and_channel", ["userId", "channelId"]),
|
.index("by_user_and_channel", ["userId", "channelId"]),
|
||||||
|
|
||||||
|
serverSettings: defineTable({
|
||||||
|
afkChannelId: v.optional(v.id("channels")),
|
||||||
|
afkTimeout: v.number(), // seconds (default 300 = 5 min)
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
69
convex/serverSettings.ts
Normal file
69
convex/serverSettings.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import { query, mutation, internalMutation } from "./_generated/server";
|
||||||
|
import { v } from "convex/values";
|
||||||
|
import { getRolesForUser } from "./roles";
|
||||||
|
|
||||||
|
export const get = query({
|
||||||
|
args: {},
|
||||||
|
returns: v.any(),
|
||||||
|
handler: async (ctx) => {
|
||||||
|
return await ctx.db.query("serverSettings").first();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const update = mutation({
|
||||||
|
args: {
|
||||||
|
userId: v.id("userProfiles"),
|
||||||
|
afkChannelId: v.optional(v.id("channels")),
|
||||||
|
afkTimeout: v.number(),
|
||||||
|
},
|
||||||
|
returns: v.null(),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
// Permission check
|
||||||
|
const roles = await getRolesForUser(ctx, args.userId);
|
||||||
|
const canManage = roles.some(
|
||||||
|
(role) => (role.permissions as Record<string, boolean>)?.["manage_channels"]
|
||||||
|
);
|
||||||
|
if (!canManage) {
|
||||||
|
throw new Error("You don't have permission to manage server settings");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate timeout range
|
||||||
|
if (args.afkTimeout < 60 || args.afkTimeout > 3600) {
|
||||||
|
throw new Error("AFK timeout must be between 60 and 3600 seconds");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate AFK channel is a voice channel if provided
|
||||||
|
if (args.afkChannelId) {
|
||||||
|
const channel = await ctx.db.get(args.afkChannelId);
|
||||||
|
if (!channel) throw new Error("AFK channel not found");
|
||||||
|
if (channel.type !== "voice") throw new Error("AFK channel must be a voice channel");
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await ctx.db.query("serverSettings").first();
|
||||||
|
if (existing) {
|
||||||
|
await ctx.db.patch(existing._id, {
|
||||||
|
afkChannelId: args.afkChannelId,
|
||||||
|
afkTimeout: args.afkTimeout,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await ctx.db.insert("serverSettings", {
|
||||||
|
afkChannelId: args.afkChannelId,
|
||||||
|
afkTimeout: args.afkTimeout,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const clearAfkChannel = internalMutation({
|
||||||
|
args: { channelId: v.id("channels") },
|
||||||
|
returns: v.null(),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const settings = await ctx.db.query("serverSettings").first();
|
||||||
|
if (settings && settings.afkChannelId === args.channelId) {
|
||||||
|
await ctx.db.patch(settings._id, { afkChannelId: undefined });
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { query, mutation } from "./_generated/server";
|
import { query, mutation } from "./_generated/server";
|
||||||
import { v } from "convex/values";
|
import { v } from "convex/values";
|
||||||
import { getPublicStorageUrl } from "./storageUrl";
|
import { getPublicStorageUrl } from "./storageUrl";
|
||||||
|
import { getRolesForUser } from "./roles";
|
||||||
|
|
||||||
async function removeUserVoiceStates(ctx: any, userId: any) {
|
async function removeUserVoiceStates(ctx: any, userId: any) {
|
||||||
const existing = await ctx.db
|
const existing = await ctx.db
|
||||||
@@ -31,6 +32,7 @@ export const join = mutation({
|
|||||||
isMuted: args.isMuted,
|
isMuted: args.isMuted,
|
||||||
isDeafened: args.isDeafened,
|
isDeafened: args.isDeafened,
|
||||||
isScreenSharing: false,
|
isScreenSharing: false,
|
||||||
|
isServerMuted: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
@@ -74,6 +76,35 @@ export const updateState = mutation({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const serverMute = mutation({
|
||||||
|
args: {
|
||||||
|
actorUserId: v.id("userProfiles"),
|
||||||
|
targetUserId: v.id("userProfiles"),
|
||||||
|
isServerMuted: v.boolean(),
|
||||||
|
},
|
||||||
|
returns: v.null(),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const roles = await getRolesForUser(ctx, args.actorUserId);
|
||||||
|
const canMute = roles.some(
|
||||||
|
(role) => (role.permissions as Record<string, boolean>)?.["mute_members"]
|
||||||
|
);
|
||||||
|
if (!canMute) {
|
||||||
|
throw new Error("You don't have permission to server mute members");
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await ctx.db
|
||||||
|
.query("voiceStates")
|
||||||
|
.withIndex("by_user", (q: any) => q.eq("userId", args.targetUserId))
|
||||||
|
.first();
|
||||||
|
|
||||||
|
if (!existing) throw new Error("Target user is not in a voice channel");
|
||||||
|
|
||||||
|
await ctx.db.patch(existing._id, { isServerMuted: args.isServerMuted });
|
||||||
|
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
export const getAll = query({
|
export const getAll = query({
|
||||||
args: {},
|
args: {},
|
||||||
returns: v.any(),
|
returns: v.any(),
|
||||||
@@ -86,6 +117,7 @@ export const getAll = query({
|
|||||||
isMuted: boolean;
|
isMuted: boolean;
|
||||||
isDeafened: boolean;
|
isDeafened: boolean;
|
||||||
isScreenSharing: boolean;
|
isScreenSharing: boolean;
|
||||||
|
isServerMuted: boolean;
|
||||||
avatarUrl: string | null;
|
avatarUrl: string | null;
|
||||||
}>> = {};
|
}>> = {};
|
||||||
|
|
||||||
@@ -102,6 +134,7 @@ export const getAll = query({
|
|||||||
isMuted: s.isMuted,
|
isMuted: s.isMuted,
|
||||||
isDeafened: s.isDeafened,
|
isDeafened: s.isDeafened,
|
||||||
isScreenSharing: s.isScreenSharing,
|
isScreenSharing: s.isScreenSharing,
|
||||||
|
isServerMuted: s.isServerMuted,
|
||||||
avatarUrl,
|
avatarUrl,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -109,3 +142,90 @@ export const getAll = query({
|
|||||||
return grouped;
|
return grouped;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const afkMove = mutation({
|
||||||
|
args: {
|
||||||
|
userId: v.id("userProfiles"),
|
||||||
|
afkChannelId: v.id("channels"),
|
||||||
|
},
|
||||||
|
returns: v.null(),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
// Validate afkChannelId matches server settings
|
||||||
|
const settings = await ctx.db.query("serverSettings").first();
|
||||||
|
if (!settings || settings.afkChannelId !== args.afkChannelId) {
|
||||||
|
throw new Error("Invalid AFK channel");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get current voice state
|
||||||
|
const currentState = await ctx.db
|
||||||
|
.query("voiceStates")
|
||||||
|
.withIndex("by_user", (q: any) => q.eq("userId", args.userId))
|
||||||
|
.first();
|
||||||
|
|
||||||
|
// No-op if not in voice or already in AFK channel
|
||||||
|
if (!currentState || currentState.channelId === args.afkChannelId) return null;
|
||||||
|
|
||||||
|
// Move to AFK channel: delete old state, insert new one muted
|
||||||
|
await ctx.db.delete(currentState._id);
|
||||||
|
await ctx.db.insert("voiceStates", {
|
||||||
|
channelId: args.afkChannelId,
|
||||||
|
userId: args.userId,
|
||||||
|
username: currentState.username,
|
||||||
|
isMuted: true,
|
||||||
|
isDeafened: currentState.isDeafened,
|
||||||
|
isScreenSharing: false,
|
||||||
|
isServerMuted: currentState.isServerMuted,
|
||||||
|
});
|
||||||
|
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const moveUser = mutation({
|
||||||
|
args: {
|
||||||
|
actorUserId: v.id("userProfiles"),
|
||||||
|
targetUserId: v.id("userProfiles"),
|
||||||
|
targetChannelId: v.id("channels"),
|
||||||
|
},
|
||||||
|
returns: v.null(),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
// Check actor has move_members permission
|
||||||
|
const roles = await getRolesForUser(ctx, args.actorUserId);
|
||||||
|
const canMove = roles.some(
|
||||||
|
(role) => (role.permissions as Record<string, boolean>)?.["move_members"]
|
||||||
|
);
|
||||||
|
if (!canMove) {
|
||||||
|
throw new Error("You don't have permission to move members");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate target channel exists and is voice
|
||||||
|
const targetChannel = await ctx.db.get(args.targetChannelId);
|
||||||
|
if (!targetChannel) throw new Error("Target channel not found");
|
||||||
|
if (targetChannel.type !== "voice") throw new Error("Target channel is not a voice channel");
|
||||||
|
|
||||||
|
// Get target user's current voice state
|
||||||
|
const currentState = await ctx.db
|
||||||
|
.query("voiceStates")
|
||||||
|
.withIndex("by_user", (q: any) => q.eq("userId", args.targetUserId))
|
||||||
|
.first();
|
||||||
|
|
||||||
|
if (!currentState) throw new Error("Target user is not in a voice channel");
|
||||||
|
|
||||||
|
// No-op if already in the target channel
|
||||||
|
if (currentState.channelId === args.targetChannelId) return null;
|
||||||
|
|
||||||
|
// Delete old voice state and insert new one preserving mute/deaf/screenshare
|
||||||
|
await ctx.db.delete(currentState._id);
|
||||||
|
await ctx.db.insert("voiceStates", {
|
||||||
|
channelId: args.targetChannelId,
|
||||||
|
userId: args.targetUserId,
|
||||||
|
username: currentState.username,
|
||||||
|
isMuted: currentState.isMuted,
|
||||||
|
isDeafened: currentState.isDeafened,
|
||||||
|
isScreenSharing: currentState.isScreenSharing,
|
||||||
|
isServerMuted: currentState.isServerMuted,
|
||||||
|
});
|
||||||
|
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user