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

@@ -0,0 +1,29 @@
"""add user name and bio
Revision ID: 0006_user_name_bio_profile
Revises: 0005_chat_public_saved_features
Create Date: 2026-03-08 02:20:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "0006_user_name_bio_profile"
down_revision: Union[str, Sequence[str], None] = "0005_chat_public_saved_features"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column("users", sa.Column("name", sa.String(length=100), nullable=True))
op.add_column("users", sa.Column("bio", sa.String(length=500), nullable=True))
op.execute("UPDATE users SET name = username WHERE name IS NULL OR name = ''")
op.alter_column("users", "name", existing_type=sa.String(length=100), nullable=False)
def downgrade() -> None:
op.drop_column("users", "bio")
op.drop_column("users", "name")

View File

@@ -5,6 +5,7 @@ from pydantic import BaseModel, ConfigDict, EmailStr, Field
class RegisterRequest(BaseModel): class RegisterRequest(BaseModel):
email: EmailStr email: EmailStr
name: str = Field(min_length=1, max_length=100)
username: str = Field(min_length=3, max_length=50) username: str = Field(min_length=3, max_length=50)
password: str = Field(min_length=8, max_length=128) password: str = Field(min_length=8, max_length=128)
@@ -50,7 +51,9 @@ class AuthUserResponse(BaseModel):
id: int id: int
email: EmailStr email: EmailStr
name: str
username: str username: str
bio: str | None = None
avatar_url: str | None = None avatar_url: str | None = None
email_verified: bool email_verified: bool
created_at: datetime created_at: datetime

View File

@@ -54,6 +54,7 @@ async def register_user(
user = await create_user( user = await create_user(
db, db,
email=payload.email, email=payload.email,
name=payload.name,
username=payload.username, username=payload.username,
password_hash=hash_password(payload.password), password_hash=hash_password(payload.password),
) )

View File

@@ -159,3 +159,9 @@ async def find_private_chat_between_users(db: AsyncSession, *, user_a_id: int, u
) )
result = await db.execute(stmt) result = await db.execute(stmt)
return result.scalar_one_or_none() return result.scalar_one_or_none()
async def get_private_counterpart_user_id(db: AsyncSession, *, chat_id: int, user_id: int) -> int | None:
stmt = select(ChatMember.user_id).where(ChatMember.chat_id == chat_id, ChatMember.user_id != user_id).limit(1)
result = await db.execute(stmt)
return result.scalar_one_or_none()

View File

@@ -26,6 +26,8 @@ from app.chats.service import (
leave_chat_for_user, leave_chat_for_user,
pin_chat_message_for_user, pin_chat_message_for_user,
remove_chat_member_for_user, remove_chat_member_for_user,
serialize_chat_for_user,
serialize_chats_for_user,
update_chat_member_role_for_user, update_chat_member_role_for_user,
update_chat_title_for_user, update_chat_title_for_user,
) )
@@ -43,7 +45,8 @@ async def list_chats(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
) -> list[ChatRead]: ) -> list[ChatRead]:
return await get_chats_for_user(db, user_id=current_user.id, limit=limit, before_id=before_id, query=query) chats = await get_chats_for_user(db, user_id=current_user.id, limit=limit, before_id=before_id, query=query)
return await serialize_chats_for_user(db, user_id=current_user.id, chats=chats)
@router.get("/saved", response_model=ChatRead) @router.get("/saved", response_model=ChatRead)
@@ -51,7 +54,8 @@ async def get_saved_messages_chat(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
) -> ChatRead: ) -> ChatRead:
return await ensure_saved_messages_chat(db, user_id=current_user.id) chat = await ensure_saved_messages_chat(db, user_id=current_user.id)
return await serialize_chat_for_user(db, user_id=current_user.id, chat=chat)
@router.get("/discover", response_model=list[ChatDiscoverRead]) @router.get("/discover", response_model=list[ChatDiscoverRead])
@@ -70,7 +74,8 @@ async def create_chat(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
) -> ChatRead: ) -> ChatRead:
return await create_chat_for_user(db, creator_id=current_user.id, payload=payload) chat = await create_chat_for_user(db, creator_id=current_user.id, payload=payload)
return await serialize_chat_for_user(db, user_id=current_user.id, chat=chat)
@router.post("/{chat_id}/join", response_model=ChatRead) @router.post("/{chat_id}/join", response_model=ChatRead)
@@ -79,7 +84,8 @@ async def join_chat(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
) -> ChatRead: ) -> ChatRead:
return await join_public_chat_for_user(db, chat_id=chat_id, user_id=current_user.id) chat = await join_public_chat_for_user(db, chat_id=chat_id, user_id=current_user.id)
return await serialize_chat_for_user(db, user_id=current_user.id, chat=chat)
@router.get("/{chat_id}", response_model=ChatDetailRead) @router.get("/{chat_id}", response_model=ChatDetailRead)
@@ -106,7 +112,8 @@ async def update_chat_title(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
) -> ChatRead: ) -> ChatRead:
return await update_chat_title_for_user(db, chat_id=chat_id, user_id=current_user.id, payload=payload) chat = await update_chat_title_for_user(db, chat_id=chat_id, user_id=current_user.id, payload=payload)
return await serialize_chat_for_user(db, user_id=current_user.id, chat=chat)
@router.get("/{chat_id}/members", response_model=list[ChatMemberRead]) @router.get("/{chat_id}/members", response_model=list[ChatMemberRead])
@@ -182,4 +189,5 @@ async def pin_chat_message(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
) -> ChatRead: ) -> ChatRead:
return await pin_chat_message_for_user(db, chat_id=chat_id, user_id=current_user.id, payload=payload) chat = await pin_chat_message_for_user(db, chat_id=chat_id, user_id=current_user.id, payload=payload)
return await serialize_chat_for_user(db, user_id=current_user.id, chat=chat)

View File

@@ -11,6 +11,7 @@ class ChatRead(BaseModel):
id: int id: int
type: ChatType type: ChatType
title: str | None = None title: str | None = None
display_title: str | None = None
handle: str | None = None handle: str | None = None
description: str | None = None description: str | None = None
is_public: bool = False is_public: bool = False

View File

@@ -4,11 +4,42 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.chats import repository from app.chats import repository
from app.chats.models import Chat, ChatMember, ChatMemberRole, ChatType from app.chats.models import Chat, ChatMember, ChatMemberRole, ChatType
from app.chats.schemas import ChatCreateRequest, ChatDeleteRequest, ChatDiscoverRead, ChatPinMessageRequest, ChatTitleUpdateRequest from app.chats.schemas import ChatCreateRequest, ChatDeleteRequest, ChatDiscoverRead, ChatPinMessageRequest, ChatRead, ChatTitleUpdateRequest
from app.messages.repository import get_message_by_id from app.messages.repository import get_message_by_id
from app.users.repository import get_user_by_id from app.users.repository import get_user_by_id
async def serialize_chat_for_user(db: AsyncSession, *, user_id: int, chat: Chat) -> ChatRead:
display_title = chat.title
if chat.is_saved:
display_title = "Saved Messages"
elif chat.type == ChatType.PRIVATE:
counterpart_id = await repository.get_private_counterpart_user_id(db, chat_id=chat.id, user_id=user_id)
if counterpart_id:
counterpart = await get_user_by_id(db, counterpart_id)
if counterpart:
display_title = counterpart.name or counterpart.username
return ChatRead.model_validate(
{
"id": chat.id,
"type": chat.type,
"title": chat.title,
"display_title": display_title,
"handle": chat.handle,
"description": chat.description,
"is_public": chat.is_public,
"is_saved": chat.is_saved,
"pinned_message_id": chat.pinned_message_id,
"created_at": chat.created_at,
}
)
async def serialize_chats_for_user(db: AsyncSession, *, user_id: int, chats: list[Chat]) -> list[ChatRead]:
return [await serialize_chat_for_user(db, user_id=user_id, chat=chat) for chat in chats]
async def create_chat_for_user(db: AsyncSession, *, creator_id: int, payload: ChatCreateRequest) -> Chat: async def create_chat_for_user(db: AsyncSession, *, creator_id: int, payload: ChatCreateRequest) -> Chat:
member_ids = list(dict.fromkeys(payload.member_ids)) member_ids = list(dict.fromkeys(payload.member_ids))
member_ids = [member_id for member_id in member_ids if member_id != creator_id] member_ids = [member_id for member_id in member_ids if member_id != creator_id]
@@ -318,7 +349,7 @@ async def join_public_chat_for_user(db: AsyncSession, *, chat_id: int, user_id:
async def delete_chat_for_user(db: AsyncSession, *, chat_id: int, user_id: int, payload: ChatDeleteRequest) -> None: async def delete_chat_for_user(db: AsyncSession, *, chat_id: int, user_id: int, payload: ChatDeleteRequest) -> None:
chat, membership = await _get_chat_and_membership(db, chat_id=chat_id, user_id=user_id) chat, membership = await _get_chat_and_membership(db, chat_id=chat_id, user_id=user_id)
delete_for_all = payload.for_all or chat.type == ChatType.CHANNEL delete_for_all = (payload.for_all and not chat.is_saved) or chat.type == ChatType.CHANNEL
if delete_for_all: if delete_for_all:
if chat.type in {ChatType.GROUP, ChatType.CHANNEL} and membership.role not in {ChatMemberRole.OWNER, ChatMemberRole.ADMIN}: if chat.type in {ChatType.GROUP, ChatType.CHANNEL} and membership.role not in {ChatMemberRole.OWNER, ChatMemberRole.ADMIN}:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Insufficient permissions") raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Insufficient permissions")

View File

@@ -16,10 +16,12 @@ class User(Base):
__tablename__ = "users" __tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True, index=True) id: Mapped[int] = mapped_column(primary_key=True, index=True)
name: Mapped[str] = mapped_column(String(100), nullable=False)
username: Mapped[str] = mapped_column(String(50), unique=True, index=True) username: Mapped[str] = mapped_column(String(50), unique=True, index=True)
email: Mapped[str] = mapped_column(String(255), unique=True, index=True) email: Mapped[str] = mapped_column(String(255), unique=True, index=True)
password_hash: Mapped[str] = mapped_column(String(255)) password_hash: Mapped[str] = mapped_column(String(255))
avatar_url: Mapped[str | None] = mapped_column(String(512), nullable=True) avatar_url: Mapped[str | None] = mapped_column(String(512), nullable=True)
bio: Mapped[str | None] = mapped_column(String(500), nullable=True)
email_verified: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False, index=True) email_verified: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False, index=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
updated_at: Mapped[datetime] = mapped_column( updated_at: Mapped[datetime] = mapped_column(

View File

@@ -4,8 +4,8 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.users.models import User from app.users.models import User
async def create_user(db: AsyncSession, *, email: str, username: str, password_hash: str) -> User: async def create_user(db: AsyncSession, *, email: str, name: str, username: str, password_hash: str) -> User:
user = User(email=email, username=username, password_hash=password_hash, email_verified=False) user = User(email=email, name=name, username=username, password_hash=password_hash, email_verified=False)
db.add(user) db.add(user)
await db.flush() await db.flush()
return user return user

View File

@@ -55,7 +55,9 @@ async def update_profile(
updated = await update_user_profile( updated = await update_user_profile(
db, db,
current_user, current_user,
name=payload.name,
username=payload.username, username=payload.username,
bio=payload.bio,
avatar_url=payload.avatar_url, avatar_url=payload.avatar_url,
) )
return updated return updated

View File

@@ -4,6 +4,7 @@ from pydantic import BaseModel, ConfigDict, EmailStr, Field
class UserBase(BaseModel): class UserBase(BaseModel):
name: str = Field(min_length=1, max_length=100)
username: str = Field(min_length=3, max_length=50) username: str = Field(min_length=3, max_length=50)
email: EmailStr email: EmailStr
@@ -17,13 +18,16 @@ class UserRead(UserBase):
id: int id: int
avatar_url: str | None = None avatar_url: str | None = None
bio: str | None = None
email_verified: bool email_verified: bool
created_at: datetime created_at: datetime
updated_at: datetime updated_at: datetime
class UserProfileUpdate(BaseModel): class UserProfileUpdate(BaseModel):
name: str | None = Field(default=None, min_length=1, max_length=100)
username: str | None = Field(default=None, min_length=3, max_length=50) username: str | None = Field(default=None, min_length=3, max_length=50)
bio: str | None = Field(default=None, max_length=500)
avatar_url: str | None = Field(default=None, max_length=512) avatar_url: str | None = Field(default=None, max_length=512)
@@ -31,5 +35,6 @@ class UserSearchRead(BaseModel):
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
id: int id: int
name: str
username: str username: str
avatar_url: str | None = None avatar_url: str | None = None

View File

@@ -36,11 +36,17 @@ async def update_user_profile(
db: AsyncSession, db: AsyncSession,
user: User, user: User,
*, *,
name: str | None = None,
username: str | None = None, username: str | None = None,
bio: str | None = None,
avatar_url: str | None = None, avatar_url: str | None = None,
) -> User: ) -> User:
if name is not None:
user.name = name
if username is not None: if username is not None:
user.username = username user.username = username
if bio is not None:
user.bio = bio
if avatar_url is not None: if avatar_url is not None:
user.avatar_url = avatar_url user.avatar_url = avatar_url
await db.commit() await db.commit()

View File

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

View File

@@ -1,5 +1,5 @@
import { http } from "./http"; 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[]> { export async function searchUsers(query: string, limit = 20): Promise<UserSearchItem[]> {
const { data } = await http.get<UserSearchItem[]>("/users/search", { const { data } = await http.get<UserSearchItem[]>("/users/search", {
@@ -7,3 +7,15 @@ export async function searchUsers(query: string, limit = 20): Promise<UserSearch
}); });
return data; 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; id: number;
type: ChatType; type: ChatType;
title: string | null; title: string | null;
display_title?: string | null;
handle?: string | null; handle?: string | null;
description?: string | null; description?: string | null;
is_public?: boolean; is_public?: boolean;
@@ -36,7 +37,9 @@ export interface Message {
export interface AuthUser { export interface AuthUser {
id: number; id: number;
email: string; email: string;
name: string;
username: string; username: string;
bio?: string | null;
avatar_url: string | null; avatar_url: string | null;
email_verified: boolean; email_verified: boolean;
created_at: string; created_at: string;
@@ -51,6 +54,7 @@ export interface TokenPair {
export interface UserSearchItem { export interface UserSearchItem {
id: number; id: number;
name: string;
username: string; username: string;
avatar_url: string | null; avatar_url: string | null;
} }

View File

@@ -9,6 +9,7 @@ export function AuthPanel() {
const loading = useAuthStore((s) => s.loading); const loading = useAuthStore((s) => s.loading);
const [mode, setMode] = useState<Mode>("login"); const [mode, setMode] = useState<Mode>("login");
const [email, setEmail] = useState(""); const [email, setEmail] = useState("");
const [name, setName] = useState("");
const [username, setUsername] = useState(""); const [username, setUsername] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -20,7 +21,7 @@ export function AuthPanel() {
setSuccess(null); setSuccess(null);
try { try {
if (mode === "register") { if (mode === "register") {
await registerRequest(email, username, password); await registerRequest(email, name, username, password);
setSuccess("Registered. Check email verification, then login."); setSuccess("Registered. Check email verification, then login.");
setMode("login"); setMode("login");
return; return;
@@ -44,7 +45,10 @@ export function AuthPanel() {
<form className="space-y-3" onSubmit={onSubmit}> <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)} /> <input className="w-full rounded bg-slate-800 px-3 py-2" placeholder="Email" value={email} onChange={(e) => setEmail(e.target.value)} />
{mode === "register" && ( {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)} /> <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"> <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 { useEffect, useState } from "react";
import { deleteChat } from "../api/chats"; import { deleteChat } from "../api/chats";
import { updateMyProfile } from "../api/users";
import { useAuthStore } from "../store/authStore"; import { useAuthStore } from "../store/authStore";
import { useChatStore } from "../store/chatStore"; import { useChatStore } from "../store/chatStore";
import { NewChatPanel } from "./NewChatPanel"; import { NewChatPanel } from "./NewChatPanel";
@@ -17,6 +18,15 @@ export function ChatList() {
const [ctxPos, setCtxPos] = useState<{ x: number; y: number } | null>(null); const [ctxPos, setCtxPos] = useState<{ x: number; y: number } | null>(null);
const [deleteModalChatId, setDeleteModalChatId] = useState<number | null>(null); const [deleteModalChatId, setDeleteModalChatId] = useState<number | null>(null);
const [deleteForAll, setDeleteForAll] = useState(false); 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(() => { useEffect(() => {
const timer = setTimeout(() => { const timer = setTimeout(() => {
@@ -25,6 +35,16 @@ export function ChatList() {
return () => clearTimeout(timer); return () => clearTimeout(timer);
}, [search, loadChats]); }, [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) => { const filteredChats = chats.filter((chat) => {
if (tab === "people") { if (tab === "people") {
return chat.type === "private"; return chat.type === "private";
@@ -58,9 +78,9 @@ export function ChatList() {
onChange={(e) => setSearch(e.target.value)} onChange={(e) => setSearch(e.target.value)}
/> />
</label> </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"> <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?.username || "u").slice(0, 1)} {(me?.name || me?.username || "u").slice(0, 1)}
</div> </button>
</div> </div>
<div className="flex items-center gap-3 overflow-x-auto pt-1 text-sm"> <div className="flex items-center gap-3 overflow-x-auto pt-1 text-sm">
{tabs.map((item) => ( {tabs.map((item) => (
@@ -84,18 +104,18 @@ export function ChatList() {
onClick={() => setActiveChatId(chat.id)} onClick={() => setActiveChatId(chat.id)}
onContextMenu={(e) => { onContextMenu={(e) => {
e.preventDefault(); e.preventDefault();
const safePos = getSafeContextPosition(e.clientX, e.clientY); const safePos = getSafeContextPosition(e.clientX, e.clientY, 176, 56);
setCtxChatId(chat.id); setCtxChatId(chat.id);
setCtxPos(safePos); setCtxPos(safePos);
}} }}
> >
<div className="flex items-start gap-3"> <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"> <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>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-2"> <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"> <span className="shrink-0 text-[11px] text-slate-400">
{messagesByChat[chat.id]?.length ? "now" : ""} {messagesByChat[chat.id]?.length ? "now" : ""}
</span> </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="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"> <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> <p className="mb-2 text-sm font-semibold">Delete chat #{deleteModalChatId}</p>
<label className="mb-3 flex items-center gap-2 text-sm"> {canDeleteForEveryone ? (
<input checked={deleteForAll} onChange={(e) => setDeleteForAll(e.target.checked)} type="checkbox" /> <label className="mb-3 flex items-center gap-2 text-sm">
Delete for everyone <input checked={deleteForAll} onChange={(e) => setDeleteForAll(e.target.checked)} type="checkbox" />
</label> Delete for everyone
</label>
) : null}
<div className="flex gap-2"> <div className="flex gap-2">
<button <button
className="flex-1 rounded bg-red-500 px-3 py-2 text-sm font-semibold text-white" className="flex-1 rounded bg-red-500 px-3 py-2 text-sm font-semibold text-white"
onClick={async () => { onClick={async () => {
await deleteChat(deleteModalChatId, deleteForAll); await deleteChat(deleteModalChatId, canDeleteForEveryone && deleteForAll);
await loadChats(search.trim() ? search : undefined); await loadChats(search.trim() ? search : undefined);
if (activeChatId === deleteModalChatId) { if (activeChatId === deleteModalChatId) {
setActiveChatId(null); setActiveChatId(null);
@@ -153,14 +175,60 @@ export function ChatList() {
</div> </div>
</div> </div>
) : null} ) : 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> </aside>
); );
} }
function getSafeContextPosition(x: number, y: number): { x: number; y: number } { function getSafeContextPosition(x: number, y: number, menuWidth: number, menuHeight: number): { x: number; y: number } {
const menuWidth = 176;
const menuHeight = 56;
const pad = 8; const pad = 8;
const safeX = Math.min(Math.max(pad, x), window.innerWidth - menuWidth - pad); const cursorOffset = 4;
const safeY = Math.min(Math.max(pad, y), window.innerHeight - menuHeight - pad); 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 }; return { x: safeX, y: safeY };
} }

View File

@@ -70,7 +70,8 @@ export function MessageList() {
}`} }`}
onContextMenu={(e) => { onContextMenu={(e) => {
e.preventDefault(); 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 ? ( {message.forwarded_from_message_id ? (
@@ -147,3 +148,13 @@ function renderStatus(status: string | undefined): string {
if (status === "read") return "✓✓"; if (status === "read") return "✓✓";
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 [title, setTitle] = useState("");
const [handle, setHandle] = useState(""); const [handle, setHandle] = useState("");
const [description, setDescription] = useState(""); const [description, setDescription] = useState("");
const [isPublic, setIsPublic] = useState(false);
const [results, setResults] = useState<UserSearchItem[]>([]); const [results, setResults] = useState<UserSearchItem[]>([]);
const [discoverResults, setDiscoverResults] = useState<DiscoverChat[]>([]); const [discoverResults, setDiscoverResults] = useState<DiscoverChat[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@@ -87,8 +88,14 @@ export function NewChatPanel() {
setError(null); setError(null);
try { try {
let chat; let chat;
if (handle.trim()) { if (isPublic) {
chat = await createPublicChat(mode, title.trim(), handle.trim().replace("@", "").toLowerCase(), description.trim() || undefined); 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 { } else {
chat = await createChat(mode as ChatType, title.trim(), []); chat = await createChat(mode as ChatType, title.trim(), []);
} }
@@ -97,6 +104,7 @@ export function NewChatPanel() {
setTitle(""); setTitle("");
setHandle(""); setHandle("");
setDescription(""); setDescription("");
setIsPublic(false);
} catch { } catch {
setError("Failed to create chat"); setError("Failed to create chat");
} finally { } finally {
@@ -124,6 +132,7 @@ export function NewChatPanel() {
setQuery(""); setQuery("");
setResults([]); setResults([]);
setDiscoverResults([]); setDiscoverResults([]);
setIsPublic(false);
} }
return ( return (
@@ -148,8 +157,8 @@ export function NewChatPanel() {
</button> </button>
</div> </div>
) : null} ) : 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)}> <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)}>
{menuOpen ? "×" : "+"} <span className="block w-5 text-center text-2xl leading-none">{menuOpen ? "" : "+"}</span>
</button> </button>
</div> </div>
@@ -169,7 +178,8 @@ export function NewChatPanel() {
<div className="tg-scrollbar max-h-44 overflow-auto"> <div className="tg-scrollbar max-h-44 overflow-auto">
{results.map((user) => ( {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)}> <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> </button>
))} ))}
{normalizedQuery && results.length === 0 ? <p className="text-xs text-slate-400">No users</p> : null} {normalizedQuery && results.length === 0 ? <p className="text-xs text-slate-400">No users</p> : null}
@@ -180,6 +190,7 @@ export function NewChatPanel() {
{dialog === "discover" ? ( {dialog === "discover" ? (
<div className="space-y-2"> <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)} /> <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"> <div className="tg-scrollbar max-h-52 overflow-auto">
{discoverResults.map((chat) => ( {discoverResults.map((chat) => (
<div className="mb-1 flex items-center justify-between rounded-lg bg-slate-800 px-3 py-2" key={chat.id}> <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" ? ( {dialog === "group" || dialog === "channel" ? (
<form className="space-y-2" onSubmit={(e) => void createByType(e, dialog)}> <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={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)} /> <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"> <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} Create {dialog}

View File

@@ -42,7 +42,7 @@ export function ChatsPage() {
Back Back
</button> </button>
<div className="min-w-0"> <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> <p className="truncate text-xs text-slate-300/80">{activeChat ? activeChat.type : "Select a chat"}</p>
</div> </div>
</div> </div>