feat: add user display profiles and fix web context menu UX
Some checks failed
CI / test (push) Failing after 17s
Some checks failed
CI / test (push) Failing after 17s
backend: - add required user name and optional bio fields - extend auth/register and user schemas/services with name/bio - add alembic migration 0006 with safe backfill name=username - compute per-user chat display_title for private chats - keep Saved Messages delete-for-all protections web: - registration now includes name - add profile edit modal (name/username/bio/avatar url) - show private chat names via display_title - fix context menus to open near cursor with viewport clamping - stabilize +/close floating button to remove visual jump
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { deleteChat } from "../api/chats";
|
||||
import { updateMyProfile } from "../api/users";
|
||||
import { useAuthStore } from "../store/authStore";
|
||||
import { useChatStore } from "../store/chatStore";
|
||||
import { NewChatPanel } from "./NewChatPanel";
|
||||
@@ -17,6 +18,15 @@ export function ChatList() {
|
||||
const [ctxPos, setCtxPos] = useState<{ x: number; y: number } | null>(null);
|
||||
const [deleteModalChatId, setDeleteModalChatId] = useState<number | null>(null);
|
||||
const [deleteForAll, setDeleteForAll] = useState(false);
|
||||
const [profileOpen, setProfileOpen] = useState(false);
|
||||
const [profileName, setProfileName] = useState("");
|
||||
const [profileUsername, setProfileUsername] = useState("");
|
||||
const [profileBio, setProfileBio] = useState("");
|
||||
const [profileAvatarUrl, setProfileAvatarUrl] = useState("");
|
||||
const [profileError, setProfileError] = useState<string | null>(null);
|
||||
const [profileSaving, setProfileSaving] = useState(false);
|
||||
const deleteModalChat = chats.find((chat) => chat.id === deleteModalChatId) ?? null;
|
||||
const canDeleteForEveryone = Boolean(deleteModalChat && !deleteModalChat.is_saved && deleteModalChat.type !== "private");
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
@@ -25,6 +35,16 @@ export function ChatList() {
|
||||
return () => clearTimeout(timer);
|
||||
}, [search, loadChats]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!me) {
|
||||
return;
|
||||
}
|
||||
setProfileName(me.name || "");
|
||||
setProfileUsername(me.username || "");
|
||||
setProfileBio(me.bio || "");
|
||||
setProfileAvatarUrl(me.avatar_url || "");
|
||||
}, [me]);
|
||||
|
||||
const filteredChats = chats.filter((chat) => {
|
||||
if (tab === "people") {
|
||||
return chat.type === "private";
|
||||
@@ -58,9 +78,9 @@ export function ChatList() {
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-sky-500/30 text-xs font-semibold uppercase text-sky-100">
|
||||
{(me?.username || "u").slice(0, 1)}
|
||||
</div>
|
||||
<button className="flex h-8 w-8 items-center justify-center rounded-full bg-sky-500/30 text-xs font-semibold uppercase text-sky-100" onClick={() => setProfileOpen(true)}>
|
||||
{(me?.name || me?.username || "u").slice(0, 1)}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 overflow-x-auto pt-1 text-sm">
|
||||
{tabs.map((item) => (
|
||||
@@ -84,18 +104,18 @@ export function ChatList() {
|
||||
onClick={() => setActiveChatId(chat.id)}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
const safePos = getSafeContextPosition(e.clientX, e.clientY);
|
||||
const safePos = getSafeContextPosition(e.clientX, e.clientY, 176, 56);
|
||||
setCtxChatId(chat.id);
|
||||
setCtxPos(safePos);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="mt-0.5 flex h-10 w-10 items-center justify-center rounded-full bg-sky-500/30 text-sm font-semibold uppercase text-sky-100">
|
||||
{(chat.title || chat.type).slice(0, 1)}
|
||||
{(chat.display_title || chat.title || chat.type).slice(0, 1)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="truncate text-sm font-semibold">{chat.title || `${chat.type} #${chat.id}`}</p>
|
||||
<p className="truncate text-sm font-semibold">{chat.display_title || chat.title || `${chat.type} #${chat.id}`}</p>
|
||||
<span className="shrink-0 text-[11px] text-slate-400">
|
||||
{messagesByChat[chat.id]?.length ? "now" : ""}
|
||||
</span>
|
||||
@@ -128,15 +148,17 @@ export function ChatList() {
|
||||
<div className="absolute inset-0 z-50 flex items-end justify-center bg-slate-950/55 p-3">
|
||||
<div className="w-full rounded-xl border border-slate-700/80 bg-slate-900 p-3">
|
||||
<p className="mb-2 text-sm font-semibold">Delete chat #{deleteModalChatId}</p>
|
||||
<label className="mb-3 flex items-center gap-2 text-sm">
|
||||
<input checked={deleteForAll} onChange={(e) => setDeleteForAll(e.target.checked)} type="checkbox" />
|
||||
Delete for everyone
|
||||
</label>
|
||||
{canDeleteForEveryone ? (
|
||||
<label className="mb-3 flex items-center gap-2 text-sm">
|
||||
<input checked={deleteForAll} onChange={(e) => setDeleteForAll(e.target.checked)} type="checkbox" />
|
||||
Delete for everyone
|
||||
</label>
|
||||
) : null}
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
className="flex-1 rounded bg-red-500 px-3 py-2 text-sm font-semibold text-white"
|
||||
onClick={async () => {
|
||||
await deleteChat(deleteModalChatId, deleteForAll);
|
||||
await deleteChat(deleteModalChatId, canDeleteForEveryone && deleteForAll);
|
||||
await loadChats(search.trim() ? search : undefined);
|
||||
if (activeChatId === deleteModalChatId) {
|
||||
setActiveChatId(null);
|
||||
@@ -153,14 +175,60 @@ export function ChatList() {
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{profileOpen ? (
|
||||
<div className="absolute inset-0 z-50 flex items-end justify-center bg-slate-950/55 p-3">
|
||||
<div className="w-full rounded-xl border border-slate-700/80 bg-slate-900 p-3">
|
||||
<p className="mb-2 text-sm font-semibold">Edit profile</p>
|
||||
<div className="space-y-2">
|
||||
<input className="w-full rounded bg-slate-800 px-3 py-2 text-sm" placeholder="Name" value={profileName} onChange={(e) => setProfileName(e.target.value)} />
|
||||
<input className="w-full rounded bg-slate-800 px-3 py-2 text-sm" placeholder="Username" value={profileUsername} onChange={(e) => setProfileUsername(e.target.value.replace("@", ""))} />
|
||||
<input className="w-full rounded bg-slate-800 px-3 py-2 text-sm" placeholder="Bio (optional)" value={profileBio} onChange={(e) => setProfileBio(e.target.value)} />
|
||||
<input className="w-full rounded bg-slate-800 px-3 py-2 text-sm" placeholder="Avatar URL (optional)" value={profileAvatarUrl} onChange={(e) => setProfileAvatarUrl(e.target.value)} />
|
||||
</div>
|
||||
{profileError ? <p className="mt-2 text-xs text-red-400">{profileError}</p> : null}
|
||||
<div className="mt-3 flex gap-2">
|
||||
<button
|
||||
className="flex-1 rounded bg-sky-500 px-3 py-2 text-sm font-semibold text-slate-950 disabled:opacity-60"
|
||||
disabled={profileSaving}
|
||||
onClick={async () => {
|
||||
setProfileSaving(true);
|
||||
setProfileError(null);
|
||||
try {
|
||||
const updated = await updateMyProfile({
|
||||
name: profileName.trim() || undefined,
|
||||
username: profileUsername.trim() || undefined,
|
||||
bio: profileBio.trim() || null,
|
||||
avatar_url: profileAvatarUrl.trim() || null
|
||||
});
|
||||
useAuthStore.setState({ me: updated });
|
||||
await loadChats(search.trim() ? search : undefined);
|
||||
setProfileOpen(false);
|
||||
} catch {
|
||||
setProfileError("Failed to update profile");
|
||||
} finally {
|
||||
setProfileSaving(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
<button className="flex-1 rounded bg-slate-700 px-3 py-2 text-sm" onClick={() => setProfileOpen(false)}>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
function getSafeContextPosition(x: number, y: number): { x: number; y: number } {
|
||||
const menuWidth = 176;
|
||||
const menuHeight = 56;
|
||||
function getSafeContextPosition(x: number, y: number, menuWidth: number, menuHeight: number): { x: number; y: number } {
|
||||
const pad = 8;
|
||||
const safeX = Math.min(Math.max(pad, x), window.innerWidth - menuWidth - pad);
|
||||
const safeY = Math.min(Math.max(pad, y), window.innerHeight - menuHeight - pad);
|
||||
const cursorOffset = 4;
|
||||
const wantedX = x + cursorOffset;
|
||||
const wantedY = y + cursorOffset;
|
||||
const safeX = Math.min(Math.max(pad, wantedX), window.innerWidth - menuWidth - pad);
|
||||
const safeY = Math.min(Math.max(pad, wantedY), window.innerHeight - menuHeight - pad);
|
||||
return { x: safeX, y: safeY };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user