fix(realtime,ui): sync message deletes and channel delete/leave behavior
All checks were successful
CI / test (push) Successful in 23s

This commit is contained in:
2026-03-08 12:52:31 +03:00
parent 613edbecfe
commit 82322c4d42
4 changed files with 64 additions and 15 deletions

View File

@@ -13,6 +13,7 @@ from app.messages.schemas import (
MessageStatusUpdateRequest, MessageStatusUpdateRequest,
MessageUpdateRequest, MessageUpdateRequest,
) )
from app.messages.repository import get_message_by_id
from app.messages.service import ( from app.messages.service import (
create_chat_message, create_chat_message,
delete_message, delete_message,
@@ -87,7 +88,10 @@ async def remove_message(
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
) -> None: ) -> None:
if for_all: if for_all:
message = await get_message_by_id(db, message_id)
await delete_message_for_all(db, message_id=message_id, user_id=current_user.id) await delete_message_for_all(db, message_id=message_id, user_id=current_user.id)
if message:
await realtime_gateway.publish_chat_updated(chat_id=message.chat_id)
return return
await delete_message(db, message_id=message_id, user_id=current_user.id) await delete_message(db, message_id=message_id, user_id=current_user.id)

View File

@@ -206,6 +206,11 @@ async def delete_message(db: AsyncSession, *, message_id: int, user_id: int) ->
membership = await chats_repository.get_chat_member(db, chat_id=message.chat_id, user_id=user_id) membership = await chats_repository.get_chat_member(db, chat_id=message.chat_id, user_id=user_id)
if not membership: if not membership:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="You are not a member of this chat") raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="You are not a member of this chat")
if chat.type == ChatType.CHANNEL and not chat.is_saved:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Channel messages can only be deleted for everyone",
)
# Telegram-like default: delete only for current user. # Telegram-like default: delete only for current user.
hidden = await repository.get_hidden_message(db, message_id=message.id, user_id=user_id) hidden = await repository.get_hidden_message(db, message_id=message.id, user_id=user_id)
if not hidden: if not hidden:

View File

