feat(core): clear saved chat and add message deletion scopes
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:
2026-03-08 01:13:20 +03:00
parent a42f97962b
commit 7f15edcb4e
15 changed files with 486 additions and 77 deletions

View 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")

View File

@@ -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,

View File

@@ -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()

View File

@@ -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

View File

@@ -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)

View File

@@ -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(

View File

@@ -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)

View File

@@ -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()

View File

@@ -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

View 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;
}
}

View File

@@ -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

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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>
);

View File

@@ -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) =>