Compare commits
7 Commits
6adb8c24d7
...
52c41b6958
| Author | SHA1 | Date | |
|---|---|---|---|
| 52c41b6958 | |||
| f01bbda14e | |||
| cc70394960 | |||
| 7c4a5f990d | |||
| 8cdcd9531d | |||
| fdf973eeab | |||
| 76f008d635 |
45
alembic/versions/0013_message_reactions.py
Normal file
45
alembic/versions/0013_message_reactions.py
Normal 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")
|
||||
|
||||
43
alembic/versions/0014_chat_user_settings.py
Normal file
43
alembic/versions/0014_chat_user_settings.py
Normal 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")
|
||||
|
||||
28
alembic/versions/0015_chat_pin_fields.py
Normal file
28
alembic/versions/0015_chat_pin_fields.py
Normal 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")
|
||||
|
||||
46
alembic/versions/0016_chat_invite_links.py
Normal file
46
alembic/versions/0016_chat_invite_links.py
Normal 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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 } });
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
32
web/src/utils/formatMessage.tsx
Normal file
32
web/src/utils/formatMessage.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
function escapeHtml(input: string): string {
|
||||
return input
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -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"}
|
||||
Reference in New Issue
Block a user