nickname
This commit is contained in:
222
packages/shared/src/components/ChangeNicknameModal.jsx
Normal file
222
packages/shared/src/components/ChangeNicknameModal.jsx
Normal file
@@ -0,0 +1,222 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import { useMutation } from 'convex/react';
|
||||
import { api } from '../../../../convex/_generated/api';
|
||||
|
||||
const ChangeNicknameModal = ({ targetUserId, targetUsername, currentNickname, actorUserId, onClose }) => {
|
||||
const [nickname, setNickname] = useState(currentNickname || '');
|
||||
const inputRef = useRef(null);
|
||||
const setNicknameMutation = useMutation(api.auth.setNickname);
|
||||
const isSelf = targetUserId === actorUserId;
|
||||
|
||||
useEffect(() => {
|
||||
inputRef.current?.focus();
|
||||
inputRef.current?.select();
|
||||
}, []);
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
await setNicknameMutation({
|
||||
actorUserId,
|
||||
targetUserId,
|
||||
displayName: nickname,
|
||||
});
|
||||
onClose();
|
||||
} catch (err) {
|
||||
console.error('Failed to set nickname:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReset = async () => {
|
||||
try {
|
||||
await setNicknameMutation({
|
||||
actorUserId,
|
||||
targetUserId,
|
||||
displayName: '',
|
||||
});
|
||||
onClose();
|
||||
} catch (err) {
|
||||
console.error('Failed to reset nickname:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
handleSave();
|
||||
} else if (e.key === 'Escape') {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
return ReactDOM.createPortal(
|
||||
<div
|
||||
onClick={onClose}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.85)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 10001,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
width: '440px',
|
||||
maxWidth: '90vw',
|
||||
backgroundColor: 'var(--bg-primary)',
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 4px 20px rgba(0, 0, 0, 0.4)',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div style={{ padding: '16px 16px 0 16px', position: 'relative' }}>
|
||||
<h2 style={{ color: 'var(--header-primary)', margin: '0 0 16px 0', fontSize: '20px', fontWeight: 600 }}>
|
||||
Change Nickname
|
||||
</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '12px',
|
||||
right: '12px',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: 'var(--interactive-normal)',
|
||||
cursor: 'pointer',
|
||||
padding: '4px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: '50%',
|
||||
}}
|
||||
>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M18.4 4L12 10.4L5.6 4L4 5.6L10.4 12L4 18.4L5.6 20L12 13.6L18.4 20L20 18.4L13.6 12L20 5.6L18.4 4Z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div style={{ padding: '0 16px 16px 16px' }}>
|
||||
{/* Notice */}
|
||||
{!isSelf && (
|
||||
<div style={{
|
||||
backgroundColor: 'var(--bg-tertiary)',
|
||||
borderLeft: '4px solid var(--text-warning, #faa61a)',
|
||||
borderRadius: '4px',
|
||||
padding: '12px',
|
||||
marginBottom: '16px',
|
||||
fontSize: '14px',
|
||||
color: 'var(--text-normal)',
|
||||
lineHeight: '1.4',
|
||||
}}>
|
||||
Nicknames are visible to everyone on this server. Do not change them unless you are enforcing a naming system or clearing a bad nickname.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Label */}
|
||||
<label style={{
|
||||
color: 'var(--header-secondary)',
|
||||
fontSize: '12px',
|
||||
fontWeight: 700,
|
||||
textTransform: 'uppercase',
|
||||
marginBottom: '8px',
|
||||
display: 'block',
|
||||
}}>
|
||||
Nickname
|
||||
</label>
|
||||
|
||||
{/* Input */}
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={nickname}
|
||||
onChange={(e) => setNickname(e.target.value.slice(0, 32))}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={targetUsername}
|
||||
maxLength={32}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '10px',
|
||||
backgroundColor: 'var(--bg-tertiary)',
|
||||
border: '1px solid var(--border-subtle)',
|
||||
borderRadius: '4px',
|
||||
color: 'var(--text-normal)',
|
||||
fontSize: '14px',
|
||||
outline: 'none',
|
||||
boxSizing: 'border-box',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Reset link */}
|
||||
<div
|
||||
onClick={handleReset}
|
||||
style={{
|
||||
color: 'var(--text-link, #00a8fc)',
|
||||
fontSize: '14px',
|
||||
cursor: 'pointer',
|
||||
marginTop: '8px',
|
||||
marginBottom: '4px',
|
||||
userSelect: 'none',
|
||||
}}
|
||||
>
|
||||
Reset Nickname
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
gap: '12px',
|
||||
padding: '16px',
|
||||
backgroundColor: 'var(--bg-secondary)',
|
||||
borderTop: '1px solid var(--border-subtle)',
|
||||
}}>
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '10px 0',
|
||||
backgroundColor: 'var(--bg-tertiary)',
|
||||
color: 'var(--text-normal)',
|
||||
border: 'none',
|
||||
borderRadius: '3px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '10px 0',
|
||||
backgroundColor: 'var(--brand-experiment)',
|
||||
color: '#fff',
|
||||
border: 'none',
|
||||
borderRadius: '3px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
};
|
||||
|
||||
export default ChangeNicknameModal;
|
||||
@@ -116,8 +116,9 @@ const filterMembersForMention = (members, query) => {
|
||||
const substring = [];
|
||||
for (const m of members) {
|
||||
const name = m.username.toLowerCase();
|
||||
if (name.startsWith(q)) prefix.push(m);
|
||||
else if (name.includes(q)) substring.push(m);
|
||||
const nick = (m.displayName || '').toLowerCase();
|
||||
if (name.startsWith(q) || nick.startsWith(q)) prefix.push(m);
|
||||
else if (name.includes(q) || nick.includes(q)) substring.push(m);
|
||||
}
|
||||
return [...prefix, ...substring];
|
||||
};
|
||||
@@ -1931,7 +1932,7 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
|
||||
{typingUsers.length > 0 && (
|
||||
<div style={{ position: 'absolute', top: '-24px', left: '0', padding: '0 8px', display: 'flex', alignItems: 'center', gap: '6px', color: '#dbdee1', fontSize: '12px', fontWeight: 'bold', pointerEvents: 'none' }}>
|
||||
<ColoredIcon src={TypingIcon} size="24px" color="#dbdee1" />
|
||||
<span>{typingUsers.map(t => t.username).join(', ')} is typing...</span>
|
||||
<span>{typingUsers.map(t => t.displayName || t.username).join(', ')} is typing...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import React from 'react';
|
||||
import React, { useState, useRef, useEffect, useLayoutEffect } from 'react';
|
||||
import { useQuery } from 'convex/react';
|
||||
import { api } from '../../../../convex/_generated/api';
|
||||
import { useOnlineUsers } from '../contexts/PresenceContext';
|
||||
import { useVoice } from '../contexts/VoiceContext';
|
||||
import { CrownIcon, SharingIcon } from '../assets/icons';
|
||||
import ColoredIcon from './ColoredIcon';
|
||||
import ChangeNicknameModal from './ChangeNicknameModal';
|
||||
|
||||
const USER_COLORS = ['#5865F2', '#EBA7CD', '#57F287', '#FEE75C', '#EB459E', '#ED4245'];
|
||||
|
||||
@@ -24,13 +25,70 @@ const STATUS_COLORS = {
|
||||
offline: '#747f8d',
|
||||
};
|
||||
|
||||
const MembersList = ({ channelId, visible, onMemberClick }) => {
|
||||
const MemberContextMenu = ({ x, y, onClose, member, isSelf, canManageNicknames, onChangeNickname, onMessage, onStartCall }) => {
|
||||
const menuRef = useRef(null);
|
||||
const [pos, setPos] = useState({ top: y, left: x });
|
||||
|
||||
useEffect(() => {
|
||||
const h = () => onClose();
|
||||
window.addEventListener('click', h);
|
||||
window.addEventListener('close-context-menus', h);
|
||||
return () => { window.removeEventListener('click', h); window.removeEventListener('close-context-menus', 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()}>
|
||||
{(isSelf || canManageNicknames) && (
|
||||
<div
|
||||
className="context-menu-item"
|
||||
onClick={(e) => { e.stopPropagation(); onChangeNickname(); onClose(); }}
|
||||
>
|
||||
<span>Change Nickname</span>
|
||||
</div>
|
||||
)}
|
||||
{(isSelf || canManageNicknames) && (!isSelf) && (
|
||||
<div className="context-menu-separator" />
|
||||
)}
|
||||
{!isSelf && (
|
||||
<>
|
||||
<div
|
||||
className="context-menu-item"
|
||||
onClick={(e) => { e.stopPropagation(); onMessage(); onClose(); }}
|
||||
>
|
||||
<span>Message</span>
|
||||
</div>
|
||||
<div
|
||||
className="context-menu-item"
|
||||
onClick={(e) => { e.stopPropagation(); onStartCall(); onClose(); }}
|
||||
>
|
||||
<span>Start a Call</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const MembersList = ({ channelId, visible, onMemberClick, userId, myPermissions, onOpenDM, onStartCallWithUser }) => {
|
||||
const members = useQuery(
|
||||
api.members.getChannelMembers,
|
||||
channelId ? { channelId } : "skip"
|
||||
) || [];
|
||||
const { resolveStatus } = useOnlineUsers();
|
||||
const { voiceStates } = useVoice();
|
||||
const [contextMenu, setContextMenu] = useState(null);
|
||||
const [nicknameModal, setNicknameModal] = useState(null);
|
||||
|
||||
const usersInVoice = new Set();
|
||||
const usersScreenSharing = new Set();
|
||||
@@ -66,6 +124,13 @@ const MembersList = ({ channelId, visible, onMemberClick }) => {
|
||||
// Sort groups by position descending
|
||||
const sortedGroups = Object.values(roleGroups).sort((a, b) => b.role.position - a.role.position);
|
||||
|
||||
const handleContextMenu = (e, member) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
window.dispatchEvent(new Event('close-context-menus'));
|
||||
setContextMenu({ x: e.clientX, y: e.clientY, member });
|
||||
};
|
||||
|
||||
const renderMember = (member) => {
|
||||
const displayRole = member.roles.find(r => r.name !== '@everyone' && r.name !== 'Owner') || null;
|
||||
const nameColor = displayRole ? displayRole.color : '#fff';
|
||||
@@ -77,6 +142,7 @@ const MembersList = ({ channelId, visible, onMemberClick }) => {
|
||||
key={member.id}
|
||||
className="member-item"
|
||||
onClick={() => onMemberClick && onMemberClick(member)}
|
||||
onContextMenu={(e) => handleContextMenu(e, member)}
|
||||
style={effectiveStatus === 'offline' ? { opacity: 0.3 } : {}}
|
||||
>
|
||||
<div className="member-avatar-wrapper">
|
||||
@@ -92,7 +158,7 @@ const MembersList = ({ channelId, visible, onMemberClick }) => {
|
||||
className="member-avatar"
|
||||
style={{ backgroundColor: getUserColor(member.username) }}
|
||||
>
|
||||
{member.username.substring(0, 1).toUpperCase()}
|
||||
{(member.displayName || member.username).substring(0, 1).toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
@@ -102,7 +168,7 @@ const MembersList = ({ channelId, visible, onMemberClick }) => {
|
||||
</div>
|
||||
<div className="member-info">
|
||||
<span className="member-name" style={{ color: nameColor, display: 'flex', alignItems: 'center', gap: '4px' }}>
|
||||
{member.username}
|
||||
{member.displayName || member.username}
|
||||
{isOwner && <ColoredIcon src={CrownIcon} color="var(--text-feedback-warning)" size="14px" />}
|
||||
</span>
|
||||
{usersScreenSharing.has(member.id) ? (
|
||||
@@ -154,6 +220,30 @@ const MembersList = ({ channelId, visible, onMemberClick }) => {
|
||||
{offlineMembers.map(renderMember)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{contextMenu && (
|
||||
<MemberContextMenu
|
||||
x={contextMenu.x}
|
||||
y={contextMenu.y}
|
||||
member={contextMenu.member}
|
||||
isSelf={contextMenu.member.id === userId}
|
||||
canManageNicknames={!!myPermissions?.manage_nicknames}
|
||||
onClose={() => setContextMenu(null)}
|
||||
onChangeNickname={() => setNicknameModal(contextMenu.member)}
|
||||
onMessage={() => onOpenDM && onOpenDM(contextMenu.member.id, contextMenu.member.displayName || contextMenu.member.username)}
|
||||
onStartCall={() => onStartCallWithUser && onStartCallWithUser(contextMenu.member.id, contextMenu.member.displayName || contextMenu.member.username)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{nicknameModal && (
|
||||
<ChangeNicknameModal
|
||||
targetUserId={nicknameModal.id}
|
||||
targetUsername={nicknameModal.username}
|
||||
currentNickname={nicknameModal.displayName || ''}
|
||||
actorUserId={userId}
|
||||
onClose={() => setNicknameModal(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -68,7 +68,7 @@ const MentionMenu = ({ items, selectedIndex, onSelect, onHover }) => {
|
||||
>
|
||||
<Avatar username={member.username} avatarUrl={member.avatarUrl} size={24} />
|
||||
<span className="mention-menu-row-primary" style={nameColor ? { color: nameColor } : undefined}>
|
||||
{member.username}
|
||||
{member.displayName || member.username}
|
||||
</span>
|
||||
<span className="mention-menu-row-secondary">{member.username}</span>
|
||||
</div>
|
||||
|
||||
@@ -308,7 +308,7 @@ const MessageItem = React.memo(({
|
||||
<div className="reply-spine" />
|
||||
<Avatar username={msg.replyToUsername} avatarUrl={msg.replyToAvatarUrl} size={16} className="reply-avatar" />
|
||||
<span className="reply-author" style={{ color: getUserColor(msg.replyToUsername) }}>
|
||||
@{msg.replyToUsername}
|
||||
@{msg.replyToDisplayName || msg.replyToUsername}
|
||||
</span>
|
||||
<span className="reply-text">{msg.decryptedReply || '[Encrypted]'}</span>
|
||||
</div>
|
||||
@@ -337,7 +337,7 @@ const MessageItem = React.memo(({
|
||||
style={{ color: userColor, cursor: 'pointer' }}
|
||||
onClick={(e) => onProfilePopup(e, msg)}
|
||||
>
|
||||
{msg.username || 'Unknown'}
|
||||
{msg.displayName || msg.username || 'Unknown'}
|
||||
</span>
|
||||
{msg.isVerified === false && <span className="verification-failed" title="Signature Verification Failed!"><svg aria-hidden="true" role="img" width="16" height="16" viewBox="0 0 24 24" fill="var(--danger)"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z"></path></svg></span>}
|
||||
<span className="timestamp">{currentDate.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })}</span>
|
||||
|
||||
@@ -437,7 +437,7 @@ const ServerSettingsModal = ({ onClose }) => {
|
||||
/>
|
||||
|
||||
<label style={labelStyle}>PERMISSIONS</label>
|
||||
{['manage_channels', 'manage_roles', 'create_invite', 'embed_links', 'attach_files', 'move_members', 'mute_members'].map(perm => (
|
||||
{['manage_channels', 'manage_roles', 'manage_nicknames', 'create_invite', 'embed_links', 'attach_files', 'move_members', 'mute_members'].map(perm => (
|
||||
<div key={perm} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10, paddingBottom: 10, borderBottom: '1px solid var(--border-subtle)' }}>
|
||||
<span style={{ color: 'var(--header-primary)', textTransform: 'capitalize' }}>{perm.replace('_', ' ')}</span>
|
||||
<input
|
||||
|
||||
@@ -10,6 +10,7 @@ import ScreenShareModal from './ScreenShareModal';
|
||||
import DMList from './DMList';
|
||||
import Avatar from './Avatar';
|
||||
import UserSettings from './UserSettings';
|
||||
import ChangeNicknameModal from './ChangeNicknameModal';
|
||||
import { Track } from 'livekit-client';
|
||||
import { DndContext, closestCenter, PointerSensor, useSensor, useSensors, DragOverlay, useDraggable } from '@dnd-kit/core';
|
||||
import { SortableContext, verticalListSortingStrategy, useSortable } from '@dnd-kit/sortable';
|
||||
@@ -380,7 +381,7 @@ function getScreenCaptureConstraints(selection) {
|
||||
};
|
||||
}
|
||||
|
||||
const VoiceUserContextMenu = ({ x, y, onClose, user, onMute, isMuted, onServerMute, isServerMuted, hasPermission, onDisconnect, hasDisconnectPermission, onMessage, isSelf, userVolume, onVolumeChange }) => {
|
||||
const VoiceUserContextMenu = ({ x, y, onClose, user, onMute, isMuted, onServerMute, isServerMuted, hasPermission, onDisconnect, hasDisconnectPermission, onMessage, isSelf, userVolume, onVolumeChange, onChangeNickname, showNicknameOption, onStartCall }) => {
|
||||
const menuRef = useRef(null);
|
||||
const [pos, setPos] = useState({ top: y, left: x });
|
||||
|
||||
@@ -480,9 +481,21 @@ const VoiceUserContextMenu = ({ x, y, onClose, user, onMute, isMuted, onServerMu
|
||||
</div>
|
||||
)}
|
||||
<div className="context-menu-separator" />
|
||||
<div className="context-menu-item" onClick={(e) => { e.stopPropagation(); onMessage(); onClose(); }}>
|
||||
<span>Message</span>
|
||||
</div>
|
||||
{showNicknameOption && (
|
||||
<div className="context-menu-item" onClick={(e) => { e.stopPropagation(); onChangeNickname(); onClose(); }}>
|
||||
<span>Change Nickname</span>
|
||||
</div>
|
||||
)}
|
||||
{!isSelf && (
|
||||
<div className="context-menu-item" onClick={(e) => { e.stopPropagation(); onMessage(); onClose(); }}>
|
||||
<span>Message</span>
|
||||
</div>
|
||||
)}
|
||||
{!isSelf && (
|
||||
<div className="context-menu-item" onClick={(e) => { e.stopPropagation(); onStartCall(); onClose(); }}>
|
||||
<span>Start a Call</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -521,6 +534,41 @@ const ChannelListContextMenu = ({ x, y, onClose, onCreateChannel, onCreateCatego
|
||||
);
|
||||
};
|
||||
|
||||
const CategoryContextMenu = ({ x, y, onClose, categoryName, onEdit, onDelete }) => {
|
||||
const menuRef = useRef(null);
|
||||
const [pos, setPos] = useState({ top: y, left: x });
|
||||
|
||||
useEffect(() => {
|
||||
const h = () => onClose();
|
||||
window.addEventListener('click', h);
|
||||
window.addEventListener('close-context-menus', h);
|
||||
return () => { window.removeEventListener('click', h); window.removeEventListener('close-context-menus', 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" onClick={(e) => { e.stopPropagation(); onEdit(); }}>
|
||||
<span>Edit Category</span>
|
||||
</div>
|
||||
<div className="context-menu-separator" />
|
||||
<div className="context-menu-item" style={{ color: '#ed4245' }} onClick={(e) => { e.stopPropagation(); onDelete(); }}>
|
||||
<span>Delete Category</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const CreateChannelModal = ({ onClose, onSubmit, categoryId }) => {
|
||||
const [channelType, setChannelType] = useState('text');
|
||||
const [channelName, setChannelName] = useState('');
|
||||
@@ -755,7 +803,7 @@ const DraggableVoiceUser = ({ userId, channelId, disabled, children }) => {
|
||||
);
|
||||
};
|
||||
|
||||
const Sidebar = ({ channels, categories, activeChannel, onSelectChannel, username, channelKeys, view, onViewChange, onOpenDM, activeDMChannel, setActiveDMChannel, dmChannels, userId, serverName = 'Secure Chat', serverIconUrl, isMobile }) => {
|
||||
const Sidebar = ({ channels, categories, activeChannel, onSelectChannel, username, channelKeys, view, onViewChange, onOpenDM, activeDMChannel, setActiveDMChannel, dmChannels, userId, serverName = 'Secure Chat', serverIconUrl, isMobile, onStartCallWithUser }) => {
|
||||
const { crypto, settings } = usePlatform();
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [isServerSettingsOpen, setIsServerSettingsOpen] = useState(false);
|
||||
@@ -774,11 +822,14 @@ const Sidebar = ({ channels, categories, activeChannel, onSelectChannel, usernam
|
||||
}, [userId]);
|
||||
const [channelListContextMenu, setChannelListContextMenu] = useState(null);
|
||||
const [voiceUserMenu, setVoiceUserMenu] = useState(null);
|
||||
const [categoryContextMenu, setCategoryContextMenu] = useState(null);
|
||||
const [editingCategoryId, setEditingCategoryId] = useState(null);
|
||||
const [showCreateChannelModal, setShowCreateChannelModal] = useState(false);
|
||||
const [showCreateCategoryModal, setShowCreateCategoryModal] = useState(false);
|
||||
const [createChannelCategoryId, setCreateChannelCategoryId] = useState(null);
|
||||
const [activeDragItem, setActiveDragItem] = useState(null);
|
||||
const [dragOverChannelId, setDragOverChannelId] = useState(null);
|
||||
const [voiceNicknameModal, setVoiceNicknameModal] = useState(null);
|
||||
|
||||
const convex = useConvex();
|
||||
|
||||
@@ -1087,7 +1138,7 @@ const Sidebar = ({ channels, categories, activeChannel, onSelectChannel, usernam
|
||||
boxShadow: activeSpeakers.has(user.userId) ? ACTIVE_SPEAKER_SHADOW : 'none'
|
||||
}}
|
||||
/>
|
||||
<span style={{ color: 'var(--header-secondary)', fontSize: 14 }}>{user.username}</span>
|
||||
<span style={{ color: 'var(--header-secondary)', fontSize: 14 }}>{user.displayName || user.username}</span>
|
||||
<div style={{ display: 'flex', marginLeft: 'auto', gap: 4, alignItems: 'center', marginRight: "16px" }}>
|
||||
{user.isScreenSharing && <div style={liveBadgeStyle}>Live</div>}
|
||||
{user.isServerMuted ? (
|
||||
@@ -1402,6 +1453,20 @@ const Sidebar = ({ channels, categories, activeChannel, onSelectChannel, usernam
|
||||
collapsed={collapsedCategories[group.id]}
|
||||
onToggle={toggleCategory}
|
||||
onAddChannel={handleAddChannelToCategory}
|
||||
onContextMenu={group.id !== '__uncategorized__' ? (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
window.dispatchEvent(new Event('close-context-menus'));
|
||||
setCategoryContextMenu({ x: e.clientX, y: e.clientY, categoryId: group.id, categoryName: group.name });
|
||||
} : undefined}
|
||||
isEditing={editingCategoryId === group.id}
|
||||
onRenameSubmit={async (newName) => {
|
||||
if (newName && newName !== group.name) {
|
||||
await convex.mutation(api.categories.rename, { id: group.id, name: newName });
|
||||
}
|
||||
setEditingCategoryId(null);
|
||||
}}
|
||||
onRenameCancel={() => setEditingCategoryId(null)}
|
||||
/>
|
||||
{(() => {
|
||||
const isCollapsed = collapsedCategories[group.id];
|
||||
@@ -1669,6 +1734,26 @@ const Sidebar = ({ channels, categories, activeChannel, onSelectChannel, usernam
|
||||
onCreateCategory={() => setShowCreateCategoryModal(true)}
|
||||
/>
|
||||
)}
|
||||
{categoryContextMenu && (
|
||||
<CategoryContextMenu
|
||||
x={categoryContextMenu.x}
|
||||
y={categoryContextMenu.y}
|
||||
categoryName={categoryContextMenu.categoryName}
|
||||
onClose={() => setCategoryContextMenu(null)}
|
||||
onEdit={() => {
|
||||
setEditingCategoryId(categoryContextMenu.categoryId);
|
||||
setCategoryContextMenu(null);
|
||||
}}
|
||||
onDelete={async () => {
|
||||
const categoryId = categoryContextMenu.categoryId;
|
||||
const categoryName = categoryContextMenu.categoryName;
|
||||
setCategoryContextMenu(null);
|
||||
if (window.confirm(`Are you sure you want to delete "${categoryName}"? Channels in this category will become uncategorized.`)) {
|
||||
await convex.mutation(api.categories.remove, { id: categoryId });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{voiceUserMenu && (
|
||||
<VoiceUserContextMenu
|
||||
x={voiceUserMenu.x}
|
||||
@@ -1684,11 +1769,25 @@ const Sidebar = ({ channels, categories, activeChannel, onSelectChannel, usernam
|
||||
onDisconnect={() => disconnectUser(voiceUserMenu.user.userId)}
|
||||
hasDisconnectPermission={!!myPermissions.move_members}
|
||||
onMessage={() => {
|
||||
onOpenDM(voiceUserMenu.user.userId, voiceUserMenu.user.username);
|
||||
onOpenDM(voiceUserMenu.user.userId, voiceUserMenu.user.displayName || voiceUserMenu.user.username);
|
||||
onViewChange('me');
|
||||
}}
|
||||
userVolume={getUserVolume(voiceUserMenu.user.userId)}
|
||||
onVolumeChange={(vol) => setUserVolume(voiceUserMenu.user.userId, vol)}
|
||||
showNicknameOption={voiceUserMenu.user.userId === userId || !!myPermissions.manage_nicknames}
|
||||
onChangeNickname={() => setVoiceNicknameModal(voiceUserMenu.user)}
|
||||
onStartCall={() => {
|
||||
if (onStartCallWithUser) onStartCallWithUser(voiceUserMenu.user.userId, voiceUserMenu.user.displayName || voiceUserMenu.user.username);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{voiceNicknameModal && (
|
||||
<ChangeNicknameModal
|
||||
targetUserId={voiceNicknameModal.userId}
|
||||
targetUsername={voiceNicknameModal.username}
|
||||
currentNickname={voiceNicknameModal.displayName || ''}
|
||||
actorUserId={userId}
|
||||
onClose={() => setVoiceNicknameModal(null)}
|
||||
/>
|
||||
)}
|
||||
{showCreateChannelModal && (
|
||||
@@ -1727,16 +1826,60 @@ const Sidebar = ({ channels, categories, activeChannel, onSelectChannel, usernam
|
||||
};
|
||||
|
||||
// Category header component (extracted for DnD drag handle)
|
||||
const CategoryHeader = React.memo(({ group, groupId, collapsed, onToggle, onAddChannel, dragListeners }) => (
|
||||
<div className="channel-category-header" onClick={() => onToggle(groupId)} {...(dragListeners || {})}>
|
||||
<span className="category-label">{group.name}</span>
|
||||
<div className={`category-chevron ${collapsed ? 'collapsed' : ''}`}>
|
||||
<ColoredIcon src={categoryCollapsedIcon} color="currentColor" size="12px" />
|
||||
const CategoryHeader = React.memo(({ group, groupId, collapsed, onToggle, onAddChannel, dragListeners, onContextMenu, isEditing, onRenameSubmit, onRenameCancel }) => {
|
||||
const [editName, setEditName] = useState(group.name);
|
||||
const inputRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isEditing && inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
inputRef.current.select();
|
||||
}
|
||||
}, [isEditing]);
|
||||
|
||||
useEffect(() => {
|
||||
setEditName(group.name);
|
||||
}, [group.name, isEditing]);
|
||||
|
||||
return (
|
||||
<div className="channel-category-header" onClick={() => !isEditing && onToggle(groupId)} onContextMenu={onContextMenu} {...(dragListeners || {})}>
|
||||
{isEditing ? (
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={editName}
|
||||
onChange={(e) => setEditName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') { e.preventDefault(); onRenameSubmit(editName.trim()); }
|
||||
if (e.key === 'Escape') { e.preventDefault(); onRenameCancel(); }
|
||||
}}
|
||||
onBlur={() => onRenameCancel()}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
background: 'var(--bg-tertiary)',
|
||||
border: '1px solid var(--brand-experiment)',
|
||||
borderRadius: '2px',
|
||||
color: 'var(--text-normal)',
|
||||
fontSize: '12px',
|
||||
fontWeight: 600,
|
||||
textTransform: 'uppercase',
|
||||
padding: '1px 4px',
|
||||
outline: 'none',
|
||||
width: '100%',
|
||||
letterSpacing: '.02em',
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<span className="category-label">{group.name}</span>
|
||||
)}
|
||||
<div className={`category-chevron ${collapsed ? 'collapsed' : ''}`}>
|
||||
<ColoredIcon src={categoryCollapsedIcon} color="currentColor" size="12px" />
|
||||
</div>
|
||||
<button className="category-add-btn" onClick={(e) => { e.stopPropagation(); onAddChannel(groupId); }} title="Create Channel">
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
<button className="category-add-btn" onClick={(e) => { e.stopPropagation(); onAddChannel(groupId); }} title="Create Channel">
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
));
|
||||
);
|
||||
});
|
||||
|
||||
export default Sidebar;
|
||||
|
||||
@@ -93,7 +93,12 @@ const UserProfilePopup = ({ userId, username, avatarUrl, status, position, onClo
|
||||
style={{ backgroundColor: STATUS_COLORS[userStatus] }}
|
||||
/>
|
||||
</div>
|
||||
<div className="user-profile-name">{username}</div>
|
||||
<div className="user-profile-name">{userData?.displayName || username}</div>
|
||||
{userData?.displayName && (
|
||||
<div style={{ color: 'var(--header-secondary)', fontSize: '12px', marginTop: '-4px', marginBottom: '4px', paddingLeft: '12px' }}>
|
||||
{username}
|
||||
</div>
|
||||
)}
|
||||
<div className="user-profile-status-text">
|
||||
{userData?.customStatus || STATUS_LABELS[userStatus] || 'Online'}
|
||||
</div>
|
||||
|
||||
@@ -775,7 +775,7 @@ const VoiceStage = ({ room, channelId, voiceStates, channelName }) => {
|
||||
|
||||
const getUsername = (identity) => {
|
||||
const user = voiceUsers.find(u => u.userId === identity);
|
||||
return user ? user.username : identity;
|
||||
return user ? (user.displayName || user.username) : identity;
|
||||
};
|
||||
|
||||
const getAvatarUrl = (identity) => {
|
||||
|
||||
@@ -2251,10 +2251,10 @@ body {
|
||||
align-items: center;
|
||||
padding: 16px 8px 4px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.2857142857142858;
|
||||
letter-spacing: 0.02em;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
@@ -84,6 +84,10 @@ const Chat = () => {
|
||||
const serverName = serverSettings?.serverName || 'Secure Chat';
|
||||
const serverIconUrl = serverSettings?.iconUrl || null;
|
||||
const allMembers = useQuery(api.members.listAll) || [];
|
||||
const myPermissions = useQuery(
|
||||
api.roles.getMyPermissions,
|
||||
userId ? { userId } : "skip"
|
||||
) || {};
|
||||
|
||||
const rawChannelKeys = useQuery(
|
||||
api.channelKeys.getKeysForUser,
|
||||
@@ -346,6 +350,30 @@ const Chat = () => {
|
||||
}
|
||||
}, [activeDMChannel, voiceActiveChannelId, disconnectVoice, connectToVoice, userId, username, channelKeys, crypto, convex, voiceStates]);
|
||||
|
||||
// Pending call pattern: open DM then start call once it's active
|
||||
const [pendingCallUserId, setPendingCallUserId] = useState(null);
|
||||
|
||||
const handleStartCallWithUser = useCallback(async (targetUserId, targetUsername) => {
|
||||
await openDM(targetUserId, targetUsername);
|
||||
setPendingCallUserId(targetUserId);
|
||||
}, [openDM]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pendingCallUserId || !activeDMChannel) return;
|
||||
// Verify the DM channel is for the pending user
|
||||
const isCorrectDM = activeDMChannel.other_user_id === pendingCallUserId ||
|
||||
activeDMChannel.channel_name?.includes(pendingCallUserId);
|
||||
if (!isCorrectDM && activeDMChannel.other_username) {
|
||||
// Just proceed - openDM should have set the right channel
|
||||
}
|
||||
setPendingCallUserId(null);
|
||||
// Small delay for key distribution to settle
|
||||
const timer = setTimeout(() => {
|
||||
handleStartDMCall();
|
||||
}, 500);
|
||||
return () => clearTimeout(timer);
|
||||
}, [pendingCallUserId, activeDMChannel, handleStartDMCall]);
|
||||
|
||||
// PiP: show when watching a stream and NOT currently viewing the voice channel in VoiceStage
|
||||
const isViewingDMCallStage = isDMView && isInDMCall;
|
||||
const isViewingVoiceStage = (view === 'server' && activeChannelObj?.type === 'voice' && activeChannel === voiceActiveChannelId) || isViewingDMCallStage;
|
||||
@@ -501,7 +529,7 @@ const Chat = () => {
|
||||
{dmCallActive && !isInDMCall && !showIncomingUI && (
|
||||
<div className="dm-call-idle-stage">
|
||||
<Avatar username={incomingDMCall?.callerUsername || activeDMChannel.other_username} avatarUrl={incomingDMCall?.callerAvatarUrl || null} size={80} />
|
||||
<div className="dm-call-idle-username">{activeDMChannel.other_username}</div>
|
||||
<div className="dm-call-idle-username">{activeDMChannel.other_displayName || activeDMChannel.other_username}</div>
|
||||
<div className="dm-call-idle-status">In a call</div>
|
||||
<button className="dm-call-join-btn" onClick={handleStartDMCall}>Join Call</button>
|
||||
</div>
|
||||
@@ -628,6 +656,10 @@ const Chat = () => {
|
||||
channelId={activeChannel}
|
||||
visible={effectiveShowMembers}
|
||||
onMemberClick={(member) => {}}
|
||||
userId={userId}
|
||||
myPermissions={myPermissions}
|
||||
onOpenDM={openDM}
|
||||
onStartCallWithUser={handleStartCallWithUser}
|
||||
/>
|
||||
<SearchPanel
|
||||
visible={showSearchResults}
|
||||
@@ -696,6 +728,7 @@ const Chat = () => {
|
||||
serverName={serverName}
|
||||
serverIconUrl={serverIconUrl}
|
||||
isMobile={isMobile}
|
||||
onStartCallWithUser={handleStartCallWithUser}
|
||||
/>
|
||||
)}
|
||||
{showMainContent && renderMainContent()}
|
||||
|
||||
Reference in New Issue
Block a user