feat: add user display profiles and fix web context menu UX
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:
2026-03-08 00:57:02 +03:00
parent 321f918dca
commit 456595a576
20 changed files with 249 additions and 39 deletions

View File

@@ -13,6 +13,7 @@ export function NewChatPanel() {
const [title, setTitle] = useState("");
const [handle, setHandle] = useState("");
const [description, setDescription] = useState("");
const [isPublic, setIsPublic] = useState(false);
const [results, setResults] = useState<UserSearchItem[]>([]);
const [discoverResults, setDiscoverResults] = useState<DiscoverChat[]>([]);
const [loading, setLoading] = useState(false);
@@ -87,8 +88,14 @@ export function NewChatPanel() {
setError(null);
try {
let chat;
if (handle.trim()) {
chat = await createPublicChat(mode, title.trim(), handle.trim().replace("@", "").toLowerCase(), description.trim() || undefined);
if (isPublic) {
const normalizedHandle = handle.trim().replace("@", "").toLowerCase();
if (!normalizedHandle) {
setError("Public chat requires @handle");
setLoading(false);
return;
}
chat = await createPublicChat(mode, title.trim(), normalizedHandle, description.trim() || undefined);
} else {
chat = await createChat(mode as ChatType, title.trim(), []);
}
@@ -97,6 +104,7 @@ export function NewChatPanel() {
setTitle("");
setHandle("");
setDescription("");
setIsPublic(false);
} catch {
setError("Failed to create chat");
} finally {
@@ -124,6 +132,7 @@ export function NewChatPanel() {
setQuery("");
setResults([]);
setDiscoverResults([]);
setIsPublic(false);
}
return (
@@ -148,8 +157,8 @@ export function NewChatPanel() {
</button>
</div>
) : null}
<button className="h-12 w-12 rounded-full bg-sky-500 text-xl font-semibold text-slate-950 shadow-lg hover:bg-sky-400" onClick={() => setMenuOpen((v) => !v)}>
{menuOpen ? "×" : "+"}
<button className="flex h-12 w-12 items-center justify-center rounded-full bg-sky-500 text-slate-950 shadow-lg hover:bg-sky-400" onClick={() => setMenuOpen((v) => !v)}>
<span className="block w-5 text-center text-2xl leading-none">{menuOpen ? "" : "+"}</span>
</button>
</div>
@@ -169,7 +178,8 @@ export function NewChatPanel() {
<div className="tg-scrollbar max-h-44 overflow-auto">
{results.map((user) => (
<button className="mb-1 block w-full rounded-lg bg-slate-800 px-3 py-2 text-left text-sm hover:bg-slate-700" key={user.id} onClick={() => void createPrivate(user.id)}>
@{user.username}
<p className="truncate font-semibold">{user.name}</p>
<p className="truncate text-xs text-slate-400">@{user.username}</p>
</button>
))}
{normalizedQuery && results.length === 0 ? <p className="text-xs text-slate-400">No users</p> : null}
@@ -180,6 +190,7 @@ export function NewChatPanel() {
{dialog === "discover" ? (
<div className="space-y-2">
<input className="w-full rounded-xl border border-slate-700/80 bg-slate-800/80 px-3 py-2 text-sm outline-none placeholder:text-slate-400 focus:border-sky-500" placeholder="Search groups/channels by title or @handle" value={query} onChange={(e) => void handleDiscover(e.target.value)} />
<p className="text-xs text-slate-400">Search works only for public groups/channels.</p>
<div className="tg-scrollbar max-h-52 overflow-auto">
{discoverResults.map((chat) => (
<div className="mb-1 flex items-center justify-between rounded-lg bg-slate-800 px-3 py-2" key={chat.id}>
@@ -204,7 +215,13 @@ export function NewChatPanel() {
{dialog === "group" || dialog === "channel" ? (
<form className="space-y-2" onSubmit={(e) => void createByType(e, dialog)}>
<input className="w-full rounded-xl border border-slate-700/80 bg-slate-800/80 px-3 py-2 text-sm outline-none placeholder:text-slate-400 focus:border-sky-500" placeholder={dialog === "group" ? "Group title" : "Channel title"} value={title} onChange={(e) => setTitle(e.target.value)} />
<input className="w-full rounded-xl border border-slate-700/80 bg-slate-800/80 px-3 py-2 text-sm outline-none placeholder:text-slate-400 focus:border-sky-500" placeholder="@handle (optional, enables public join/search)" value={handle} onChange={(e) => setHandle(e.target.value)} />
<label className="flex items-center gap-2 rounded-xl border border-slate-700/80 bg-slate-800/60 px-3 py-2 text-sm">
<input checked={isPublic} onChange={(e) => setIsPublic(e.target.checked)} type="checkbox" />
Public {dialog} (discover + join by others)
</label>
{isPublic ? (
<input className="w-full rounded-xl border border-slate-700/80 bg-slate-800/80 px-3 py-2 text-sm outline-none placeholder:text-slate-400 focus:border-sky-500" placeholder="@handle (required for public)" value={handle} onChange={(e) => setHandle(e.target.value)} />
) : null}
<input className="w-full rounded-xl border border-slate-700/80 bg-slate-800/80 px-3 py-2 text-sm outline-none placeholder:text-slate-400 focus:border-sky-500" placeholder="Description (optional)" value={description} onChange={(e) => setDescription(e.target.value)} />
<button className="w-full rounded-lg bg-sky-500 px-3 py-2 text-sm font-semibold text-slate-950 hover:bg-sky-400 disabled:opacity-60" disabled={loading} type="submit">
Create {dialog}