feat(core): clear saved chat and add message deletion scopes
Some checks failed
CI / test (push) Failing after 26s
Some checks failed
CI / test (push) Failing after 26s
backend:
- add message_hidden table for per-user message hiding
- support DELETE /messages/{id}?for_all=true|false
- implement delete-for-me vs delete-for-all logic by chat type/permissions
- add POST /chats/{chat_id}/clear and route saved chat deletion to clear
web:
- saved messages action changed from delete to clear
- message context menu now supports delete modal: for me / for everyone
- add local store helpers removeMessage/clearChatMessages
- include realtime stability improvements and app error boundary
This commit is contained in:
41
alembic/versions/0007_message_hidden_table.py
Normal file
41
alembic/versions/0007_message_hidden_table.py
Normal file
@@ -0,0 +1,41 @@
|
||||
"""add message hidden table for per-user delete
|
||||
|
||||
Revision ID: 0007_message_hidden_table
|
||||
Revises: 0006_user_name_bio_profile
|
||||
Create Date: 2026-03-08 03:35:00.000000
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = "0007_message_hidden_table"
|
||||
down_revision: Union[str, Sequence[str], None] = "0006_user_name_bio_profile"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"message_hidden",
|
||||
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("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.ForeignKeyConstraint(["message_id"], ["messages.id"], name=op.f("fk_message_hidden_message_id_messages"), ondelete="CASCADE"),
|
||||
sa.ForeignKeyConstraint(["user_id"], ["users.id"], name=op.f("fk_message_hidden_user_id_users"), ondelete="CASCADE"),
|
||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_message_hidden")),
|
||||
sa.UniqueConstraint("message_id", "user_id", name="uq_message_hidden_message_user"),
|
||||
)
|
||||
op.create_index(op.f("ix_message_hidden_id"), "message_hidden", ["id"], unique=False)
|
||||
op.create_index(op.f("ix_message_hidden_message_id"), "message_hidden", ["message_id"], unique=False)
|
||||
op.create_index(op.f("ix_message_hidden_user_id"), "message_hidden", ["user_id"], unique=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index(op.f("ix_message_hidden_user_id"), table_name="message_hidden")
|
||||
op.drop_index(op.f("ix_message_hidden_message_id"), table_name="message_hidden")
|
||||
op.drop_index(op.f("ix_message_hidden_id"), table_name="message_hidden")
|
||||
op.drop_table("message_hidden")
|
||||
@@ -17,6 +17,7 @@ from app.chats.schemas import (
|
||||
from app.chats.service import (
|
||||
add_chat_member_for_user,
|
||||
create_chat_for_user,
|
||||
clear_chat_for_user,
|
||||
delete_chat_for_user,
|
||||
discover_public_chats_for_user,
|
||||
ensure_saved_messages_chat,
|
||||
@@ -182,6 +183,15 @@ async def delete_chat(
|
||||
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}/clear", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def clear_chat(
|
||||
chat_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
) -> None:
|
||||
await clear_chat_for_user(db, chat_id=chat_id, user_id=current_user.id)
|
||||
|
||||
|
||||
@router.post("/{chat_id}/pin", response_model=ChatRead)
|
||||
async def pin_chat_message(
|
||||
chat_id: int,
|
||||
|
||||
@@ -5,7 +5,13 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.chats import repository
|
||||
from app.chats.models import Chat, ChatMember, ChatMemberRole, ChatType
|
||||
from app.chats.schemas import ChatCreateRequest, ChatDeleteRequest, ChatDiscoverRead, ChatPinMessageRequest, ChatRead, ChatTitleUpdateRequest
|
||||
from app.messages.repository import get_message_by_id
|
||||
from app.messages.repository import (
|
||||
delete_messages_in_chat,
|
||||
get_hidden_message,
|
||||
get_message_by_id,
|
||||
hide_message_for_user,
|
||||
list_chat_message_ids,
|
||||
)
|
||||
from app.users.repository import get_user_by_id
|
||||
|
||||
|
||||
@@ -349,6 +355,9 @@ async def join_public_chat_for_user(db: AsyncSession, *, chat_id: int, user_id:
|
||||
|
||||
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)
|
||||
if chat.is_saved:
|
||||
await clear_chat_for_user(db, chat_id=chat_id, user_id=user_id)
|
||||
return
|
||||
delete_for_all = (payload.for_all and not chat.is_saved) 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}:
|
||||
@@ -358,3 +367,18 @@ async def delete_chat_for_user(db: AsyncSession, *, chat_id: int, user_id: int,
|
||||
return
|
||||
await repository.delete_chat_member(db, membership)
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def clear_chat_for_user(db: AsyncSession, *, chat_id: int, user_id: int) -> None:
|
||||
chat, _membership = await _get_chat_and_membership(db, chat_id=chat_id, user_id=user_id)
|
||||
if chat.is_saved:
|
||||
await delete_messages_in_chat(db, chat_id=chat_id)
|
||||
await db.commit()
|
||||
return
|
||||
message_ids = await list_chat_message_ids(db, chat_id=chat_id)
|
||||
for message_id in message_ids:
|
||||
already_hidden = await get_hidden_message(db, message_id=message_id, user_id=user_id)
|
||||
if already_hidden:
|
||||
continue
|
||||
await hide_message_for_user(db, message_id=message_id, user_id=user_id)
|
||||
await db.commit()
|
||||
|
||||
@@ -2,7 +2,7 @@ from app.auth.models import EmailVerificationToken, PasswordResetToken
|
||||
from app.chats.models import Chat, ChatMember
|
||||
from app.email.models import EmailLog
|
||||
from app.media.models import Attachment
|
||||
from app.messages.models import Message, MessageIdempotencyKey, MessageReceipt
|
||||
from app.messages.models import Message, MessageHidden, MessageIdempotencyKey, MessageReceipt
|
||||
from app.notifications.models import NotificationLog
|
||||
from app.users.models import User
|
||||
|
||||
|
||||
@@ -83,3 +83,13 @@ class MessageReceipt(Base):
|
||||
onupdate=func.now(),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
|
||||
class MessageHidden(Base):
|
||||
__tablename__ = "message_hidden"
|
||||
__table_args__ = (UniqueConstraint("message_id", "user_id", name="uq_message_hidden_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)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import delete, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.chats.models import ChatMember
|
||||
from app.messages.models import Message, MessageIdempotencyKey, MessageReceipt, MessageType
|
||||
from app.messages.models import Message, MessageHidden, MessageIdempotencyKey, MessageReceipt, MessageType
|
||||
|
||||
|
||||
async def create_message(
|
||||
@@ -76,10 +76,18 @@ async def list_chat_messages(
|
||||
db: AsyncSession,
|
||||
chat_id: int,
|
||||
*,
|
||||
user_id: int,
|
||||
limit: int = 50,
|
||||
before_id: int | None = None,
|
||||
) -> list[Message]:
|
||||
query = select(Message).where(Message.chat_id == chat_id)
|
||||
query = (
|
||||
select(Message)
|
||||
.outerjoin(
|
||||
MessageHidden,
|
||||
(MessageHidden.message_id == Message.id) & (MessageHidden.user_id == user_id),
|
||||
)
|
||||
.where(Message.chat_id == chat_id, MessageHidden.id.is_(None))
|
||||
)
|
||||
if before_id is not None:
|
||||
query = query.where(Message.id < before_id)
|
||||
result = await db.execute(query.order_by(Message.id.desc()).limit(limit))
|
||||
@@ -97,10 +105,15 @@ async def search_messages(
|
||||
stmt = (
|
||||
select(Message)
|
||||
.join(ChatMember, ChatMember.chat_id == Message.chat_id)
|
||||
.outerjoin(
|
||||
MessageHidden,
|
||||
(MessageHidden.message_id == Message.id) & (MessageHidden.user_id == user_id),
|
||||
)
|
||||
.where(
|
||||
ChatMember.user_id == user_id,
|
||||
Message.text.is_not(None),
|
||||
Message.text.ilike(f"%{query.strip()}%"),
|
||||
MessageHidden.id.is_(None),
|
||||
)
|
||||
.order_by(Message.id.desc())
|
||||
.limit(limit)
|
||||
@@ -115,6 +128,29 @@ async def delete_message(db: AsyncSession, message: Message) -> None:
|
||||
await db.delete(message)
|
||||
|
||||
|
||||
async def hide_message_for_user(db: AsyncSession, *, message_id: int, user_id: int) -> MessageHidden:
|
||||
hidden = MessageHidden(message_id=message_id, user_id=user_id)
|
||||
db.add(hidden)
|
||||
await db.flush()
|
||||
return hidden
|
||||
|
||||
|
||||
async def get_hidden_message(db: AsyncSession, *, message_id: int, user_id: int) -> MessageHidden | None:
|
||||
result = await db.execute(
|
||||
select(MessageHidden).where(MessageHidden.message_id == message_id, MessageHidden.user_id == user_id).limit(1)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def list_chat_message_ids(db: AsyncSession, *, chat_id: int) -> list[int]:
|
||||
result = await db.execute(select(Message.id).where(Message.chat_id == chat_id))
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def delete_messages_in_chat(db: AsyncSession, *, chat_id: int) -> None:
|
||||
await db.execute(delete(Message).where(Message.chat_id == chat_id))
|
||||
|
||||
|
||||
async def get_message_receipt(db: AsyncSession, *, chat_id: int, user_id: int) -> MessageReceipt | None:
|
||||
result = await db.execute(
|
||||
select(MessageReceipt).where(
|
||||
|
||||
@@ -4,7 +4,15 @@ 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.service import create_chat_message, delete_message, forward_message, get_messages, search_messages, update_message
|
||||
from app.messages.service import (
|
||||
create_chat_message,
|
||||
delete_message,
|
||||
delete_message_for_all,
|
||||
forward_message,
|
||||
get_messages,
|
||||
search_messages,
|
||||
update_message,
|
||||
)
|
||||
from app.realtime.schemas import MessageStatusPayload
|
||||
from app.realtime.service import realtime_gateway
|
||||
from app.users.models import User
|
||||
@@ -62,9 +70,13 @@ async def edit_message(
|
||||
@router.delete("/{message_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def remove_message(
|
||||
message_id: int,
|
||||
for_all: bool = False,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
) -> None:
|
||||
if for_all:
|
||||
await delete_message_for_all(db, message_id=message_id, user_id=current_user.id)
|
||||
return
|
||||
await delete_message(db, message_id=message_id, user_id=current_user.id)
|
||||
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@ from fastapi import HTTPException, status
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.chats import repository as chats_repository
|
||||
from app.chats.models import ChatMemberRole, ChatType
|
||||
from app.chats.service import ensure_chat_membership
|
||||
from app.messages import repository
|
||||
from app.messages.models import Message
|
||||
@@ -79,7 +81,7 @@ async def get_messages(
|
||||
) -> list[Message]:
|
||||
await ensure_chat_membership(db, chat_id=chat_id, user_id=user_id)
|
||||
safe_limit = max(1, min(limit, 100))
|
||||
return await repository.list_chat_messages(db, chat_id, limit=safe_limit, before_id=before_id)
|
||||
return await repository.list_chat_messages(db, chat_id, user_id=user_id, limit=safe_limit, before_id=before_id)
|
||||
|
||||
|
||||
async def search_messages(
|
||||
@@ -129,8 +131,48 @@ async def delete_message(db: AsyncSession, *, message_id: int, user_id: int) ->
|
||||
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)
|
||||
if message.sender_id != user_id:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="You can delete only your own messages")
|
||||
chat = await chats_repository.get_chat_by_id(db, message.chat_id)
|
||||
if not chat:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Chat not found")
|
||||
membership = await chats_repository.get_chat_member(db, chat_id=message.chat_id, user_id=user_id)
|
||||
if not membership:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="You are not a member of this chat")
|
||||
# Telegram-like default: delete only for current user.
|
||||
hidden = await repository.get_hidden_message(db, message_id=message.id, user_id=user_id)
|
||||
if not hidden:
|
||||
try:
|
||||
await repository.hide_message_for_user(db, message_id=message.id, user_id=user_id)
|
||||
except IntegrityError:
|
||||
await db.rollback()
|
||||
return
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def delete_message_for_all(db: AsyncSession, *, message_id: int, user_id: int) -> None:
|
||||
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)
|
||||
chat = await chats_repository.get_chat_by_id(db, message.chat_id)
|
||||
if not chat:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Chat not found")
|
||||
membership = await chats_repository.get_chat_member(db, chat_id=message.chat_id, user_id=user_id)
|
||||
if not membership:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="You are not a member of this chat")
|
||||
if chat.is_saved:
|
||||
await delete_message(db, message_id=message_id, user_id=user_id)
|
||||
return
|
||||
|
||||
can_delete_for_all = False
|
||||
if chat.type == ChatType.PRIVATE:
|
||||
can_delete_for_all = True
|
||||
elif message.sender_id == user_id:
|
||||
can_delete_for_all = True
|
||||
elif chat.type in {ChatType.GROUP, ChatType.CHANNEL} and membership.role in {ChatMemberRole.OWNER, ChatMemberRole.ADMIN}:
|
||||
can_delete_for_all = True
|
||||
|
||||
if not can_delete_for_all:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Insufficient permissions for delete-for-all")
|
||||
await repository.delete_message(db, message)
|
||||
await db.commit()
|
||||
|
||||
|
||||
@@ -150,6 +150,14 @@ export async function deleteChat(chatId: number, forAll: boolean): Promise<void>
|
||||
await http.delete(`/chats/${chatId}`, { params: { for_all: forAll } });
|
||||
}
|
||||
|
||||
export async function clearChat(chatId: number): Promise<void> {
|
||||
await http.post(`/chats/${chatId}/clear`);
|
||||
}
|
||||
|
||||
export async function deleteMessage(messageId: number, forAll = false): Promise<void> {
|
||||
await http.delete(`/messages/${messageId}`, { 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
|
||||
|
||||
44
web/src/components/AppErrorBoundary.tsx
Normal file
44
web/src/components/AppErrorBoundary.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { Component, type ErrorInfo, type ReactNode } from "react";
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
interface State {
|
||||
hasError: boolean;
|
||||
}
|
||||
|
||||
export class AppErrorBoundary extends Component<Props, State> {
|
||||
state: State = { hasError: false };
|
||||
|
||||
static getDerivedStateFromError(): State {
|
||||
return { hasError: true };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, info: ErrorInfo): void {
|
||||
console.error("UI crash captured by AppErrorBoundary", error, info);
|
||||
}
|
||||
|
||||
render(): ReactNode {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<div className="flex h-screen items-center justify-center px-4">
|
||||
<div className="w-full max-w-md rounded-2xl border border-slate-700/80 bg-slate-900/95 p-5 text-slate-100 shadow-2xl">
|
||||
<p className="text-base font-semibold">Something went wrong</p>
|
||||
<p className="mt-2 text-sm text-slate-300">
|
||||
The app hit an unexpected UI error. Reload to continue.
|
||||
</p>
|
||||
<button
|
||||
className="mt-4 w-full rounded bg-sky-500 px-3 py-2 text-sm font-semibold text-slate-950 hover:bg-sky-400"
|
||||
onClick={() => window.location.reload()}
|
||||
type="button"
|
||||
>
|
||||
Reload
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { deleteChat } from "../api/chats";
|
||||
import { clearChat, deleteChat } from "../api/chats";
|
||||
import { updateMyProfile } from "../api/users";
|
||||
import { useAuthStore } from "../store/authStore";
|
||||
import { useChatStore } from "../store/chatStore";
|
||||
@@ -27,7 +27,11 @@ export function ChatList() {
|
||||
const [profileError, setProfileError] = useState<string | null>(null);
|
||||
const [profileSaving, setProfileSaving] = useState(false);
|
||||
const deleteModalChat = chats.find((chat) => chat.id === deleteModalChatId) ?? null;
|
||||
const canDeleteForEveryone = Boolean(deleteModalChat && !deleteModalChat.is_saved && deleteModalChat.type === "group");
|
||||
const canDeleteForEveryone = Boolean(
|
||||
deleteModalChat &&
|
||||
!deleteModalChat.is_saved &&
|
||||
(deleteModalChat.type === "group" || deleteModalChat.type === "private")
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
@@ -36,6 +40,20 @@ export function ChatList() {
|
||||
return () => clearTimeout(timer);
|
||||
}, [search, loadChats]);
|
||||
|
||||
useEffect(() => {
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key !== "Escape") {
|
||||
return;
|
||||
}
|
||||
setCtxChatId(null);
|
||||
setCtxPos(null);
|
||||
setDeleteModalChatId(null);
|
||||
setProfileOpen(false);
|
||||
};
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
return () => window.removeEventListener("keydown", onKeyDown);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!me) {
|
||||
return;
|
||||
@@ -141,7 +159,7 @@ export function ChatList() {
|
||||
setDeleteForAll(false);
|
||||
}}
|
||||
>
|
||||
Delete chat
|
||||
{chats.find((c) => c.id === ctxChatId)?.is_saved ? "Clear chat" : "Delete chat"}
|
||||
</button>
|
||||
</div>,
|
||||
document.body
|
||||
@@ -151,10 +169,15 @@ export function ChatList() {
|
||||
{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: {deleteModalChat ? chatLabel(deleteModalChat) : "selected chat"}</p>
|
||||
<p className="mb-2 text-sm font-semibold">
|
||||
{deleteModalChat?.is_saved ? "Clear chat" : "Delete chat"}: {deleteModalChat ? chatLabel(deleteModalChat) : "selected chat"}
|
||||
</p>
|
||||
{deleteModalChat?.type === "channel" ? (
|
||||
<p className="mb-3 text-xs text-slate-400">Channels are removed for all subscribers.</p>
|
||||
) : null}
|
||||
{deleteModalChat?.is_saved ? (
|
||||
<p className="mb-3 text-xs text-slate-400">This will clear all messages in Saved Messages.</p>
|
||||
) : null}
|
||||
{canDeleteForEveryone ? (
|
||||
<label className="mb-3 flex items-center gap-2 text-sm">
|
||||
<input checked={deleteForAll} onChange={(e) => setDeleteForAll(e.target.checked)} type="checkbox" />
|
||||
@@ -165,6 +188,12 @@ export function ChatList() {
|
||||
<button
|
||||
className="flex-1 rounded bg-red-500 px-3 py-2 text-sm font-semibold text-white"
|
||||
onClick={async () => {
|
||||
if (deleteModalChat?.is_saved) {
|
||||
await clearChat(deleteModalChatId);
|
||||
useChatStore.getState().clearChatMessages(deleteModalChatId);
|
||||
setDeleteModalChatId(null);
|
||||
return;
|
||||
}
|
||||
await deleteChat(deleteModalChatId, canDeleteForEveryone && deleteForAll);
|
||||
await loadChats(search.trim() ? search : undefined);
|
||||
if (activeChatId === deleteModalChatId) {
|
||||
@@ -173,7 +202,7 @@ export function ChatList() {
|
||||
setDeleteModalChatId(null);
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
{deleteModalChat?.is_saved ? "Clear" : "Delete"}
|
||||
</button>
|
||||
<button className="flex-1 rounded bg-slate-700 px-3 py-2 text-sm" onClick={() => setDeleteModalChatId(null)}>
|
||||
Cancel
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { forwardMessage, pinMessage } from "../api/chats";
|
||||
import { deleteMessage, forwardMessage, pinMessage } from "../api/chats";
|
||||
import { useAuthStore } from "../store/authStore";
|
||||
import { useChatStore } from "../store/chatStore";
|
||||
import { formatTime } from "../utils/format";
|
||||
@@ -19,11 +19,14 @@ export function MessageList() {
|
||||
const chats = useChatStore((s) => s.chats);
|
||||
const setReplyToMessage = useChatStore((s) => s.setReplyToMessage);
|
||||
const updateChatPinnedMessage = useChatStore((s) => s.updateChatPinnedMessage);
|
||||
const removeMessage = useChatStore((s) => s.removeMessage);
|
||||
const [ctx, setCtx] = useState<ContextMenuState>(null);
|
||||
const [forwardMessageId, setForwardMessageId] = useState<number | null>(null);
|
||||
const [forwardQuery, setForwardQuery] = useState("");
|
||||
const [forwardError, setForwardError] = useState<string | null>(null);
|
||||
const [isForwarding, setIsForwarding] = useState(false);
|
||||
const [deleteMessageId, setDeleteMessageId] = useState<number | null>(null);
|
||||
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||||
|
||||
const messages = useMemo(() => {
|
||||
if (!activeChatId) {
|
||||
@@ -44,6 +47,19 @@ export function MessageList() {
|
||||
});
|
||||
}, [chats, forwardQuery]);
|
||||
|
||||
useEffect(() => {
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key !== "Escape") {
|
||||
return;
|
||||
}
|
||||
setCtx(null);
|
||||
setForwardMessageId(null);
|
||||
setDeleteMessageId(null);
|
||||
};
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
return () => window.removeEventListener("keydown", onKeyDown);
|
||||
}, []);
|
||||
|
||||
if (!activeChatId) {
|
||||
return <div className="flex h-full items-center justify-center text-slate-300/80">Select a chat</div>;
|
||||
}
|
||||
@@ -72,6 +88,20 @@ export function MessageList() {
|
||||
setCtx(null);
|
||||
}
|
||||
|
||||
async function handleDelete(forAll: boolean) {
|
||||
if (!deleteMessageId) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await deleteMessage(deleteMessageId, forAll);
|
||||
removeMessage(chatId, deleteMessageId);
|
||||
setDeleteMessageId(null);
|
||||
setDeleteError(null);
|
||||
} catch {
|
||||
setDeleteError("Failed to delete message");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col" onClick={() => setCtx(null)}>
|
||||
{activeChat?.pinned_message_id ? (
|
||||
@@ -148,6 +178,16 @@ export function MessageList() {
|
||||
>
|
||||
Forward
|
||||
</button>
|
||||
<button
|
||||
className="block w-full rounded px-2 py-1.5 text-left text-sm text-red-300 hover:bg-slate-800"
|
||||
onClick={() => {
|
||||
setDeleteMessageId(ctx.messageId);
|
||||
setDeleteError(null);
|
||||
setCtx(null);
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</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>
|
||||
@@ -187,6 +227,29 @@ export function MessageList() {
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{deleteMessageId ? (
|
||||
<div className="absolute inset-0 z-50 flex items-end justify-center bg-slate-950/55 p-3" onClick={() => setDeleteMessageId(null)}>
|
||||
<div className="w-full rounded-xl border border-slate-700/80 bg-slate-900 p-3" onClick={(e) => e.stopPropagation()}>
|
||||
<p className="mb-2 text-sm font-semibold">Delete message</p>
|
||||
<p className="mb-3 text-xs text-slate-400">Choose how to delete this message.</p>
|
||||
<div className="space-y-2">
|
||||
<button className="w-full rounded bg-red-500 px-3 py-2 text-sm font-semibold text-white" onClick={() => void handleDelete(false)}>
|
||||
Delete for me
|
||||
</button>
|
||||
{canDeleteForEveryone(messagesMap.get(deleteMessageId), activeChat, me?.id) ? (
|
||||
<button className="w-full rounded bg-red-600 px-3 py-2 text-sm font-semibold text-white" onClick={() => void handleDelete(true)}>
|
||||
Delete for everyone
|
||||
</button>
|
||||
) : null}
|
||||
<button className="w-full rounded bg-slate-700 px-3 py-2 text-sm" onClick={() => setDeleteMessageId(null)}>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
{deleteError ? <p className="mt-2 text-xs text-red-400">{deleteError}</p> : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -231,3 +294,14 @@ function chatLabel(chat: { display_title?: string | null; title: string | null;
|
||||
if (chat.type === "group") return "Group";
|
||||
return "Channel";
|
||||
}
|
||||
|
||||
function canDeleteForEveryone(
|
||||
message: { sender_id: number } | undefined,
|
||||
chat: { type: "private" | "group" | "channel"; is_saved?: boolean } | undefined,
|
||||
meId: number | undefined
|
||||
): boolean {
|
||||
if (!message || !chat || !meId) return false;
|
||||
if (chat.is_saved) return false;
|
||||
if (chat.type === "private") return true;
|
||||
return message.sender_id === meId;
|
||||
}
|
||||
|
||||
@@ -12,14 +12,12 @@ interface RealtimeEnvelope {
|
||||
|
||||
export function useRealtime() {
|
||||
const accessToken = useAuthStore((s) => s.accessToken);
|
||||
const me = useAuthStore((s) => s.me);
|
||||
const prependMessage = useChatStore((s) => s.prependMessage);
|
||||
const confirmMessageByClientId = useChatStore((s) => s.confirmMessageByClientId);
|
||||
const setMessageDeliveryStatus = useChatStore((s) => s.setMessageDeliveryStatus);
|
||||
const loadChats = useChatStore((s) => s.loadChats);
|
||||
const chats = useChatStore((s) => s.chats);
|
||||
const activeChatId = useChatStore((s) => s.activeChatId);
|
||||
const meId = useAuthStore((s) => s.me?.id ?? null);
|
||||
const typingByChat = useRef<Record<number, Set<number>>>({});
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const reconnectTimeoutRef = useRef<number | null>(null);
|
||||
const reconnectAttemptsRef = useRef(0);
|
||||
const manualCloseRef = useRef(false);
|
||||
|
||||
const wsUrl = useMemo(() => {
|
||||
return accessToken ? buildWsUrl(accessToken) : null;
|
||||
@@ -27,69 +25,129 @@ export function useRealtime() {
|
||||
|
||||
useEffect(() => {
|
||||
if (!wsUrl) {
|
||||
manualCloseRef.current = true;
|
||||
if (reconnectTimeoutRef.current !== null) {
|
||||
window.clearTimeout(reconnectTimeoutRef.current);
|
||||
reconnectTimeoutRef.current = null;
|
||||
}
|
||||
wsRef.current?.close();
|
||||
wsRef.current = null;
|
||||
return;
|
||||
}
|
||||
const ws = new WebSocket(wsUrl);
|
||||
manualCloseRef.current = false;
|
||||
|
||||
ws.onmessage = (messageEvent) => {
|
||||
const event: RealtimeEnvelope = JSON.parse(messageEvent.data);
|
||||
if (event.event === "receive_message") {
|
||||
const chatId = Number(event.payload.chat_id);
|
||||
const message = event.payload.message as Message;
|
||||
const clientMessageId = event.payload.client_message_id as string | undefined;
|
||||
if (clientMessageId && message.sender_id === me?.id) {
|
||||
confirmMessageByClientId(chatId, clientMessageId, message);
|
||||
} else {
|
||||
prependMessage(chatId, message);
|
||||
}
|
||||
if (message.sender_id !== me?.id) {
|
||||
ws.send(JSON.stringify({ event: "message_delivered", payload: { chat_id: chatId, message_id: message.id } }));
|
||||
if (chatId === activeChatId) {
|
||||
ws.send(JSON.stringify({ event: "message_read", payload: { chat_id: chatId, message_id: message.id } }));
|
||||
}
|
||||
}
|
||||
if (!chats.some((chat) => chat.id === chatId)) {
|
||||
void loadChats();
|
||||
}
|
||||
}
|
||||
if (event.event === "typing_start") {
|
||||
const chatId = Number(event.payload.chat_id);
|
||||
const userId = Number(event.payload.user_id);
|
||||
if (userId === me?.id) {
|
||||
const connect = () => {
|
||||
const ws = new WebSocket(wsUrl);
|
||||
wsRef.current = ws;
|
||||
|
||||
ws.onopen = () => {
|
||||
reconnectAttemptsRef.current = 0;
|
||||
};
|
||||
|
||||
ws.onmessage = (messageEvent) => {
|
||||
let event: RealtimeEnvelope;
|
||||
try {
|
||||
event = JSON.parse(messageEvent.data) as RealtimeEnvelope;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (!typingByChat.current[chatId]) {
|
||||
typingByChat.current[chatId] = new Set<number>();
|
||||
const chatStore = useChatStore.getState();
|
||||
const authStore = useAuthStore.getState();
|
||||
if (event.event === "receive_message") {
|
||||
const chatId = Number(event.payload.chat_id);
|
||||
const message = event.payload.message as Message;
|
||||
const clientMessageId = event.payload.client_message_id as string | undefined;
|
||||
if (!Number.isFinite(chatId) || !message?.id) {
|
||||
return;
|
||||
}
|
||||
if (clientMessageId && message.sender_id === authStore.me?.id) {
|
||||
chatStore.confirmMessageByClientId(chatId, clientMessageId, message);
|
||||
} else {
|
||||
chatStore.prependMessage(chatId, message);
|
||||
}
|
||||
if (message.sender_id !== authStore.me?.id) {
|
||||
ws.send(JSON.stringify({ event: "message_delivered", payload: { chat_id: chatId, message_id: message.id } }));
|
||||
if (chatId === chatStore.activeChatId) {
|
||||
ws.send(JSON.stringify({ event: "message_read", payload: { chat_id: chatId, message_id: message.id } }));
|
||||
}
|
||||
}
|
||||
if (!chatStore.chats.some((chat) => chat.id === chatId)) {
|
||||
void chatStore.loadChats();
|
||||
}
|
||||
}
|
||||
typingByChat.current[chatId].add(userId);
|
||||
useChatStore.getState().setTypingUsers(chatId, [...typingByChat.current[chatId]]);
|
||||
}
|
||||
if (event.event === "typing_stop") {
|
||||
const chatId = Number(event.payload.chat_id);
|
||||
const userId = Number(event.payload.user_id);
|
||||
typingByChat.current[chatId]?.delete(userId);
|
||||
useChatStore.getState().setTypingUsers(chatId, [...(typingByChat.current[chatId] ?? [])]);
|
||||
}
|
||||
if (event.event === "message_delivered") {
|
||||
const chatId = Number(event.payload.chat_id);
|
||||
const messageId = Number(event.payload.message_id);
|
||||
const userId = Number(event.payload.user_id);
|
||||
if (userId !== me?.id) {
|
||||
setMessageDeliveryStatus(chatId, messageId, "delivered");
|
||||
if (event.event === "typing_start") {
|
||||
const chatId = Number(event.payload.chat_id);
|
||||
const userId = Number(event.payload.user_id);
|
||||
if (!Number.isFinite(chatId) || !Number.isFinite(userId) || userId === authStore.me?.id) {
|
||||
return;
|
||||
}
|
||||
if (!typingByChat.current[chatId]) {
|
||||
typingByChat.current[chatId] = new Set<number>();
|
||||
}
|
||||
typingByChat.current[chatId].add(userId);
|
||||
chatStore.setTypingUsers(chatId, [...typingByChat.current[chatId]]);
|
||||
}
|
||||
}
|
||||
if (event.event === "message_read") {
|
||||
const chatId = Number(event.payload.chat_id);
|
||||
const messageId = Number(event.payload.message_id);
|
||||
const userId = Number(event.payload.user_id);
|
||||
if (userId !== me?.id) {
|
||||
setMessageDeliveryStatus(chatId, messageId, "read");
|
||||
if (event.event === "typing_stop") {
|
||||
const chatId = Number(event.payload.chat_id);
|
||||
const userId = Number(event.payload.user_id);
|
||||
if (!Number.isFinite(chatId) || !Number.isFinite(userId)) {
|
||||
return;
|
||||
}
|
||||
typingByChat.current[chatId]?.delete(userId);
|
||||
chatStore.setTypingUsers(chatId, [...(typingByChat.current[chatId] ?? [])]);
|
||||
}
|
||||
}
|
||||
if (event.event === "message_delivered") {
|
||||
const chatId = Number(event.payload.chat_id);
|
||||
const messageId = Number(event.payload.message_id);
|
||||
const userId = Number(event.payload.user_id);
|
||||
if (!Number.isFinite(chatId) || !Number.isFinite(messageId) || !Number.isFinite(userId)) {
|
||||
return;
|
||||
}
|
||||
if (userId !== authStore.me?.id) {
|
||||
chatStore.setMessageDeliveryStatus(chatId, messageId, "delivered");
|
||||
}
|
||||
}
|
||||
if (event.event === "message_read") {
|
||||
const chatId = Number(event.payload.chat_id);
|
||||
const messageId = Number(event.payload.message_id);
|
||||
const userId = Number(event.payload.user_id);
|
||||
if (!Number.isFinite(chatId) || !Number.isFinite(messageId) || !Number.isFinite(userId)) {
|
||||
return;
|
||||
}
|
||||
if (userId !== authStore.me?.id) {
|
||||
chatStore.setMessageDeliveryStatus(chatId, messageId, "read");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
if (manualCloseRef.current) {
|
||||
return;
|
||||
}
|
||||
reconnectAttemptsRef.current += 1;
|
||||
const delay = Math.min(15000, 1000 * 2 ** Math.min(reconnectAttemptsRef.current, 4));
|
||||
reconnectTimeoutRef.current = window.setTimeout(connect, delay);
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
ws.close();
|
||||
};
|
||||
};
|
||||
|
||||
return () => ws.close();
|
||||
}, [wsUrl, prependMessage, confirmMessageByClientId, setMessageDeliveryStatus, loadChats, chats, me?.id, activeChatId]);
|
||||
connect();
|
||||
|
||||
return () => {
|
||||
manualCloseRef.current = true;
|
||||
if (reconnectTimeoutRef.current !== null) {
|
||||
window.clearTimeout(reconnectTimeoutRef.current);
|
||||
reconnectTimeoutRef.current = null;
|
||||
}
|
||||
wsRef.current?.close();
|
||||
wsRef.current = null;
|
||||
typingByChat.current = {};
|
||||
useChatStore.setState({ typingByChat: {} });
|
||||
};
|
||||
}, [wsUrl, meId]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { App } from "./app/App";
|
||||
import { AppErrorBoundary } from "./components/AppErrorBoundary";
|
||||
import "./index.css";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
<AppErrorBoundary>
|
||||
<App />
|
||||
</AppErrorBoundary>
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
||||
@@ -22,6 +22,8 @@ interface ChatState {
|
||||
confirmMessageByClientId: (chatId: number, clientMessageId: string, message: Message) => void;
|
||||
removeOptimisticMessage: (chatId: number, clientMessageId: string) => void;
|
||||
setMessageDeliveryStatus: (chatId: number, messageId: number, status: DeliveryStatus) => void;
|
||||
removeMessage: (chatId: number, messageId: number) => void;
|
||||
clearChatMessages: (chatId: number) => void;
|
||||
setTypingUsers: (chatId: number, userIds: number[]) => void;
|
||||
setReplyToMessage: (chatId: number, message: Message | null) => void;
|
||||
updateChatPinnedMessage: (chatId: number, pinnedMessageId: number | null) => void;
|
||||
@@ -137,6 +139,22 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
||||
messagesByChat: { ...state.messagesByChat, [chatId]: next }
|
||||
}));
|
||||
},
|
||||
removeMessage: (chatId, messageId) => {
|
||||
const old = get().messagesByChat[chatId] ?? [];
|
||||
set((state) => ({
|
||||
messagesByChat: {
|
||||
...state.messagesByChat,
|
||||
[chatId]: old.filter((m) => m.id !== messageId)
|
||||
}
|
||||
}));
|
||||
},
|
||||
clearChatMessages: (chatId) =>
|
||||
set((state) => ({
|
||||
messagesByChat: {
|
||||
...state.messagesByChat,
|
||||
[chatId]: []
|
||||
}
|
||||
})),
|
||||
setTypingUsers: (chatId, userIds) =>
|
||||
set((state) => ({ typingByChat: { ...state.typingByChat, [chatId]: userIds } })),
|
||||
setReplyToMessage: (chatId, message) =>
|
||||
|
||||
Reference in New Issue
Block a user