feat: add saved messages, public chat discovery/join, and chat delete options
All checks were successful
CI / test (push) Successful in 19s
All checks were successful
CI / test (push) Successful in 19s
- add Saved Messages system chat with dedicated API - add public group/channel metadata and discover/join endpoints - add chat delete flow with for_all option and channel-wide delete - switch message actions to context menu and improve reply/forward visuals - improve microphone permission handling for voice recording
This commit is contained in:
37
alembic/versions/0005_chat_public_saved_features.py
Normal file
37
alembic/versions/0005_chat_public_saved_features.py
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
"""chat public and saved features
|
||||||
|
|
||||||
|
Revision ID: 0005_chat_public_saved_features
|
||||||
|
Revises: 0004_reply_forward_pin
|
||||||
|
Create Date: 2026-03-08 04:10:00.000000
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
|
||||||
|
revision: str = "0005_chat_public_saved_features"
|
||||||
|
down_revision: Union[str, Sequence[str], None] = "0004_reply_forward_pin"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column("chats", sa.Column("handle", sa.String(length=64), nullable=True))
|
||||||
|
op.add_column("chats", sa.Column("description", sa.String(length=512), nullable=True))
|
||||||
|
op.add_column("chats", sa.Column("is_public", sa.Boolean(), server_default=sa.false(), nullable=False))
|
||||||
|
op.add_column("chats", sa.Column("is_saved", sa.Boolean(), server_default=sa.false(), nullable=False))
|
||||||
|
op.create_index(op.f("ix_chats_handle"), "chats", ["handle"], unique=True)
|
||||||
|
op.create_index(op.f("ix_chats_is_public"), "chats", ["is_public"], unique=False)
|
||||||
|
op.create_index(op.f("ix_chats_is_saved"), "chats", ["is_saved"], unique=False)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index(op.f("ix_chats_is_saved"), table_name="chats")
|
||||||
|
op.drop_index(op.f("ix_chats_is_public"), table_name="chats")
|
||||||
|
op.drop_index(op.f("ix_chats_handle"), table_name="chats")
|
||||||
|
op.drop_column("chats", "is_saved")
|
||||||
|
op.drop_column("chats", "is_public")
|
||||||
|
op.drop_column("chats", "description")
|
||||||
|
op.drop_column("chats", "handle")
|
||||||
@@ -2,7 +2,7 @@ from datetime import datetime
|
|||||||
from enum import Enum
|
from enum import Enum
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from sqlalchemy import DateTime, Enum as SAEnum, ForeignKey, String, UniqueConstraint, func
|
from sqlalchemy import Boolean, DateTime, Enum as SAEnum, ForeignKey, String, UniqueConstraint, func
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
from app.database.base import Base
|
from app.database.base import Base
|
||||||
@@ -30,6 +30,10 @@ class Chat(Base):
|
|||||||
id: Mapped[int] = mapped_column(primary_key=True, index=True)
|
id: Mapped[int] = mapped_column(primary_key=True, index=True)
|
||||||
type: Mapped[ChatType] = mapped_column(SAEnum(ChatType), nullable=False, index=True)
|
type: Mapped[ChatType] = mapped_column(SAEnum(ChatType), nullable=False, index=True)
|
||||||
title: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
title: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
handle: Mapped[str | None] = mapped_column(String(64), nullable=True, unique=True, index=True)
|
||||||
|
description: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||||
|
is_public: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="false")
|
||||||
|
is_saved: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="false")
|
||||||
pinned_message_id: Mapped[int | None] = mapped_column(ForeignKey("messages.id", ondelete="SET NULL"), nullable=True, index=True)
|
pinned_message_id: Mapped[int | None] = mapped_column(ForeignKey("messages.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,29 @@ async def create_chat(db: AsyncSession, *, chat_type: ChatType, title: str | Non
|
|||||||
return chat
|
return chat
|
||||||
|
|
||||||
|
|
||||||
|
async def create_chat_with_meta(
|
||||||
|
db: AsyncSession,
|
||||||
|
*,
|
||||||
|
chat_type: ChatType,
|
||||||
|
title: str | None,
|
||||||
|
handle: str | None,
|
||||||
|
description: str | None,
|
||||||
|
is_public: bool,
|
||||||
|
is_saved: bool = False,
|
||||||
|
) -> Chat:
|
||||||
|
chat = Chat(
|
||||||
|
type=chat_type,
|
||||||
|
title=title,
|
||||||
|
handle=handle,
|
||||||
|
description=description,
|
||||||
|
is_public=is_public,
|
||||||
|
is_saved=is_saved,
|
||||||
|
)
|
||||||
|
db.add(chat)
|
||||||
|
await db.flush()
|
||||||
|
return chat
|
||||||
|
|
||||||
|
|
||||||
async def add_chat_member(db: AsyncSession, *, chat_id: int, user_id: int, role: ChatMemberRole) -> ChatMember:
|
async def add_chat_member(db: AsyncSession, *, chat_id: int, user_id: int, role: ChatMemberRole) -> ChatMember:
|
||||||
member = ChatMember(chat_id=chat_id, user_id=user_id, role=role)
|
member = ChatMember(chat_id=chat_id, user_id=user_id, role=role)
|
||||||
db.add(member)
|
db.add(member)
|
||||||
@@ -61,6 +84,11 @@ async def get_chat_by_id(db: AsyncSession, chat_id: int) -> Chat | None:
|
|||||||
return result.scalar_one_or_none()
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
async def get_chat_by_handle(db: AsyncSession, handle: str) -> Chat | None:
|
||||||
|
result = await db.execute(select(Chat).where(Chat.handle == handle))
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
async def get_chat_member(db: AsyncSession, *, chat_id: int, user_id: int) -> ChatMember | None:
|
async def get_chat_member(db: AsyncSession, *, chat_id: int, user_id: int) -> ChatMember | None:
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(ChatMember).where(
|
select(ChatMember).where(
|
||||||
@@ -83,6 +111,38 @@ async def list_user_chat_ids(db: AsyncSession, *, user_id: int) -> list[int]:
|
|||||||
return list(result.scalars().all())
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
|
||||||
|
async def find_saved_chat_for_user(db: AsyncSession, *, user_id: int) -> Chat | None:
|
||||||
|
stmt = (
|
||||||
|
select(Chat)
|
||||||
|
.join(ChatMember, ChatMember.chat_id == Chat.id)
|
||||||
|
.where(ChatMember.user_id == user_id, Chat.is_saved.is_(True))
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
async def discover_public_chats(
|
||||||
|
db: AsyncSession,
|
||||||
|
*,
|
||||||
|
user_id: int,
|
||||||
|
query: str | None = None,
|
||||||
|
limit: int = 30,
|
||||||
|
) -> list[tuple[Chat, bool]]:
|
||||||
|
q = select(Chat).where(Chat.is_public.is_(True), Chat.type.in_([ChatType.GROUP, ChatType.CHANNEL]), Chat.is_saved.is_(False))
|
||||||
|
if query and query.strip():
|
||||||
|
like = f"%{query.strip()}%"
|
||||||
|
q = q.where(or_(Chat.title.ilike(like), Chat.handle.ilike(like), Chat.description.ilike(like)))
|
||||||
|
q = q.order_by(Chat.id.desc()).limit(limit)
|
||||||
|
chats = list((await db.execute(q)).scalars().all())
|
||||||
|
if not chats:
|
||||||
|
return []
|
||||||
|
chat_ids = [c.id for c in chats]
|
||||||
|
m_stmt = select(ChatMember.chat_id).where(ChatMember.user_id == user_id, ChatMember.chat_id.in_(chat_ids))
|
||||||
|
memberships = set((await db.execute(m_stmt)).scalars().all())
|
||||||
|
return [(chat, chat.id in memberships) for chat in chats]
|
||||||
|
|
||||||
|
|
||||||
async def find_private_chat_between_users(db: AsyncSession, *, user_a_id: int, user_b_id: int) -> Chat | None:
|
async def find_private_chat_between_users(db: AsyncSession, *, user_a_id: int, user_b_id: int) -> Chat | None:
|
||||||
cm_a = aliased(ChatMember)
|
cm_a = aliased(ChatMember)
|
||||||
cm_b = aliased(ChatMember)
|
cm_b = aliased(ChatMember)
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ from app.auth.service import get_current_user
|
|||||||
from app.chats.schemas import (
|
from app.chats.schemas import (
|
||||||
ChatCreateRequest,
|
ChatCreateRequest,
|
||||||
ChatDetailRead,
|
ChatDetailRead,
|
||||||
|
ChatDiscoverRead,
|
||||||
|
ChatDeleteRequest,
|
||||||
ChatMemberAddRequest,
|
ChatMemberAddRequest,
|
||||||
ChatMemberRead,
|
ChatMemberRead,
|
||||||
ChatMemberRoleUpdateRequest,
|
ChatMemberRoleUpdateRequest,
|
||||||
@@ -15,8 +17,12 @@ from app.chats.schemas import (
|
|||||||
from app.chats.service import (
|
from app.chats.service import (
|
||||||
add_chat_member_for_user,
|
add_chat_member_for_user,
|
||||||
create_chat_for_user,
|
create_chat_for_user,
|
||||||
|
delete_chat_for_user,
|
||||||
|
discover_public_chats_for_user,
|
||||||
|
ensure_saved_messages_chat,
|
||||||
get_chat_for_user,
|
get_chat_for_user,
|
||||||
get_chats_for_user,
|
get_chats_for_user,
|
||||||
|
join_public_chat_for_user,
|
||||||
leave_chat_for_user,
|
leave_chat_for_user,
|
||||||
pin_chat_message_for_user,
|
pin_chat_message_for_user,
|
||||||
remove_chat_member_for_user,
|
remove_chat_member_for_user,
|
||||||
@@ -40,6 +46,24 @@ async def list_chats(
|
|||||||
return await get_chats_for_user(db, user_id=current_user.id, limit=limit, before_id=before_id, query=query)
|
return await get_chats_for_user(db, user_id=current_user.id, limit=limit, before_id=before_id, query=query)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/saved", response_model=ChatRead)
|
||||||
|
async def get_saved_messages_chat(
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
) -> ChatRead:
|
||||||
|
return await ensure_saved_messages_chat(db, user_id=current_user.id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/discover", response_model=list[ChatDiscoverRead])
|
||||||
|
async def discover_chats(
|
||||||
|
query: str | None = None,
|
||||||
|
limit: int = 30,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
) -> list[ChatDiscoverRead]:
|
||||||
|
return await discover_public_chats_for_user(db, user_id=current_user.id, query=query, limit=limit)
|
||||||
|
|
||||||
|
|
||||||
@router.post("", response_model=ChatRead)
|
@router.post("", response_model=ChatRead)
|
||||||
async def create_chat(
|
async def create_chat(
|
||||||
payload: ChatCreateRequest,
|
payload: ChatCreateRequest,
|
||||||
@@ -49,6 +73,15 @@ async def create_chat(
|
|||||||
return await create_chat_for_user(db, creator_id=current_user.id, payload=payload)
|
return await create_chat_for_user(db, creator_id=current_user.id, payload=payload)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{chat_id}/join", response_model=ChatRead)
|
||||||
|
async def join_chat(
|
||||||
|
chat_id: int,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
) -> ChatRead:
|
||||||
|
return await join_public_chat_for_user(db, chat_id=chat_id, user_id=current_user.id)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{chat_id}", response_model=ChatDetailRead)
|
@router.get("/{chat_id}", response_model=ChatDetailRead)
|
||||||
async def get_chat(
|
async def get_chat(
|
||||||
chat_id: int,
|
chat_id: int,
|
||||||
@@ -132,6 +165,16 @@ async def leave_chat(
|
|||||||
await leave_chat_for_user(db, chat_id=chat_id, user_id=current_user.id)
|
await leave_chat_for_user(db, chat_id=chat_id, user_id=current_user.id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{chat_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
async def delete_chat(
|
||||||
|
chat_id: int,
|
||||||
|
for_all: bool = False,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
) -> None:
|
||||||
|
await delete_chat_for_user(db, chat_id=chat_id, user_id=current_user.id, payload=ChatDeleteRequest(for_all=for_all))
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{chat_id}/pin", response_model=ChatRead)
|
@router.post("/{chat_id}/pin", response_model=ChatRead)
|
||||||
async def pin_chat_message(
|
async def pin_chat_message(
|
||||||
chat_id: int,
|
chat_id: int,
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ class ChatRead(BaseModel):
|
|||||||
id: int
|
id: int
|
||||||
type: ChatType
|
type: ChatType
|
||||||
title: str | None = None
|
title: str | None = None
|
||||||
|
handle: str | None = None
|
||||||
|
description: str | None = None
|
||||||
|
is_public: bool = False
|
||||||
|
is_saved: bool = False
|
||||||
pinned_message_id: int | None = None
|
pinned_message_id: int | None = None
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
|
||||||
@@ -31,6 +35,9 @@ class ChatDetailRead(ChatRead):
|
|||||||
class ChatCreateRequest(BaseModel):
|
class ChatCreateRequest(BaseModel):
|
||||||
type: ChatType
|
type: ChatType
|
||||||
title: str | None = Field(default=None, max_length=255)
|
title: str | None = Field(default=None, max_length=255)
|
||||||
|
handle: str | None = Field(default=None, max_length=64)
|
||||||
|
description: str | None = Field(default=None, max_length=512)
|
||||||
|
is_public: bool = False
|
||||||
member_ids: list[int] = Field(default_factory=list)
|
member_ids: list[int] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
@@ -48,3 +55,11 @@ class ChatTitleUpdateRequest(BaseModel):
|
|||||||
|
|
||||||
class ChatPinMessageRequest(BaseModel):
|
class ChatPinMessageRequest(BaseModel):
|
||||||
message_id: int | None = None
|
message_id: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ChatDeleteRequest(BaseModel):
|
||||||
|
for_all: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class ChatDiscoverRead(ChatRead):
|
||||||
|
is_member: bool
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
|
|
||||||
from app.chats import repository
|
from app.chats import repository
|
||||||
from app.chats.models import Chat, ChatMember, ChatMemberRole, ChatType
|
from app.chats.models import Chat, ChatMember, ChatMemberRole, ChatType
|
||||||
from app.chats.schemas import ChatCreateRequest, ChatPinMessageRequest, ChatTitleUpdateRequest
|
from app.chats.schemas import ChatCreateRequest, ChatDeleteRequest, ChatDiscoverRead, ChatPinMessageRequest, ChatTitleUpdateRequest
|
||||||
from app.messages.repository import get_message_by_id
|
from app.messages.repository import get_message_by_id
|
||||||
from app.users.repository import get_user_by_id
|
from app.users.repository import get_user_by_id
|
||||||
|
|
||||||
@@ -31,13 +31,29 @@ async def create_chat_for_user(db: AsyncSession, *, creator_id: int, payload: Ch
|
|||||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
detail="Group and channel chats require title.",
|
detail="Group and channel chats require title.",
|
||||||
)
|
)
|
||||||
|
if payload.type == ChatType.PRIVATE and payload.is_public:
|
||||||
|
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Private chat cannot be public")
|
||||||
|
if payload.is_public and payload.type in {ChatType.GROUP, ChatType.CHANNEL} and not payload.handle:
|
||||||
|
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Public chat requires handle")
|
||||||
|
if payload.handle:
|
||||||
|
existing = await repository.get_chat_by_handle(db, payload.handle.strip().lower())
|
||||||
|
if existing:
|
||||||
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Handle is already taken")
|
||||||
|
|
||||||
for member_id in member_ids:
|
for member_id in member_ids:
|
||||||
user = await get_user_by_id(db, member_id)
|
user = await get_user_by_id(db, member_id)
|
||||||
if not user:
|
if not user:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"User {member_id} not found")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"User {member_id} not found")
|
||||||
|
|
||||||
chat = await repository.create_chat(db, chat_type=payload.type, title=payload.title)
|
chat = await repository.create_chat_with_meta(
|
||||||
|
db,
|
||||||
|
chat_type=payload.type,
|
||||||
|
title=payload.title,
|
||||||
|
handle=payload.handle.strip().lower() if payload.handle else None,
|
||||||
|
description=payload.description,
|
||||||
|
is_public=payload.is_public,
|
||||||
|
is_saved=False,
|
||||||
|
)
|
||||||
await repository.add_chat_member(db, chat_id=chat.id, user_id=creator_id, role=ChatMemberRole.OWNER)
|
await repository.add_chat_member(db, chat_id=chat.id, user_id=creator_id, role=ChatMemberRole.OWNER)
|
||||||
|
|
||||||
default_role = ChatMemberRole.MEMBER
|
default_role = ChatMemberRole.MEMBER
|
||||||
@@ -57,7 +73,13 @@ async def get_chats_for_user(
|
|||||||
query: str | None = None,
|
query: str | None = None,
|
||||||
) -> list[Chat]:
|
) -> list[Chat]:
|
||||||
safe_limit = max(1, min(limit, 100))
|
safe_limit = max(1, min(limit, 100))
|
||||||
return await repository.list_user_chats(db, user_id=user_id, limit=safe_limit, before_id=before_id, query=query)
|
chats = await repository.list_user_chats(db, user_id=user_id, limit=safe_limit, before_id=before_id, query=query)
|
||||||
|
saved = await ensure_saved_messages_chat(db, user_id=user_id)
|
||||||
|
if saved.id not in [c.id for c in chats]:
|
||||||
|
chats = [saved, *chats]
|
||||||
|
else:
|
||||||
|
chats = [saved, *[c for c in chats if c.id != saved.id]]
|
||||||
|
return chats
|
||||||
|
|
||||||
|
|
||||||
async def get_chat_for_user(db: AsyncSession, *, chat_id: int, user_id: int) -> tuple[Chat, list]:
|
async def get_chat_for_user(db: AsyncSession, *, chat_id: int, user_id: int) -> tuple[Chat, list]:
|
||||||
@@ -222,8 +244,8 @@ async def pin_chat_message_for_user(
|
|||||||
payload: ChatPinMessageRequest,
|
payload: ChatPinMessageRequest,
|
||||||
) -> Chat:
|
) -> Chat:
|
||||||
chat, membership = await _get_chat_and_membership(db, chat_id=chat_id, user_id=user_id)
|
chat, membership = await _get_chat_and_membership(db, chat_id=chat_id, user_id=user_id)
|
||||||
_ensure_group_or_channel(chat.type)
|
if chat.type in {ChatType.GROUP, ChatType.CHANNEL}:
|
||||||
_ensure_manage_permission(membership.role)
|
_ensure_manage_permission(membership.role)
|
||||||
if payload.message_id is None:
|
if payload.message_id is None:
|
||||||
chat.pinned_message_id = None
|
chat.pinned_message_id = None
|
||||||
await db.commit()
|
await db.commit()
|
||||||
@@ -237,3 +259,71 @@ async def pin_chat_message_for_user(
|
|||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(chat)
|
await db.refresh(chat)
|
||||||
return chat
|
return chat
|
||||||
|
|
||||||
|
|
||||||
|
async def ensure_saved_messages_chat(db: AsyncSession, *, user_id: int) -> Chat:
|
||||||
|
saved = await repository.find_saved_chat_for_user(db, user_id=user_id)
|
||||||
|
if saved:
|
||||||
|
return saved
|
||||||
|
chat = await repository.create_chat_with_meta(
|
||||||
|
db,
|
||||||
|
chat_type=ChatType.PRIVATE,
|
||||||
|
title="Saved Messages",
|
||||||
|
handle=None,
|
||||||
|
description="Personal cloud chat",
|
||||||
|
is_public=False,
|
||||||
|
is_saved=True,
|
||||||
|
)
|
||||||
|
await repository.add_chat_member(db, chat_id=chat.id, user_id=user_id, role=ChatMemberRole.OWNER)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(chat)
|
||||||
|
return chat
|
||||||
|
|
||||||
|
|
||||||
|
async def discover_public_chats_for_user(db: AsyncSession, *, user_id: int, query: str | None, limit: int = 30) -> list[ChatDiscoverRead]:
|
||||||
|
rows = await repository.discover_public_chats(db, user_id=user_id, query=query, limit=max(1, min(limit, 50)))
|
||||||
|
return [
|
||||||
|
ChatDiscoverRead.model_validate(
|
||||||
|
{
|
||||||
|
"id": chat.id,
|
||||||
|
"type": chat.type,
|
||||||
|
"title": chat.title,
|
||||||
|
"handle": chat.handle,
|
||||||
|
"description": chat.description,
|
||||||
|
"is_public": chat.is_public,
|
||||||
|
"is_saved": chat.is_saved,
|
||||||
|
"pinned_message_id": chat.pinned_message_id,
|
||||||
|
"created_at": chat.created_at,
|
||||||
|
"is_member": is_member,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
for chat, is_member in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
async def join_public_chat_for_user(db: AsyncSession, *, chat_id: int, user_id: int) -> Chat:
|
||||||
|
chat = await repository.get_chat_by_id(db, chat_id)
|
||||||
|
if not chat:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Chat not found")
|
||||||
|
if not chat.is_public or chat.type not in {ChatType.GROUP, ChatType.CHANNEL}:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Chat is not joinable")
|
||||||
|
membership = await repository.get_chat_member(db, chat_id=chat_id, user_id=user_id)
|
||||||
|
if membership:
|
||||||
|
return chat
|
||||||
|
await repository.add_chat_member(db, chat_id=chat_id, user_id=user_id, role=ChatMemberRole.MEMBER)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(chat)
|
||||||
|
return chat
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_chat_for_user(db: AsyncSession, *, chat_id: int, user_id: int, payload: ChatDeleteRequest) -> None:
|
||||||
|
chat, membership = await _get_chat_and_membership(db, chat_id=chat_id, user_id=user_id)
|
||||||
|
delete_for_all = payload.for_all or chat.type == ChatType.CHANNEL
|
||||||
|
if delete_for_all:
|
||||||
|
if chat.type in {ChatType.GROUP, ChatType.CHANNEL} and membership.role not in {ChatMemberRole.OWNER, ChatMemberRole.ADMIN}:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Insufficient permissions")
|
||||||
|
await db.delete(chat)
|
||||||
|
await db.commit()
|
||||||
|
return
|
||||||
|
await repository.delete_chat_member(db, membership)
|
||||||
|
await db.commit()
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { http } from "./http";
|
import { http } from "./http";
|
||||||
import type { Chat, ChatType, Message, MessageType } from "../chat/types";
|
import type { Chat, ChatType, DiscoverChat, Message, MessageType } from "../chat/types";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
|
|
||||||
export async function getChats(query?: string): Promise<Chat[]> {
|
export async function getChats(query?: string): Promise<Chat[]> {
|
||||||
@@ -17,11 +17,24 @@ export async function createChat(type: ChatType, title: string | null, memberIds
|
|||||||
const { data } = await http.post<Chat>("/chats", {
|
const { data } = await http.post<Chat>("/chats", {
|
||||||
type,
|
type,
|
||||||
title,
|
title,
|
||||||
|
is_public: false,
|
||||||
member_ids: memberIds
|
member_ids: memberIds
|
||||||
});
|
});
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function createPublicChat(type: "group" | "channel", title: string, handle: string, description?: string): Promise<Chat> {
|
||||||
|
const { data } = await http.post<Chat>("/chats", {
|
||||||
|
type,
|
||||||
|
title,
|
||||||
|
handle,
|
||||||
|
description,
|
||||||
|
is_public: true,
|
||||||
|
member_ids: []
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
export async function getMessages(chatId: number, beforeId?: number): Promise<Message[]> {
|
export async function getMessages(chatId: number, beforeId?: number): Promise<Message[]> {
|
||||||
const { data } = await http.get<Message[]>(`/messages/${chatId}`, {
|
const { data } = await http.get<Message[]>(`/messages/${chatId}`, {
|
||||||
params: {
|
params: {
|
||||||
@@ -132,3 +145,24 @@ export async function pinMessage(chatId: number, messageId: number | null): Prom
|
|||||||
});
|
});
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function deleteChat(chatId: number, forAll: boolean): Promise<void> {
|
||||||
|
await http.delete(`/chats/${chatId}`, { params: { for_all: forAll } });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function discoverChats(query?: string): Promise<DiscoverChat[]> {
|
||||||
|
const { data } = await http.get<DiscoverChat[]>("/chats/discover", {
|
||||||
|
params: query?.trim() ? { query: query.trim() } : undefined
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function joinChat(chatId: number): Promise<Chat> {
|
||||||
|
const { data } = await http.post<Chat>(`/chats/${chatId}/join`);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSavedMessagesChat(): Promise<Chat> {
|
||||||
|
const { data } = await http.get<Chat>("/chats/saved");
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,10 +6,18 @@ export interface Chat {
|
|||||||
id: number;
|
id: number;
|
||||||
type: ChatType;
|
type: ChatType;
|
||||||
title: string | null;
|
title: string | null;
|
||||||
|
handle?: string | null;
|
||||||
|
description?: string | null;
|
||||||
|
is_public?: boolean;
|
||||||
|
is_saved?: boolean;
|
||||||
pinned_message_id?: number | null;
|
pinned_message_id?: number | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DiscoverChat extends Chat {
|
||||||
|
is_member: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Message {
|
export interface Message {
|
||||||
id: number;
|
id: number;
|
||||||
chat_id: number;
|
chat_id: number;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
import { deleteChat } from "../api/chats";
|
||||||
import { useAuthStore } from "../store/authStore";
|
import { useAuthStore } from "../store/authStore";
|
||||||
import { useChatStore } from "../store/chatStore";
|
import { useChatStore } from "../store/chatStore";
|
||||||
import { NewChatPanel } from "./NewChatPanel";
|
import { NewChatPanel } from "./NewChatPanel";
|
||||||
@@ -12,6 +13,10 @@ export function ChatList() {
|
|||||||
const me = useAuthStore((s) => s.me);
|
const me = useAuthStore((s) => s.me);
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [tab, setTab] = useState<"all" | "people" | "groups" | "channels">("all");
|
const [tab, setTab] = useState<"all" | "people" | "groups" | "channels">("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);
|
||||||
|
const [deleteForAll, setDeleteForAll] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
@@ -41,7 +46,7 @@ export function ChatList() {
|
|||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className="relative flex h-full w-full max-w-xs flex-col bg-slate-900/60">
|
<aside className="relative flex h-full w-full max-w-xs flex-col bg-slate-900/60" onClick={() => { setCtxChatId(null); setCtxPos(null); }}>
|
||||||
<div className="border-b border-slate-700/50 px-3 py-3">
|
<div className="border-b border-slate-700/50 px-3 py-3">
|
||||||
<div className="mb-2 flex items-center gap-2">
|
<div className="mb-2 flex items-center gap-2">
|
||||||
<button className="rounded-lg bg-slate-700/70 px-2 py-2 text-xs">☰</button>
|
<button className="rounded-lg bg-slate-700/70 px-2 py-2 text-xs">☰</button>
|
||||||
@@ -77,6 +82,11 @@ export function ChatList() {
|
|||||||
}`}
|
}`}
|
||||||
key={chat.id}
|
key={chat.id}
|
||||||
onClick={() => setActiveChatId(chat.id)}
|
onClick={() => setActiveChatId(chat.id)}
|
||||||
|
onContextMenu={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setCtxChatId(chat.id);
|
||||||
|
setCtxPos({ x: e.clientX, y: e.clientY });
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
<div className="mt-0.5 flex h-10 w-10 items-center justify-center rounded-full bg-sky-500/30 text-sm font-semibold uppercase text-sky-100">
|
<div className="mt-0.5 flex h-10 w-10 items-center justify-center rounded-full bg-sky-500/30 text-sm font-semibold uppercase text-sky-100">
|
||||||
@@ -96,6 +106,52 @@ export function ChatList() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<NewChatPanel />
|
<NewChatPanel />
|
||||||
|
|
||||||
|
{ctxChatId && ctxPos ? (
|
||||||
|
<div className="fixed z-50 w-44 rounded-lg border border-slate-700/80 bg-slate-900/95 p-1 shadow-2xl" style={{ left: ctxPos.x, top: ctxPos.y }} onClick={(e) => e.stopPropagation()}>
|
||||||
|
<button
|
||||||
|
className="block w-full rounded px-2 py-1.5 text-left text-sm text-red-300 hover:bg-slate-800"
|
||||||
|
onClick={() => {
|
||||||
|
setDeleteModalChatId(ctxChatId);
|
||||||
|
setCtxChatId(null);
|
||||||
|
setCtxPos(null);
|
||||||
|
setDeleteForAll(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Delete chat
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{deleteModalChatId ? (
|
||||||
|
<div className="absolute inset-0 z-50 flex items-end justify-center bg-slate-950/55 p-3">
|
||||||
|
<div className="w-full rounded-xl border border-slate-700/80 bg-slate-900 p-3">
|
||||||
|
<p className="mb-2 text-sm font-semibold">Delete chat #{deleteModalChatId}</p>
|
||||||
|
<label className="mb-3 flex items-center gap-2 text-sm">
|
||||||
|
<input checked={deleteForAll} onChange={(e) => setDeleteForAll(e.target.checked)} type="checkbox" />
|
||||||
|
Delete for everyone
|
||||||
|
</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
className="flex-1 rounded bg-red-500 px-3 py-2 text-sm font-semibold text-white"
|
||||||
|
onClick={async () => {
|
||||||
|
await deleteChat(deleteModalChatId, deleteForAll);
|
||||||
|
await loadChats(search.trim() ? search : undefined);
|
||||||
|
if (activeChatId === deleteModalChatId) {
|
||||||
|
setActiveChatId(null);
|
||||||
|
}
|
||||||
|
setDeleteModalChatId(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
<button className="flex-1 rounded bg-slate-700 px-3 py-2 text-sm" onClick={() => setDeleteModalChatId(null)}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -168,6 +168,13 @@ export function MessageComposer() {
|
|||||||
|
|
||||||
async function startRecord() {
|
async function startRecord() {
|
||||||
try {
|
try {
|
||||||
|
if (navigator.permissions && navigator.permissions.query) {
|
||||||
|
const permission = await navigator.permissions.query({ name: "microphone" as PermissionName });
|
||||||
|
if (permission.state === "denied") {
|
||||||
|
setUploadError("Microphone access denied. Allow microphone in browser site permissions.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||||
const recorder = new MediaRecorder(stream);
|
const recorder = new MediaRecorder(stream);
|
||||||
chunksRef.current = [];
|
chunksRef.current = [];
|
||||||
@@ -182,7 +189,7 @@ export function MessageComposer() {
|
|||||||
recorder.start();
|
recorder.start();
|
||||||
setIsRecording(true);
|
setIsRecording(true);
|
||||||
} catch {
|
} catch {
|
||||||
setUploadError("Microphone access denied.");
|
setUploadError("Microphone access denied. Please allow microphone and retry.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,15 @@
|
|||||||
import { useMemo } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { forwardMessage, pinMessage } from "../api/chats";
|
import { forwardMessage, pinMessage } from "../api/chats";
|
||||||
import { useAuthStore } from "../store/authStore";
|
import { useAuthStore } from "../store/authStore";
|
||||||
import { useChatStore } from "../store/chatStore";
|
import { useChatStore } from "../store/chatStore";
|
||||||
import { formatTime } from "../utils/format";
|
import { formatTime } from "../utils/format";
|
||||||
|
|
||||||
|
type ContextMenuState = {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
messageId: number;
|
||||||
|
} | null;
|
||||||
|
|
||||||
export function MessageList() {
|
export function MessageList() {
|
||||||
const me = useAuthStore((s) => s.me);
|
const me = useAuthStore((s) => s.me);
|
||||||
const activeChatId = useChatStore((s) => s.activeChatId);
|
const activeChatId = useChatStore((s) => s.activeChatId);
|
||||||
@@ -12,6 +18,7 @@ export function MessageList() {
|
|||||||
const chats = useChatStore((s) => s.chats);
|
const chats = useChatStore((s) => s.chats);
|
||||||
const setReplyToMessage = useChatStore((s) => s.setReplyToMessage);
|
const setReplyToMessage = useChatStore((s) => s.setReplyToMessage);
|
||||||
const updateChatPinnedMessage = useChatStore((s) => s.updateChatPinnedMessage);
|
const updateChatPinnedMessage = useChatStore((s) => s.updateChatPinnedMessage);
|
||||||
|
const [ctx, setCtx] = useState<ContextMenuState>(null);
|
||||||
|
|
||||||
const messages = useMemo(() => {
|
const messages = useMemo(() => {
|
||||||
if (!activeChatId) {
|
if (!activeChatId) {
|
||||||
@@ -19,6 +26,8 @@ export function MessageList() {
|
|||||||
}
|
}
|
||||||
return messagesByChat[activeChatId] ?? [];
|
return messagesByChat[activeChatId] ?? [];
|
||||||
}, [activeChatId, messagesByChat]);
|
}, [activeChatId, messagesByChat]);
|
||||||
|
|
||||||
|
const messagesMap = useMemo(() => new Map(messages.map((m) => [m.id, m])), [messages]);
|
||||||
const activeChat = chats.find((chat) => chat.id === activeChatId);
|
const activeChat = chats.find((chat) => chat.id === activeChatId);
|
||||||
|
|
||||||
if (!activeChatId) {
|
if (!activeChatId) {
|
||||||
@@ -28,88 +37,100 @@ export function MessageList() {
|
|||||||
|
|
||||||
async function handleForward(messageId: number) {
|
async function handleForward(messageId: number) {
|
||||||
const targetRaw = window.prompt("Forward to chat id:");
|
const targetRaw = window.prompt("Forward to chat id:");
|
||||||
if (!targetRaw) {
|
if (!targetRaw) return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
const targetId = Number(targetRaw);
|
const targetId = Number(targetRaw);
|
||||||
if (!Number.isFinite(targetId) || targetId <= 0) {
|
if (!Number.isFinite(targetId) || targetId <= 0) return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
await forwardMessage(messageId, targetId);
|
await forwardMessage(messageId, targetId);
|
||||||
|
setCtx(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handlePin(messageId: number) {
|
async function handlePin(messageId: number) {
|
||||||
const nextPinned = activeChat?.pinned_message_id === messageId ? null : messageId;
|
const nextPinned = activeChat?.pinned_message_id === messageId ? null : messageId;
|
||||||
const chat = await pinMessage(chatId, nextPinned);
|
const chat = await pinMessage(chatId, nextPinned);
|
||||||
updateChatPinnedMessage(chatId, chat.pinned_message_id ?? null);
|
updateChatPinnedMessage(chatId, chat.pinned_message_id ?? null);
|
||||||
|
setCtx(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col">
|
<div className="flex h-full flex-col" onClick={() => setCtx(null)}>
|
||||||
{activeChat?.pinned_message_id ? (
|
{activeChat?.pinned_message_id ? (
|
||||||
<div className="border-b border-slate-700/50 bg-slate-900/60 px-4 py-2 text-xs text-sky-300">
|
<div className="border-b border-slate-700/50 bg-slate-900/60 px-4 py-2 text-xs text-sky-300">
|
||||||
Pinned message ID: {activeChat.pinned_message_id}
|
📌 {messagesMap.get(activeChat.pinned_message_id)?.text || `Pinned message #${activeChat.pinned_message_id}`}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<div className="tg-scrollbar flex-1 overflow-auto px-3 py-4 md:px-6">
|
<div className="tg-scrollbar flex-1 overflow-auto px-3 py-4 md:px-6">
|
||||||
{messages.map((message) => {
|
{messages.map((message) => {
|
||||||
const own = message.sender_id === me?.id;
|
const own = message.sender_id === me?.id;
|
||||||
|
const replySource = message.reply_to_message_id ? messagesMap.get(message.reply_to_message_id) : null;
|
||||||
return (
|
return (
|
||||||
<div className={`mb-2 flex ${own ? "justify-end" : "justify-start"}`} key={`${message.id}-${message.client_message_id ?? ""}`}>
|
<div className={`mb-2 flex ${own ? "justify-end" : "justify-start"}`} key={`${message.id}-${message.client_message_id ?? ""}`}>
|
||||||
<div
|
<div
|
||||||
className={`max-w-[86%] rounded-2xl px-3 py-2 shadow-sm md:max-w-[72%] ${
|
className={`max-w-[86%] rounded-2xl px-3 py-2 shadow-sm md:max-w-[72%] ${
|
||||||
own
|
own ? "rounded-br-md bg-sky-500/90 text-slate-950" : "rounded-bl-md border border-slate-700/60 bg-slate-900/80 text-slate-100"
|
||||||
? "rounded-br-md bg-sky-500/90 text-slate-950"
|
|
||||||
: "rounded-bl-md border border-slate-700/60 bg-slate-900/80 text-slate-100"
|
|
||||||
}`}
|
}`}
|
||||||
|
onContextMenu={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setCtx({ x: e.clientX, y: e.clientY, messageId: message.id });
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{message.forwarded_from_message_id ? (
|
{message.forwarded_from_message_id ? (
|
||||||
<p className={`mb-1 text-[11px] font-semibold ${own ? "text-slate-900/75" : "text-sky-300"}`}>Forwarded</p>
|
<div className={`mb-1 rounded-md border-l-2 px-2 py-1 text-[11px] ${own ? "border-slate-900/60 bg-slate-900/10 text-slate-900/75" : "border-sky-400 bg-slate-800/60 text-sky-300"}`}>
|
||||||
|
↪ Forwarded message
|
||||||
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{message.reply_to_message_id ? (
|
{replySource ? (
|
||||||
<p className={`mb-1 text-[11px] ${own ? "text-slate-900/75" : "text-slate-300"}`}>Reply to #{message.reply_to_message_id}</p>
|
<div className={`mb-1 rounded-md border-l-2 px-2 py-1 text-[11px] ${own ? "border-slate-900/60 bg-slate-900/10 text-slate-900/75" : "border-sky-400 bg-slate-800/60 text-slate-300"}`}>
|
||||||
|
<p className="truncate font-semibold">{replySource.sender_id === me?.id ? "You" : "Reply"}</p>
|
||||||
|
<p className="truncate">{replySource.text || "[media]"}</p>
|
||||||
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{renderContent(message.type, message.text)}
|
{renderContent(message.type, message.text)}
|
||||||
<div className="mt-1 flex items-center justify-between gap-2">
|
<p className={`mt-1 flex items-center justify-end gap-1 text-[11px] ${own ? "text-slate-900/85" : "text-slate-400"}`}>
|
||||||
<div className="flex gap-1">
|
|
||||||
<button className={`rounded px-1.5 py-0.5 text-[10px] ${own ? "bg-slate-900/15" : "bg-slate-700/70"}`} onClick={() => setReplyToMessage(chatId, message)}>
|
|
||||||
Reply
|
|
||||||
</button>
|
|
||||||
<button className={`rounded px-1.5 py-0.5 text-[10px] ${own ? "bg-slate-900/15" : "bg-slate-700/70"}`} onClick={() => void handleForward(message.id)}>
|
|
||||||
Fwd
|
|
||||||
</button>
|
|
||||||
<button className={`rounded px-1.5 py-0.5 text-[10px] ${own ? "bg-slate-900/15" : "bg-slate-700/70"}`} onClick={() => void handlePin(message.id)}>
|
|
||||||
Pin
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<p className={`flex items-center justify-end gap-1 text-[11px] ${own ? "text-slate-900/85" : "text-slate-400"}`}>
|
|
||||||
<span>{formatTime(message.created_at)}</span>
|
<span>{formatTime(message.created_at)}</span>
|
||||||
{own ? <span className={message.delivery_status === "read" ? "text-cyan-900" : ""}>{renderStatus(message.delivery_status)}</span> : null}
|
{own ? <span className={message.delivery_status === "read" ? "text-cyan-900" : ""}>{renderStatus(message.delivery_status)}</span> : null}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
<div className="px-5 pb-2 text-xs text-slate-300/80">{(typingByChat[activeChatId] ?? []).length > 0 ? "typing..." : ""}</div>
|
<div className="px-5 pb-2 text-xs text-slate-300/80">{(typingByChat[activeChatId] ?? []).length > 0 ? "typing..." : ""}</div>
|
||||||
|
|
||||||
|
{ctx ? (
|
||||||
|
<div
|
||||||
|
className="fixed z-50 w-40 rounded-lg border border-slate-700/80 bg-slate-900/95 p-1 shadow-2xl"
|
||||||
|
style={{ left: ctx.x, top: ctx.y }}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
className="block w-full rounded px-2 py-1.5 text-left text-sm hover:bg-slate-800"
|
||||||
|
onClick={() => {
|
||||||
|
const msg = messagesMap.get(ctx.messageId);
|
||||||
|
if (msg) {
|
||||||
|
setReplyToMessage(chatId, msg);
|
||||||
|
}
|
||||||
|
setCtx(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Reply
|
||||||
|
</button>
|
||||||
|
<button className="block w-full rounded px-2 py-1.5 text-left text-sm hover:bg-slate-800" onClick={() => void handleForward(ctx.messageId)}>
|
||||||
|
Forward
|
||||||
|
</button>
|
||||||
|
<button className="block w-full rounded px-2 py-1.5 text-left text-sm hover:bg-slate-800" onClick={() => void handlePin(ctx.messageId)}>
|
||||||
|
Pin / Unpin
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderContent(messageType: string, text: string | null) {
|
function renderContent(messageType: string, text: string | null) {
|
||||||
if (!text) {
|
if (!text) return <p className="opacity-80">[empty]</p>;
|
||||||
return <p className="opacity-80">[empty]</p>;
|
if (messageType === "image") return <img alt="attachment" className="max-h-72 rounded-lg object-cover" src={text} />;
|
||||||
}
|
if (messageType === "video" || messageType === "circle_video") return <video className="max-h-72 rounded-lg" controls src={text} />;
|
||||||
if (messageType === "image") {
|
if (messageType === "audio" || messageType === "voice") return <audio controls src={text} />;
|
||||||
return <img alt="attachment" className="max-h-72 rounded-lg object-cover" src={text} />;
|
|
||||||
}
|
|
||||||
if (messageType === "video" || messageType === "circle_video") {
|
|
||||||
return <video className="max-h-72 rounded-lg" controls src={text} />;
|
|
||||||
}
|
|
||||||
if (messageType === "audio" || messageType === "voice") {
|
|
||||||
return <audio controls src={text} />;
|
|
||||||
}
|
|
||||||
if (messageType === "file") {
|
if (messageType === "file") {
|
||||||
return (
|
return (
|
||||||
<a className="underline" href={text} rel="noreferrer" target="_blank">
|
<a className="underline" href={text} rel="noreferrer" target="_blank">
|
||||||
@@ -121,14 +142,8 @@ function renderContent(messageType: string, text: string | null) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderStatus(status: string | undefined): string {
|
function renderStatus(status: string | undefined): string {
|
||||||
if (status === "sending") {
|
if (status === "sending") return "⌛";
|
||||||
return "⌛";
|
if (status === "delivered") return "✓✓";
|
||||||
}
|
if (status === "read") return "✓✓";
|
||||||
if (status === "delivered") {
|
|
||||||
return "✓✓";
|
|
||||||
}
|
|
||||||
if (status === "read") {
|
|
||||||
return "✓✓";
|
|
||||||
}
|
|
||||||
return "✓";
|
return "✓";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,20 @@
|
|||||||
import { FormEvent, useMemo, useState } from "react";
|
import { FormEvent, useMemo, useState } from "react";
|
||||||
import { createChat, createPrivateChat, getChats } from "../api/chats";
|
import { createChat, createPrivateChat, createPublicChat, discoverChats, getChats, getSavedMessagesChat, joinChat } from "../api/chats";
|
||||||
import { searchUsers } from "../api/users";
|
import { searchUsers } from "../api/users";
|
||||||
import type { ChatType, UserSearchItem } from "../chat/types";
|
import type { ChatType, DiscoverChat, UserSearchItem } from "../chat/types";
|
||||||
import { useChatStore } from "../store/chatStore";
|
import { useChatStore } from "../store/chatStore";
|
||||||
|
|
||||||
type CreateMode = "group" | "channel";
|
type CreateMode = "group" | "channel";
|
||||||
type DialogMode = "none" | "private" | "group" | "channel";
|
type DialogMode = "none" | "private" | "group" | "channel" | "discover";
|
||||||
|
|
||||||
export function NewChatPanel() {
|
export function NewChatPanel() {
|
||||||
const [dialog, setDialog] = useState<DialogMode>("none");
|
const [dialog, setDialog] = useState<DialogMode>("none");
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const [title, setTitle] = useState("");
|
const [title, setTitle] = useState("");
|
||||||
|
const [handle, setHandle] = useState("");
|
||||||
|
const [description, setDescription] = useState("");
|
||||||
const [results, setResults] = useState<UserSearchItem[]>([]);
|
const [results, setResults] = useState<UserSearchItem[]>([]);
|
||||||
|
const [discoverResults, setDiscoverResults] = useState<DiscoverChat[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [menuOpen, setMenuOpen] = useState(false);
|
const [menuOpen, setMenuOpen] = useState(false);
|
||||||
@@ -19,7 +22,7 @@ export function NewChatPanel() {
|
|||||||
|
|
||||||
const normalizedQuery = useMemo(() => query.trim(), [query]);
|
const normalizedQuery = useMemo(() => query.trim(), [query]);
|
||||||
|
|
||||||
async function handleSearch(value: string) {
|
async function handleSearchUsers(value: string) {
|
||||||
setQuery(value);
|
setQuery(value);
|
||||||
setError(null);
|
setError(null);
|
||||||
if (value.trim().replace("@", "").length < 2) {
|
if (value.trim().replace("@", "").length < 2) {
|
||||||
@@ -34,20 +37,36 @@ export function NewChatPanel() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refreshChatsAndSelectLast() {
|
async function handleDiscover(value: string) {
|
||||||
|
setQuery(value);
|
||||||
|
setError(null);
|
||||||
|
const items = await discoverChats(value.trim() ? value : undefined);
|
||||||
|
setDiscoverResults(items);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshChatsAndSelect(chatId?: number) {
|
||||||
const chats = await getChats();
|
const chats = await getChats();
|
||||||
useChatStore.setState({ chats });
|
useChatStore.setState({ chats });
|
||||||
|
if (chatId) {
|
||||||
|
setActiveChatId(chatId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (chats[0]) {
|
if (chats[0]) {
|
||||||
setActiveChatId(chats[0].id);
|
setActiveChatId(chats[0].id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function openSavedMessages() {
|
||||||
|
const saved = await getSavedMessagesChat();
|
||||||
|
await refreshChatsAndSelect(saved.id);
|
||||||
|
}
|
||||||
|
|
||||||
async function createPrivate(userId: number) {
|
async function createPrivate(userId: number) {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
await createPrivateChat(userId);
|
const chat = await createPrivateChat(userId);
|
||||||
await refreshChatsAndSelectLast();
|
await refreshChatsAndSelect(chat.id);
|
||||||
setDialog("none");
|
setDialog("none");
|
||||||
setQuery("");
|
setQuery("");
|
||||||
setResults([]);
|
setResults([]);
|
||||||
@@ -67,10 +86,17 @@ export function NewChatPanel() {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
await createChat(mode as ChatType, title.trim(), []);
|
let chat;
|
||||||
await refreshChatsAndSelectLast();
|
if (handle.trim()) {
|
||||||
|
chat = await createPublicChat(mode, title.trim(), handle.trim().replace("@", "").toLowerCase(), description.trim() || undefined);
|
||||||
|
} else {
|
||||||
|
chat = await createChat(mode as ChatType, title.trim(), []);
|
||||||
|
}
|
||||||
|
await refreshChatsAndSelect(chat.id);
|
||||||
setDialog("none");
|
setDialog("none");
|
||||||
setTitle("");
|
setTitle("");
|
||||||
|
setHandle("");
|
||||||
|
setDescription("");
|
||||||
} catch {
|
} catch {
|
||||||
setError("Failed to create chat");
|
setError("Failed to create chat");
|
||||||
} finally {
|
} finally {
|
||||||
@@ -78,49 +104,51 @@ export function NewChatPanel() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function joinPublicChat(chatId: number) {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const joined = await joinChat(chatId);
|
||||||
|
await refreshChatsAndSelect(joined.id);
|
||||||
|
setDialog("none");
|
||||||
|
} catch {
|
||||||
|
setError("Failed to join chat");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function closeDialog() {
|
function closeDialog() {
|
||||||
setDialog("none");
|
setDialog("none");
|
||||||
setError(null);
|
setError(null);
|
||||||
|
setQuery("");
|
||||||
|
setResults([]);
|
||||||
|
setDiscoverResults([]);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="absolute bottom-4 right-4 z-20">
|
<div className="absolute bottom-4 right-4 z-20">
|
||||||
{menuOpen ? (
|
{menuOpen ? (
|
||||||
<div className="mb-2 w-44 rounded-xl border border-slate-700/80 bg-slate-900/95 p-1 shadow-xl">
|
<div className="mb-2 w-48 rounded-xl border border-slate-700/80 bg-slate-900/95 p-1 shadow-xl">
|
||||||
<button
|
<button className="block w-full rounded-lg px-3 py-2 text-left text-sm hover:bg-slate-800" onClick={() => void openSavedMessages()}>
|
||||||
className="block w-full rounded-lg px-3 py-2 text-left text-sm hover:bg-slate-800"
|
Saved Messages
|
||||||
onClick={() => {
|
</button>
|
||||||
setDialog("channel");
|
<button className="block w-full rounded-lg px-3 py-2 text-left text-sm hover:bg-slate-800" onClick={() => { setDialog("discover"); setMenuOpen(false); }}>
|
||||||
setMenuOpen(false);
|
Discover Chats
|
||||||
}}
|
</button>
|
||||||
>
|
<button className="block w-full rounded-lg px-3 py-2 text-left text-sm hover:bg-slate-800" onClick={() => { setDialog("channel"); setMenuOpen(false); }}>
|
||||||
New Channel
|
New Channel
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button className="block w-full rounded-lg px-3 py-2 text-left text-sm hover:bg-slate-800" onClick={() => { setDialog("group"); setMenuOpen(false); }}>
|
||||||
className="block w-full rounded-lg px-3 py-2 text-left text-sm hover:bg-slate-800"
|
|
||||||
onClick={() => {
|
|
||||||
setDialog("group");
|
|
||||||
setMenuOpen(false);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
New Group
|
New Group
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button className="block w-full rounded-lg px-3 py-2 text-left text-sm hover:bg-slate-800" onClick={() => { setDialog("private"); setMenuOpen(false); }}>
|
||||||
className="block w-full rounded-lg px-3 py-2 text-left text-sm hover:bg-slate-800"
|
|
||||||
onClick={() => {
|
|
||||||
setDialog("private");
|
|
||||||
setMenuOpen(false);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
New Message
|
New Message
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<button
|
<button className="h-12 w-12 rounded-full bg-sky-500 text-xl font-semibold text-slate-950 shadow-lg hover:bg-sky-400" onClick={() => setMenuOpen((v) => !v)}>
|
||||||
className="h-12 w-12 rounded-full bg-sky-500 text-xl font-semibold text-slate-950 shadow-lg hover:bg-sky-400"
|
|
||||||
onClick={() => setMenuOpen((v) => !v)}
|
|
||||||
>
|
|
||||||
{menuOpen ? "×" : "+"}
|
{menuOpen ? "×" : "+"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -130,47 +158,60 @@ export function NewChatPanel() {
|
|||||||
<div className="w-full max-w-sm rounded-2xl border border-slate-700/80 bg-slate-900 p-3 shadow-2xl">
|
<div className="w-full max-w-sm rounded-2xl border border-slate-700/80 bg-slate-900 p-3 shadow-2xl">
|
||||||
<div className="mb-2 flex items-center justify-between">
|
<div className="mb-2 flex items-center justify-between">
|
||||||
<p className="text-sm font-semibold">
|
<p className="text-sm font-semibold">
|
||||||
{dialog === "private" ? "New Message" : dialog === "group" ? "New Group" : "New Channel"}
|
{dialog === "private" ? "New Message" : dialog === "group" ? "New Group" : dialog === "channel" ? "New Channel" : "Discover Chats"}
|
||||||
</p>
|
</p>
|
||||||
<button className="rounded-md bg-slate-700/80 px-2 py-1 text-xs" onClick={closeDialog}>
|
<button className="rounded-md bg-slate-700/80 px-2 py-1 text-xs" onClick={closeDialog}>Close</button>
|
||||||
Close
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{dialog === "private" ? (
|
{dialog === "private" ? (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<input
|
<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="@username" value={query} onChange={(e) => void handleSearchUsers(e.target.value)} />
|
||||||
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="@username"
|
|
||||||
value={query}
|
|
||||||
onChange={(e) => void handleSearch(e.target.value)}
|
|
||||||
/>
|
|
||||||
<div className="tg-scrollbar max-h-44 overflow-auto">
|
<div className="tg-scrollbar max-h-44 overflow-auto">
|
||||||
{results.map((user) => (
|
{results.map((user) => (
|
||||||
<button
|
<button className="mb-1 block w-full rounded-lg bg-slate-800 px-3 py-2 text-left text-sm hover:bg-slate-700" key={user.id} onClick={() => void createPrivate(user.id)}>
|
||||||
className="mb-1 block w-full rounded-lg bg-slate-800 px-3 py-2 text-left text-sm hover:bg-slate-700"
|
|
||||||
key={user.id}
|
|
||||||
onClick={() => void createPrivate(user.id)}
|
|
||||||
>
|
|
||||||
@{user.username}
|
@{user.username}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
{normalizedQuery && results.length === 0 ? <p className="text-xs text-slate-400">No users</p> : null}
|
{normalizedQuery && results.length === 0 ? <p className="text-xs text-slate-400">No users</p> : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : null}
|
||||||
<form className="space-y-2" onSubmit={(e) => void createByType(e, dialog as CreateMode)}>
|
|
||||||
<input
|
{dialog === "discover" ? (
|
||||||
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"
|
<div className="space-y-2">
|
||||||
placeholder={dialog === "group" ? "Group title" : "Channel title"}
|
<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="Search groups/channels by title or @handle" value={query} onChange={(e) => void handleDiscover(e.target.value)} />
|
||||||
value={title}
|
<div className="tg-scrollbar max-h-52 overflow-auto">
|
||||||
onChange={(e) => setTitle(e.target.value)}
|
{discoverResults.map((chat) => (
|
||||||
/>
|
<div className="mb-1 flex items-center justify-between rounded-lg bg-slate-800 px-3 py-2" key={chat.id}>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="truncate text-sm font-semibold">{chat.title || `${chat.type} #${chat.id}`}</p>
|
||||||
|
<p className="truncate text-xs text-slate-400">{chat.handle ? `@${chat.handle}` : chat.type}</p>
|
||||||
|
</div>
|
||||||
|
{chat.is_member ? (
|
||||||
|
<span className="text-xs text-slate-400">joined</span>
|
||||||
|
) : (
|
||||||
|
<button className="rounded bg-sky-500 px-2 py-1 text-xs font-semibold text-slate-950" onClick={() => void joinPublicChat(chat.id)}>
|
||||||
|
Join
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{discoverResults.length === 0 ? <p className="text-xs text-slate-400">No public chats</p> : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{dialog === "group" || dialog === "channel" ? (
|
||||||
|
<form className="space-y-2" onSubmit={(e) => void createByType(e, dialog)}>
|
||||||
|
<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={dialog === "group" ? "Group title" : "Channel title"} value={title} onChange={(e) => setTitle(e.target.value)} />
|
||||||
|
<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="@handle (optional, enables public join/search)" value={handle} onChange={(e) => setHandle(e.target.value)} />
|
||||||
|
<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="Description (optional)" value={description} onChange={(e) => setDescription(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">
|
<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">
|
||||||
Create {dialog}
|
Create {dialog}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
)}
|
) : null}
|
||||||
|
|
||||||
{error ? <p className="mt-2 text-xs text-red-400">{error}</p> : null}
|
{error ? <p className="mt-2 text-xs text-red-400">{error}</p> : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user