fix(realtime,ui): sync message deletes and channel delete/leave behavior
All checks were successful
CI / test (push) Successful in 23s
All checks were successful
CI / test (push) Successful in 23s
This commit is contained in:
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
if (channelMemberLeaveOnly) {
|
||||||
|
await leaveChat(deleteModalChatId);
|
||||||
|
} else {
|
||||||
await deleteChat(deleteModalChatId, canDeleteForEveryone && deleteForAll);
|
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
|
||||||
|
|||||||
@@ -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">
|
||||||
|
{!channelOnlyDeleteForAll ? (
|
||||||
<button className="rounded bg-red-500 px-2 py-1 font-semibold text-white" onClick={() => void deleteSelectedForMe()}>
|
<button className="rounded bg-red-500 px-2 py-1 font-semibold text-white" onClick={() => void deleteSelectedForMe()}>
|
||||||
Delete for me
|
Delete for me
|
||||||
</button>
|
</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">
|
||||||
|
{!channelOnlyDeleteForAll ? (
|
||||||
<button className="w-full rounded bg-red-500 px-3 py-2 text-sm font-semibold text-white" onClick={() => void handleDelete(false)}>
|
<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
|
Delete for me
|
||||||
</button>
|
</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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user