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(),
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.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
@@ -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]]:
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():
q = f"%{query.strip()}%"
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),
)
)
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(
@@ -80,6 +92,34 @@ async def list_user_chats(
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:
result = await db.execute(select(Chat).where(Chat.id == chat_id))
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:
setting = await get_chat_notification_setting(db, chat_id=chat_id, user_id=user_id)
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,
ChatDiscoverRead,
ChatDeleteRequest,
ChatInviteLinkRead,
ChatJoinByInviteRequest,
ChatMemberAddRequest,
ChatMemberRead,
ChatMemberRoleUpdateRequest,
@@ -19,6 +21,7 @@ from app.chats.schemas import (
from app.chats.service import (
add_chat_member_for_user,
create_chat_for_user,
create_chat_invite_link_for_user,
clear_chat_for_user,
delete_chat_for_user,
discover_public_chats_for_user,
@@ -27,11 +30,14 @@ from app.chats.service import (
get_chat_notification_settings_for_user,
get_chats_for_user,
join_public_chat_for_user,
join_chat_by_invite_for_user,
leave_chat_for_user,
pin_chat_message_for_user,
remove_chat_member_for_user,
serialize_chat_for_user,
serialize_chats_for_user,
set_chat_archived_for_user,
set_chat_pinned_for_user,
update_chat_member_role_for_user,
update_chat_notification_settings_for_user,
update_chat_title_for_user,
@@ -47,10 +53,18 @@ async def list_chats(
limit: int = 50,
before_id: int | None = None,
query: str | None = None,
archived: bool = False,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> 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)
@@ -228,3 +242,62 @@ async def update_chat_notifications(
user_id=current_user.id,
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
is_public: bool = False
is_saved: bool = False
archived: bool = False
pinned: bool = False
unread_count: int = 0
pinned_message_id: int | None = None
members_count: int | None = None
@@ -85,3 +87,13 @@ class ChatNotificationSettingsRead(BaseModel):
class ChatNotificationSettingsUpdate(BaseModel):
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 sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
@@ -8,12 +10,15 @@ from app.chats.schemas import (
ChatCreateRequest,
ChatDeleteRequest,
ChatDiscoverRead,
ChatJoinByInviteRequest,
ChatNotificationSettingsRead,
ChatNotificationSettingsUpdate,
ChatInviteLinkRead,
ChatPinMessageRequest,
ChatRead,
ChatTitleUpdateRequest,
)
from app.config.settings import settings
from app.messages.repository import (
delete_messages_in_chat,
get_hidden_message,
@@ -62,6 +67,9 @@ async def serialize_chat_for_user(db: AsyncSession, *, user_id: int, chat: Chat)
subscribers_count = members_count
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(
{
@@ -74,6 +82,8 @@ async def serialize_chat_for_user(db: AsyncSession, *, user_id: int, chat: Chat)
"description": chat.description,
"is_public": chat.is_public,
"is_saved": chat.is_saved,
"archived": archived,
"pinned": pinned,
"unread_count": unread_count,
"pinned_message_id": chat.pinned_message_id,
"members_count": members_count,
@@ -167,14 +177,18 @@ async def get_chats_for_user(
limit: int = 50,
before_id: int | None = None,
query: str | None = None,
archived: bool = False,
) -> list[Chat]:
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)
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]
if archived:
chats = await repository.list_archived_user_chats(db, user_id=user_id, limit=safe_limit, before_id=before_id)
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
@@ -480,3 +494,70 @@ async def update_chat_notification_settings_for_user(
user_id=user_id,
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.chats.models import Chat, ChatMember
from app.chats.models import Chat, ChatInviteLink, ChatMember, ChatUserSetting
from app.email.models import EmailLog
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.users.models import User
__all__ = [
"Attachment",
"Chat",
"ChatInviteLink",
"ChatMember",
"ChatUserSetting",
"EmailLog",
"EmailVerificationToken",
"Message",
"MessageIdempotencyKey",
"MessageReaction",
"MessageReceipt",
"NotificationLog",
"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)
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 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 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(
@@ -178,3 +178,44 @@ async def create_message_receipt(
db.add(receipt)
await db.flush()
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.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 (
create_chat_message,
delete_message,
delete_message_for_all,
forward_message,
forward_message_bulk,
get_messages,
list_message_reactions,
search_messages,
toggle_message_reaction,
update_message,
)
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)
await realtime_gateway.publish_message_created(message=message, sender_id=current_user.id)
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):
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.models import Message
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.users.repository import has_block_relation_between_users
from app.users.service import get_user_by_id
@@ -267,3 +275,86 @@ async def forward_message(
await db.commit()
await db.refresh(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 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";
export interface ChatNotificationSettings {
@@ -8,9 +8,12 @@ export interface ChatNotificationSettings {
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", {
params: query?.trim() ? { query: query.trim() } : undefined
params: {
...(query?.trim() ? { query: query.trim() } : {}),
...(archived ? { archived: true } : {})
}
});
return data;
}
@@ -160,6 +163,23 @@ export async function forwardMessage(messageId: number, targetChatId: number): P
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> {
const { data } = await http.post<Chat>(`/chats/${chatId}/pin`, {
message_id: messageId
@@ -175,6 +195,36 @@ export async function clearChat(chatId: number): Promise<void> {
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> {
await http.delete(`/messages/${messageId}`, { params: { for_all: forAll } });
}

View File

@@ -12,6 +12,8 @@ export interface Chat {
description?: string | null;
is_public?: boolean;
is_saved?: boolean;
archived?: boolean;
pinned?: boolean;
unread_count?: number;
pinned_message_id?: number | null;
members_count?: number | null;
@@ -58,6 +60,12 @@ export interface Message {
is_pending?: boolean;
}
export interface MessageReaction {
emoji: string;
count: number;
reacted: boolean;
}
export interface AuthUser {
id: number;
email: string;
@@ -83,3 +91,9 @@ export interface UserSearchItem {
username: string;
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 {
addChatMember,
createInviteLink,
getChatNotificationSettings,
getChatDetail,
leaveChat,
@@ -39,6 +40,7 @@ export function ChatInfoPanel({ chatId, open, onClose }: Props) {
const [savingMute, setSavingMute] = useState(false);
const [counterpartBlocked, setCounterpartBlocked] = 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 isGroupLike = chat?.type === "group" || chat?.type === "channel";
@@ -183,6 +185,27 @@ export function ChatInfoPanel({ chatId, open, onClose }: Props) {
) : 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}
{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>
{showMembersSection ? (

View File

@@ -1,6 +1,6 @@
import { useEffect, useState } from "react";
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 type { DiscoverChat, Message, UserSearchItem } from "../chat/types";
import { updateMyProfile } from "../api/users";
@@ -20,8 +20,9 @@ export function ChatList() {
const [userResults, setUserResults] = useState<UserSearchItem[]>([]);
const [discoverResults, setDiscoverResults] = useState<DiscoverChat[]>([]);
const [messageResults, setMessageResults] = useState<Message[]>([]);
const [archivedChats, setArchivedChats] = useState<typeof chats>([]);
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 [ctxPos, setCtxPos] = useState<{ x: number; y: number } | null>(null);
const [deleteModalChatId, setDeleteModalChatId] = useState<number | null>(null);
@@ -45,6 +46,28 @@ export function ChatList() {
void 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(() => {
const term = search.trim();
if (term.replace("@", "").length < 2) {
@@ -108,6 +131,9 @@ export function ChatList() {
}, [me]);
const filteredChats = chats.filter((chat) => {
if (chat.archived) {
return false;
}
if (tab === "people") {
return chat.type === "private";
}
@@ -120,13 +146,16 @@ export function ChatList() {
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: "people", label: "Люди" },
{ id: "groups", label: "Groups" },
{ id: "channels", label: "Каналы" }
{ id: "channels", label: "Каналы" },
{ id: "archived", label: "Архив" }
];
const visibleChats = tab === "archived" ? archivedChats : filteredChats;
return (
<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">
@@ -238,7 +267,7 @@ export function ChatList() {
) : null}
</div>
<div className="tg-scrollbar flex-1 overflow-auto">
{filteredChats.map((chat) => (
{visibleChats.map((chat) => (
<button
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"
@@ -254,7 +283,7 @@ export function ChatList() {
>
<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">
{(chat.display_title || chat.title || chat.type).slice(0, 1)}
{chat.pinned ? "📌" : (chat.display_title || chat.title || chat.type).slice(0, 1)}
</div>
<div className="min-w-0 flex-1">
<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"}
</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>,
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 { useAuthStore } from "../store/authStore";
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") {
if (!activeChatId || !me) {
return;
@@ -314,10 +321,12 @@ export function MessageComposer() {
}}
/>
</label>
<input
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"
<textarea
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..."
rows={1}
value={text}
onKeyDown={onComposerKeyDown}
onChange={(e) => {
const next = e.target.value;
setText(next);

View File

@@ -1,10 +1,11 @@
import { useEffect, useMemo, useState } from "react";
import { createPortal } from "react-dom";
import { deleteMessage, forwardMessage, pinMessage } from "../api/chats";
import type { Message } from "../chat/types";
import { deleteMessage, forwardMessageBulk, listMessageReactions, pinMessage, toggleMessageReaction } from "../api/chats";
import type { Message, MessageReaction } from "../chat/types";
import { useAuthStore } from "../store/authStore";
import { useChatStore } from "../store/chatStore";
import { formatTime } from "../utils/format";
import { formatMessageHtml } from "../utils/formatMessage";
type ContextMenuState = {
x: number;
@@ -40,11 +41,13 @@ export function MessageList() {
const [forwardQuery, setForwardQuery] = useState("");
const [forwardError, setForwardError] = useState<string | null>(null);
const [isForwarding, setIsForwarding] = useState(false);
const [forwardSelectedChatIds, setForwardSelectedChatIds] = useState<Set<number>>(new Set());
const [deleteMessageId, setDeleteMessageId] = useState<number | null>(null);
const [deleteError, setDeleteError] = useState<string | null>(null);
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
const [pendingDelete, setPendingDelete] = useState<PendingDeleteState>(null);
const [undoTick, setUndoTick] = useState(0);
const [reactionsByMessage, setReactionsByMessage] = useState<Record<number, MessageReaction[]>>({});
const messages = useMemo(() => {
if (!activeChatId) {
@@ -85,6 +88,7 @@ export function MessageList() {
}
setCtx(null);
setForwardMessageId(null);
setForwardSelectedChatIds(new Set());
setDeleteMessageId(null);
setSelectedIds(new Set());
};
@@ -97,6 +101,8 @@ export function MessageList() {
setCtx(null);
setDeleteMessageId(null);
setForwardMessageId(null);
setForwardSelectedChatIds(new Set());
setReactionsByMessage({});
}, [activeChatId]);
useEffect(() => {
@@ -125,13 +131,19 @@ export function MessageList() {
}
const chatId = activeChatId;
async function handleForward(targetChatId: number) {
async function handleForwardSubmit() {
if (!forwardMessageId) return;
const targetChatIds = [...forwardSelectedChatIds];
if (!targetChatIds.length) {
setForwardError("Select at least one chat");
return;
}
setIsForwarding(true);
setForwardError(null);
try {
await forwardMessage(forwardMessageId, targetChatId);
await forwardMessageBulk(forwardMessageId, targetChatIds);
setForwardMessageId(null);
setForwardSelectedChatIds(new Set());
setForwardQuery("");
} catch {
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) {
setSelectedIds((prev) => {
const next = new Set(prev);
@@ -314,6 +347,7 @@ export function MessageList() {
}}
onContextMenu={(e) => {
e.preventDefault();
void ensureReactionsLoaded(message.id);
const pos = getSafeContextPosition(e.clientX, e.clientY, 160, 108);
setCtx({ x: pos.x, y: pos.y, messageId: message.id });
}}
@@ -335,6 +369,24 @@ export function MessageList() {
</div>
) : null}
{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"}`}>
<span>{formatTime(message.created_at)}</span>
{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);
setForwardQuery("");
setForwardError(null);
setForwardSelectedChatIds(new Set());
setCtx(null);
}}
>
@@ -417,10 +470,20 @@ export function MessageList() {
<div className="tg-scrollbar max-h-56 space-y-1 overflow-auto">
{forwardTargets.map((chat) => (
<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}
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 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}
</div>
{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)}>
Cancel
</button>
<div className="mt-3 flex gap-2">
<button className="w-full rounded bg-sky-500 px-3 py-2 text-sm font-semibold text-slate-950" onClick={() => void handleForwardSubmit()}>
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>
) : null}
@@ -488,7 +556,7 @@ function renderContent(messageType: string, text: string | null) {
</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 {

View File

@@ -1,11 +1,11 @@
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 type { ChatType, UserSearchItem } from "../chat/types";
import { useChatStore } from "../store/chatStore";
type CreateMode = "group" | "channel";
type DialogMode = "none" | "private" | "group" | "channel";
type DialogMode = "none" | "private" | "group" | "channel" | "invite";
export function NewChatPanel() {
const [dialog, setDialog] = useState<DialogMode>("none");
@@ -13,6 +13,7 @@ export function NewChatPanel() {
const [title, setTitle] = useState("");
const [handle, setHandle] = useState("");
const [description, setDescription] = useState("");
const [inviteToken, setInviteToken] = useState("");
const [isPublic, setIsPublic] = useState(false);
const [results, setResults] = useState<UserSearchItem[]>([]);
const [loading, setLoading] = useState(false);
@@ -110,6 +111,7 @@ export function NewChatPanel() {
setQuery("");
setResults([]);
setIsPublic(false);
setInviteToken("");
}
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); }}>
New Message
</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>
) : 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)}>
@@ -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="mb-2 flex items-center justify-between">
<p className="text-sm font-semibold">
{dialog === "private" ? "New Message" : dialog === "group" ? "New Group" : "New Channel"}
{dialog === "private" ? "New Message" : dialog === "group" ? "New Group" : dialog === "channel" ? "New Channel" : "Join by Link"}
</p>
<button className="rounded-md bg-slate-700/80 px-2 py-1 text-xs" onClick={closeDialog}>Close</button>
</div>
@@ -178,6 +183,43 @@ export function NewChatPanel() {
</form>
) : 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}
</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"}