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

27
TODO.md
View File

@@ -13,32 +13,5 @@
- You should not be allowed to edit a image or video file upload message.
- Is their anyway we can show the users master key when they are logged in. Letting them download it to store it somewhere safe. Since if they forget their password its the only way we can techically recover their account. So this is what MEGA says on how they recover their users passwords
"As the users password is effectively the root of all client-side encryption in a users account, forgetting or
losing it results in the inability to decrypt the users data, which is highly destructive. For this reason, MEGA
allows and highly recommends users to export their “Recovery Key” (which is technically their Master Key).
MEGA clients detect when a user has not entered their password for a lengthy period of time (for example
due to enabling the “remember me” checkbox while logging in) and reminds users of the importance of their
password. This reminder dialog prompts the user to test their password and/or export their Recovery Key.
MEGA has a convenient recovery interface where novice users are guided based on their circumstances in case
of password loss: https://mega.nz/recovery
MEGA has found that users who forget or lose their password are often still logged in on another client (e.g. a
mobile app or MEGAsync). For this reason, MEGA allows users with an active session to change their password
in that client without first proving knowledge of the current password.
If the user has no other accessible active sessions, the user can use the Recovery Key (which is in effect the
Master Key) to reset the password of the account. Technically, the user would re-encrypt the Master Key with
a new password. Such a procedure requires email confirmation, so access to the Recovery Key alone is not
sufficient to breach a MEGA account"
We dont do emails. So as long as you have the master key we will allow you to reset your password.
- Lets make it so if i right click on a category i get a popup for that category for options like "Edit Category", "Delete Category".
- Lets make it so if i right click on someone on the memebers list or if they are in voice we get a couple more options. As is if they are in voice we get server mute and all that. Thats fine only when they are in voice but we should have more options for someone like, Change Nickname (If you have permission to change people nicknames), Message (To send them a direct message), Start a Call (To start a private call). Also this change nickname is for the whole server to see. So everywhere their username would be will be their nickname instead of their username. So if they have a nickname it will show up in the chat and in the members list instead of their username for everyone to see.

View File

