diff --git a/alembic/versions/0017_user_contacts.py b/alembic/versions/0017_user_contacts.py new file mode 100644 index 0000000..3fb984f --- /dev/null +++ b/alembic/versions/0017_user_contacts.py @@ -0,0 +1,42 @@ +"""add user contacts table + +Revision ID: 0017_user_contacts +Revises: 0016_chat_invites +Create Date: 2026-03-08 23:10:00.000000 +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = "0017_user_contacts" +down_revision: Union[str, Sequence[str], None] = "0016_chat_invites" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "user_contacts", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("user_id", sa.Integer(), nullable=False), + sa.Column("contact_user_id", sa.Integer(), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.ForeignKeyConstraint(["user_id"], ["users.id"], name=op.f("fk_user_contacts_user_id_users"), ondelete="CASCADE"), + sa.ForeignKeyConstraint(["contact_user_id"], ["users.id"], name=op.f("fk_user_contacts_contact_user_id_users"), ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id", name=op.f("pk_user_contacts")), + sa.UniqueConstraint("user_id", "contact_user_id", name="uq_user_contacts_pair"), + ) + op.create_index(op.f("ix_user_contacts_id"), "user_contacts", ["id"], unique=False) + op.create_index(op.f("ix_user_contacts_user_id"), "user_contacts", ["user_id"], unique=False) + op.create_index(op.f("ix_user_contacts_contact_user_id"), "user_contacts", ["contact_user_id"], unique=False) + + +def downgrade() -> None: + op.drop_index(op.f("ix_user_contacts_contact_user_id"), table_name="user_contacts") + op.drop_index(op.f("ix_user_contacts_user_id"), table_name="user_contacts") + op.drop_index(op.f("ix_user_contacts_id"), table_name="user_contacts") + op.drop_table("user_contacts") + diff --git a/app/database/models.py b/app/database/models.py index 206a5c7..93dc1f4 100644 --- a/app/database/models.py +++ b/app/database/models.py @@ -4,7 +4,7 @@ from app.email.models import EmailLog from app.media.models import Attachment from app.messages.models import Message, MessageHidden, MessageIdempotencyKey, MessageReaction, MessageReceipt from app.notifications.models import NotificationLog -from app.users.models import User +from app.users.models import User, UserContact __all__ = [ "Attachment", @@ -21,4 +21,5 @@ __all__ = [ "NotificationLog", "PasswordResetToken", "User", + "UserContact", ] diff --git a/app/users/models.py b/app/users/models.py index 55addd0..3bae010 100644 --- a/app/users/models.py +++ b/app/users/models.py @@ -53,3 +53,13 @@ class BlockedUser(Base): user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True) blocked_user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False) + + +class UserContact(Base): + __tablename__ = "user_contacts" + __table_args__ = (UniqueConstraint("user_id", "contact_user_id", name="uq_user_contacts_pair"),) + + id: Mapped[int] = mapped_column(primary_key=True, index=True) + user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True) + contact_user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False) diff --git a/app/users/repository.py b/app/users/repository.py index 9d1c562..8381e79 100644 --- a/app/users/repository.py +++ b/app/users/repository.py @@ -3,7 +3,7 @@ from datetime import datetime, timezone from sqlalchemy import and_, func, or_, select from sqlalchemy.ext.asyncio import AsyncSession -from app.users.models import BlockedUser, User +from app.users.models import BlockedUser, User, UserContact async def create_user(db: AsyncSession, *, email: str, name: str, username: str, password_hash: str) -> User: @@ -107,3 +107,40 @@ async def list_blocked_users(db: AsyncSession, *, user_id: int) -> list[User]: ) result = await db.execute(stmt) return list(result.scalars().all()) + + +async def add_contact(db: AsyncSession, *, user_id: int, contact_user_id: int) -> UserContact: + existing = await get_contact_relation(db, user_id=user_id, contact_user_id=contact_user_id) + if existing: + return existing + relation = UserContact(user_id=user_id, contact_user_id=contact_user_id) + db.add(relation) + await db.flush() + return relation + + +async def remove_contact(db: AsyncSession, *, user_id: int, contact_user_id: int) -> None: + relation = await get_contact_relation(db, user_id=user_id, contact_user_id=contact_user_id) + if relation: + await db.delete(relation) + + +async def get_contact_relation(db: AsyncSession, *, user_id: int, contact_user_id: int) -> UserContact | None: + result = await db.execute( + select(UserContact).where( + UserContact.user_id == user_id, + UserContact.contact_user_id == contact_user_id, + ) + ) + return result.scalar_one_or_none() + + +async def list_contacts(db: AsyncSession, *, user_id: int) -> list[User]: + stmt = ( + select(User) + .join(UserContact, UserContact.contact_user_id == User.id) + .where(UserContact.user_id == user_id) + .order_by(User.username.asc()) + ) + result = await db.execute(stmt) + return list(result.scalars().all()) diff --git a/app/users/router.py b/app/users/router.py index a8a0e5a..7a1f996 100644 --- a/app/users/router.py +++ b/app/users/router.py @@ -6,10 +6,14 @@ from app.database.session import get_db from app.users.models import User from app.users.schemas import UserProfileUpdate, UserRead, UserSearchRead from app.users.service import ( + add_contact, block_user, get_user_by_id, get_user_by_username, list_blocked_users, + list_contacts, + has_block_relation_between_users, + remove_contact, search_users_by_username, unblock_user, update_user_profile, @@ -72,6 +76,42 @@ async def read_blocked_users( return await list_blocked_users(db, user_id=current_user.id) +@router.get("/contacts", response_model=list[UserSearchRead]) +async def read_contacts( + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +) -> list[UserSearchRead]: + return await list_contacts(db, user_id=current_user.id) + + +@router.post("/{user_id}/contacts", status_code=status.HTTP_204_NO_CONTENT) +async def add_contact_endpoint( + user_id: int, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +) -> None: + if user_id == current_user.id: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Cannot add yourself") + target = await get_user_by_id(db, user_id) + if not target: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found") + is_blocked = await has_block_relation_between_users(db, user_a_id=current_user.id, user_b_id=user_id) + if is_blocked: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Cannot add contact while blocked") + await add_contact(db, user_id=current_user.id, contact_user_id=user_id) + + +@router.delete("/{user_id}/contacts", status_code=status.HTTP_204_NO_CONTENT) +async def remove_contact_endpoint( + user_id: int, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +) -> None: + if user_id == current_user.id: + return + await remove_contact(db, user_id=current_user.id, contact_user_id=user_id) + + @router.post("/{user_id}/block", status_code=status.HTTP_204_NO_CONTENT) async def block_user_endpoint( user_id: int, diff --git a/app/users/service.py b/app/users/service.py index fcf9cd4..44907d2 100644 --- a/app/users/service.py +++ b/app/users/service.py @@ -73,3 +73,17 @@ async def has_block_relation_between_users(db: AsyncSession, *, user_a_id: int, async def list_blocked_users(db: AsyncSession, *, user_id: int) -> list[User]: return await repository.list_blocked_users(db, user_id=user_id) + + +async def add_contact(db: AsyncSession, *, user_id: int, contact_user_id: int) -> None: + await repository.add_contact(db, user_id=user_id, contact_user_id=contact_user_id) + await db.commit() + + +async def remove_contact(db: AsyncSession, *, user_id: int, contact_user_id: int) -> None: + await repository.remove_contact(db, user_id=user_id, contact_user_id=contact_user_id) + await db.commit() + + +async def list_contacts(db: AsyncSession, *, user_id: int) -> list[User]: + return await repository.list_contacts(db, user_id=user_id) diff --git a/web/src/api/users.ts b/web/src/api/users.ts index e659557..e102ac0 100644 --- a/web/src/api/users.ts +++ b/web/src/api/users.ts @@ -38,3 +38,16 @@ export async function blockUser(userId: number): Promise { export async function unblockUser(userId: number): Promise { await http.delete(`/users/${userId}/block`); } + +export async function listContacts(): Promise { + const { data } = await http.get("/users/contacts"); + return data; +} + +export async function addContact(userId: number): Promise { + await http.post(`/users/${userId}/contacts`); +} + +export async function removeContact(userId: number): Promise { + await http.delete(`/users/${userId}/contacts`); +} diff --git a/web/src/components/ChatList.tsx b/web/src/components/ChatList.tsx index eb21f5b..2b0bcfd 100644 --- a/web/src/components/ChatList.tsx +++ b/web/src/components/ChatList.tsx @@ -3,7 +3,7 @@ import { createPortal } from "react-dom"; import { archiveChat, clearChat, createPrivateChat, deleteChat, getChats, getSavedMessagesChat, joinChat, pinChat, unarchiveChat, unpinChat } from "../api/chats"; import { globalSearch } from "../api/search"; import type { DiscoverChat, Message, UserSearchItem } from "../chat/types"; -import { updateMyProfile } from "../api/users"; +import { addContact, listContacts, removeContact, updateMyProfile } from "../api/users"; import { useAuthStore } from "../store/authStore"; import { useChatStore } from "../store/chatStore"; import { NewChatPanel } from "./NewChatPanel"; @@ -23,8 +23,10 @@ export function ChatList() { const [messageResults, setMessageResults] = useState([]); const [archivedChats, setArchivedChats] = useState([]); const [searchLoading, setSearchLoading] = useState(false); - const [tab, setTab] = useState<"all" | "people" | "groups" | "channels" | "archived">("all"); + const [tab, setTab] = useState<"all" | "contacts" | "people" | "groups" | "channels" | "archived">("all"); const [archivedLoading, setArchivedLoading] = useState(false); + const [contactsLoading, setContactsLoading] = useState(false); + const [contacts, setContacts] = useState([]); const [ctxChatId, setCtxChatId] = useState(null); const [ctxPos, setCtxPos] = useState<{ x: number; y: number } | null>(null); const [deleteModalChatId, setDeleteModalChatId] = useState(null); @@ -74,6 +76,33 @@ export function ChatList() { }; }, [tab, chats.length]); + useEffect(() => { + if (tab !== "contacts") { + return; + } + let cancelled = false; + setContactsLoading(true); + void (async () => { + try { + const rows = await listContacts(); + if (!cancelled) { + setContacts(rows); + } + } catch { + if (!cancelled) { + setContacts([]); + } + } finally { + if (!cancelled) { + setContactsLoading(false); + } + } + })(); + return () => { + cancelled = true; + }; + }, [tab]); + useEffect(() => { const term = search.trim(); if (term.replace("@", "").length < 2) { @@ -162,8 +191,9 @@ export function ChatList() { return true; }); - const tabs: Array<{ id: "all" | "people" | "groups" | "channels" | "archived"; label: string }> = [ + const tabs: Array<{ id: "all" | "contacts" | "people" | "groups" | "channels" | "archived"; label: string }> = [ { id: "all", label: "All" }, + { id: "contacts", label: "Contacts" }, { id: "people", label: "Люди" }, { id: "groups", label: "Groups" }, { id: "channels", label: "Каналы" }, @@ -242,7 +272,7 @@ export function ChatList() { - +
+ + +
))} ) : null} @@ -388,15 +432,49 @@ export function ChatList() { {tab === "archived" && !archivedLoading && archivedChats.length === 0 ? (

Archive is empty

) : null} + {tab === "contacts" && contactsLoading ? ( +

Loading contacts...

+ ) : null} + {tab === "contacts" && !contactsLoading && contacts.length === 0 ? ( +

No contacts yet

+ ) : null} + {tab === "contacts" + ? contacts.map((user) => ( +
+ + +
+ )) + : null} - {pinnedVisibleChats.length > 0 ? ( + {tab !== "contacts" && pinnedVisibleChats.length > 0 ? (

Pinned

) : null} - {pinnedVisibleChats.map(renderChatRow)} - {regularVisibleChats.length > 0 ? ( + {tab !== "contacts" ? pinnedVisibleChats.map(renderChatRow) : null} + {tab !== "contacts" && regularVisibleChats.length > 0 ? (

Chats

) : null} - {regularVisibleChats.map(renderChatRow)} + {tab !== "contacts" ? regularVisibleChats.map(renderChatRow) : null}