Compare commits

...

7 Commits

24 changed files with 1030 additions and 41 deletions

View File

@@ -0,0 +1,45 @@
"""add message reactions
Revision ID: 0013_msg_reactions
Revises: 0012_user_pm_privacy
Create Date: 2026-03-08 18:40:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "0013_msg_reactions"
down_revision: Union[str, Sequence[str], None] = "0012_user_pm_privacy"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"message_reactions",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("message_id", sa.Integer(), nullable=False),
sa.Column("user_id", sa.Integer(), nullable=False),
sa.Column("emoji", sa.String(length=16), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.ForeignKeyConstraint(["message_id"], ["messages.id"], name=op.f("fk_message_reactions_message_id_messages"), ondelete="CASCADE"),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], name=op.f("fk_message_reactions_user_id_users"), ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id", name=op.f("pk_message_reactions")),
sa.UniqueConstraint("message_id", "user_id", name="uq_message_reactions_message_user"),
)
op.create_index(op.f("ix_message_reactions_id"), "message_reactions", ["id"], unique=False)
op.create_index(op.f("ix_message_reactions_message_id"), "message_reactions", ["message_id"], unique=False)
op.create_index(op.f("ix_message_reactions_user_id"), "message_reactions", ["user_id"], unique=False)
op.create_index(op.f("ix_message_reactions_emoji"), "message_reactions", ["emoji"], unique=False)
def downgrade() -> None:
op.drop_index(op.f("ix_message_reactions_emoji"), table_name="message_reactions")
op.drop_index(op.f("ix_message_reactions_user_id"), table_name="message_reactions")
op.drop_index(op.f("ix_message_reactions_message_id"), table_name="message_reactions")
op.drop_index(op.f("ix_message_reactions_id"), table_name="message_reactions")
op.drop_table("message_reactions")

View File

@@ -0,0 +1,43 @@
"""add chat user settings for archive
Revision ID: 0014_chat_user_set
Revises: 0013_msg_reactions
Create Date: 2026-03-08 19:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "0014_chat_user_set"
down_revision: Union[str, Sequence[str], None] = "0013_msg_reactions"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"chat_user_settings",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("chat_id", sa.Integer(), nullable=False),
sa.Column("user_id", sa.Integer(), nullable=False),
sa.Column("archived", sa.Boolean(), nullable=False, server_default=sa.text("false")),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.ForeignKeyConstraint(["chat_id"], ["chats.id"], name=op.f("fk_chat_user_settings_chat_id_chats"), ondelete="CASCADE"),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], name=op.f("fk_chat_user_settings_user_id_users"), ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id", name=op.f("pk_chat_user_settings")),
sa.UniqueConstraint("chat_id", "user_id", name="uq_chat_user_settings_chat_user"),
)
op.create_index(op.f("ix_chat_user_settings_id"), "chat_user_settings", ["id"], unique=False)
op.create_index(op.f("ix_chat_user_settings_chat_id"), "chat_user_settings", ["chat_id"], unique=False)
op.create_index(op.f("ix_chat_user_settings_user_id"), "chat_user_settings", ["user_id"], unique=False)
def downgrade() -> None:
op.drop_index(op.f("ix_chat_user_settings_user_id"), table_name="chat_user_settings")
op.drop_index(op.f("ix_chat_user_settings_chat_id"), table_name="chat_user_settings")
op.drop_index(op.f("ix_chat_user_settings_id"), table_name="chat_user_settings")
op.drop_table("chat_user_settings")

View File

@@ -0,0 +1,28 @@
"""add chat pin fields to chat user settings
Revision ID: 0015_chat_pin_set
Revises: 0014_chat_user_set
Create Date: 2026-03-08 19:20:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "0015_chat_pin_set"
down_revision: Union[str, Sequence[str], None] = "0014_chat_user_set"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column("chat_user_settings", sa.Column("pinned", sa.Boolean(), nullable=False, server_default=sa.text("false")))
op.add_column("chat_user_settings", sa.Column("pinned_at", sa.DateTime(timezone=True), nullable=True))
def downgrade() -> None:
op.drop_column("chat_user_settings", "pinned_at")
op.drop_column("chat_user_settings", "pinned")

View File

@@ -0,0 +1,46 @@
"""add chat invite links
Revision ID: 0016_chat_invites
Revises: 0015_chat_pin_set
Create Date: 2026-03-08 19:45:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "0016_chat_invites"
down_revision: Union[str, Sequence[str], None] = "0015_chat_pin_set"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"chat_invite_links",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("chat_id", sa.Integer(), nullable=False),
sa.Column("creator_user_id", sa.Integer(), nullable=False),
sa.Column("token", sa.String(length=64), nullable=False),
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.text("true")),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.ForeignKeyConstraint(["chat_id"], ["chats.id"], name=op.f("fk_chat_invite_links_chat_id_chats"), ondelete="CASCADE"),
sa.ForeignKeyConstraint(["creator_user_id"], ["users.id"], name=op.f("fk_chat_invite_links_creator_user_id_users"), ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id", name=op.f("pk_chat_invite_links")),
sa.UniqueConstraint("token", name="uq_chat_invite_links_token"),
)
op.create_index(op.f("ix_chat_invite_links_id"), "chat_invite_links", ["id"], unique=False)
op.create_index(op.f("ix_chat_invite_links_chat_id"), "chat_invite_links", ["chat_id"], unique=False)
op.create_index(op.f("ix_chat_invite_links_creator_user_id"), "chat_invite_links", ["creator_user_id"], unique=False)
op.create_index(op.f("ix_chat_invite_links_token"), "chat_invite_links", ["token"], unique=False)
def downgrade() -> None:
op.drop_index(op.f("ix_chat_invite_links_token"), table_name="chat_invite_links")
op.drop_index(op.f("ix_chat_invite_links_creator_user_id"), table_name="chat_invite_links")
op.drop_index(op.f("ix_chat_invite_links_chat_id"), table_name="chat_invite_links")
op.drop_index(op.f("ix_chat_invite_links_id"), table_name="chat_invite_links")
op.drop_table("chat_invite_links")

View File

@@ -75,3 +75,35 @@ class ChatNotificationSetting(Base):
onupdate=func.now(), onupdate=func.now(),
nullable=False, nullable=False,
) )
class ChatUserSetting(Base):
__tablename__ = "chat_user_settings"
__table_args__ = (UniqueConstraint("chat_id", "user_id", name="uq_chat_user_settings_chat_user"),)
id: Mapped[int] = mapped_column(primary_key=True, index=True)
chat_id: Mapped[int] = mapped_column(ForeignKey("chats.id", ondelete="CASCADE"), nullable=False, index=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
archived: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="false")
pinned: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="false")
pinned_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
server_default=func.now(),
onupdate=func.now(),
nullable=False,
)
class ChatInviteLink(Base):
__tablename__ = "chat_invite_links"
__table_args__ = (
UniqueConstraint("token", name="uq_chat_invite_links_token"),
)
id: Mapped[int] = mapped_column(primary_key=True, index=True)
chat_id: Mapped[int] = mapped_column(ForeignKey("chats.id", ondelete="CASCADE"), nullable=False, index=True)
creator_user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
token: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, server_default="true")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)

View File

