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

@@ -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()