This commit is contained in:
Bryan1029384756
2026-02-18 10:16:12 -06:00
parent ce9902d95d
commit ff269ee154
19 changed files with 583 additions and 64 deletions

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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) => {

View File

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

View File

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