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:
29
alembic/versions/0006_user_name_bio_profile.py
Normal file
29
alembic/versions/0006_user_name_bio_profile.py
Normal 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")
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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> {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user