@@ -2,7 +2,7 @@ from sqlalchemy import Select, String, func, or_, select
from sqlalchemy.orm import aliased from sqlalchemy.orm import aliased
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.chats.models import Chat, ChatMember, ChatMemberRole, ChatNotificationSetting, ChatType from app.chats.models import Chat, ChatInviteLink, ChatMember, ChatMemberRole, ChatNotificationSetting, ChatType, ChatUserSetting
from app.messages.models import Message, MessageHidden, MessageReceipt from app.messages.models import Message, MessageHidden, MessageReceipt
@@ -53,7 +53,15 @@ async def count_chat_members(db: AsyncSession, *, chat_id: int) -> int:
def _user_chats_query(user_id: int, query: str | None = None) -> Select[tuple[Chat]]: def _user_chats_query(user_id: int, query: str | None = None) -> Select[tuple[Chat]]:
stmt = select(Chat).join(ChatMember, ChatMember.chat_id == Chat.id).where(ChatMember.user_id == user_id) stmt = (
select(Chat)
.join(ChatMember, ChatMember.chat_id == Chat.id)
.outerjoin(
ChatUserSetting,
(ChatUserSetting.chat_id == Chat.id) & (ChatUserSetting.user_id == user_id),
)
.where(ChatMember.user_id == user_id, func.coalesce(ChatUserSetting.archived, False).is_(False))
)
if query and query.strip(): if query and query.strip():
q = f"%{query.strip()}%" q = f"%{query.strip()}%"
stmt = stmt.where( stmt = stmt.where(
@@ -62,7 +70,11 @@ def _user_chats_query(user_id: int, query: str | None = None) -> Select[tuple[Ch
Chat.type.cast(String).ilike(q), Chat.type.cast(String).ilike(q),
) )
) )
return stmt.order_by(Chat.id.desc()) return stmt.order_by(
func.coalesce(ChatUserSetting.pinned, False).desc(),
ChatUserSetting.pinned_at.desc().nullslast(),
Chat.id.desc(),
)
async def list_user_chats( async def list_user_chats(
@@ -80,6 +92,34 @@ async def list_user_chats(
return list(result.scalars().all()) return list(result.scalars().all())
async def list_archived_user_chats(
db: AsyncSession,
*,
user_id: int,
limit: int = 50,
before_id: int | None = None,
) -> list[Chat]:
stmt = (
select(Chat)
.join(ChatMember, ChatMember.chat_id == Chat.id)
.join(
ChatUserSetting,
(ChatUserSetting.chat_id == Chat.id) & (ChatUserSetting.user_id == user_id),
)
.where(ChatMember.user_id == user_id, ChatUserSetting.archived.is_(True))
.order_by(
func.coalesce(ChatUserSetting.pinned, False).desc(),
ChatUserSetting.pinned_at.desc().nullslast(),
Chat.id.desc(),
)
.limit(limit)
)
if before_id is not None:
stmt = stmt.where(Chat.id < before_id)
result = await db.execute(stmt)
return list(result.scalars().all())
async def get_chat_by_id(db: AsyncSession, chat_id: int) -> Chat | None: async def get_chat_by_id(db: AsyncSession, chat_id: int) -> Chat | None:
result = await db.execute(select(Chat).where(Chat.id == chat_id)) result = await db.execute(select(Chat).where(Chat.id == chat_id))
return result.scalar_one_or_none() return result.scalar_one_or_none()
@@ -230,3 +270,75 @@ async def upsert_chat_notification_setting(
async def is_chat_muted_for_user(db: AsyncSession, *, chat_id: int, user_id: int) -> bool: async def is_chat_muted_for_user(db: AsyncSession, *, chat_id: int, user_id: int) -> bool:
setting = await get_chat_notification_setting(db, chat_id=chat_id, user_id=user_id) setting = await get_chat_notification_setting(db, chat_id=chat_id, user_id=user_id)
return bool(setting and setting.muted) return bool(setting and setting.muted)
async def get_chat_user_setting(db: AsyncSession, *, chat_id: int, user_id: int) -> ChatUserSetting | None:
result = await db.execute(
select(ChatUserSetting).where(ChatUserSetting.chat_id == chat_id, ChatUserSetting.user_id == user_id)
)
return result.scalar_one_or_none()
async def upsert_chat_archived_setting(
db: AsyncSession,
*,
chat_id: int,
user_id: int,
archived: bool,
) -> ChatUserSetting:
setting = await get_chat_user_setting(db, chat_id=chat_id, user_id=user_id)
if setting:
setting.archived = archived
await db.flush()
return setting
setting = ChatUserSetting(chat_id=chat_id, user_id=user_id, archived=archived)
db.add(setting)
await db.flush()
return setting
async def upsert_chat_pinned_setting(
db: AsyncSession,
*,
chat_id: int,
user_id: int,
pinned: bool,
) -> ChatUserSetting:
setting = await get_chat_user_setting(db, chat_id=chat_id, user_id=user_id)
if setting:
setting.pinned = pinned
setting.pinned_at = func.now() if pinned else None
await db.flush()
return setting
setting = ChatUserSetting(
chat_id=chat_id,
user_id=user_id,
archived=False,
pinned=pinned,
pinned_at=func.now() if pinned else None,
)
db.add(setting)
await db.flush()
return setting
async def create_chat_invite_link(
db: AsyncSession,
*,
chat_id: int,
creator_user_id: int,
token: str,
) -> ChatInviteLink:
link = ChatInviteLink(chat_id=chat_id, creator_user_id=creator_user_id, token=token, is_active=True)
db.add(link)
await db.flush()
return link
async def get_active_chat_invite_by_token(db: AsyncSession, *, token: str) -> ChatInviteLink | None:
result = await db.execute(
select(ChatInviteLink)
.where(ChatInviteLink.token == token, ChatInviteLink.is_active.is_(True))
.limit(1)
)
return result.scalar_one_or_none()

View File

@@ -7,6 +7,8 @@ from app.chats.schemas import (
ChatDetailRead, ChatDetailRead,
ChatDiscoverRead, ChatDiscoverRead,
ChatDeleteRequest, ChatDeleteRequest,
ChatInviteLinkRead,
ChatJoinByInviteRequest,
ChatMemberAddRequest, ChatMemberAddRequest,
ChatMemberRead, ChatMemberRead,
ChatMemberRoleUpdateRequest, ChatMemberRoleUpdateRequest,
@@ -19,6 +21,7 @@ from app.chats.schemas import (
from app.chats.service import ( from app.chats.service import (
add_chat_member_for_user, add_chat_member_for_user,
create_chat_for_user, create_chat_for_user,
create_chat_invite_link_for_user,
clear_chat_for_user, clear_chat_for_user,
delete_chat_for_user, delete_chat_for_user,
discover_public_chats_for_user, discover_public_chats_for_user,
@@ -27,11 +30,14 @@ from app.chats.service import (
get_chat_notification_settings_for_user, get_chat_notification_settings_for_user,
get_chats_for_user, get_chats_for_user,
join_public_chat_for_user, join_public_chat_for_user,
join_chat_by_invite_for_user,
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_chat_for_user,
serialize_chats_for_user, serialize_chats_for_user,
set_chat_archived_for_user,
set_chat_pinned_for_user,
update_chat_member_role_for_user, update_chat_member_role_for_user,
update_chat_notification_settings_for_user, update_chat_notification_settings_for_user,
update_chat_title_for_user, update_chat_title_for_user,
@@ -47,10 +53,18 @@ async def list_chats(
limit: int = 50, limit: int = 50,
before_id: int | None = None, before_id: int | None = None,
query: str | None = None, query: str | None = None,
archived: bool = False,
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]:
chats = 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,
archived=archived,
)
return await serialize_chats_for_user(db, user_id=current_user.id, chats=chats) return await serialize_chats_for_user(db, user_id=current_user.id, chats=chats)
@@ -228,3 +242,62 @@ async def update_chat_notifications(
user_id=current_user.id, user_id=current_user.id,
payload=payload, payload=payload,
) )
@router.post("/{chat_id}/archive", response_model=ChatRead)
async def archive_chat(
chat_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> ChatRead:
chat = await set_chat_archived_for_user(db, chat_id=chat_id, user_id=current_user.id, archived=True)
return await serialize_chat_for_user(db, user_id=current_user.id, chat=chat)
@router.post("/{chat_id}/unarchive", response_model=ChatRead)
async def unarchive_chat(
chat_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> ChatRead:
chat = await set_chat_archived_for_user(db, chat_id=chat_id, user_id=current_user.id, archived=False)
return await serialize_chat_for_user(db, user_id=current_user.id, chat=chat)
@router.post("/{chat_id}/pin-chat", response_model=ChatRead)
async def pin_chat(
chat_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> ChatRead:
chat = await set_chat_pinned_for_user(db, chat_id=chat_id, user_id=current_user.id, pinned=True)
return await serialize_chat_for_user(db, user_id=current_user.id, chat=chat)
@router.post("/{chat_id}/unpin-chat", response_model=ChatRead)
async def unpin_chat(
chat_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> ChatRead:
chat = await set_chat_pinned_for_user(db, chat_id=chat_id, user_id=current_user.id, pinned=False)
return await serialize_chat_for_user(db, user_id=current_user.id, chat=chat)
@router.post("/{chat_id}/invite-link", response_model=ChatInviteLinkRead)
async def create_invite_link(
chat_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> ChatInviteLinkRead:
return await create_chat_invite_link_for_user(db, chat_id=chat_id, user_id=current_user.id)
@router.post("/join-by-invite", response_model=ChatRead)
async def join_by_invite(
payload: ChatJoinByInviteRequest,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> ChatRead:
chat = await join_chat_by_invite_for_user(db, user_id=current_user.id, payload=payload)
return await serialize_chat_for_user(db, user_id=current_user.id, chat=chat)

View File

@@ -17,6 +17,8 @@ class ChatRead(BaseModel):
description: str | None = None description: str | None = None
is_public: bool = False is_public: bool = False
is_saved: bool = False is_saved: bool = False
archived: bool = False
pinned: bool = False
unread_count: int = 0 unread_count: int = 0
pinned_message_id: int | None = None pinned_message_id: int | None = None
members_count: int | None = None members_count: int | None = None
@@ -85,3 +87,13 @@ class ChatNotificationSettingsRead(BaseModel):
class ChatNotificationSettingsUpdate(BaseModel): class ChatNotificationSettingsUpdate(BaseModel):
muted: bool muted: bool
class ChatInviteLinkRead(BaseModel):
chat_id: int
token: str
invite_url: str
class ChatJoinByInviteRequest(BaseModel):
token: str = Field(min_length=8, max_length=64)

View File

@@ -1,3 +1,5 @@
import secrets
from fastapi import HTTPException, status from fastapi import HTTPException, status
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -8,12 +10,15 @@ from app.chats.schemas import (
ChatCreateRequest, ChatCreateRequest,
ChatDeleteRequest, ChatDeleteRequest,
ChatDiscoverRead, ChatDiscoverRead,
ChatJoinByInviteRequest,
ChatNotificationSettingsRead, ChatNotificationSettingsRead,
ChatNotificationSettingsUpdate, ChatNotificationSettingsUpdate,
ChatInviteLinkRead,
ChatPinMessageRequest, ChatPinMessageRequest,
ChatRead, ChatRead,
ChatTitleUpdateRequest, ChatTitleUpdateRequest,
) )
from app.config.settings import settings
from app.messages.repository import ( from app.messages.repository import (
delete_messages_in_chat, delete_messages_in_chat,
get_hidden_message, get_hidden_message,
@@ -62,6 +67,9 @@ async def serialize_chat_for_user(db: AsyncSession, *, user_id: int, chat: Chat)
subscribers_count = members_count subscribers_count = members_count
unread_count = await repository.get_unread_count_for_chat(db, chat_id=chat.id, user_id=user_id) unread_count = await repository.get_unread_count_for_chat(db, chat_id=chat.id, user_id=user_id)
user_setting = await repository.get_chat_user_setting(db, chat_id=chat.id, user_id=user_id)
archived = bool(user_setting and user_setting.archived)
pinned = bool(user_setting and user_setting.pinned)
return ChatRead.model_validate( return ChatRead.model_validate(
{ {
@@ -74,6 +82,8 @@ async def serialize_chat_for_user(db: AsyncSession, *, user_id: int, chat: Chat)
"description": chat.description, "description": chat.description,
"is_public": chat.is_public, "is_public": chat.is_public,
"is_saved": chat.is_saved, "is_saved": chat.is_saved,
"archived": archived,
"pinned": pinned,
"unread_count": unread_count, "unread_count": unread_count,
"pinned_message_id": chat.pinned_message_id, "pinned_message_id": chat.pinned_message_id,
"members_count": members_count, "members_count": members_count,
@@ -167,14 +177,18 @@ async def get_chats_for_user(
limit: int = 50, limit: int = 50,
before_id: int | None = None, before_id: int | None = None,
query: str | None = None, query: str | None = None,
archived: bool = False,
) -> list[Chat]: ) -> list[Chat]:
safe_limit = max(1, min(limit, 100)) safe_limit = max(1, min(limit, 100))
chats = await repository.list_user_chats(db, user_id=user_id, limit=safe_limit, before_id=before_id, query=query) if archived:
saved = await ensure_saved_messages_chat(db, user_id=user_id) chats = await repository.list_archived_user_chats(db, user_id=user_id, limit=safe_limit, before_id=before_id)
if saved.id not in [c.id for c in chats]:
chats = [saved, *chats]
else: else:
chats = [saved, *[c for c in chats if c.id != saved.id]] 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 return chats
@@ -480,3 +494,70 @@ async def update_chat_notification_settings_for_user(
user_id=user_id, user_id=user_id,
muted=setting.muted, muted=setting.muted,
) )
async def set_chat_archived_for_user(
db: AsyncSession,
*,
chat_id: int,
user_id: int,
archived: bool,
) -> Chat:
chat, _membership = await _get_chat_and_membership(db, chat_id=chat_id, user_id=user_id)
await repository.upsert_chat_archived_setting(db, chat_id=chat_id, user_id=user_id, archived=archived)
await db.commit()
await db.refresh(chat)
return chat
async def set_chat_pinned_for_user(
db: AsyncSession,
*,
chat_id: int,
user_id: int,
pinned: bool,
) -> Chat:
chat, _membership = await _get_chat_and_membership(db, chat_id=chat_id, user_id=user_id)
await repository.upsert_chat_pinned_setting(db, chat_id=chat_id, user_id=user_id, pinned=pinned)
await db.commit()
await db.refresh(chat)
return chat
async def create_chat_invite_link_for_user(
db: AsyncSession,
*,
chat_id: int,
user_id: int,
) -> ChatInviteLinkRead:
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)
token = secrets.token_urlsafe(18)
await repository.create_chat_invite_link(db, chat_id=chat_id, creator_user_id=user_id, token=token)
await db.commit()
invite_url = f"{settings.frontend_base_url.rstrip('/')}/join?token={token}"
return ChatInviteLinkRead(chat_id=chat_id, token=token, invite_url=invite_url)
async def join_chat_by_invite_for_user(
db: AsyncSession,
*,
user_id: int,
payload: ChatJoinByInviteRequest,
) -> Chat:
link = await repository.get_active_chat_invite_by_token(db, token=payload.token.strip())
if not link:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Invite link not found")
chat = await repository.get_chat_by_id(db, link.chat_id)
if not chat:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Chat not found")
if chat.type not in {ChatType.GROUP, ChatType.CHANNEL}:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Invite link is not valid for this chat")
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

View File

@@ -1,19 +1,22 @@
from app.auth.models import EmailVerificationToken, PasswordResetToken from app.auth.models import EmailVerificationToken, PasswordResetToken
from app.chats.models import Chat, ChatMember from app.chats.models import Chat, ChatInviteLink, ChatMember, ChatUserSetting
from app.email.models import EmailLog from app.email.models import EmailLog
from app.media.models import Attachment from app.media.models import Attachment
from app.messages.models import Message, MessageHidden, MessageIdempotencyKey, MessageReceipt from app.messages.models import Message, MessageHidden, MessageIdempotencyKey, MessageReaction, MessageReceipt
from app.notifications.models import NotificationLog from app.notifications.models import NotificationLog
from app.users.models import User from app.users.models import User
__all__ = [ __all__ = [
"Attachment", "Attachment",
"Chat", "Chat",
"ChatInviteLink",
"ChatMember", "ChatMember",
"ChatUserSetting",
"EmailLog", "EmailLog",
"EmailVerificationToken", "EmailVerificationToken",
"Message", "Message",
"MessageIdempotencyKey", "MessageIdempotencyKey",
"MessageReaction",
"MessageReceipt", "MessageReceipt",
"NotificationLog", "NotificationLog",
"PasswordResetToken", "PasswordResetToken",

View File

@@ -93,3 +93,16 @@ class MessageHidden(Base):
message_id: Mapped[int] = mapped_column(ForeignKey("messages.id", ondelete="CASCADE"), nullable=False, index=True) message_id: Mapped[int] = mapped_column(ForeignKey("messages.id", ondelete="CASCADE"), nullable=False, index=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True) 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) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
class MessageReaction(Base):
__tablename__ = "message_reactions"
__table_args__ = (
UniqueConstraint("message_id", "user_id", name="uq_message_reactions_message_user"),
)
id: Mapped[int] = mapped_column(primary_key=True, index=True)
message_id: Mapped[int] = mapped_column(ForeignKey("messages.id", ondelete="CASCADE"), nullable=False, index=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
emoji: Mapped[str] = mapped_column(String(16), nullable=False, index=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)

View File

@@ -1,8 +1,8 @@
from sqlalchemy import delete, select from sqlalchemy import delete, func, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.chats.models import ChatMember from app.chats.models import ChatMember
from app.messages.models import Message, MessageHidden, MessageIdempotencyKey, MessageReceipt, MessageType from app.messages.models import Message, MessageHidden, MessageIdempotencyKey, MessageReaction, MessageReceipt, MessageType
async def create_message( async def create_message(
@@ -178,3 +178,44 @@ async def create_message_receipt(
db.add(receipt) db.add(receipt)
await db.flush() await db.flush()
return receipt return receipt
async def get_message_reaction(db: AsyncSession, *, message_id: int, user_id: int) -> MessageReaction | None:
result = await db.execute(
select(MessageReaction)
.where(MessageReaction.message_id == message_id, MessageReaction.user_id == user_id)
.limit(1)
)
return result.scalar_one_or_none()
async def list_message_reactions(db: AsyncSession, *, message_id: int) -> list[tuple[str, int]]:
result = await db.execute(
select(MessageReaction.emoji, func.count(MessageReaction.id))
.where(MessageReaction.message_id == message_id)
.group_by(MessageReaction.emoji)
.order_by(func.count(MessageReaction.id).desc(), MessageReaction.emoji.asc())
)
return [(str(emoji), int(count)) for emoji, count in result.all()]
async def upsert_message_reaction(
db: AsyncSession,
*,
message_id: int,
user_id: int,
emoji: str,
) -> tuple[MessageReaction | None, str]:
existing = await get_message_reaction(db, message_id=message_id, user_id=user_id)
if existing and existing.emoji == emoji:
await db.delete(existing)
await db.flush()
return None, "removed"
if existing:
existing.emoji = emoji
await db.flush()
return existing, "updated"
reaction = MessageReaction(message_id=message_id, user_id=user_id, emoji=emoji)
db.add(reaction)
await db.flush()
return reaction, "added"

View File

@@ -3,14 +3,26 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.auth.service import get_current_user from app.auth.service import get_current_user
from app.database.session import get_db from app.database.session import get_db
from app.messages.schemas import MessageCreateRequest, MessageForwardRequest, MessageRead, MessageStatusUpdateRequest, MessageUpdateRequest from app.messages.schemas import (
MessageCreateRequest,
MessageForwardBulkRequest,
MessageForwardRequest,
MessageReactionRead,
MessageReactionToggleRequest,
MessageRead,
MessageStatusUpdateRequest,
MessageUpdateRequest,
)
from app.messages.service import ( from app.messages.service import (
create_chat_message, create_chat_message,
delete_message, delete_message,
delete_message_for_all, delete_message_for_all,
forward_message, forward_message,
forward_message_bulk,
get_messages, get_messages,
list_message_reactions,
search_messages, search_messages,
toggle_message_reaction,
update_message, update_message,
) )
from app.realtime.schemas import MessageStatusPayload from app.realtime.schemas import MessageStatusPayload
@@ -104,3 +116,35 @@ async def forward_message_endpoint(
message = await forward_message(db, source_message_id=message_id, sender_id=current_user.id, payload=payload) message = await forward_message(db, source_message_id=message_id, sender_id=current_user.id, payload=payload)
await realtime_gateway.publish_message_created(message=message, sender_id=current_user.id) await realtime_gateway.publish_message_created(message=message, sender_id=current_user.id)
return message return message
@router.post("/{message_id}/forward-bulk", response_model=list[MessageRead], status_code=status.HTTP_201_CREATED)
async def forward_message_bulk_endpoint(
message_id: int,
payload: MessageForwardBulkRequest,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> list[MessageRead]:
messages = await forward_message_bulk(db, source_message_id=message_id, sender_id=current_user.id, payload=payload)
for message in messages:
await realtime_gateway.publish_message_created(message=message, sender_id=current_user.id)
return messages
@router.get("/{message_id}/reactions", response_model=list[MessageReactionRead])
async def list_reactions_endpoint(
message_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> list[MessageReactionRead]:
return await list_message_reactions(db, message_id=message_id, user_id=current_user.id)
@router.post("/{message_id}/reactions/toggle", response_model=list[MessageReactionRead])
async def toggle_reaction_endpoint(
message_id: int,
payload: MessageReactionToggleRequest,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> list[MessageReactionRead]:
return await toggle_message_reaction(db, message_id=message_id, user_id=current_user.id, payload=payload)

View File

@@ -40,3 +40,17 @@ class MessageStatusUpdateRequest(BaseModel):
class MessageForwardRequest(BaseModel): class MessageForwardRequest(BaseModel):
target_chat_id: int target_chat_id: int
class MessageForwardBulkRequest(BaseModel):
target_chat_ids: list[int] = Field(min_length=1, max_length=20)
class MessageReactionToggleRequest(BaseModel):
emoji: str = Field(min_length=1, max_length=16)
class MessageReactionRead(BaseModel):
emoji: str
count: int
reacted: bool = False

View File

@@ -8,7 +8,15 @@ from app.chats.service import ensure_chat_membership
from app.messages import repository from app.messages import repository
from app.messages.models import Message from app.messages.models import Message
from app.messages.spam_guard import enforce_message_spam_policy from app.messages.spam_guard import enforce_message_spam_policy
from app.messages.schemas import MessageCreateRequest, MessageForwardRequest, MessageStatusUpdateRequest, MessageUpdateRequest from app.messages.schemas import (
MessageCreateRequest,
MessageForwardBulkRequest,
MessageForwardRequest,
MessageReactionRead,
MessageReactionToggleRequest,
MessageStatusUpdateRequest,
MessageUpdateRequest,
)
from app.notifications.service import dispatch_message_notifications from app.notifications.service import dispatch_message_notifications
from app.users.repository import has_block_relation_between_users from app.users.repository import has_block_relation_between_users
from app.users.service import get_user_by_id from app.users.service import get_user_by_id
@@ -267,3 +275,86 @@ async def forward_message(
await db.commit() await db.commit()
await db.refresh(forwarded) await db.refresh(forwarded)
return forwarded return forwarded
async def forward_message_bulk(
db: AsyncSession,
*,
source_message_id: int,
sender_id: int,
payload: MessageForwardBulkRequest,
) -> list[Message]:
source = await repository.get_message_by_id(db, source_message_id)
if not source:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Source message not found")
await ensure_chat_membership(db, chat_id=source.chat_id, user_id=sender_id)
target_chat_ids = list(dict.fromkeys(payload.target_chat_ids))
if not target_chat_ids:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="No target chats")
forwarded_messages: list[Message] = []
for target_chat_id in target_chat_ids:
await ensure_chat_membership(db, chat_id=target_chat_id, user_id=sender_id)
target_chat = await chats_repository.get_chat_by_id(db, target_chat_id)
if not target_chat:
continue
target_membership = await chats_repository.get_chat_member(db, chat_id=target_chat_id, user_id=sender_id)
if not target_membership:
continue
if target_chat.type == ChatType.CHANNEL and target_membership.role == ChatMemberRole.MEMBER:
continue
forwarded = await repository.create_message(
db,
chat_id=target_chat_id,
sender_id=sender_id,
reply_to_message_id=None,
forwarded_from_message_id=source.id,
message_type=source.type,
text=source.text,
)
forwarded_messages.append(forwarded)
await db.commit()
for message in forwarded_messages:
await db.refresh(message)
return forwarded_messages
async def list_message_reactions(
db: AsyncSession,
*,
message_id: int,
user_id: int,
) -> list[MessageReactionRead]:
message = await repository.get_message_by_id(db, message_id)
if not message:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Message not found")
await ensure_chat_membership(db, chat_id=message.chat_id, user_id=user_id)
counts = await repository.list_message_reactions(db, message_id=message_id)
mine = await repository.get_message_reaction(db, message_id=message_id, user_id=user_id)
mine_emoji = mine.emoji if mine else None
return [
MessageReactionRead(emoji=emoji, count=count, reacted=(emoji == mine_emoji))
for emoji, count in counts
]
async def toggle_message_reaction(
db: AsyncSession,
*,
message_id: int,
user_id: int,
payload: MessageReactionToggleRequest,
) -> list[MessageReactionRead]:
message = await repository.get_message_by_id(db, message_id)
if not message:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Message not found")
await ensure_chat_membership(db, chat_id=message.chat_id, user_id=user_id)
await repository.upsert_message_reaction(
db,
message_id=message_id,
user_id=user_id,
emoji=payload.emoji.strip(),
)
await db.commit()
return await list_message_reactions(db, message_id=message_id, user_id=user_id)

View File

@@ -1,5 +1,5 @@
import { http } from "./http"; import { http } from "./http";
import type { Chat, ChatDetail, ChatMember, ChatMemberRole, ChatType, DiscoverChat, Message, MessageType } from "../chat/types"; import type { Chat, ChatDetail, ChatInviteLink, ChatMember, ChatMemberRole, ChatType, DiscoverChat, Message, MessageReaction, MessageType } from "../chat/types";
import axios from "axios"; import axios from "axios";
export interface ChatNotificationSettings { export interface ChatNotificationSettings {
@@ -8,9 +8,12 @@ export interface ChatNotificationSettings {
muted: boolean; muted: boolean;
} }
export async function getChats(query?: string): Promise<Chat[]> { export async function getChats(query?: string, archived = false): Promise<Chat[]> {
const { data } = await http.get<Chat[]>("/chats", { const { data } = await http.get<Chat[]>("/chats", {
params: query?.trim() ? { query: query.trim() } : undefined params: {
...(query?.trim() ? { query: query.trim() } : {}),
...(archived ? { archived: true } : {})
}
}); });
return data; return data;
} }
@@ -160,6 +163,23 @@ export async function forwardMessage(messageId: number, targetChatId: number): P
return data; return data;
} }
export async function forwardMessageBulk(messageId: number, targetChatIds: number[]): Promise<Message[]> {
const { data } = await http.post<Message[]>(`/messages/${messageId}/forward-bulk`, {
target_chat_ids: targetChatIds
});
return data;
}
export async function listMessageReactions(messageId: number): Promise<MessageReaction[]> {
const { data } = await http.get<MessageReaction[]>(`/messages/${messageId}/reactions`);
return data;
}
export async function toggleMessageReaction(messageId: number, emoji: string): Promise<MessageReaction[]> {
const { data } = await http.post<MessageReaction[]>(`/messages/${messageId}/reactions/toggle`, { emoji });
return data;
}
export async function pinMessage(chatId: number, messageId: number | null): Promise<Chat> { export async function pinMessage(chatId: number, messageId: number | null): Promise<Chat> {
const { data } = await http.post<Chat>(`/chats/${chatId}/pin`, { const { data } = await http.post<Chat>(`/chats/${chatId}/pin`, {
message_id: messageId message_id: messageId
@@ -175,6 +195,36 @@ export async function clearChat(chatId: number): Promise<void> {
await http.post(`/chats/${chatId}/clear`); await http.post(`/chats/${chatId}/clear`);
} }
export async function archiveChat(chatId: number): Promise<Chat> {
const { data } = await http.post<Chat>(`/chats/${chatId}/archive`);
return data;
}
export async function unarchiveChat(chatId: number): Promise<Chat> {
const { data } = await http.post<Chat>(`/chats/${chatId}/unarchive`);
return data;
}
export async function pinChat(chatId: number): Promise<Chat> {
const { data } = await http.post<Chat>(`/chats/${chatId}/pin-chat`);
return data;
}
export async function unpinChat(chatId: number): Promise<Chat> {
const { data } = await http.post<Chat>(`/chats/${chatId}/unpin-chat`);
return data;
}
export async function createInviteLink(chatId: number): Promise<ChatInviteLink> {
const { data } = await http.post<ChatInviteLink>(`/chats/${chatId}/invite-link`);
return data;
}
export async function joinByInvite(token: string): Promise<Chat> {
const { data } = await http.post<Chat>("/chats/join-by-invite", { token });
return data;
}
export async function deleteMessage(messageId: number, forAll = false): Promise<void> { export async function deleteMessage(messageId: number, forAll = false): Promise<void> {
await http.delete(`/messages/${messageId}`, { params: { for_all: forAll } }); await http.delete(`/messages/${messageId}`, { params: { for_all: forAll } });
} }

View File

@@ -12,6 +12,8 @@ export interface Chat {
description?: string | null; description?: string | null;
is_public?: boolean; is_public?: boolean;
is_saved?: boolean; is_saved?: boolean;
archived?: boolean;
pinned?: boolean;
unread_count?: number; unread_count?: number;
pinned_message_id?: number | null; pinned_message_id?: number | null;
members_count?: number | null; members_count?: number | null;
@@ -58,6 +60,12 @@ export interface Message {
is_pending?: boolean; is_pending?: boolean;
} }
export interface MessageReaction {
emoji: string;
count: number;
reacted: boolean;
}
export interface AuthUser { export interface AuthUser {
id: number; id: number;
email: string; email: string;
@@ -83,3 +91,9 @@ export interface UserSearchItem {
username: string; username: string;
avatar_url: string | null; avatar_url: string | null;
} }
export interface ChatInviteLink {
chat_id: number;
token: string;
invite_url: string;
}

View File

@@ -2,6 +2,7 @@ import { useEffect, useMemo, useState } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { import {
addChatMember, addChatMember,
createInviteLink,
getChatNotificationSettings, getChatNotificationSettings,
getChatDetail, getChatDetail,
leaveChat, leaveChat,
@@ -39,6 +40,7 @@ export function ChatInfoPanel({ chatId, open, onClose }: Props) {
const [savingMute, setSavingMute] = useState(false); const [savingMute, setSavingMute] = useState(false);
const [counterpartBlocked, setCounterpartBlocked] = useState(false); const [counterpartBlocked, setCounterpartBlocked] = useState(false);
const [savingBlock, setSavingBlock] = useState(false); const [savingBlock, setSavingBlock] = useState(false);
const [inviteLink, setInviteLink] = useState<string | null>(null);
const myRole = useMemo(() => members.find((m) => m.user_id === me?.id)?.role, [members, me?.id]); const myRole = useMemo(() => members.find((m) => m.user_id === me?.id)?.role, [members, me?.id]);
const isGroupLike = chat?.type === "group" || chat?.type === "channel"; const isGroupLike = chat?.type === "group" || chat?.type === "channel";
@@ -183,6 +185,27 @@ export function ChatInfoPanel({ chatId, open, onClose }: Props) {
) : null} ) : null}
{chat.handle ? <p className="mt-2 text-xs text-slate-300">@{chat.handle}</p> : null} {chat.handle ? <p className="mt-2 text-xs text-slate-300">@{chat.handle}</p> : null}
{chat.description ? <p className="mt-1 text-xs text-slate-400">{chat.description}</p> : null} {chat.description ? <p className="mt-1 text-xs text-slate-400">{chat.description}</p> : null}
{isGroupLike && canManageMembers ? (
<div className="mt-2">
<button
className="w-full rounded bg-slate-700 px-3 py-2 text-xs"
onClick={async () => {
try {
const link = await createInviteLink(chatId);
setInviteLink(link.invite_url);
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(link.invite_url);
}
} catch {
setError("Failed to create invite link");
}
}}
>
Create invite link
</button>
{inviteLink ? <p className="mt-1 break-all text-[11px] text-sky-300">{inviteLink}</p> : null}
</div>
) : null}
</div> </div>
{showMembersSection ? ( {showMembersSection ? (

View File

@@ -1,6 +1,6 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { clearChat, createPrivateChat, deleteChat, getChats, joinChat } from "../api/chats"; import { archiveChat, clearChat, createPrivateChat, deleteChat, getChats, joinChat, pinChat, unarchiveChat, unpinChat } from "../api/chats";
import { globalSearch } from "../api/search"; import { globalSearch } from "../api/search";
import type { DiscoverChat, Message, UserSearchItem } from "../chat/types"; import type { DiscoverChat, Message, UserSearchItem } from "../chat/types";
import { updateMyProfile } from "../api/users"; import { updateMyProfile } from "../api/users";
@@ -20,8 +20,9 @@ export function ChatList() {
const [userResults, setUserResults] = useState<UserSearchItem[]>([]); const [userResults, setUserResults] = useState<UserSearchItem[]>([]);
const [discoverResults, setDiscoverResults] = useState<DiscoverChat[]>([]); const [discoverResults, setDiscoverResults] = useState<DiscoverChat[]>([]);
const [messageResults, setMessageResults] = useState<Message[]>([]); const [messageResults, setMessageResults] = useState<Message[]>([]);
const [archivedChats, setArchivedChats] = useState<typeof chats>([]);
const [searchLoading, setSearchLoading] = useState(false); const [searchLoading, setSearchLoading] = useState(false);
const [tab, setTab] = useState<"all" | "people" | "groups" | "channels">("all"); const [tab, setTab] = useState<"all" | "people" | "groups" | "channels" | "archived">("all");
const [ctxChatId, setCtxChatId] = useState<number | null>(null); const [ctxChatId, setCtxChatId] = useState<number | null>(null);
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);
@@ -45,6 +46,28 @@ export function ChatList() {
void loadChats(); void loadChats();
}, [loadChats]); }, [loadChats]);
useEffect(() => {
if (tab !== "archived") {
return;
}
let cancelled = false;
void (async () => {
try {
const rows = await getChats(undefined, true);
if (!cancelled) {
setArchivedChats(rows);
}
} catch {
if (!cancelled) {
setArchivedChats([]);
}
}
})();
return () => {
cancelled = true;
};
}, [tab]);
useEffect(() => { useEffect(() => {
const term = search.trim(); const term = search.trim();
if (term.replace("@", "").length < 2) { if (term.replace("@", "").length < 2) {
@@ -108,6 +131,9 @@ export function ChatList() {
}, [me]); }, [me]);
const filteredChats = chats.filter((chat) => { const filteredChats = chats.filter((chat) => {
if (chat.archived) {
return false;
}
if (tab === "people") { if (tab === "people") {
return chat.type === "private"; return chat.type === "private";
} }
@@ -120,13 +146,16 @@ export function ChatList() {
return true; return true;
}); });
const tabs: Array<{ id: "all" | "people" | "groups" | "channels"; label: string }> = [ const tabs: Array<{ id: "all" | "people" | "groups" | "channels" | "archived"; label: string }> = [
{ id: "all", label: "All" }, { id: "all", label: "All" },
{ id: "people", label: "Люди" }, { id: "people", label: "Люди" },
{ id: "groups", label: "Groups" }, { id: "groups", label: "Groups" },
{ id: "channels", label: "Каналы" } { id: "channels", label: "Каналы" },
{ id: "archived", label: "Архив" }
]; ];
const visibleChats = tab === "archived" ? archivedChats : filteredChats;
return ( return (
<aside className="relative flex h-full w-full max-w-xs flex-col bg-slate-900/60" onClick={() => { setCtxChatId(null); setCtxPos(null); }}> <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="border-b border-slate-700/50 px-3 py-3">
@@ -238,7 +267,7 @@ export function ChatList() {
) : null} ) : null}
</div> </div>
<div className="tg-scrollbar flex-1 overflow-auto"> <div className="tg-scrollbar flex-1 overflow-auto">
{filteredChats.map((chat) => ( {visibleChats.map((chat) => (
<button <button
className={`block w-full border-b border-slate-800/60 px-4 py-3 text-left transition ${ className={`block w-full border-b border-slate-800/60 px-4 py-3 text-left transition ${
activeChatId === chat.id ? "bg-sky-500/20" : "hover:bg-slate-800/65" activeChatId === chat.id ? "bg-sky-500/20" : "hover:bg-slate-800/65"
@@ -254,7 +283,7 @@ export function ChatList() {
> >
<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.display_title || chat.title || chat.type).slice(0, 1)} {chat.pinned ? "📌" : (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">
@@ -291,6 +320,50 @@ export function ChatList() {
> >
{chats.find((c) => c.id === ctxChatId)?.is_saved ? "Clear chat" : "Delete chat"} {chats.find((c) => c.id === ctxChatId)?.is_saved ? "Clear chat" : "Delete chat"}
</button> </button>
<button
className="block w-full rounded px-2 py-1.5 text-left text-sm hover:bg-slate-800"
onClick={async () => {
const target = chats.find((c) => c.id === ctxChatId) ?? archivedChats.find((c) => c.id === ctxChatId);
if (!target) {
return;
}
if (target.archived) {
await unarchiveChat(target.id);
} else {
await archiveChat(target.id);
}
await loadChats();
const nextArchived = await getChats(undefined, true);
setArchivedChats(nextArchived);
setCtxChatId(null);
setCtxPos(null);
}}
>
{(chats.find((c) => c.id === ctxChatId) ?? archivedChats.find((c) => c.id === ctxChatId))?.archived ? "Unarchive" : "Archive"}
</button>
<button
className="block w-full rounded px-2 py-1.5 text-left text-sm hover:bg-slate-800"
onClick={async () => {
const target = chats.find((c) => c.id === ctxChatId) ?? archivedChats.find((c) => c.id === ctxChatId);
if (!target) {
return;
}
if (target.pinned) {
await unpinChat(target.id);
} else {
await pinChat(target.id);
}
await loadChats();
if (tab === "archived") {
const nextArchived = await getChats(undefined, true);
setArchivedChats(nextArchived);
}
setCtxChatId(null);
setCtxPos(null);
}}
>
{(chats.find((c) => c.id === ctxChatId) ?? archivedChats.find((c) => c.id === ctxChatId))?.pinned ? "Unpin chat" : "Pin chat"}
</button>
</div>, </div>,
document.body document.body
) )

View File

@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState, type KeyboardEvent } from "react";
import { attachFile, requestUploadUrl, sendMessageWithClientId, uploadToPresignedUrl } from "../api/chats"; import { attachFile, requestUploadUrl, sendMessageWithClientId, uploadToPresignedUrl } from "../api/chats";
import { useAuthStore } from "../store/authStore"; import { useAuthStore } from "../store/authStore";
import { useChatStore } from "../store/chatStore"; import { useChatStore } from "../store/chatStore";
@@ -104,6 +104,13 @@ export function MessageComposer() {
} }
} }
function onComposerKeyDown(event: KeyboardEvent<HTMLTextAreaElement>) {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
void handleSend();
}
}
async function handleUpload(file: File, messageType: "file" | "image" | "video" | "audio" | "voice" = "file") { async function handleUpload(file: File, messageType: "file" | "image" | "video" | "audio" | "voice" = "file") {
if (!activeChatId || !me) { if (!activeChatId || !me) {
return; return;
@@ -314,10 +321,12 @@ export function MessageComposer() {
}} }}
/> />
</label> </label>
<input <textarea
className="flex-1 rounded-full border border-slate-700/80 bg-slate-800/80 px-4 py-2.5 text-sm outline-none placeholder:text-slate-400 focus:border-sky-500" className="flex-1 resize-none rounded-2xl border border-slate-700/80 bg-slate-800/80 px-4 py-2.5 text-sm outline-none placeholder:text-slate-400 focus:border-sky-500"
placeholder="Write a message..." placeholder="Write a message..."
rows={1}
value={text} value={text}
onKeyDown={onComposerKeyDown}
onChange={(e) => { onChange={(e) => {
const next = e.target.value; const next = e.target.value;
setText(next); setText(next);

View File

@@ -1,10 +1,11 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { deleteMessage, forwardMessage, pinMessage } from "../api/chats"; import { deleteMessage, forwardMessageBulk, listMessageReactions, pinMessage, toggleMessageReaction } from "../api/chats";
import type { Message } from "../chat/types"; import type { Message, MessageReaction } from "../chat/types";
import { useAuthStore } from "../store/authStore"; import { useAuthStore } from "../store/authStore";
import { useChatStore } from "../store/chatStore"; import { useChatStore } from "../store/chatStore";
import { formatTime } from "../utils/format"; import { formatTime } from "../utils/format";
import { formatMessageHtml } from "../utils/formatMessage";
type ContextMenuState = { type ContextMenuState = {
x: number; x: number;
@@ -40,11 +41,13 @@ export function MessageList() {
const [forwardQuery, setForwardQuery] = useState(""); const [forwardQuery, setForwardQuery] = useState("");
const [forwardError, setForwardError] = useState<string | null>(null); const [forwardError, setForwardError] = useState<string | null>(null);
const [isForwarding, setIsForwarding] = useState(false); const [isForwarding, setIsForwarding] = useState(false);
const [forwardSelectedChatIds, setForwardSelectedChatIds] = useState<Set<number>>(new Set());
const [deleteMessageId, setDeleteMessageId] = useState<number | null>(null); const [deleteMessageId, setDeleteMessageId] = useState<number | null>(null);
const [deleteError, setDeleteError] = useState<string | null>(null); const [deleteError, setDeleteError] = useState<string | null>(null);
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set()); const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
const [pendingDelete, setPendingDelete] = useState<PendingDeleteState>(null); const [pendingDelete, setPendingDelete] = useState<PendingDeleteState>(null);
const [undoTick, setUndoTick] = useState(0); const [undoTick, setUndoTick] = useState(0);
const [reactionsByMessage, setReactionsByMessage] = useState<Record<number, MessageReaction[]>>({});
const messages = useMemo(() => { const messages = useMemo(() => {
if (!activeChatId) { if (!activeChatId) {
@@ -85,6 +88,7 @@ export function MessageList() {
} }
setCtx(null); setCtx(null);
setForwardMessageId(null); setForwardMessageId(null);
setForwardSelectedChatIds(new Set());
setDeleteMessageId(null); setDeleteMessageId(null);
setSelectedIds(new Set()); setSelectedIds(new Set());
}; };
@@ -97,6 +101,8 @@ export function MessageList() {
setCtx(null); setCtx(null);
setDeleteMessageId(null); setDeleteMessageId(null);
setForwardMessageId(null); setForwardMessageId(null);
setForwardSelectedChatIds(new Set());
setReactionsByMessage({});
}, [activeChatId]); }, [activeChatId]);
useEffect(() => { useEffect(() => {
@@ -125,13 +131,19 @@ export function MessageList() {
} }
const chatId = activeChatId; const chatId = activeChatId;
async function handleForward(targetChatId: number) { async function handleForwardSubmit() {
if (!forwardMessageId) return; if (!forwardMessageId) return;
const targetChatIds = [...forwardSelectedChatIds];
if (!targetChatIds.length) {
setForwardError("Select at least one chat");
return;
}
setIsForwarding(true); setIsForwarding(true);
setForwardError(null); setForwardError(null);
try { try {
await forwardMessage(forwardMessageId, targetChatId); await forwardMessageBulk(forwardMessageId, targetChatIds);
setForwardMessageId(null); setForwardMessageId(null);
setForwardSelectedChatIds(new Set());
setForwardQuery(""); setForwardQuery("");
} catch { } catch {
setForwardError("Failed to forward message"); setForwardError("Failed to forward message");
@@ -162,6 +174,27 @@ export function MessageList() {
} }
} }
async function ensureReactionsLoaded(messageId: number) {
if (reactionsByMessage[messageId]) {
return;
}
try {
const rows = await listMessageReactions(messageId);
setReactionsByMessage((state) => ({ ...state, [messageId]: rows }));
} catch {
return;
}
}
async function handleToggleReaction(messageId: number, emoji: string) {
try {
const rows = await toggleMessageReaction(messageId, emoji);
setReactionsByMessage((state) => ({ ...state, [messageId]: rows }));
} catch {
return;
}
}
function toggleSelected(messageId: number) { function toggleSelected(messageId: number) {
setSelectedIds((prev) => { setSelectedIds((prev) => {
const next = new Set(prev); const next = new Set(prev);
@@ -314,6 +347,7 @@ export function MessageList() {
}} }}
onContextMenu={(e) => { onContextMenu={(e) => {
e.preventDefault(); e.preventDefault();
void ensureReactionsLoaded(message.id);
const pos = getSafeContextPosition(e.clientX, e.clientY, 160, 108); const pos = getSafeContextPosition(e.clientX, e.clientY, 160, 108);
setCtx({ x: pos.x, y: pos.y, messageId: message.id }); setCtx({ x: pos.x, y: pos.y, messageId: message.id });
}} }}
@@ -335,6 +369,24 @@ export function MessageList() {
</div> </div>
) : null} ) : null}
{renderContent(message.type, message.text)} {renderContent(message.type, message.text)}
<div className="mt-1 flex flex-wrap gap-1">
{["👍", "❤️", "🔥"].map((emoji) => {
const items = reactionsByMessage[message.id] ?? [];
const item = items.find((reaction) => reaction.emoji === emoji);
return (
<button
className={`rounded-full border px-2 py-0.5 text-[11px] ${
item?.reacted ? "border-sky-300 bg-sky-500/30" : "border-slate-500/60 bg-slate-700/40"
}`}
key={`${message.id}-${emoji}`}
onClick={() => void handleToggleReaction(message.id, emoji)}
type="button"
>
{emoji}{item ? ` ${item.count}` : ""}
</button>
);
})}
</div>
<p className={`mt-1 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> <span>{formatTime(message.created_at)}</span>
{own ? <span className={message.delivery_status === "read" ? "text-cyan-900" : ""}>{renderStatus(message.delivery_status)}</span> : null} {own ? <span className={message.delivery_status === "read" ? "text-cyan-900" : ""}>{renderStatus(message.delivery_status)}</span> : null}
@@ -372,6 +424,7 @@ export function MessageList() {
setForwardMessageId(ctx.messageId); setForwardMessageId(ctx.messageId);
setForwardQuery(""); setForwardQuery("");
setForwardError(null); setForwardError(null);
setForwardSelectedChatIds(new Set());
setCtx(null); setCtx(null);
}} }}
> >
@@ -417,10 +470,20 @@ export function MessageList() {
<div className="tg-scrollbar max-h-56 space-y-1 overflow-auto"> <div className="tg-scrollbar max-h-56 space-y-1 overflow-auto">
{forwardTargets.map((chat) => ( {forwardTargets.map((chat) => (
<button <button
className="block w-full rounded-lg bg-slate-800 px-3 py-2 text-left text-sm hover:bg-slate-700 disabled:opacity-60" className={`block w-full rounded-lg px-3 py-2 text-left text-sm disabled:opacity-60 ${forwardSelectedChatIds.has(chat.id) ? "bg-sky-500/30" : "bg-slate-800 hover:bg-slate-700"}`}
disabled={isForwarding} disabled={isForwarding}
key={chat.id} key={chat.id}
onClick={() => void handleForward(chat.id)} onClick={() => {
setForwardSelectedChatIds((prev) => {
const next = new Set(prev);
if (next.has(chat.id)) {
next.delete(chat.id);
} else {
next.add(chat.id);
}
return next;
});
}}
> >
<p className="truncate font-semibold">{chatLabel(chat)}</p> <p className="truncate font-semibold">{chatLabel(chat)}</p>
<p className="truncate text-xs text-slate-400">{chat.handle ? `@${chat.handle}` : chat.type}</p> <p className="truncate text-xs text-slate-400">{chat.handle ? `@${chat.handle}` : chat.type}</p>
@@ -429,9 +492,14 @@ export function MessageList() {
{forwardTargets.length === 0 ? <p className="px-1 py-2 text-xs text-slate-400">No chats found</p> : null} {forwardTargets.length === 0 ? <p className="px-1 py-2 text-xs text-slate-400">No chats found</p> : null}
</div> </div>
{forwardError ? <p className="mt-2 text-xs text-red-400">{forwardError}</p> : null} {forwardError ? <p className="mt-2 text-xs text-red-400">{forwardError}</p> : null}
<button className="mt-3 w-full rounded bg-slate-700 px-3 py-2 text-sm" onClick={() => setForwardMessageId(null)}> <div className="mt-3 flex gap-2">
Cancel <button className="w-full rounded bg-sky-500 px-3 py-2 text-sm font-semibold text-slate-950" onClick={() => void handleForwardSubmit()}>
</button> Forward ({forwardSelectedChatIds.size})
</button>
<button className="w-full rounded bg-slate-700 px-3 py-2 text-sm" onClick={() => setForwardMessageId(null)}>
Cancel
</button>
</div>
</div> </div>
</div> </div>
) : null} ) : null}
@@ -488,7 +556,7 @@ function renderContent(messageType: string, text: string | null) {
</a> </a>
); );
} }
return <p className="whitespace-pre-wrap break-words">{text}</p>; return <p className="whitespace-pre-wrap break-words" dangerouslySetInnerHTML={{ __html: formatMessageHtml(text) }} />;
} }
function renderStatus(status: string | undefined): string { function renderStatus(status: string | undefined): string {

View File

@@ -1,11 +1,11 @@
import { FormEvent, useMemo, useState } from "react"; import { FormEvent, useMemo, useState } from "react";
import { createChat, createPrivateChat, createPublicChat, getChats, getSavedMessagesChat } from "../api/chats"; import { createChat, createPrivateChat, createPublicChat, getChats, getSavedMessagesChat, joinByInvite } from "../api/chats";
import { searchUsers } from "../api/users"; import { searchUsers } from "../api/users";
import type { ChatType, UserSearchItem } from "../chat/types"; import type { ChatType, UserSearchItem } from "../chat/types";
import { useChatStore } from "../store/chatStore"; import { useChatStore } from "../store/chatStore";
type CreateMode = "group" | "channel"; type CreateMode = "group" | "channel";
type DialogMode = "none" | "private" | "group" | "channel"; type DialogMode = "none" | "private" | "group" | "channel" | "invite";
export function NewChatPanel() { export function NewChatPanel() {
const [dialog, setDialog] = useState<DialogMode>("none"); const [dialog, setDialog] = useState<DialogMode>("none");
@@ -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 [inviteToken, setInviteToken] = useState("");
const [isPublic, setIsPublic] = useState(false); const [isPublic, setIsPublic] = useState(false);
const [results, setResults] = useState<UserSearchItem[]>([]); const [results, setResults] = useState<UserSearchItem[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@@ -110,6 +111,7 @@ export function NewChatPanel() {
setQuery(""); setQuery("");
setResults([]); setResults([]);
setIsPublic(false); setIsPublic(false);
setInviteToken("");
} }
return ( return (
@@ -129,6 +131,9 @@ export function NewChatPanel() {
<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 New Message
</button> </button>
<button className="block w-full rounded-lg px-3 py-2 text-left text-sm hover:bg-slate-800" onClick={() => { setDialog("invite"); setMenuOpen(false); }}>
Join by Link
</button>
</div> </div>
) : null} ) : null}
<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)}> <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)}>
@@ -141,7 +146,7 @@ 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="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"> <div className="mb-2 flex items-center justify-between">
<p className="text-sm font-semibold"> <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" : "Join by Link"}
</p> </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> </div>
@@ -178,6 +183,43 @@ export function NewChatPanel() {
</form> </form>
) : null} ) : null}
{dialog === "invite" ? (
<form
className="space-y-2"
onSubmit={async (e) => {
e.preventDefault();
if (!inviteToken.trim()) {
setError("Invite token is required");
return;
}
setLoading(true);
setError(null);
try {
const raw = inviteToken.trim();
const match = raw.match(/[?&]token=([^&]+)/i);
const token = match ? decodeURIComponent(match[1]) : raw;
const chat = await joinByInvite(token);
await refreshChatsAndSelect(chat.id);
closeDialog();
} catch {
setError("Failed to join by invite");
} finally {
setLoading(false);
}
}}
>
<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="Invite link or token"
value={inviteToken}
onChange={(e) => setInviteToken(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">
Join
</button>
</form>
) : null}
{error ? <p className="mt-2 text-xs text-red-400">{error}</p> : null} {error ? <p className="mt-2 text-xs text-red-400">{error}</p> : null}
</div> </div>
</div> </div>

View File

@@ -0,0 +1,32 @@
function escapeHtml(input: string): string {
return input
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
function sanitizeHref(input: string): string {
const normalized = input.trim();
if (/^https?:\/\//i.test(normalized)) {
return normalized;
}
return "#";
}
export function formatMessageHtml(text: string): string {
let html = escapeHtml(text);
html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_m, label: string, href: string) => {
const safeHref = sanitizeHref(href);
return `<a href="${safeHref}" target="_blank" rel="noreferrer" class="underline text-sky-300">${label}</a>`;
});
html = html.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
html = html.replace(/\*([^*]+)\*/g, "<em>$1</em>");
html = html.replace(/__([^_]+)__/g, "<u>$1</u>");
html = html.replace(/`([^`]+)`/g, "<code class=\"rounded bg-slate-700/60 px-1 py-0.5 text-[12px]\">$1</code>");
html = html.replace(/\|\|([^|]+)\|\|/g, "<span class=\"rounded bg-slate-700/80 px-1 text-transparent hover:text-inherit\">$1</span>");
html = html.replace(/\n/g, "<br/>");
return html;
}

View File

@@ -1 +1 @@
{"root":["./src/main.tsx","./src/vite-env.d.ts","./src/api/auth.ts","./src/api/chats.ts","./src/api/http.ts","./src/api/notifications.ts","./src/api/search.ts","./src/api/users.ts","./src/app/app.tsx","./src/chat/types.ts","./src/components/apperrorboundary.tsx","./src/components/authpanel.tsx","./src/components/chatinfopanel.tsx","./src/components/chatlist.tsx","./src/components/messagecomposer.tsx","./src/components/messagelist.tsx","./src/components/newchatpanel.tsx","./src/hooks/userealtime.ts","./src/pages/authpage.tsx","./src/pages/chatspage.tsx","./src/store/authstore.ts","./src/store/chatstore.ts","./src/utils/format.ts","./src/utils/ws.ts"],"version":"5.9.2"} {"root":["./src/main.tsx","./src/vite-env.d.ts","./src/api/auth.ts","./src/api/chats.ts","./src/api/http.ts","./src/api/notifications.ts","./src/api/search.ts","./src/api/users.ts","./src/app/app.tsx","./src/chat/types.ts","./src/components/apperrorboundary.tsx","./src/components/authpanel.tsx","./src/components/chatinfopanel.tsx","./src/components/chatlist.tsx","./src/components/messagecomposer.tsx","./src/components/messagelist.tsx","./src/components/newchatpanel.tsx","./src/hooks/userealtime.ts","./src/pages/authpage.tsx","./src/pages/chatspage.tsx","./src/store/authstore.ts","./src/store/chatstore.ts","./src/utils/format.ts","./src/utils/formatmessage.tsx","./src/utils/ws.ts"],"version":"5.9.2"}