@@ -1,6 +1,7 @@
import { query, mutation, internalQuery, internalMutation } from "./_generated/server";
import { v } from "convex/values";
import { getPublicStorageUrl } from "./storageUrl";
import { getRolesForUser } from "./roles";
async function sha256Hex(input: string): Promise<string> {
const buffer = await crypto.subtle.digest(
@@ -165,6 +166,7 @@ export const createUserWithProfile = mutation({
manage_channels: true,
manage_roles: true,
manage_messages: true,
manage_nicknames: true,
create_invite: true,
embed_links: true,
attach_files: true,
@@ -337,6 +339,34 @@ export const getUserForRecovery = internalQuery({
},
});
// Set nickname (displayName) for a user
export const setNickname = mutation({
args: {
actorUserId: v.id("userProfiles"),
targetUserId: v.id("userProfiles"),
displayName: v.string(),
},
returns: v.null(),
handler: async (ctx, args) => {
// Self-changes are always allowed
if (args.actorUserId !== args.targetUserId) {
const roles = await getRolesForUser(ctx, args.actorUserId);
const canManage = roles.some(
(role) => (role.permissions as Record<string, boolean>)?.["manage_nicknames"]
);
if (!canManage) {
throw new Error("You don't have permission to change other users' nicknames");
}
}
const trimmed = args.displayName.trim();
await ctx.db.patch(args.targetUserId, {
displayName: trimmed || undefined,
});
return null;
},
});
// Internal: update credentials after password reset
export const updateCredentials = internalMutation({
args: {

View File

@@ -50,6 +50,7 @@ export const listDMs = query({
channel_name: v.string(),
other_user_id: v.string(),
other_username: v.string(),
other_displayName: v.union(v.string(), v.null()),
other_user_status: v.optional(v.string()),
other_user_avatar_url: v.optional(v.union(v.string(), v.null())),
})
@@ -85,6 +86,7 @@ export const listDMs = query({
channel_name: channel.name,
other_user_id: otherUser._id as string,
other_username: otherUser.username,
other_displayName: otherUser.displayName || null,
other_user_status: otherUser.status || "offline",
other_user_avatar_url: avatarUrl,
};

View File

@@ -51,6 +51,7 @@ export const getChannelMembers = query({
members.push({
id: user._id,
username: user.username,
displayName: user.displayName || null,
status: user.status || "offline",
roles: roles.sort((a, b) => b.position - a.position),
avatarUrl,
@@ -77,6 +78,7 @@ export const listAll = query({
results.push({
id: user._id,
username: user.username,
displayName: user.displayName || null,
status: user.status || "offline",
avatarUrl,
});

View File

@@ -27,6 +27,7 @@ async function enrichMessage(ctx: any, msg: any, userId?: any) {
}
let replyToUsername: string | null = null;
let replyToDisplayName: string | null = null;
let replyToContent: string | null = null;
let replyToNonce: string | null = null;
let replyToAvatarUrl: string | null = null;
@@ -35,6 +36,7 @@ async function enrichMessage(ctx: any, msg: any, userId?: any) {
if (repliedMsg) {
const repliedSender = await ctx.db.get(repliedMsg.senderId);
replyToUsername = repliedSender?.username || "Unknown";
replyToDisplayName = repliedSender?.displayName || null;
replyToContent = repliedMsg.ciphertext;
replyToNonce = repliedMsg.nonce;
if (repliedSender?.avatarStorageId) {
@@ -53,11 +55,13 @@ async function enrichMessage(ctx: any, msg: any, userId?: any) {
key_version: msg.keyVersion,
created_at: new Date(msg._creationTime).toISOString(),
username: sender?.username || "Unknown",
displayName: sender?.displayName || null,
public_signing_key: sender?.publicSigningKey || "",
avatarUrl,
reactions: Object.keys(reactions).length > 0 ? reactions : null,
replyToId: msg.replyTo || null,
replyToUsername,
replyToDisplayName,
replyToContent,
replyToNonce,
replyToAvatarUrl,

View File

@@ -12,6 +12,7 @@ const PERMISSION_KEYS = [
"attach_files",
"move_members",
"mute_members",
"manage_nicknames",
] as const;
export async function getRolesForUser(
@@ -191,6 +192,7 @@ export const getMyPermissions = query({
attach_files: v.boolean(),
move_members: v.boolean(),
mute_members: v.boolean(),
manage_nicknames: v.boolean(),
}),
handler: async (ctx, args) => {
const roles = await getRolesForUser(ctx, args.userId);
@@ -211,6 +213,7 @@ export const getMyPermissions = query({
attach_files: boolean;
move_members: boolean;
mute_members: boolean;
manage_nicknames: boolean;
};
},
});

View File

@@ -64,6 +64,7 @@ export const getTyping = query({
v.object({
userId: v.id("userProfiles"),
username: v.string(),
displayName: v.union(v.string(), v.null()),
})
),
handler: async (ctx, args) => {
@@ -73,9 +74,17 @@ export const getTyping = query({
.withIndex("by_channel", (q) => q.eq("channelId", args.channelId))
.collect();
return indicators
.filter((t) => t.expiresAt > now)
.map((t) => ({ userId: t.userId, username: t.username }));
const active = indicators.filter((t) => t.expiresAt > now);
const results = [];
for (const t of active) {
const user = await ctx.db.get(t.userId);
results.push({
userId: t.userId,
username: t.username,
displayName: user?.displayName || null,
});
}
return results;
},
});

View File

@@ -146,6 +146,7 @@ export const getAll = query({
const grouped: Record<string, Array<{
userId: string;
username: string;
displayName: string | null;
isMuted: boolean;
isDeafened: boolean;
isScreenSharing: boolean;
@@ -169,6 +170,7 @@ export const getAll = query({
(grouped[s.channelId] ??= []).push({
userId: s.userId,
username: s.username,
displayName: user?.displayName || null,
isMuted: s.isMuted,
isDeafened: s.isDeafened,
isScreenSharing: s.isScreenSharing,

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