diff --git a/TODO.md b/TODO.md index 302de8e..05cebed 100644 --- a/TODO.md +++ b/TODO.md @@ -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 user’s password is effectively the root of all client-side encryption in a user’s account, forgetting or -losing it results in the inability to decrypt the user’s 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. \ No newline at end of file diff --git a/convex/auth.ts b/convex/auth.ts index 9c8aa50..398b075 100644 --- a/convex/auth.ts +++ b/convex/auth.ts @@ -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 { 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)?.["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: { diff --git a/convex/dms.ts b/convex/dms.ts index 5ec25fa..44d482e 100644 --- a/convex/dms.ts +++ b/convex/dms.ts @@ -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, }; diff --git a/convex/members.ts b/convex/members.ts index 77e6240..6a06612 100644 --- a/convex/members.ts +++ b/convex/members.ts @@ -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, }); diff --git a/convex/messages.ts b/convex/messages.ts index f1d2332..934e2aa 100644 --- a/convex/messages.ts +++ b/convex/messages.ts @@ -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, diff --git a/convex/roles.ts b/convex/roles.ts index 0bf02fa..f8c75a5 100644 --- a/convex/roles.ts +++ b/convex/roles.ts @@ -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; }; }, }); diff --git a/convex/typing.ts b/convex/typing.ts index 916ff3e..64b0d4a 100644 --- a/convex/typing.ts +++ b/convex/typing.ts @@ -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; }, }); diff --git a/convex/voiceState.ts b/convex/voiceState.ts index 18296a2..a3ac2fa 100644 --- a/convex/voiceState.ts +++ b/convex/voiceState.ts @@ -146,6 +146,7 @@ export const getAll = query({ const grouped: Record { + 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( +
+
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 */} +
+

+ Change Nickname +