@@ -1,6 +1,6 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { archiveChat, clearChat, createPrivateChat, deleteChat, getChats, getSavedMessagesChat, joinChat, pinChat, unarchiveChat, unpinChat } from "../api/chats"; import { archiveChat, clearChat, createPrivateChat, deleteChat, getChats, getSavedMessagesChat, joinChat, leaveChat, pinChat, unarchiveChat, unpinChat } from "../api/chats";
import { globalSearch } from "../api/search"; import { globalSearch } from "../api/search";
import type { DiscoverChat, Message, UserSearchItem } from "../chat/types"; import type { DiscoverChat, Message, UserSearchItem } from "../chat/types";
import { addContact, addContactByEmail, listContacts, removeContact, updateMyProfile } from "../api/users"; import { addContact, addContactByEmail, listContacts, removeContact, updateMyProfile } from "../api/users";
@@ -50,7 +50,16 @@ export function ChatList() {
const canDeleteForEveryone = Boolean( const canDeleteForEveryone = Boolean(
deleteModalChat && deleteModalChat &&
!deleteModalChat.is_saved && !deleteModalChat.is_saved &&
(deleteModalChat.type === "group" || deleteModalChat.type === "private") (
deleteModalChat.type === "group" ||
deleteModalChat.type === "private" ||
(deleteModalChat.type === "channel" && (deleteModalChat.my_role === "owner" || deleteModalChat.my_role === "admin"))
)
);
const channelMemberLeaveOnly = Boolean(
deleteModalChat &&
deleteModalChat.type === "channel" &&
deleteModalChat.my_role === "member"
); );
useEffect(() => { useEffect(() => {
@@ -598,7 +607,13 @@ export function ChatList() {
setDeleteForAll(false); setDeleteForAll(false);
}} }}
> >
{chats.find((c) => c.id === ctxChatId)?.is_saved ? "Clear chat" : "Delete chat"} {(() => {
const chat = chats.find((c) => c.id === ctxChatId);
if (!chat) return "Delete chat";
if (chat.is_saved) return "Clear chat";
if (chat.type === "channel" && chat.my_role === "member") return "Leave channel";
return "Delete chat";
})()}
</button> </button>
<button <button
className="block w-full rounded px-2 py-1.5 text-left text-sm hover:bg-slate-800" className="block w-full rounded px-2 py-1.5 text-left text-sm hover:bg-slate-800"
@@ -653,10 +668,19 @@ export function ChatList() {
<div className="absolute inset-0 z-50 flex items-end justify-center bg-slate-950/55 p-3"> <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"> <div className="w-full rounded-xl border border-slate-700/80 bg-slate-900 p-3">
<p className="mb-2 text-sm font-semibold"> <p className="mb-2 text-sm font-semibold">
{deleteModalChat?.is_saved ? "Clear chat" : "Delete chat"}: {deleteModalChat ? chatLabel(deleteModalChat) : "selected chat"} {(deleteModalChat?.is_saved
? "Clear chat"
: deleteModalChat?.type === "channel" && deleteModalChat.my_role === "member"
? "Leave channel"
: "Delete chat")}
: {deleteModalChat ? chatLabel(deleteModalChat) : "selected chat"}
</p> </p>
{deleteModalChat?.type === "channel" ? ( {deleteModalChat?.type === "channel" ? (
<p className="mb-3 text-xs text-slate-400">Channels are removed for all subscribers.</p> <p className="mb-3 text-xs text-slate-400">
{channelMemberLeaveOnly
? "You will leave this channel."
: "Channel deletion removes it for all subscribers."}
</p>
) : null} ) : null}
{deleteModalChat?.is_saved ? ( {deleteModalChat?.is_saved ? (
<p className="mb-3 text-xs text-slate-400">This will clear all messages in Saved Messages.</p> <p className="mb-3 text-xs text-slate-400">This will clear all messages in Saved Messages.</p>
@@ -677,7 +701,11 @@ export function ChatList() {
setDeleteModalChatId(null); setDeleteModalChatId(null);
return; return;
} }
await deleteChat(deleteModalChatId, canDeleteForEveryone && deleteForAll); if (channelMemberLeaveOnly) {
await leaveChat(deleteModalChatId);
} else {
await deleteChat(deleteModalChatId, canDeleteForEveryone && deleteForAll);
}
await loadChats(); await loadChats();
if (activeChatId === deleteModalChatId) { if (activeChatId === deleteModalChatId) {
setActiveChatId(null); setActiveChatId(null);
@@ -685,7 +713,7 @@ export function ChatList() {
setDeleteModalChatId(null); setDeleteModalChatId(null);
}} }}
> >
{deleteModalChat?.is_saved ? "Clear" : "Delete"} {deleteModalChat?.is_saved ? "Clear" : channelMemberLeaveOnly ? "Leave" : "Delete"}
</button> </button>
<button className="flex-1 rounded bg-slate-700 px-3 py-2 text-sm" onClick={() => setDeleteModalChatId(null)}> <button className="flex-1 rounded bg-slate-700 px-3 py-2 text-sm" onClick={() => setDeleteModalChatId(null)}>
Cancel Cancel

View File

@@ -94,6 +94,7 @@ export function MessageList() {
const hasMore = Boolean(activeChatId && hasMoreByChat[activeChatId]); const hasMore = Boolean(activeChatId && hasMoreByChat[activeChatId]);
const isLoadingMore = Boolean(activeChatId && loadingMoreByChat[activeChatId]); const isLoadingMore = Boolean(activeChatId && loadingMoreByChat[activeChatId]);
const selectedMessages = useMemo(() => messages.filter((m) => selectedIds.has(m.id)), [messages, selectedIds]); const selectedMessages = useMemo(() => messages.filter((m) => selectedIds.has(m.id)), [messages, selectedIds]);
const channelOnlyDeleteForAll = activeChat?.type === "channel" && !activeChat?.is_saved;
const canDeleteAllForSelection = useMemo( const canDeleteAllForSelection = useMemo(
() => selectedMessages.length > 0 && selectedMessages.every((m) => canDeleteForEveryone(m, activeChat, me?.id)), () => selectedMessages.length > 0 && selectedMessages.every((m) => canDeleteForEveryone(m, activeChat, me?.id)),
[selectedMessages, activeChat, me?.id] [selectedMessages, activeChat, me?.id]
@@ -329,9 +330,11 @@ export function MessageList() {
<div className="flex items-center justify-between border-b border-slate-700/50 bg-slate-900/80 px-3 py-2 text-xs"> <div className="flex items-center justify-between border-b border-slate-700/50 bg-slate-900/80 px-3 py-2 text-xs">
<span className="font-semibold text-slate-200">{selectedIds.size} selected</span> <span className="font-semibold text-slate-200">{selectedIds.size} selected</span>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<button className="rounded bg-red-500 px-2 py-1 font-semibold text-white" onClick={() => void deleteSelectedForMe()}> {!channelOnlyDeleteForAll ? (
Delete for me <button className="rounded bg-red-500 px-2 py-1 font-semibold text-white" onClick={() => void deleteSelectedForMe()}>
</button> Delete for me
</button>
) : null}
{canDeleteAllForSelection ? ( {canDeleteAllForSelection ? (
<button className="rounded bg-red-600 px-2 py-1 font-semibold text-white" onClick={() => void deleteSelectedForEveryone()}> <button className="rounded bg-red-600 px-2 py-1 font-semibold text-white" onClick={() => void deleteSelectedForEveryone()}>
Delete for everyone Delete for everyone
@@ -747,11 +750,17 @@ export function MessageList() {
<div className="absolute inset-0 z-50 flex items-end justify-center bg-slate-950/55 p-3" onClick={() => setDeleteMessageId(null)}> <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={(event) => event.stopPropagation()}> <div className="w-full rounded-xl border border-slate-700/80 bg-slate-900 p-3" onClick={(event) => event.stopPropagation()}>
<p className="mb-2 text-sm font-semibold">Delete message</p> <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> <p className="mb-3 text-xs text-slate-400">
{channelOnlyDeleteForAll
? "In channels, messages can only be deleted for everyone."
: "Choose how to delete this message."}
</p>
<div className="space-y-2"> <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)}> {!channelOnlyDeleteForAll ? (
Delete for me <button className="w-full rounded bg-red-500 px-3 py-2 text-sm font-semibold text-white" onClick={() => void handleDelete(false)}>
</button> Delete for me
</button>
) : null}
{canDeleteForEveryone(messagesMap.get(deleteMessageId), activeChat, me?.id) ? ( {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)}> <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 Delete for everyone
@@ -904,12 +913,15 @@ function chatLabel(chat: { display_title?: string | null; title: string | null;
function canDeleteForEveryone( function canDeleteForEveryone(
message: { sender_id: number } | undefined, message: { sender_id: number } | undefined,
chat: { type: "private" | "group" | "channel"; is_saved?: boolean } | undefined, chat: { type: "private" | "group" | "channel"; is_saved?: boolean; my_role?: "owner" | "admin" | "member" | null } | undefined,
meId: number | undefined meId: number | undefined
): boolean { ): boolean {
if (!message || !chat || !meId) return false; if (!message || !chat || !meId) return false;
if (chat.is_saved) return false; if (chat.is_saved) return false;
if (chat.type === "private") return true; if (chat.type === "private") return true;
if (chat.type === "channel") {
return chat.my_role === "owner" || chat.my_role === "admin";
}
return message.sender_id === meId; return message.sender_id === meId;
} }