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

@@ -1,8 +1,8 @@
import { http } from "./http";
import type { AuthUser, TokenPair } from "../chat/types";
export async function registerRequest(email: string, username: string, password: string): Promise<void> {
await http.post("/auth/register", { email, username, password });
export async function registerRequest(email: string, name: string, username: string, password: string): Promise<void> {
await http.post("/auth/register", { email, name, username, password });
}
export async function loginRequest(email: string, password: string): Promise<TokenPair> {

View File

@@ -1,5 +1,5 @@
import { http } from "./http";
import type { UserSearchItem } from "../chat/types";
import type { AuthUser, UserSearchItem } from "../chat/types";
export async function searchUsers(query: string, limit = 20): Promise<UserSearchItem[]> {
const { data } = await http.get<UserSearchItem[]>("/users/search", {
@@ -7,3 +7,15 @@ export async function searchUsers(query: string, limit = 20): Promise<UserSearch
});
return data;
}
interface UserProfileUpdatePayload {
name?: string;
username?: string;
bio?: string | null;
avatar_url?: string | null;
}
export async function updateMyProfile(payload: UserProfileUpdatePayload): Promise<AuthUser> {
const { data } = await http.put<AuthUser>("/users/profile", payload);
return data;
}

View File

@@ -6,6 +6,7 @@ export interface Chat {
id: number;
type: ChatType;
title: string | null;
display_title?: string | null;
handle?: string | null;
description?: string | null;
is_public?: boolean;
@@ -36,7 +37,9 @@ export interface Message {
export interface AuthUser {
id: number;
email: string;
name: string;
username: string;
bio?: string | null;
avatar_url: string | null;
email_verified: boolean;
created_at: string;
@@ -51,6 +54,7 @@ export interface TokenPair {
export interface UserSearchItem {
id: number;
name: string;
username: string;
avatar_url: string | null;
}

View File

@@ -9,6 +9,7 @@ export function AuthPanel() {
const loading = useAuthStore((s) => s.loading);
const [mode, setMode] = useState<Mode>("login");
const [email, setEmail] = useState("");
const [name, setName] = useState("");
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
@@ -20,7 +21,7 @@ export function AuthPanel() {
setSuccess(null);
try {
if (mode === "register") {
await registerRequest(email, username, password);
await registerRequest(email, name, username, password);
setSuccess("Registered. Check email verification, then login.");
setMode("login");
return;
@@ -44,7 +45,10 @@ export function AuthPanel() {
<form className="space-y-3" onSubmit={onSubmit}>
<input className="w-full rounded bg-slate-800 px-3 py-2" placeholder="Email" value={email} onChange={(e) => setEmail(e.target.value)} />
{mode === "register" && (
<input className="w-full rounded bg-slate-800 px-3 py-2" placeholder="Username" value={username} onChange={(e) => setUsername(e.target.value)} />
<>
<input className="w-full rounded bg-slate-800 px-3 py-2" placeholder="Name" value={name} onChange={(e) => setName(e.target.value)} />
<input className="w-full rounded bg-slate-800 px-3 py-2" placeholder="Username" value={username} onChange={(e) => setUsername(e.target.value)} />
</>
)}
<input className="w-full rounded bg-slate-800 px-3 py-2" placeholder="Password" type="password" value={password} onChange={(e) => setPassword(e.target.value)} />
<button className="w-full rounded bg-accent px-3 py-2 font-semibold text-black disabled:opacity-50" disabled={loading} type="submit">

View File

@@ -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 };
}

View File

@@ -70,7 +70,8 @@ export function MessageList() {
}`}
onContextMenu={(e) => {
e.preventDefault();
setCtx({ x: e.clientX, y: e.clientY, messageId: message.id });
const pos = getSafeContextPosition(e.clientX, e.clientY, 160, 108);
setCtx({ x: pos.x, y: pos.y, messageId: message.id });
}}
>
{message.forwarded_from_message_id ? (
@@ -147,3 +148,13 @@ function renderStatus(status: string | undefined): string {
if (status === "read") return "✓✓";
return "✓";
}
function getSafeContextPosition(x: number, y: number, menuWidth: number, menuHeight: number): { x: number; y: number } {
const pad = 8;
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 };
}

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}

View File

@@ -42,7 +42,7 @@ export function ChatsPage() {
Back
</button>
<div className="min-w-0">
<p className="truncate text-sm font-semibold">{activeChat?.title || `@${me?.username}`}</p>
<p className="truncate text-sm font-semibold">{activeChat?.display_title || activeChat?.title || me?.name || `@${me?.username}`}</p>
<p className="truncate text-xs text-slate-300/80">{activeChat ? activeChat.type : "Select a chat"}</p>
</div>
</div>