+ +
+ + {/* Content */} +
+ {/* Notice */} + {!isSelf && ( +
+ Nicknames are visible to everyone on this server. Do not change them unless you are enforcing a naming system or clearing a bad nickname. +
+ )} + + {/* Label */} + + + {/* Input */} + 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 */} +
+ Reset Nickname +
+
+ + {/* Footer */} +
+ + +
+
+
, + document.body + ); +}; + +export default ChangeNicknameModal; diff --git a/packages/shared/src/components/ChatArea.jsx b/packages/shared/src/components/ChatArea.jsx index 1817fb8..ce21de1 100644 --- a/packages/shared/src/components/ChatArea.jsx +++ b/packages/shared/src/components/ChatArea.jsx @@ -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 && (
- {typingUsers.map(t => t.username).join(', ')} is typing... + {typingUsers.map(t => t.displayName || t.username).join(', ')} is typing...
)} diff --git a/packages/shared/src/components/MembersList.jsx b/packages/shared/src/components/MembersList.jsx index ff9f0b8..3ac774e 100644 --- a/packages/shared/src/components/MembersList.jsx +++ b/packages/shared/src/components/MembersList.jsx @@ -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 ( +
e.stopPropagation()}> + {(isSelf || canManageNicknames) && ( +
{ e.stopPropagation(); onChangeNickname(); onClose(); }} + > + Change Nickname +
+ )} + {(isSelf || canManageNicknames) && (!isSelf) && ( +
+ )} + {!isSelf && ( + <> +
{ e.stopPropagation(); onMessage(); onClose(); }} + > + Message +
+
{ e.stopPropagation(); onStartCall(); onClose(); }} + > + Start a Call +
+ + )} +
+ ); +}; + +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 } : {}} >
@@ -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()}
)}
{
- {member.username} + {member.displayName || member.username} {isOwner && } {usersScreenSharing.has(member.id) ? ( @@ -154,6 +220,30 @@ const MembersList = ({ channelId, visible, onMemberClick }) => { {offlineMembers.map(renderMember)} )} + + {contextMenu && ( + 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 && ( + setNicknameModal(null)} + /> + )}
); }; diff --git a/packages/shared/src/components/MentionMenu.jsx b/packages/shared/src/components/MentionMenu.jsx index 8679fed..a706612 100644 --- a/packages/shared/src/components/MentionMenu.jsx +++ b/packages/shared/src/components/MentionMenu.jsx @@ -68,7 +68,7 @@ const MentionMenu = ({ items, selectedIndex, onSelect, onHover }) => { > - {member.username} + {member.displayName || member.username} {member.username}
diff --git a/packages/shared/src/components/MessageItem.jsx b/packages/shared/src/components/MessageItem.jsx index 7bfff44..9ef12d6 100644 --- a/packages/shared/src/components/MessageItem.jsx +++ b/packages/shared/src/components/MessageItem.jsx @@ -308,7 +308,7 @@ const MessageItem = React.memo(({
- @{msg.replyToUsername} + @{msg.replyToDisplayName || msg.replyToUsername} {msg.decryptedReply || '[Encrypted]'}
@@ -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'} {msg.isVerified === false && } {currentDate.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })} diff --git a/packages/shared/src/components/ServerSettingsModal.jsx b/packages/shared/src/components/ServerSettingsModal.jsx index 5820297..cfc1cb3 100644 --- a/packages/shared/src/components/ServerSettingsModal.jsx +++ b/packages/shared/src/components/ServerSettingsModal.jsx @@ -437,7 +437,7 @@ const ServerSettingsModal = ({ onClose }) => { /> - {['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 => (
{perm.replace('_', ' ')} { +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
)}
-
{ e.stopPropagation(); onMessage(); onClose(); }}> - Message -
+ {showNicknameOption && ( +
{ e.stopPropagation(); onChangeNickname(); onClose(); }}> + Change Nickname +
+ )} + {!isSelf && ( +
{ e.stopPropagation(); onMessage(); onClose(); }}> + Message +
+ )} + {!isSelf && ( +
{ e.stopPropagation(); onStartCall(); onClose(); }}> + Start a Call +
+ )}
); }; @@ -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 ( +
e.stopPropagation()}> +
{ e.stopPropagation(); onEdit(); }}> + Edit Category +
+
+
{ e.stopPropagation(); onDelete(); }}> + Delete Category +
+
+ ); +}; + 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' }} /> - {user.username} + {user.displayName || user.username}
{user.isScreenSharing &&
Live
} {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 && ( + 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 && ( 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 && ( + 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 }) => ( -
onToggle(groupId)} {...(dragListeners || {})}> - {group.name} -
- +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 ( +
!isEditing && onToggle(groupId)} onContextMenu={onContextMenu} {...(dragListeners || {})}> + {isEditing ? ( + 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', + }} + /> + ) : ( + {group.name} + )} +
+ +
+
- -
-)); + ); +}); export default Sidebar; diff --git a/packages/shared/src/components/UserProfilePopup.jsx b/packages/shared/src/components/UserProfilePopup.jsx index be3786d..52384c5 100644 --- a/packages/shared/src/components/UserProfilePopup.jsx +++ b/packages/shared/src/components/UserProfilePopup.jsx @@ -93,7 +93,12 @@ const UserProfilePopup = ({ userId, username, avatarUrl, status, position, onClo style={{ backgroundColor: STATUS_COLORS[userStatus] }} />
-
{username}
+
{userData?.displayName || username}
+ {userData?.displayName && ( +
+ {username} +
+ )}
{userData?.customStatus || STATUS_LABELS[userStatus] || 'Online'}
diff --git a/packages/shared/src/components/VoiceStage.jsx b/packages/shared/src/components/VoiceStage.jsx index 0232ca5..6be2e8f 100644 --- a/packages/shared/src/components/VoiceStage.jsx +++ b/packages/shared/src/components/VoiceStage.jsx @@ -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) => { diff --git a/packages/shared/src/index.css b/packages/shared/src/index.css index be6a350..d1b5b87 100644 --- a/packages/shared/src/index.css +++ b/packages/shared/src/index.css @@ -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; } diff --git a/packages/shared/src/pages/Chat.jsx b/packages/shared/src/pages/Chat.jsx index be44171..ff002cb 100644 --- a/packages/shared/src/pages/Chat.jsx +++ b/packages/shared/src/pages/Chat.jsx @@ -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 && (
-
{activeDMChannel.other_username}
+
{activeDMChannel.other_displayName || activeDMChannel.other_username}
In a call
@@ -628,6 +656,10 @@ const Chat = () => { channelId={activeChannel} visible={effectiveShowMembers} onMemberClick={(member) => {}} + userId={userId} + myPermissions={myPermissions} + onOpenDM={openDM} + onStartCallWithUser={handleStartCallWithUser} /> { serverName={serverName} serverIconUrl={serverIconUrl} isMobile={isMobile} + onStartCallWithUser={handleStartCallWithUser} /> )} {showMainContent && renderMainContent()}