diff --git a/alembic/versions/0005_chat_public_saved_features.py b/alembic/versions/0005_chat_public_saved_features.py new file mode 100644 index 0000000..b0ef36d --- /dev/null +++ b/alembic/versions/0005_chat_public_saved_features.py @@ -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") diff --git a/app/chats/models.py b/app/chats/models.py index 964fbf9..8f16f10 100644 --- a/app/chats/models.py +++ b/app/chats/models.py @@ -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) diff --git a/app/chats/repository.py b/app/chats/repository.py index 412df7f..778bbeb 100644 --- a/app/chats/repository.py +++ b/app/chats/repository.py @@ -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) diff --git a/app/chats/router.py b/app/chats/router.py index f445745..4c9c892 100644 --- a/app/chats/router.py +++ b/app/chats/router.py @@ -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, diff --git a/app/chats/schemas.py b/app/chats/schemas.py index e0b2335..cd17386 100644 --- a/app/chats/schemas.py +++ b/app/chats/schemas.py @@ -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 diff --git a/app/chats/service.py b/app/chats/service.py index a56ef30..74e06aa 100644 --- a/app/chats/service.py +++ b/app/chats/service.py @@ -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() diff --git a/web/src/api/chats.ts b/web/src/api/chats.ts index 776f00c..fc7085a 100644 --- a/web/src/api/chats.ts +++ b/web/src/api/chats.ts @@ -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 { @@ -17,11 +17,24 @@ export async function createChat(type: ChatType, title: string | null, memberIds const { data } = await http.post("/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 { + const { data } = await http.post("/chats", { + type, + title, + handle, + description, + is_public: true, + member_ids: [] + }); + return data; +} + export async function getMessages(chatId: number, beforeId?: number): Promise { const { data } = await http.get(`/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 { + await http.delete(`/chats/${chatId}`, { params: { for_all: forAll } }); +} + +export async function discoverChats(query?: string): Promise { + const { data } = await http.get("/chats/discover", { + params: query?.trim() ? { query: query.trim() } : undefined + }); + return data; +} + +export async function joinChat(chatId: number): Promise { + const { data } = await http.post(`/chats/${chatId}/join`); + return data; +} + +export async function getSavedMessagesChat(): Promise { + const { data } = await http.get("/chats/saved"); + return data; +} diff --git a/web/src/chat/types.ts b/web/src/chat/types.ts index fa25de0..a758346 100644 --- a/web/src/chat/types.ts +++ b/web/src/chat/types.ts @@ -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; diff --git a/web/src/components/ChatList.tsx b/web/src/components/ChatList.tsx index e93c08d..3399194 100644 --- a/web/src/components/ChatList.tsx +++ b/web/src/components/ChatList.tsx @@ -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(null); + const [ctxPos, setCtxPos] = useState<{ x: number; y: number } | null>(null); + const [deleteModalChatId, setDeleteModalChatId] = useState(null); + const [deleteForAll, setDeleteForAll] = useState(false); useEffect(() => { const timer = setTimeout(() => { @@ -41,7 +46,7 @@ export function ChatList() { ]; return ( -