feat: add saved messages, public chat discovery/join, and chat delete options
All checks were successful
CI / test (push) Successful in 19s

- add Saved Messages system chat with dedicated API

- add public group/channel metadata and discover/join endpoints

- add chat delete flow with for_all option and channel-wide delete

- switch message actions to context menu and improve reply/forward visuals

- improve microphone permission handling for voice recording
This commit is contained in:
2026-03-08 00:41:35 +03:00
parent b5a7d733c6
commit b9f71b9528
12 changed files with 529 additions and 119 deletions

View File

@@ -0,0 +1,37 @@
"""chat public and saved features
Revision ID: 0005_chat_public_saved_features
Revises: 0004_reply_forward_pin
Create Date: 2026-03-08 04:10:00.000000
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0005_chat_public_saved_features"
down_revision: Union[str, Sequence[str], None] = "0004_reply_forward_pin"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column("chats", sa.Column("handle", sa.String(length=64), nullable=True))
op.add_column("chats", sa.Column("description", sa.String(length=512), nullable=True))
op.add_column("chats", sa.Column("is_public", sa.Boolean(), server_default=sa.false(), nullable=False))
op.add_column("chats", sa.Column("is_saved", sa.Boolean(), server_default=sa.false(), nullable=False))
op.create_index(op.f("ix_chats_handle"), "chats", ["handle"], unique=True)
op.create_index(op.f("ix_chats_is_public"), "chats", ["is_public"], unique=False)
op.create_index(op.f("ix_chats_is_saved"), "chats", ["is_saved"], unique=False)
def downgrade() -> None:
op.drop_index(op.f("ix_chats_is_saved"), table_name="chats")
op.drop_index(op.f("ix_chats_is_public"), table_name="chats")
op.drop_index(op.f("ix_chats_handle"), table_name="chats")
op.drop_column("chats", "is_saved")
op.drop_column("chats", "is_public")
op.drop_column("chats", "description")
op.drop_column("chats", "handle")

View File

@@ -2,7 +2,7 @@ from datetime import datetime
from enum import Enum
from typing import TYPE_CHECKING
from sqlalchemy import DateTime, Enum as SAEnum, ForeignKey, String, UniqueConstraint, func
from sqlalchemy import Boolean, DateTime, Enum as SAEnum, ForeignKey, String, UniqueConstraint, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database.base import Base
@@ -30,6 +30,10 @@ class Chat(Base):
id: Mapped[int] = mapped_column(primary_key=True, index=True)
type: Mapped[ChatType] = mapped_column(SAEnum(ChatType), nullable=False, index=True)
title: Mapped[str | None] = mapped_column(String(255), nullable=True)
handle: Mapped[str | None] = mapped_column(String(64), nullable=True, unique=True, index=True)
description: Mapped[str | None] = mapped_column(String(512), nullable=True)
is_public: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="false")
is_saved: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="false")
pinned_message_id: Mapped[int | None] = mapped_column(ForeignKey("messages.id", ondelete="SET NULL"), nullable=True, index=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)

View File

@@ -12,6 +12,29 @@ async def create_chat(db: AsyncSession, *, chat_type: ChatType, title: str | Non
return chat
async def create_chat_with_meta(
db: AsyncSession,
*,
chat_type: ChatType,
title: str | None,
handle: str | None,
description: str | None,
is_public: bool,
is_saved: bool = False,
) -> Chat:
chat = Chat(
type=chat_type,
title=title,
handle=handle,
description=description,
is_public=is_public,
is_saved=is_saved,
)
db.add(chat)
await db.flush()
return chat
async def add_chat_member(db: AsyncSession, *, chat_id: int, user_id: int, role: ChatMemberRole) -> ChatMember:
member = ChatMember(chat_id=chat_id, user_id=user_id, role=role)
db.add(member)
@@ -61,6 +84,11 @@ async def get_chat_by_id(db: AsyncSession, chat_id: int) -> Chat | None:
return result.scalar_one_or_none()
async def get_chat_by_handle(db: AsyncSession, handle: str) -> Chat | None:
result = await db.execute(select(Chat).where(Chat.handle == handle))
return result.scalar_one_or_none()
async def get_chat_member(db: AsyncSession, *, chat_id: int, user_id: int) -> ChatMember | None:
result = await db.execute(
select(ChatMember).where(
@@ -83,6 +111,38 @@ async def list_user_chat_ids(db: AsyncSession, *, user_id: int) -> list[int]:
return list(result.scalars().all())
async def find_saved_chat_for_user(db: AsyncSession, *, user_id: int) -> Chat | None:
stmt = (
select(Chat)
.join(ChatMember, ChatMember.chat_id == Chat.id)
.where(ChatMember.user_id == user_id, Chat.is_saved.is_(True))
.limit(1)
)
result = await db.execute(stmt)
return result.scalar_one_or_none()
async def discover_public_chats(
db: AsyncSession,
*,
user_id: int,
query: str | None = None,
limit: int = 30,
) -> list[tuple[Chat, bool]]:
q = select(Chat).where(Chat.is_public.is_(True), Chat.type.in_([ChatType.GROUP, ChatType.CHANNEL]), Chat.is_saved.is_(False))
if query and query.strip():
like = f"%{query.strip()}%"
q = q.where(or_(Chat.title.ilike(like), Chat.handle.ilike(like), Chat.description.ilike(like)))
q = q.order_by(Chat.id.desc()).limit(limit)
chats = list((await db.execute(q)).scalars().all())
if not chats:
return []
chat_ids = [c.id for c in chats]
m_stmt = select(ChatMember.chat_id).where(ChatMember.user_id == user_id, ChatMember.chat_id.in_(chat_ids))
memberships = set((await db.execute(m_stmt)).scalars().all())
return [(chat, chat.id in memberships) for chat in chats]
async def find_private_chat_between_users(db: AsyncSession, *, user_a_id: int, user_b_id: int) -> Chat | None:
cm_a = aliased(ChatMember)
cm_b = aliased(ChatMember)

View File

@@ -5,6 +5,8 @@ from app.auth.service import get_current_user
from app.chats.schemas import (
ChatCreateRequest,
ChatDetailRead,
ChatDiscoverRead,
ChatDeleteRequest,
ChatMemberAddRequest,
ChatMemberRead,
ChatMemberRoleUpdateRequest,
@@ -15,8 +17,12 @@ from app.chats.schemas import (
from app.chats.service import (
add_chat_member_for_user,
create_chat_for_user,
delete_chat_for_user,
discover_public_chats_for_user,
ensure_saved_messages_chat,
get_chat_for_user,
get_chats_for_user,
join_public_chat_for_user,
leave_chat_for_user,
pin_chat_message_for_user,
remove_chat_member_for_user,
@@ -40,6 +46,24 @@ async def list_chats(
return await get_chats_for_user(db, user_id=current_user.id, limit=limit, before_id=before_id, query=query)
@router.get("/saved", response_model=ChatRead)
async def get_saved_messages_chat(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> ChatRead:
return await ensure_saved_messages_chat(db, user_id=current_user.id)
@router.get("/discover", response_model=list[ChatDiscoverRead])
async def discover_chats(
query: str | None = None,
limit: int = 30,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> list[ChatDiscoverRead]:
return await discover_public_chats_for_user(db, user_id=current_user.id, query=query, limit=limit)
@router.post("", response_model=ChatRead)
async def create_chat(
payload: ChatCreateRequest,
@@ -49,6 +73,15 @@ async def create_chat(
return await create_chat_for_user(db, creator_id=current_user.id, payload=payload)
@router.post("/{chat_id}/join", response_model=ChatRead)
async def join_chat(
chat_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> ChatRead:
return await join_public_chat_for_user(db, chat_id=chat_id, user_id=current_user.id)
@router.get("/{chat_id}", response_model=ChatDetailRead)
async def get_chat(
chat_id: int,
@@ -132,6 +165,16 @@ async def leave_chat(
await leave_chat_for_user(db, chat_id=chat_id, user_id=current_user.id)
@router.delete("/{chat_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_chat(
chat_id: int,
for_all: bool = False,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> None:
await delete_chat_for_user(db, chat_id=chat_id, user_id=current_user.id, payload=ChatDeleteRequest(for_all=for_all))
@router.post("/{chat_id}/pin", response_model=ChatRead)
async def pin_chat_message(
chat_id: int,

View File

@@ -11,6 +11,10 @@ class ChatRead(BaseModel):
id: int
type: ChatType
title: str | None = None
handle: str | None = None
description: str | None = None
is_public: bool = False
is_saved: bool = False
pinned_message_id: int | None = None
created_at: datetime
@@ -31,6 +35,9 @@ class ChatDetailRead(ChatRead):
class ChatCreateRequest(BaseModel):
type: ChatType
title: str | None = Field(default=None, max_length=255)
handle: str | None = Field(default=None, max_length=64)
description: str | None = Field(default=None, max_length=512)
is_public: bool = False
member_ids: list[int] = Field(default_factory=list)
@@ -48,3 +55,11 @@ class ChatTitleUpdateRequest(BaseModel):
class ChatPinMessageRequest(BaseModel):
message_id: int | None = None
class ChatDeleteRequest(BaseModel):
for_all: bool = False
class ChatDiscoverRead(ChatRead):
is_member: bool

View File

@@ -4,7 +4,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.chats import repository
from app.chats.models import Chat, ChatMember, ChatMemberRole, ChatType
from app.chats.schemas import ChatCreateRequest, ChatPinMessageRequest, ChatTitleUpdateRequest
from app.chats.schemas import ChatCreateRequest, ChatDeleteRequest, ChatDiscoverRead, ChatPinMessageRequest, ChatTitleUpdateRequest
from app.messages.repository import get_message_by_id
from app.users.repository import get_user_by_id
@@ -31,13 +31,29 @@ async def create_chat_for_user(db: AsyncSession, *, creator_id: int, payload: Ch
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Group and channel chats require title.",
)
if payload.type == ChatType.PRIVATE and payload.is_public:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Private chat cannot be public")
if payload.is_public and payload.type in {ChatType.GROUP, ChatType.CHANNEL} and not payload.handle:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Public chat requires handle")
if payload.handle:
existing = await repository.get_chat_by_handle(db, payload.handle.strip().lower())
if existing:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Handle is already taken")
for member_id in member_ids:
user = await get_user_by_id(db, member_id)
if not user:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"User {member_id} not found")
chat = await repository.create_chat(db, chat_type=payload.type, title=payload.title)
chat = await repository.create_chat_with_meta(
db,
chat_type=payload.type,
title=payload.title,
handle=payload.handle.strip().lower() if payload.handle else None,
description=payload.description,
is_public=payload.is_public,
is_saved=False,
)
await repository.add_chat_member(db, chat_id=chat.id, user_id=creator_id, role=ChatMemberRole.OWNER)
default_role = ChatMemberRole.MEMBER
@@ -57,7 +73,13 @@ async def get_chats_for_user(
query: str | None = None,
) -> list[Chat]:
safe_limit = max(1, min(limit, 100))
return await repository.list_user_chats(db, user_id=user_id, limit=safe_limit, before_id=before_id, query=query)
chats = await repository.list_user_chats(db, user_id=user_id, limit=safe_limit, before_id=before_id, query=query)
saved = await ensure_saved_messages_chat(db, user_id=user_id)
if saved.id not in [c.id for c in chats]:
chats = [saved, *chats]
else:
chats = [saved, *[c for c in chats if c.id != saved.id]]
return chats
async def get_chat_for_user(db: AsyncSession, *, chat_id: int, user_id: int) -> tuple[Chat, list]:
@@ -222,8 +244,8 @@ async def pin_chat_message_for_user(
payload: ChatPinMessageRequest,
) -> Chat:
chat, membership = await _get_chat_and_membership(db, chat_id=chat_id, user_id=user_id)
_ensure_group_or_channel(chat.type)
_ensure_manage_permission(membership.role)
if chat.type in {ChatType.GROUP, ChatType.CHANNEL}:
_ensure_manage_permission(membership.role)
if payload.message_id is None:
chat.pinned_message_id = None
await db.commit()
@@ -237,3 +259,71 @@ async def pin_chat_message_for_user(
await db.commit()
await db.refresh(chat)
return chat
async def ensure_saved_messages_chat(db: AsyncSession, *, user_id: int) -> Chat:
saved = await repository.find_saved_chat_for_user(db, user_id=user_id)
if saved:
return saved
chat = await repository.create_chat_with_meta(
db,
chat_type=ChatType.PRIVATE,
title="Saved Messages",
handle=None,
description="Personal cloud chat",
is_public=False,
is_saved=True,
)
await repository.add_chat_member(db, chat_id=chat.id, user_id=user_id, role=ChatMemberRole.OWNER)
await db.commit()
await db.refresh(chat)
return chat
async def discover_public_chats_for_user(db: AsyncSession, *, user_id: int, query: str | None, limit: int = 30) -> list[ChatDiscoverRead]:
rows = await repository.discover_public_chats(db, user_id=user_id, query=query, limit=max(1, min(limit, 50)))
return [
ChatDiscoverRead.model_validate(
{
"id": chat.id,
"type": chat.type,
"title": chat.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,
"is_member": is_member,
}
)
for chat, is_member in rows
]
async def join_public_chat_for_user(db: AsyncSession, *, chat_id: int, user_id: int) -> Chat:
chat = await repository.get_chat_by_id(db, chat_id)
if not chat:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Chat not found")
if not chat.is_public or chat.type not in {ChatType.GROUP, ChatType.CHANNEL}:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Chat is not joinable")
membership = await repository.get_chat_member(db, chat_id=chat_id, user_id=user_id)
if membership:
return chat
await repository.add_chat_member(db, chat_id=chat_id, user_id=user_id, role=ChatMemberRole.MEMBER)
await db.commit()
await db.refresh(chat)
return chat
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)
delete_for_all = payload.for_all or chat.type == ChatType.CHANNEL
if delete_for_all:
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")
await db.delete(chat)
await db.commit()
return
await repository.delete_chat_member(db, membership)
await db.commit()

View File

@@ -1,5 +1,5 @@
import { http } from "./http";
import type { Chat, ChatType, Message, MessageType } from "../chat/types";
import type { Chat, ChatType, DiscoverChat, Message, MessageType } from "../chat/types";
import axios from "axios";
export async function getChats(query?: string): Promise<Chat[]> {
@@ -17,11 +17,24 @@ export async function createChat(type: ChatType, title: string | null, memberIds
const { data } = await http.post<Chat>("/chats", {
type,
title,
is_public: false,
member_ids: memberIds
});
return data;
}
export async function createPublicChat(type: "group" | "channel", title: string, handle: string, description?: string): Promise<Chat> {
const { data } = await http.post<Chat>("/chats", {
type,
title,
handle,
description,
is_public: true,
member_ids: []
});
return data;
}
export async function getMessages(chatId: number, beforeId?: number): Promise<Message[]> {
const { data } = await http.get<Message[]>(`/messages/${chatId}`, {
params: {
@@ -132,3 +145,24 @@ export async function pinMessage(chatId: number, messageId: number | null): Prom
});
return data;
}
export async function deleteChat(chatId: number, forAll: boolean): Promise<void> {
await http.delete(`/chats/${chatId}`, { params: { for_all: forAll } });
}
export async function discoverChats(query?: string): Promise<DiscoverChat[]> {
const { data } = await http.get<DiscoverChat[]>("/chats/discover", {
params: query?.trim() ? { query: query.trim() } : undefined
});
return data;
}
export async function joinChat(chatId: number): Promise<Chat> {
const { data } = await http.post<Chat>(`/chats/${chatId}/join`);
return data;
}
export async function getSavedMessagesChat(): Promise<Chat> {
const { data } = await http.get<Chat>("/chats/saved");
return data;
}

View File

@@ -6,10 +6,18 @@ export interface Chat {
id: number;
type: ChatType;
title: string | null;
handle?: string | null;
description?: string | null;
is_public?: boolean;
is_saved?: boolean;
pinned_message_id?: number | null;
created_at: string;
}
export interface DiscoverChat extends Chat {
is_member: boolean;
}
export interface Message {
id: number;
chat_id: number;

View File

@@ -1,4 +1,5 @@
import { useEffect, useState } from "react";
import { deleteChat } from "../api/chats";
import { useAuthStore } from "../store/authStore";
import { useChatStore } from "../store/chatStore";
import { NewChatPanel } from "./NewChatPanel";
@@ -12,6 +13,10 @@ export function ChatList() {
const me = useAuthStore((s) => s.me);
const [search, setSearch] = useState("");
const [tab, setTab] = useState<"all" | "people" | "groups" | "channels">("all");
const [ctxChatId, setCtxChatId] = useState<number | null>(null);
const [ctxPos, setCtxPos] = useState<{ x: number; y: number } | null>(null);
const [deleteModalChatId, setDeleteModalChatId] = useState<number | null>(null);
const [deleteForAll, setDeleteForAll] = useState(false);
useEffect(() => {
const timer = setTimeout(() => {
@@ -41,7 +46,7 @@ export function ChatList() {
];
return (
<aside className="relative flex h-full w-full max-w-xs flex-col bg-slate-900/60">
<aside className="relative flex h-full w-full max-w-xs flex-col bg-slate-900/60" onClick={() => { setCtxChatId(null); setCtxPos(null); }}>
<div className="border-b border-slate-700/50 px-3 py-3">
<div className="mb-2 flex items-center gap-2">
<button className="rounded-lg bg-slate-700/70 px-2 py-2 text-xs"></button>
@@ -77,6 +82,11 @@ export function ChatList() {
}`}
key={chat.id}
onClick={() => setActiveChatId(chat.id)}
onContextMenu={(e) => {
e.preventDefault();
setCtxChatId(chat.id);
setCtxPos({ x: e.clientX, y: e.clientY });
}}
>
<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">
@@ -96,6 +106,52 @@ export function ChatList() {
))}
</div>
<NewChatPanel />
{ctxChatId && ctxPos ? (
<div className="fixed z-50 w-44 rounded-lg border border-slate-700/80 bg-slate-900/95 p-1 shadow-2xl" style={{ left: ctxPos.x, top: ctxPos.y }} onClick={(e) => e.stopPropagation()}>
<button
className="block w-full rounded px-2 py-1.5 text-left text-sm text-red-300 hover:bg-slate-800"
onClick={() => {
setDeleteModalChatId(ctxChatId);
setCtxChatId(null);
setCtxPos(null);
setDeleteForAll(false);
}}
>
Delete chat
</button>
</div>
) : null}
{deleteModalChatId ? (
<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>
<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 loadChats(search.trim() ? search : undefined);
if (activeChatId === deleteModalChatId) {
setActiveChatId(null);
}
setDeleteModalChatId(null);
}}
>
Delete
</button>
<button className="flex-1 rounded bg-slate-700 px-3 py-2 text-sm" onClick={() => setDeleteModalChatId(null)}>
Cancel
</button>
</div>
</div>
</div>
) : null}
</aside>
);
}

View File

@@ -168,6 +168,13 @@ export function MessageComposer() {
async function startRecord() {
try {
if (navigator.permissions && navigator.permissions.query) {
const permission = await navigator.permissions.query({ name: "microphone" as PermissionName });
if (permission.state === "denied") {
setUploadError("Microphone access denied. Allow microphone in browser site permissions.");
return;
}
}
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const recorder = new MediaRecorder(stream);
chunksRef.current = [];
@@ -182,7 +189,7 @@ export function MessageComposer() {
recorder.start();
setIsRecording(true);
} catch {
setUploadError("Microphone access denied.");
setUploadError("Microphone access denied. Please allow microphone and retry.");
}
}

View File

@@ -1,9 +1,15 @@
import { useMemo } from "react";
import { useMemo, useState } from "react";
import { forwardMessage, pinMessage } from "../api/chats";
import { useAuthStore } from "../store/authStore";
import { useChatStore } from "../store/chatStore";
import { formatTime } from "../utils/format";
type ContextMenuState = {
x: number;
y: number;
messageId: number;
} | null;
export function MessageList() {
const me = useAuthStore((s) => s.me);
const activeChatId = useChatStore((s) => s.activeChatId);
@@ -12,6 +18,7 @@ export function MessageList() {
const chats = useChatStore((s) => s.chats);
const setReplyToMessage = useChatStore((s) => s.setReplyToMessage);
const updateChatPinnedMessage = useChatStore((s) => s.updateChatPinnedMessage);
const [ctx, setCtx] = useState<ContextMenuState>(null);
const messages = useMemo(() => {
if (!activeChatId) {
@@ -19,6 +26,8 @@ export function MessageList() {
}
return messagesByChat[activeChatId] ?? [];
}, [activeChatId, messagesByChat]);
const messagesMap = useMemo(() => new Map(messages.map((m) => [m.id, m])), [messages]);
const activeChat = chats.find((chat) => chat.id === activeChatId);
if (!activeChatId) {
@@ -28,88 +37,100 @@ export function MessageList() {
async function handleForward(messageId: number) {
const targetRaw = window.prompt("Forward to chat id:");
if (!targetRaw) {
return;
}
if (!targetRaw) return;
const targetId = Number(targetRaw);
if (!Number.isFinite(targetId) || targetId <= 0) {
return;
}
if (!Number.isFinite(targetId) || targetId <= 0) return;
await forwardMessage(messageId, targetId);
setCtx(null);
}
async function handlePin(messageId: number) {
const nextPinned = activeChat?.pinned_message_id === messageId ? null : messageId;
const chat = await pinMessage(chatId, nextPinned);
updateChatPinnedMessage(chatId, chat.pinned_message_id ?? null);
setCtx(null);
}
return (
<div className="flex h-full flex-col">
<div className="flex h-full flex-col" onClick={() => setCtx(null)}>
{activeChat?.pinned_message_id ? (
<div className="border-b border-slate-700/50 bg-slate-900/60 px-4 py-2 text-xs text-sky-300">
Pinned message ID: {activeChat.pinned_message_id}
📌 {messagesMap.get(activeChat.pinned_message_id)?.text || `Pinned message #${activeChat.pinned_message_id}`}
</div>
) : null}
<div className="tg-scrollbar flex-1 overflow-auto px-3 py-4 md:px-6">
{messages.map((message) => {
const own = message.sender_id === me?.id;
const replySource = message.reply_to_message_id ? messagesMap.get(message.reply_to_message_id) : null;
return (
<div className={`mb-2 flex ${own ? "justify-end" : "justify-start"}`} key={`${message.id}-${message.client_message_id ?? ""}`}>
<div
className={`max-w-[86%] rounded-2xl px-3 py-2 shadow-sm md:max-w-[72%] ${
own
? "rounded-br-md bg-sky-500/90 text-slate-950"
: "rounded-bl-md border border-slate-700/60 bg-slate-900/80 text-slate-100"
own ? "rounded-br-md bg-sky-500/90 text-slate-950" : "rounded-bl-md border border-slate-700/60 bg-slate-900/80 text-slate-100"
}`}
onContextMenu={(e) => {
e.preventDefault();
setCtx({ x: e.clientX, y: e.clientY, messageId: message.id });
}}
>
{message.forwarded_from_message_id ? (
<p className={`mb-1 text-[11px] font-semibold ${own ? "text-slate-900/75" : "text-sky-300"}`}>Forwarded</p>
<div className={`mb-1 rounded-md border-l-2 px-2 py-1 text-[11px] ${own ? "border-slate-900/60 bg-slate-900/10 text-slate-900/75" : "border-sky-400 bg-slate-800/60 text-sky-300"}`}>
Forwarded message
</div>
) : null}
{message.reply_to_message_id ? (
<p className={`mb-1 text-[11px] ${own ? "text-slate-900/75" : "text-slate-300"}`}>Reply to #{message.reply_to_message_id}</p>
{replySource ? (
<div className={`mb-1 rounded-md border-l-2 px-2 py-1 text-[11px] ${own ? "border-slate-900/60 bg-slate-900/10 text-slate-900/75" : "border-sky-400 bg-slate-800/60 text-slate-300"}`}>
<p className="truncate font-semibold">{replySource.sender_id === me?.id ? "You" : "Reply"}</p>
<p className="truncate">{replySource.text || "[media]"}</p>
</div>
) : null}
{renderContent(message.type, message.text)}
<div className="mt-1 flex items-center justify-between gap-2">
<div className="flex gap-1">
<button className={`rounded px-1.5 py-0.5 text-[10px] ${own ? "bg-slate-900/15" : "bg-slate-700/70"}`} onClick={() => setReplyToMessage(chatId, message)}>
Reply
</button>
<button className={`rounded px-1.5 py-0.5 text-[10px] ${own ? "bg-slate-900/15" : "bg-slate-700/70"}`} onClick={() => void handleForward(message.id)}>
Fwd
</button>
<button className={`rounded px-1.5 py-0.5 text-[10px] ${own ? "bg-slate-900/15" : "bg-slate-700/70"}`} onClick={() => void handlePin(message.id)}>
Pin
</button>
</div>
<p className={`flex items-center justify-end gap-1 text-[11px] ${own ? "text-slate-900/85" : "text-slate-400"}`}>
<p className={`mt-1 flex items-center justify-end gap-1 text-[11px] ${own ? "text-slate-900/85" : "text-slate-400"}`}>
<span>{formatTime(message.created_at)}</span>
{own ? <span className={message.delivery_status === "read" ? "text-cyan-900" : ""}>{renderStatus(message.delivery_status)}</span> : null}
</p>
</div>
</p>
</div>
</div>
);
})}
</div>
<div className="px-5 pb-2 text-xs text-slate-300/80">{(typingByChat[activeChatId] ?? []).length > 0 ? "typing..." : ""}</div>
{ctx ? (
<div
className="fixed z-50 w-40 rounded-lg border border-slate-700/80 bg-slate-900/95 p-1 shadow-2xl"
style={{ left: ctx.x, top: ctx.y }}
onClick={(e) => e.stopPropagation()}
>
<button
className="block w-full rounded px-2 py-1.5 text-left text-sm hover:bg-slate-800"
onClick={() => {
const msg = messagesMap.get(ctx.messageId);
if (msg) {
setReplyToMessage(chatId, msg);
}
setCtx(null);
}}
>
Reply
</button>
<button className="block w-full rounded px-2 py-1.5 text-left text-sm hover:bg-slate-800" onClick={() => void handleForward(ctx.messageId)}>
Forward
</button>
<button className="block w-full rounded px-2 py-1.5 text-left text-sm hover:bg-slate-800" onClick={() => void handlePin(ctx.messageId)}>
Pin / Unpin
</button>
</div>
) : null}
</div>
);
}
function renderContent(messageType: string, text: string | null) {
if (!text) {
return <p className="opacity-80">[empty]</p>;
}
if (messageType === "image") {
return <img alt="attachment" className="max-h-72 rounded-lg object-cover" src={text} />;
}
if (messageType === "video" || messageType === "circle_video") {
return <video className="max-h-72 rounded-lg" controls src={text} />;
}
if (messageType === "audio" || messageType === "voice") {
return <audio controls src={text} />;
}
if (!text) return <p className="opacity-80">[empty]</p>;
if (messageType === "image") return <img alt="attachment" className="max-h-72 rounded-lg object-cover" src={text} />;
if (messageType === "video" || messageType === "circle_video") return <video className="max-h-72 rounded-lg" controls src={text} />;
if (messageType === "audio" || messageType === "voice") return <audio controls src={text} />;
if (messageType === "file") {
return (
<a className="underline" href={text} rel="noreferrer" target="_blank">
@@ -121,14 +142,8 @@ function renderContent(messageType: string, text: string | null) {
}
function renderStatus(status: string | undefined): string {
if (status === "sending") {
return "";
}
if (status === "delivered") {
return "✓✓";
}
if (status === "read") {
return "✓✓";
}
if (status === "sending") return "⌛";
if (status === "delivered") return "✓✓";
if (status === "read") return "✓✓";
return "✓";
}

View File

@@ -1,17 +1,20 @@
import { FormEvent, useMemo, useState } from "react";
import { createChat, createPrivateChat, getChats } from "../api/chats";
import { createChat, createPrivateChat, createPublicChat, discoverChats, getChats, getSavedMessagesChat, joinChat } from "../api/chats";
import { searchUsers } from "../api/users";
import type { ChatType, UserSearchItem } from "../chat/types";
import type { ChatType, DiscoverChat, UserSearchItem } from "../chat/types";
import { useChatStore } from "../store/chatStore";
type CreateMode = "group" | "channel";
type DialogMode = "none" | "private" | "group" | "channel";
type DialogMode = "none" | "private" | "group" | "channel" | "discover";
export function NewChatPanel() {
const [dialog, setDialog] = useState<DialogMode>("none");
const [query, setQuery] = useState("");
const [title, setTitle] = useState("");
const [handle, setHandle] = useState("");
const [description, setDescription] = useState("");
const [results, setResults] = useState<UserSearchItem[]>([]);
const [discoverResults, setDiscoverResults] = useState<DiscoverChat[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [menuOpen, setMenuOpen] = useState(false);
@@ -19,7 +22,7 @@ export function NewChatPanel() {
const normalizedQuery = useMemo(() => query.trim(), [query]);
async function handleSearch(value: string) {
async function handleSearchUsers(value: string) {
setQuery(value);
setError(null);
if (value.trim().replace("@", "").length < 2) {
@@ -34,20 +37,36 @@ export function NewChatPanel() {
}
}
async function refreshChatsAndSelectLast() {
async function handleDiscover(value: string) {
setQuery(value);
setError(null);
const items = await discoverChats(value.trim() ? value : undefined);
setDiscoverResults(items);
}
async function refreshChatsAndSelect(chatId?: number) {
const chats = await getChats();
useChatStore.setState({ chats });
if (chatId) {
setActiveChatId(chatId);
return;
}
if (chats[0]) {
setActiveChatId(chats[0].id);
}
}
async function openSavedMessages() {
const saved = await getSavedMessagesChat();
await refreshChatsAndSelect(saved.id);
}
async function createPrivate(userId: number) {
setLoading(true);
setError(null);
try {
await createPrivateChat(userId);
await refreshChatsAndSelectLast();
const chat = await createPrivateChat(userId);
await refreshChatsAndSelect(chat.id);
setDialog("none");
setQuery("");
setResults([]);
@@ -67,10 +86,17 @@ export function NewChatPanel() {
setLoading(true);
setError(null);
try {
await createChat(mode as ChatType, title.trim(), []);
await refreshChatsAndSelectLast();
let chat;
if (handle.trim()) {
chat = await createPublicChat(mode, title.trim(), handle.trim().replace("@", "").toLowerCase(), description.trim() || undefined);
} else {
chat = await createChat(mode as ChatType, title.trim(), []);
}
await refreshChatsAndSelect(chat.id);
setDialog("none");
setTitle("");
setHandle("");
setDescription("");
} catch {
setError("Failed to create chat");
} finally {
@@ -78,49 +104,51 @@ export function NewChatPanel() {
}
}
async function joinPublicChat(chatId: number) {
setLoading(true);
setError(null);
try {
const joined = await joinChat(chatId);
await refreshChatsAndSelect(joined.id);
setDialog("none");
} catch {
setError("Failed to join chat");
} finally {
setLoading(false);
}
}
function closeDialog() {
setDialog("none");
setError(null);
setQuery("");
setResults([]);
setDiscoverResults([]);
}
return (
<>
<div className="absolute bottom-4 right-4 z-20">
{menuOpen ? (
<div className="mb-2 w-44 rounded-xl border border-slate-700/80 bg-slate-900/95 p-1 shadow-xl">
<button
className="block w-full rounded-lg px-3 py-2 text-left text-sm hover:bg-slate-800"
onClick={() => {
setDialog("channel");
setMenuOpen(false);
}}
>
<div className="mb-2 w-48 rounded-xl border border-slate-700/80 bg-slate-900/95 p-1 shadow-xl">
<button className="block w-full rounded-lg px-3 py-2 text-left text-sm hover:bg-slate-800" onClick={() => void openSavedMessages()}>
Saved Messages
</button>
<button className="block w-full rounded-lg px-3 py-2 text-left text-sm hover:bg-slate-800" onClick={() => { setDialog("discover"); setMenuOpen(false); }}>
Discover Chats
</button>
<button className="block w-full rounded-lg px-3 py-2 text-left text-sm hover:bg-slate-800" onClick={() => { setDialog("channel"); setMenuOpen(false); }}>
New Channel
</button>
<button
className="block w-full rounded-lg px-3 py-2 text-left text-sm hover:bg-slate-800"
onClick={() => {
setDialog("group");
setMenuOpen(false);
}}
>
<button className="block w-full rounded-lg px-3 py-2 text-left text-sm hover:bg-slate-800" onClick={() => { setDialog("group"); setMenuOpen(false); }}>
New Group
</button>
<button
className="block w-full rounded-lg px-3 py-2 text-left text-sm hover:bg-slate-800"
onClick={() => {
setDialog("private");
setMenuOpen(false);
}}
>
<button className="block w-full rounded-lg px-3 py-2 text-left text-sm hover:bg-slate-800" onClick={() => { setDialog("private"); setMenuOpen(false); }}>
New Message
</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)}
>
<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>
</div>
@@ -130,47 +158,60 @@ export function NewChatPanel() {
<div className="w-full max-w-sm rounded-2xl border border-slate-700/80 bg-slate-900 p-3 shadow-2xl">
<div className="mb-2 flex items-center justify-between">
<p className="text-sm font-semibold">
{dialog === "private" ? "New Message" : dialog === "group" ? "New Group" : "New Channel"}
{dialog === "private" ? "New Message" : dialog === "group" ? "New Group" : dialog === "channel" ? "New Channel" : "Discover Chats"}
</p>
<button className="rounded-md bg-slate-700/80 px-2 py-1 text-xs" onClick={closeDialog}>
Close
</button>
<button className="rounded-md bg-slate-700/80 px-2 py-1 text-xs" onClick={closeDialog}>Close</button>
</div>
{dialog === "private" ? (
<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="@username"
value={query}
onChange={(e) => void handleSearch(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="@username" value={query} onChange={(e) => void handleSearchUsers(e.target.value)} />
<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)}
>
<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}
</button>
))}
{normalizedQuery && results.length === 0 ? <p className="text-xs text-slate-400">No users</p> : null}
</div>
</div>
) : (
<form className="space-y-2" onSubmit={(e) => void createByType(e, dialog as CreateMode)}>
<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)}
/>
) : null}
{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)} />
<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}>
<div className="min-w-0">
<p className="truncate text-sm font-semibold">{chat.title || `${chat.type} #${chat.id}`}</p>
<p className="truncate text-xs text-slate-400">{chat.handle ? `@${chat.handle}` : chat.type}</p>
</div>
{chat.is_member ? (
<span className="text-xs text-slate-400">joined</span>
) : (
<button className="rounded bg-sky-500 px-2 py-1 text-xs font-semibold text-slate-950" onClick={() => void joinPublicChat(chat.id)}>
Join
</button>
)}
</div>
))}
{discoverResults.length === 0 ? <p className="text-xs text-slate-400">No public chats</p> : null}
</div>
</div>
) : null}
{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)} />
<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}
</button>
</form>
)}
) : null}
{error ? <p className="mt-2 text-xs text-red-400">{error}</p> : null}
</div>
</div>