nickname
This commit is contained in:
27
TODO.md
27
TODO.md
@@ -13,32 +13,5 @@
|
|||||||
- You should not be allowed to edit a image or video file upload message.
|
- 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.
|
- 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.
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { query, mutation, internalQuery, internalMutation } from "./_generated/server";
|
import { query, mutation, internalQuery, internalMutation } from "./_generated/server";
|
||||||
import { v } from "convex/values";
|
import { v } from "convex/values";
|
||||||
import { getPublicStorageUrl } from "./storageUrl";
|
import { getPublicStorageUrl } from "./storageUrl";
|
||||||
|
import { getRolesForUser } from "./roles";
|
||||||
|
|
||||||
async function sha256Hex(input: string): Promise<string> {
|
async function sha256Hex(input: string): Promise<string> {
|
||||||
const buffer = await crypto.subtle.digest(
|
const buffer = await crypto.subtle.digest(
|
||||||
@@ -165,6 +166,7 @@ export const createUserWithProfile = mutation({
|
|||||||
manage_channels: true,
|
manage_channels: true,
|
||||||
manage_roles: true,
|
manage_roles: true,
|
||||||
manage_messages: true,
|
manage_messages: true,
|
||||||
|
manage_nicknames: true,
|
||||||
create_invite: true,
|
create_invite: true,
|
||||||
embed_links: true,
|
embed_links: true,
|
||||||
attach_files: 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
|
// Internal: update credentials after password reset
|
||||||
export const updateCredentials = internalMutation({
|
export const updateCredentials = internalMutation({
|
||||||
args: {
|
args: {
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ export const listDMs = query({
|
|||||||
channel_name: v.string(),
|
channel_name: v.string(),
|
||||||
other_user_id: v.string(),
|
other_user_id: v.string(),
|
||||||
other_username: v.string(),
|
other_username: v.string(),
|
||||||
|
other_displayName: v.union(v.string(), v.null()),
|
||||||
other_user_status: v.optional(v.string()),
|
other_user_status: v.optional(v.string()),
|
||||||
other_user_avatar_url: v.optional(v.union(v.string(), v.null())),
|
other_user_avatar_url: v.optional(v.union(v.string(), v.null())),
|
||||||
})
|
})
|
||||||
@@ -85,6 +86,7 @@ export const listDMs = query({
|
|||||||
channel_name: channel.name,
|
channel_name: channel.name,
|
||||||
other_user_id: otherUser._id as string,
|
other_user_id: otherUser._id as string,
|
||||||
other_username: otherUser.username,
|
other_username: otherUser.username,
|
||||||
|
other_displayName: otherUser.displayName || null,
|
||||||
other_user_status: otherUser.status || "offline",
|
other_user_status: otherUser.status || "offline",
|
||||||
other_user_avatar_url: avatarUrl,
|
other_user_avatar_url: avatarUrl,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ export const getChannelMembers = query({
|
|||||||
members.push({
|
members.push({
|
||||||
id: user._id,
|
id: user._id,
|
||||||
username: user.username,
|
username: user.username,
|
||||||
|
displayName: user.displayName || null,
|
||||||
status: user.status || "offline",
|
status: user.status || "offline",
|
||||||
roles: roles.sort((a, b) => b.position - a.position),
|
roles: roles.sort((a, b) => b.position - a.position),
|
||||||
avatarUrl,
|
avatarUrl,
|
||||||
@@ -77,6 +78,7 @@ export const listAll = query({
|
|||||||
results.push({
|
results.push({
|
||||||
id: user._id,
|
id: user._id,
|
||||||
username: user.username,
|
username: user.username,
|
||||||
|
displayName: user.displayName || null,
|
||||||
status: user.status || "offline",
|
status: user.status || "offline",
|
||||||
avatarUrl,
|
avatarUrl,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ async function enrichMessage(ctx: any, msg: any, userId?: any) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let replyToUsername: string | null = null;
|
let replyToUsername: string | null = null;
|
||||||
|
let replyToDisplayName: string | null = null;
|
||||||
let replyToContent: string | null = null;
|
let replyToContent: string | null = null;
|
||||||
let replyToNonce: string | null = null;
|
let replyToNonce: string | null = null;
|
||||||
let replyToAvatarUrl: string | null = null;
|
let replyToAvatarUrl: string | null = null;
|
||||||
@@ -35,6 +36,7 @@ async function enrichMessage(ctx: any, msg: any, userId?: any) {
|
|||||||
if (repliedMsg) {
|
if (repliedMsg) {
|
||||||
const repliedSender = await ctx.db.get(repliedMsg.senderId);
|
const repliedSender = await ctx.db.get(repliedMsg.senderId);
|
||||||
replyToUsername = repliedSender?.username || "Unknown";
|
replyToUsername = repliedSender?.username || "Unknown";
|
||||||
|
replyToDisplayName = repliedSender?.displayName || null;
|
||||||
replyToContent = repliedMsg.ciphertext;
|
replyToContent = repliedMsg.ciphertext;
|
||||||
replyToNonce = repliedMsg.nonce;
|
replyToNonce = repliedMsg.nonce;
|
||||||
if (repliedSender?.avatarStorageId) {
|
if (repliedSender?.avatarStorageId) {
|
||||||
@@ -53,11 +55,13 @@ async function enrichMessage(ctx: any, msg: any, userId?: any) {
|
|||||||
key_version: msg.keyVersion,
|
key_version: msg.keyVersion,
|
||||||
created_at: new Date(msg._creationTime).toISOString(),
|
created_at: new Date(msg._creationTime).toISOString(),
|
||||||
username: sender?.username || "Unknown",
|
username: sender?.username || "Unknown",
|
||||||
|
displayName: sender?.displayName || null,
|
||||||
public_signing_key: sender?.publicSigningKey || "",
|
public_signing_key: sender?.publicSigningKey || "",
|
||||||
avatarUrl,
|
avatarUrl,
|
||||||
reactions: Object.keys(reactions).length > 0 ? reactions : null,
|
reactions: Object.keys(reactions).length > 0 ? reactions : null,
|
||||||
replyToId: msg.replyTo || null,
|
replyToId: msg.replyTo || null,
|
||||||
replyToUsername,
|
replyToUsername,
|
||||||
|
replyToDisplayName,
|
||||||
replyToContent,
|
replyToContent,
|
||||||
replyToNonce,
|
replyToNonce,
|
||||||
replyToAvatarUrl,
|
replyToAvatarUrl,
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ const PERMISSION_KEYS = [
|
|||||||
"attach_files",
|
"attach_files",
|
||||||
"move_members",
|
"move_members",
|
||||||
"mute_members",
|
"mute_members",
|
||||||
|
"manage_nicknames",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export async function getRolesForUser(
|
export async function getRolesForUser(
|
||||||
@@ -191,6 +192,7 @@ export const getMyPermissions = query({
|
|||||||
attach_files: v.boolean(),
|
attach_files: v.boolean(),
|
||||||
move_members: v.boolean(),
|
move_members: v.boolean(),
|
||||||
mute_members: v.boolean(),
|
mute_members: v.boolean(),
|
||||||
|
manage_nicknames: v.boolean(),
|
||||||
}),
|
}),
|
||||||
handler: async (ctx, args) => {
|
handler: async (ctx, args) => {
|
||||||
const roles = await getRolesForUser(ctx, args.userId);
|
const roles = await getRolesForUser(ctx, args.userId);
|
||||||
@@ -211,6 +213,7 @@ export const getMyPermissions = query({
|
|||||||
attach_files: boolean;
|
attach_files: boolean;
|
||||||
move_members: boolean;
|
move_members: boolean;
|
||||||
mute_members: boolean;
|
mute_members: boolean;
|
||||||
|
manage_nicknames: boolean;
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ export const getTyping = query({
|
|||||||
v.object({
|
v.object({
|
||||||
userId: v.id("userProfiles"),
|
userId: v.id("userProfiles"),
|
||||||
username: v.string(),
|
username: v.string(),
|
||||||
|
displayName: v.union(v.string(), v.null()),
|
||||||
})
|
})
|
||||||
),
|
),
|
||||||
handler: async (ctx, args) => {
|
handler: async (ctx, args) => {
|
||||||
@@ -73,9 +74,17 @@ export const getTyping = query({
|
|||||||
.withIndex("by_channel", (q) => q.eq("channelId", args.channelId))
|
.withIndex("by_channel", (q) => q.eq("channelId", args.channelId))
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
return indicators
|
const active = indicators.filter((t) => t.expiresAt > now);
|
||||||
.filter((t) => t.expiresAt > now)
|
const results = [];
|
||||||
.map((t) => ({ userId: t.userId, username: t.username }));
|
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;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -146,6 +146,7 @@ export const getAll = query({
|
|||||||
const grouped: Record<string, Array<{
|
const grouped: Record<string, Array<{
|
||||||
userId: string;
|
userId: string;
|
||||||
username: string;
|
username: string;
|
||||||
|
displayName: string | null;
|
||||||
isMuted: boolean;
|
isMuted: boolean;
|
||||||
isDeafened: boolean;
|
isDeafened: boolean;
|
||||||
isScreenSharing: boolean;
|
isScreenSharing: boolean;
|
||||||
@@ -169,6 +170,7 @@ export const getAll = query({
|
|||||||
(grouped[s.channelId] ??= []).push({
|
(grouped[s.channelId] ??= []).push({
|
||||||
userId: s.userId,
|
userId: s.userId,
|
||||||
username: s.username,
|
username: s.username,
|
||||||
|
displayName: user?.displayName || null,
|
||||||
isMuted: s.isMuted,
|
isMuted: s.isMuted,
|
||||||
isDeafened: s.isDeafened,
|
isDeafened: s.isDeafened,
|
||||||
isScreenSharing: s.isScreenSharing,
|
isScreenSharing: s.isScreenSharing,
|
||||||
|
|||||||
222
packages/shared/src/components/ChangeNicknameModal.jsx
Normal file
222
packages/shared/src/components/ChangeNicknameModal.jsx
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
|
import ReactDOM from 'react-dom';
|
||||||
|
import { useMutation } from 'convex/react';
|
||||||
|
import { api } from '../../../../convex/_generated/api';
|
||||||
|
|
||||||
|
const ChangeNicknameModal = ({ targetUserId, targetUsername, currentNickname, actorUserId, onClose }) => {
|
||||||
|
const [nickname, setNickname] = useState(currentNickname || '');
|
||||||
|
const inputRef = useRef(null);
|
||||||
|
const setNicknameMutation = useMutation(api.auth.setNickname);
|
||||||
|
const isSelf = targetUserId === actorUserId;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
inputRef.current?.focus();
|
||||||
|
inputRef.current?.select();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
try {
|
||||||
|
await setNicknameMutation({
|
||||||
|
actorUserId,
|
||||||
|
targetUserId,
|
||||||
|
displayName: nickname,
|
||||||
|
});
|
||||||
|
onClose();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to set nickname:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReset = async () => {
|
||||||
|
try {
|
||||||
|
await setNicknameMutation({
|
||||||
|
actorUserId,
|
||||||
|
targetUserId,
|
||||||
|
displayName: '',
|
||||||
|
});
|
||||||
|
onClose();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to reset nickname:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
handleSave();
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return ReactDOM.createPortal(
|
||||||
|
<div
|
||||||
|
onClick={onClose}
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
backgroundColor: 'rgba(0, 0, 0, 0.85)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
zIndex: 10001,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
style={{
|
||||||
|
width: '440px',
|
||||||
|
maxWidth: '90vw',
|
||||||
|
backgroundColor: 'var(--bg-primary)',
|
||||||
|
borderRadius: '8px',
|
||||||
|
boxShadow: '0 4px 20px rgba(0, 0, 0, 0.4)',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div style={{ padding: '16px 16px 0 16px', position: 'relative' }}>
|
||||||
|
<h2 style={{ color: 'var(--header-primary)', margin: '0 0 16px 0', fontSize: '20px', fontWeight: 600 }}>
|
||||||
|
Change Nickname
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: '12px',
|
||||||
|
right: '12px',
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
color: 'var(--interactive-normal)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
padding: '4px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
borderRadius: '50%',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M18.4 4L12 10.4L5.6 4L4 5.6L10.4 12L4 18.4L5.6 20L12 13.6L18.4 20L20 18.4L13.6 12L20 5.6L18.4 4Z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div style={{ padding: '0 16px 16px 16px' }}>
|
||||||
|
{/* Notice */}
|
||||||
|
{!isSelf && (
|
||||||
|
<div style={{
|
||||||
|
backgroundColor: 'var(--bg-tertiary)',
|
||||||
|
borderLeft: '4px solid var(--text-warning, #faa61a)',
|
||||||
|
borderRadius: '4px',
|
||||||
|
padding: '12px',
|
||||||
|
marginBottom: '16px',
|
||||||
|
fontSize: '14px',
|
||||||
|
color: 'var(--text-normal)',
|
||||||
|
lineHeight: '1.4',
|
||||||
|
}}>
|
||||||
|
Nicknames are visible to everyone on this server. Do not change them unless you are enforcing a naming system or clearing a bad nickname.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Label */}
|
||||||
|
<label style={{
|
||||||
|
color: 'var(--header-secondary)',
|
||||||
|
fontSize: '12px',
|
||||||
|
fontWeight: 700,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
marginBottom: '8px',
|
||||||
|
display: 'block',
|
||||||
|
}}>
|
||||||
|
Nickname
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{/* Input */}
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="text"
|
||||||
|
value={nickname}
|
||||||
|
onChange={(e) => setNickname(e.target.value.slice(0, 32))}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
placeholder={targetUsername}
|
||||||
|
maxLength={32}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
padding: '10px',
|
||||||
|
backgroundColor: 'var(--bg-tertiary)',
|
||||||
|
border: '1px solid var(--border-subtle)',
|
||||||
|
borderRadius: '4px',
|
||||||
|
color: 'var(--text-normal)',
|
||||||
|
fontSize: '14px',
|
||||||
|
outline: 'none',
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Reset link */}
|
||||||
|
<div
|
||||||
|
onClick={handleReset}
|
||||||
|
style={{
|
||||||
|
color: 'var(--text-link, #00a8fc)',
|
||||||
|
fontSize: '14px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
marginTop: '8px',
|
||||||
|
marginBottom: '4px',
|
||||||
|
userSelect: 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Reset Nickname
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
gap: '12px',
|
||||||
|
padding: '16px',
|
||||||
|
backgroundColor: 'var(--bg-secondary)',
|
||||||
|
borderTop: '1px solid var(--border-subtle)',
|
||||||
|
}}>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
padding: '10px 0',
|
||||||
|
backgroundColor: 'var(--bg-tertiary)',
|
||||||
|
color: 'var(--text-normal)',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '3px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: 500,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
padding: '10px 0',
|
||||||
|
backgroundColor: 'var(--brand-experiment)',
|
||||||
|
color: '#fff',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '3px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: 500,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ChangeNicknameModal;
|
||||||
@@ -116,8 +116,9 @@ const filterMembersForMention = (members, query) => {
|
|||||||
const substring = [];
|
const substring = [];
|
||||||
for (const m of members) {
|
for (const m of members) {
|
||||||
const name = m.username.toLowerCase();
|
const name = m.username.toLowerCase();
|
||||||
if (name.startsWith(q)) prefix.push(m);
|
const nick = (m.displayName || '').toLowerCase();
|
||||||
else if (name.includes(q)) substring.push(m);
|
if (name.startsWith(q) || nick.startsWith(q)) prefix.push(m);
|
||||||
|
else if (name.includes(q) || nick.includes(q)) substring.push(m);
|
||||||
}
|
}
|
||||||
return [...prefix, ...substring];
|
return [...prefix, ...substring];
|
||||||
};
|
};
|
||||||
@@ -1931,7 +1932,7 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
|
|||||||
{typingUsers.length > 0 && (
|
{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' }}>
|
<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" />
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import React from 'react';
|
import React, { useState, useRef, useEffect, useLayoutEffect } from 'react';
|
||||||
import { useQuery } from 'convex/react';
|
import { useQuery } from 'convex/react';
|
||||||
import { api } from '../../../../convex/_generated/api';
|
import { api } from '../../../../convex/_generated/api';
|
||||||
import { useOnlineUsers } from '../contexts/PresenceContext';
|
import { useOnlineUsers } from '../contexts/PresenceContext';
|
||||||
import { useVoice } from '../contexts/VoiceContext';
|
import { useVoice } from '../contexts/VoiceContext';
|
||||||
import { CrownIcon, SharingIcon } from '../assets/icons';
|
import { CrownIcon, SharingIcon } from '../assets/icons';
|
||||||
import ColoredIcon from './ColoredIcon';
|
import ColoredIcon from './ColoredIcon';
|
||||||
|
import ChangeNicknameModal from './ChangeNicknameModal';
|
||||||
|
|
||||||
const USER_COLORS = ['#5865F2', '#EBA7CD', '#57F287', '#FEE75C', '#EB459E', '#ED4245'];
|
const USER_COLORS = ['#5865F2', '#EBA7CD', '#57F287', '#FEE75C', '#EB459E', '#ED4245'];
|
||||||
|
|
||||||
@@ -24,13 +25,70 @@ const STATUS_COLORS = {
|
|||||||
offline: '#747f8d',
|
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(
|
const members = useQuery(
|
||||||
api.members.getChannelMembers,
|
api.members.getChannelMembers,
|
||||||
channelId ? { channelId } : "skip"
|
channelId ? { channelId } : "skip"
|
||||||
) || [];
|
) || [];
|
||||||
const { resolveStatus } = useOnlineUsers();
|
const { resolveStatus } = useOnlineUsers();
|
||||||
const { voiceStates } = useVoice();
|
const { voiceStates } = useVoice();
|
||||||
|
const [contextMenu, setContextMenu] = useState(null);
|
||||||
|
const [nicknameModal, setNicknameModal] = useState(null);
|
||||||
|
|
||||||
const usersInVoice = new Set();
|
const usersInVoice = new Set();
|
||||||
const usersScreenSharing = new Set();
|
const usersScreenSharing = new Set();
|
||||||
@@ -66,6 +124,13 @@ const MembersList = ({ channelId, visible, onMemberClick }) => {
|
|||||||
// Sort groups by position descending
|
// Sort groups by position descending
|
||||||
const sortedGroups = Object.values(roleGroups).sort((a, b) => b.role.position - a.role.position);
|
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 renderMember = (member) => {
|
||||||
const displayRole = member.roles.find(r => r.name !== '@everyone' && r.name !== 'Owner') || null;
|
const displayRole = member.roles.find(r => r.name !== '@everyone' && r.name !== 'Owner') || null;
|
||||||
const nameColor = displayRole ? displayRole.color : '#fff';
|
const nameColor = displayRole ? displayRole.color : '#fff';
|
||||||
@@ -77,6 +142,7 @@ const MembersList = ({ channelId, visible, onMemberClick }) => {
|
|||||||
key={member.id}
|
key={member.id}
|
||||||
className="member-item"
|
className="member-item"
|
||||||
onClick={() => onMemberClick && onMemberClick(member)}
|
onClick={() => onMemberClick && onMemberClick(member)}
|
||||||
|
onContextMenu={(e) => handleContextMenu(e, member)}
|
||||||
style={effectiveStatus === 'offline' ? { opacity: 0.3 } : {}}
|
style={effectiveStatus === 'offline' ? { opacity: 0.3 } : {}}
|
||||||
>
|
>
|
||||||
<div className="member-avatar-wrapper">
|
<div className="member-avatar-wrapper">
|
||||||
@@ -92,7 +158,7 @@ const MembersList = ({ channelId, visible, onMemberClick }) => {
|
|||||||
className="member-avatar"
|
className="member-avatar"
|
||||||
style={{ backgroundColor: getUserColor(member.username) }}
|
style={{ backgroundColor: getUserColor(member.username) }}
|
||||||
>
|
>
|
||||||
{member.username.substring(0, 1).toUpperCase()}
|
{(member.displayName || member.username).substring(0, 1).toUpperCase()}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div
|
<div
|
||||||
@@ -102,7 +168,7 @@ const MembersList = ({ channelId, visible, onMemberClick }) => {
|
|||||||
</div>
|
</div>
|
||||||
<div className="member-info">
|
<div className="member-info">
|
||||||
<span className="member-name" style={{ color: nameColor, display: 'flex', alignItems: 'center', gap: '4px' }}>
|
<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" />}
|
{isOwner && <ColoredIcon src={CrownIcon} color="var(--text-feedback-warning)" size="14px" />}
|
||||||
</span>
|
</span>
|
||||||
{usersScreenSharing.has(member.id) ? (
|
{usersScreenSharing.has(member.id) ? (
|
||||||
@@ -154,6 +220,30 @@ const MembersList = ({ channelId, visible, onMemberClick }) => {
|
|||||||
{offlineMembers.map(renderMember)}
|
{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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ const MentionMenu = ({ items, selectedIndex, onSelect, onHover }) => {
|
|||||||
>
|
>
|
||||||
<Avatar username={member.username} avatarUrl={member.avatarUrl} size={24} />
|
<Avatar username={member.username} avatarUrl={member.avatarUrl} size={24} />
|
||||||
<span className="mention-menu-row-primary" style={nameColor ? { color: nameColor } : undefined}>
|
<span className="mention-menu-row-primary" style={nameColor ? { color: nameColor } : undefined}>
|
||||||
{member.username}
|
{member.displayName || member.username}
|
||||||
</span>
|
</span>
|
||||||
<span className="mention-menu-row-secondary">{member.username}</span>
|
<span className="mention-menu-row-secondary">{member.username}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -308,7 +308,7 @@ const MessageItem = React.memo(({
|
|||||||
<div className="reply-spine" />
|
<div className="reply-spine" />
|
||||||
<Avatar username={msg.replyToUsername} avatarUrl={msg.replyToAvatarUrl} size={16} className="reply-avatar" />
|
<Avatar username={msg.replyToUsername} avatarUrl={msg.replyToAvatarUrl} size={16} className="reply-avatar" />
|
||||||
<span className="reply-author" style={{ color: getUserColor(msg.replyToUsername) }}>
|
<span className="reply-author" style={{ color: getUserColor(msg.replyToUsername) }}>
|
||||||
@{msg.replyToUsername}
|
@{msg.replyToDisplayName || msg.replyToUsername}
|
||||||
</span>
|
</span>
|
||||||
<span className="reply-text">{msg.decryptedReply || '[Encrypted]'}</span>
|
<span className="reply-text">{msg.decryptedReply || '[Encrypted]'}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -337,7 +337,7 @@ const MessageItem = React.memo(({
|
|||||||
style={{ color: userColor, cursor: 'pointer' }}
|
style={{ color: userColor, cursor: 'pointer' }}
|
||||||
onClick={(e) => onProfilePopup(e, msg)}
|
onClick={(e) => onProfilePopup(e, msg)}
|
||||||
>
|
>
|
||||||
{msg.username || 'Unknown'}
|
{msg.displayName || msg.username || 'Unknown'}
|
||||||
</span>
|
</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>}
|
{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>
|
<span className="timestamp">{currentDate.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })}</span>
|
||||||
|
|||||||
@@ -437,7 +437,7 @@ const ServerSettingsModal = ({ onClose }) => {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<label style={labelStyle}>PERMISSIONS</label>
|
<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)' }}>
|
<div key={perm} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10, paddingBottom: 10, borderBottom: '1px solid var(--border-subtle)' }}>
|
||||||
<span style={{ color: 'var(--header-primary)', textTransform: 'capitalize' }}>{perm.replace('_', ' ')}</span>
|
<span style={{ color: 'var(--header-primary)', textTransform: 'capitalize' }}>{perm.replace('_', ' ')}</span>
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import ScreenShareModal from './ScreenShareModal';
|
|||||||
import DMList from './DMList';
|
import DMList from './DMList';
|
||||||
import Avatar from './Avatar';
|
import Avatar from './Avatar';
|
||||||
import UserSettings from './UserSettings';
|
import UserSettings from './UserSettings';
|
||||||
|
import ChangeNicknameModal from './ChangeNicknameModal';
|
||||||
import { Track } from 'livekit-client';
|
import { Track } from 'livekit-client';
|
||||||
import { DndContext, closestCenter, PointerSensor, useSensor, useSensors, DragOverlay, useDraggable } from '@dnd-kit/core';
|
import { DndContext, closestCenter, PointerSensor, useSensor, useSensors, DragOverlay, useDraggable } from '@dnd-kit/core';
|
||||||
import { SortableContext, verticalListSortingStrategy, useSortable } from '@dnd-kit/sortable';
|
import { SortableContext, verticalListSortingStrategy, useSortable } from '@dnd-kit/sortable';
|
||||||
@@ -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 menuRef = useRef(null);
|
||||||
const [pos, setPos] = useState({ top: y, left: x });
|
const [pos, setPos] = useState({ top: y, left: x });
|
||||||
|
|
||||||
@@ -480,9 +481,21 @@ const VoiceUserContextMenu = ({ x, y, onClose, user, onMute, isMuted, onServerMu
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="context-menu-separator" />
|
<div className="context-menu-separator" />
|
||||||
<div className="context-menu-item" onClick={(e) => { e.stopPropagation(); onMessage(); onClose(); }}>
|
{showNicknameOption && (
|
||||||
<span>Message</span>
|
<div className="context-menu-item" onClick={(e) => { e.stopPropagation(); onChangeNickname(); onClose(); }}>
|
||||||
</div>
|
<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>
|
</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 CreateChannelModal = ({ onClose, onSubmit, categoryId }) => {
|
||||||
const [channelType, setChannelType] = useState('text');
|
const [channelType, setChannelType] = useState('text');
|
||||||
const [channelName, setChannelName] = useState('');
|
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 { crypto, settings } = usePlatform();
|
||||||
const [isCreating, setIsCreating] = useState(false);
|
const [isCreating, setIsCreating] = useState(false);
|
||||||
const [isServerSettingsOpen, setIsServerSettingsOpen] = useState(false);
|
const [isServerSettingsOpen, setIsServerSettingsOpen] = useState(false);
|
||||||
@@ -774,11 +822,14 @@ const Sidebar = ({ channels, categories, activeChannel, onSelectChannel, usernam
|
|||||||
}, [userId]);
|
}, [userId]);
|
||||||
const [channelListContextMenu, setChannelListContextMenu] = useState(null);
|
const [channelListContextMenu, setChannelListContextMenu] = useState(null);
|
||||||
const [voiceUserMenu, setVoiceUserMenu] = useState(null);
|
const [voiceUserMenu, setVoiceUserMenu] = useState(null);
|
||||||
|
const [categoryContextMenu, setCategoryContextMenu] = useState(null);
|
||||||
|
const [editingCategoryId, setEditingCategoryId] = useState(null);
|
||||||
const [showCreateChannelModal, setShowCreateChannelModal] = useState(false);
|
const [showCreateChannelModal, setShowCreateChannelModal] = useState(false);
|
||||||
const [showCreateCategoryModal, setShowCreateCategoryModal] = useState(false);
|
const [showCreateCategoryModal, setShowCreateCategoryModal] = useState(false);
|
||||||
const [createChannelCategoryId, setCreateChannelCategoryId] = useState(null);
|
const [createChannelCategoryId, setCreateChannelCategoryId] = useState(null);
|
||||||
const [activeDragItem, setActiveDragItem] = useState(null);
|
const [activeDragItem, setActiveDragItem] = useState(null);
|
||||||
const [dragOverChannelId, setDragOverChannelId] = useState(null);
|
const [dragOverChannelId, setDragOverChannelId] = useState(null);
|
||||||
|
const [voiceNicknameModal, setVoiceNicknameModal] = useState(null);
|
||||||
|
|
||||||
const convex = useConvex();
|
const convex = useConvex();
|
||||||
|
|
||||||
@@ -1087,7 +1138,7 @@ const Sidebar = ({ channels, categories, activeChannel, onSelectChannel, usernam
|
|||||||
boxShadow: activeSpeakers.has(user.userId) ? ACTIVE_SPEAKER_SHADOW : 'none'
|
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" }}>
|
<div style={{ display: 'flex', marginLeft: 'auto', gap: 4, alignItems: 'center', marginRight: "16px" }}>
|
||||||
{user.isScreenSharing && <div style={liveBadgeStyle}>Live</div>}
|
{user.isScreenSharing && <div style={liveBadgeStyle}>Live</div>}
|
||||||
{user.isServerMuted ? (
|
{user.isServerMuted ? (
|
||||||
@@ -1402,6 +1453,20 @@ const Sidebar = ({ channels, categories, activeChannel, onSelectChannel, usernam
|
|||||||
collapsed={collapsedCategories[group.id]}
|
collapsed={collapsedCategories[group.id]}
|
||||||
onToggle={toggleCategory}
|
onToggle={toggleCategory}
|
||||||
onAddChannel={handleAddChannelToCategory}
|
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];
|
const isCollapsed = collapsedCategories[group.id];
|
||||||
@@ -1669,6 +1734,26 @@ const Sidebar = ({ channels, categories, activeChannel, onSelectChannel, usernam
|
|||||||
onCreateCategory={() => setShowCreateCategoryModal(true)}
|
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 && (
|
{voiceUserMenu && (
|
||||||
<VoiceUserContextMenu
|
<VoiceUserContextMenu
|
||||||
x={voiceUserMenu.x}
|
x={voiceUserMenu.x}
|
||||||
@@ -1684,11 +1769,25 @@ const Sidebar = ({ channels, categories, activeChannel, onSelectChannel, usernam
|
|||||||
onDisconnect={() => disconnectUser(voiceUserMenu.user.userId)}
|
onDisconnect={() => disconnectUser(voiceUserMenu.user.userId)}
|
||||||
hasDisconnectPermission={!!myPermissions.move_members}
|
hasDisconnectPermission={!!myPermissions.move_members}
|
||||||
onMessage={() => {
|
onMessage={() => {
|
||||||
onOpenDM(voiceUserMenu.user.userId, voiceUserMenu.user.username);
|
onOpenDM(voiceUserMenu.user.userId, voiceUserMenu.user.displayName || voiceUserMenu.user.username);
|
||||||
onViewChange('me');
|
onViewChange('me');
|
||||||
}}
|
}}
|
||||||
userVolume={getUserVolume(voiceUserMenu.user.userId)}
|
userVolume={getUserVolume(voiceUserMenu.user.userId)}
|
||||||
onVolumeChange={(vol) => setUserVolume(voiceUserMenu.user.userId, vol)}
|
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 && (
|
{showCreateChannelModal && (
|
||||||
@@ -1727,16 +1826,60 @@ const Sidebar = ({ channels, categories, activeChannel, onSelectChannel, usernam
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Category header component (extracted for DnD drag handle)
|
// Category header component (extracted for DnD drag handle)
|
||||||
const CategoryHeader = React.memo(({ group, groupId, collapsed, onToggle, onAddChannel, dragListeners }) => (
|
const CategoryHeader = React.memo(({ group, groupId, collapsed, onToggle, onAddChannel, dragListeners, onContextMenu, isEditing, onRenameSubmit, onRenameCancel }) => {
|
||||||
<div className="channel-category-header" onClick={() => onToggle(groupId)} {...(dragListeners || {})}>
|
const [editName, setEditName] = useState(group.name);
|
||||||
<span className="category-label">{group.name}</span>
|
const inputRef = useRef(null);
|
||||||
<div className={`category-chevron ${collapsed ? 'collapsed' : ''}`}>
|
|
||||||
<ColoredIcon src={categoryCollapsedIcon} color="currentColor" size="12px" />
|
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>
|
</div>
|
||||||
<button className="category-add-btn" onClick={(e) => { e.stopPropagation(); onAddChannel(groupId); }} title="Create Channel">
|
);
|
||||||
+
|
});
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
));
|
|
||||||
|
|
||||||
export default Sidebar;
|
export default Sidebar;
|
||||||
|
|||||||
@@ -93,7 +93,12 @@ const UserProfilePopup = ({ userId, username, avatarUrl, status, position, onClo
|
|||||||
style={{ backgroundColor: STATUS_COLORS[userStatus] }}
|
style={{ backgroundColor: STATUS_COLORS[userStatus] }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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">
|
<div className="user-profile-status-text">
|
||||||
{userData?.customStatus || STATUS_LABELS[userStatus] || 'Online'}
|
{userData?.customStatus || STATUS_LABELS[userStatus] || 'Online'}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -775,7 +775,7 @@ const VoiceStage = ({ room, channelId, voiceStates, channelName }) => {
|
|||||||
|
|
||||||
const getUsername = (identity) => {
|
const getUsername = (identity) => {
|
||||||
const user = voiceUsers.find(u => u.userId === identity);
|
const user = voiceUsers.find(u => u.userId === identity);
|
||||||
return user ? user.username : identity;
|
return user ? (user.displayName || user.username) : identity;
|
||||||
};
|
};
|
||||||
|
|
||||||
const getAvatarUrl = (identity) => {
|
const getAvatarUrl = (identity) => {
|
||||||
|
|||||||
@@ -2251,10 +2251,10 @@ body {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 16px 8px 4px;
|
padding: 16px 8px 4px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 12px;
|
font-size: 14px;
|
||||||
font-weight: 600;
|
font-weight: 500;
|
||||||
text-transform: uppercase;
|
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
|
line-height: 1.2857142857142858;
|
||||||
letter-spacing: 0.02em;
|
letter-spacing: 0.02em;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,6 +84,10 @@ const Chat = () => {
|
|||||||
const serverName = serverSettings?.serverName || 'Secure Chat';
|
const serverName = serverSettings?.serverName || 'Secure Chat';
|
||||||
const serverIconUrl = serverSettings?.iconUrl || null;
|
const serverIconUrl = serverSettings?.iconUrl || null;
|
||||||
const allMembers = useQuery(api.members.listAll) || [];
|
const allMembers = useQuery(api.members.listAll) || [];
|
||||||
|
const myPermissions = useQuery(
|
||||||
|
api.roles.getMyPermissions,
|
||||||
|
userId ? { userId } : "skip"
|
||||||
|
) || {};
|
||||||
|
|
||||||
const rawChannelKeys = useQuery(
|
const rawChannelKeys = useQuery(
|
||||||
api.channelKeys.getKeysForUser,
|
api.channelKeys.getKeysForUser,
|
||||||
@@ -346,6 +350,30 @@ const Chat = () => {
|
|||||||
}
|
}
|
||||||
}, [activeDMChannel, voiceActiveChannelId, disconnectVoice, connectToVoice, userId, username, channelKeys, crypto, convex, voiceStates]);
|
}, [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
|
// PiP: show when watching a stream and NOT currently viewing the voice channel in VoiceStage
|
||||||
const isViewingDMCallStage = isDMView && isInDMCall;
|
const isViewingDMCallStage = isDMView && isInDMCall;
|
||||||
const isViewingVoiceStage = (view === 'server' && activeChannelObj?.type === 'voice' && activeChannel === voiceActiveChannelId) || isViewingDMCallStage;
|
const isViewingVoiceStage = (view === 'server' && activeChannelObj?.type === 'voice' && activeChannel === voiceActiveChannelId) || isViewingDMCallStage;
|
||||||
@@ -501,7 +529,7 @@ const Chat = () => {
|
|||||||
{dmCallActive && !isInDMCall && !showIncomingUI && (
|
{dmCallActive && !isInDMCall && !showIncomingUI && (
|
||||||
<div className="dm-call-idle-stage">
|
<div className="dm-call-idle-stage">
|
||||||
<Avatar username={incomingDMCall?.callerUsername || activeDMChannel.other_username} avatarUrl={incomingDMCall?.callerAvatarUrl || null} size={80} />
|
<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>
|
<div className="dm-call-idle-status">In a call</div>
|
||||||
<button className="dm-call-join-btn" onClick={handleStartDMCall}>Join Call</button>
|
<button className="dm-call-join-btn" onClick={handleStartDMCall}>Join Call</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -628,6 +656,10 @@ const Chat = () => {
|
|||||||
channelId={activeChannel}
|
channelId={activeChannel}
|
||||||
visible={effectiveShowMembers}
|
visible={effectiveShowMembers}
|
||||||
onMemberClick={(member) => {}}
|
onMemberClick={(member) => {}}
|
||||||
|
userId={userId}
|
||||||
|
myPermissions={myPermissions}
|
||||||
|
onOpenDM={openDM}
|
||||||
|
onStartCallWithUser={handleStartCallWithUser}
|
||||||
/>
|
/>
|
||||||
<SearchPanel
|
<SearchPanel
|
||||||
visible={showSearchResults}
|
visible={showSearchResults}
|
||||||
@@ -696,6 +728,7 @@ const Chat = () => {
|
|||||||
serverName={serverName}
|
serverName={serverName}
|
||||||
serverIconUrl={serverIconUrl}
|
serverIconUrl={serverIconUrl}
|
||||||
isMobile={isMobile}
|
isMobile={isMobile}
|
||||||
|
onStartCallWithUser={handleStartCallWithUser}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{showMainContent && renderMainContent()}
|
{showMainContent && renderMainContent()}
|
||||||
|
|||||||
Reference in New Issue
Block a user