Fixing the complete removal

This commit is contained in:
2026-09-29 21:36:31 +03:30
parent 83504816c5
commit 88dc7c5a1c
2 changed files with 105 additions and 68 deletions
+102 -61
View File
@@ -37,10 +37,23 @@ const PAGE_SIZE = 50;
const IMAGE_MAX_EDGE = 1600;
const IMAGE_WEBP_QUALITY = 0.82;
const MAX_UPLOAD_BYTES = 40 * 1024 * 1024;
const DELETED_CONTENT = 'این پیام حذف شد';
/** Synced hard-delete marker (edit channel). Clients purge the message; never shown in UI. */
const HARD_DELETE_MARKER = '__MENTO_DEL__';
function isDeletedContent(content: string): boolean {
return /^(?:\[?\s*deleted\s*\]?|این پیام حذف شد|پیام حذف شد)$/i.test(content.trim());
const t = content.trim();
return (
/^(?:\[?\s*deleted\s*\]?|این پیام حذف شد|پیام حذف شد)$/i.test(t) ||
t.includes('MENTO_DEL') ||
t === HARD_DELETE_MARKER
);
}
function purgeMessageById(id: string) {
const key = String(id || '').trim();
if (!key) return;
const next = messages.value.filter((m) => m.id !== key);
if (next.length !== messages.value.length) messages.value = next;
}
let socket: WebSocket | null = null;
@@ -306,12 +319,11 @@ function normalizeIncoming(
const type = inferMediaType(declared, content, fileName);
const isMedia = type !== 'text';
const mediaUrl = isMedia ? buildChatFileUrl(content, downloadUrl) : undefined;
const deleted = Boolean(
base.deleted ||
base.is_deleted ||
base.isDeleted ||
isDeletedContent(content),
);
const deletedFlag = Boolean(base.deleted || base.is_deleted || base.isDeleted);
if (deletedFlag || isDeletedContent(content)) {
// Hard-delete: drop from history / ignore tombstones
return null;
}
const edited = Boolean(base.edited || base.is_edited || base.isEdited || base.edited_at);
return {
@@ -320,57 +332,50 @@ function normalizeIncoming(
senderId: String(
base.sender_id ?? base.user_id ?? base.from_user_id ?? raw.sender_id ?? '',
),
content: deleted ? DELETED_CONTENT : content,
type: deleted ? 'text' : type,
content,
type,
createdAt: String(
base.created_at ?? base.createdAt ?? raw.created_at ?? new Date().toISOString(),
),
reply: base.reply ? String(base.reply) : null,
readAt: readAt ? String(readAt) : null,
delivery: readAt ? 'read' : 'sent',
mediaUrl: deleted ? undefined : mediaUrl,
fileName: deleted ? undefined : fileName,
edited: deleted ? false : edited,
deleted,
mediaUrl,
fileName,
edited,
deleted: false,
};
}
function mergeMessages(incoming: ChatMessage, list: ChatMessage[]): ChatMessage[] {
if (incoming.deleted || isDeletedContent(incoming.content)) {
return list.filter((m) => m.id !== incoming.id);
}
const byId = list.findIndex((m) => m.id === incoming.id);
if (byId >= 0) {
const prev = list[byId];
if (prev.deleted) {
return list.filter((m) => m.id !== prev.id);
}
const next = [...list];
next[byId] = {
...prev,
...incoming,
optimistic: false,
type:
prev.deleted || incoming.deleted
? 'text'
: inferMediaType(
prev.type === 'voice' || incoming.type === 'voice'
? 'voice'
: prev.type === 'video' || incoming.type === 'video'
? 'video'
: incoming.type,
incoming.content || prev.content,
incoming.fileName || prev.fileName,
),
content:
prev.deleted || incoming.deleted
? DELETED_CONTENT
: incoming.content || prev.content,
type: inferMediaType(
prev.type === 'voice' || incoming.type === 'voice'
? 'voice'
: prev.type === 'video' || incoming.type === 'video'
? 'video'
: incoming.type,
incoming.content || prev.content,
incoming.fileName || prev.fileName,
),
localPreviewUrl: incoming.localPreviewUrl ?? prev.localPreviewUrl,
mediaUrl:
prev.deleted || incoming.deleted
? undefined
: incoming.mediaUrl || prev.mediaUrl,
fileName:
prev.deleted || incoming.deleted
? undefined
: incoming.fileName || prev.fileName,
mediaUrl: incoming.mediaUrl || prev.mediaUrl,
fileName: incoming.fileName || prev.fileName,
edited: incoming.edited || prev.edited,
deleted: prev.deleted || incoming.deleted,
deleted: false,
delivery:
incoming.delivery === 'read' || prev.delivery === 'read'
? 'read'
@@ -538,9 +543,9 @@ function applyEditedMessage(payload: Record<string, unknown>) {
if (!id) return;
const content = String(base.content ?? base.text ?? base.body ?? '').trim();
if (!content) return;
// Delete-for-everyone is synced via edit tombstone (native delete is not broadcast by chat WS)
// Hard-delete sync uses edit channel with purge marker
if (isDeletedContent(content)) {
applyDeletedMessage({ id });
purgeMessageById(id);
return;
}
messages.value = messages.value.map((m) =>
@@ -564,21 +569,25 @@ function applyDeletedMessage(payload: Record<string, unknown>) {
? (payload.message as Record<string, unknown>)
: payload;
const id = String(base.id ?? base.message_id ?? payload.message_id ?? '').trim();
if (!id) return;
messages.value = messages.value.map((m) =>
m.id === id
? {
...m,
content: DELETED_CONTENT,
type: 'text',
deleted: true,
edited: false,
mediaUrl: undefined,
fileName: undefined,
localPreviewUrl: undefined,
}
: m,
);
purgeMessageById(id);
}
function extractPayloadContent(parsed: Record<string, unknown>): string {
const base =
parsed.message && typeof parsed.message === 'object'
? (parsed.message as Record<string, unknown>)
: parsed;
return String(
base.content ?? base.text ?? base.body ?? parsed.content ?? '',
).trim();
}
function extractPayloadId(parsed: Record<string, unknown>): string {
const base =
parsed.message && typeof parsed.message === 'object'
? (parsed.message as Record<string, unknown>)
: parsed;
return String(base.id ?? base.message_id ?? parsed.message_id ?? '').trim();
}
function onSocketMessage(event: MessageEvent) {
@@ -614,6 +623,12 @@ function onSocketMessage(event: MessageEvent) {
applyDeletedMessage(parsed);
return;
}
const purgeId = extractPayloadId(parsed);
const purgeContent = extractPayloadContent(parsed);
if (purgeId && isDeletedContent(purgeContent)) {
purgeMessageById(purgeId);
return;
}
const msg = normalizeIncoming(parsed, activeRoomId.value);
if (!msg) return;
handleIncomingMessage(msg);
@@ -1065,18 +1080,18 @@ export function useOrgChat() {
await connectSocket();
if (!socket || socket.readyState !== WebSocket.OPEN) throw new Error('ws not open');
applyDeletedMessage({ id: messageId });
// Remove locally immediately (no tombstone)
purgeMessageById(messageId);
// Chat WS does not reliably broadcast type:delete — sync via edit (proven channel).
// Sync to peers via edit channel (proven to broadcast) + best-effort delete
socket.send(
JSON.stringify({
type: 'edit',
id: messageId,
room_id: roomId,
content: DELETED_CONTENT,
content: HARD_DELETE_MARKER,
}),
);
// Best-effort native delete for backends that support it
socket.send(
JSON.stringify({
type: 'delete',
@@ -1085,10 +1100,36 @@ export function useOrgChat() {
room_id: roomId,
}),
);
socket.send(
JSON.stringify({
type: 'remove',
id: messageId,
message_id: messageId,
room_id: roomId,
}),
);
// Best-effort REST delete if the chat host supports it
try {
const session = await ensureSession();
const apiKey = new URL(session.wsUrl).searchParams.get('api_key') ?? '';
const params = new URLSearchParams({
api_key: apiKey,
room_id: roomId,
user_id: session.chatUserId,
});
void fetch(`${session.messagesBaseUrl}/api/messages/${encodeURIComponent(messageId)}?${params}`, {
method: 'DELETE',
headers: { Accept: 'application/json' },
}).catch(() => undefined);
} catch {
// ignore
}
const last = [...messages.value].reverse().find((m) => m.roomId === roomId);
emitRoomLive({
roomId,
preview: DELETED_CONTENT,
preview: last ? previewLabelForMessage(last) : ' ',
at: new Date().toISOString(),
fromSelf: true,
});
+3 -7
View File
@@ -837,18 +837,14 @@ onUnmounted(() => {
<div
class="bubble"
:class="{
media: msg.type !== 'text' && !msg.deleted,
deleted: msg.deleted,
media: msg.type !== 'text',
}"
@touchstart.passive="onBubbleTouchStart(msg, $event)"
@touchend="onBubbleTouchEnd"
@touchmove="onBubbleTouchEnd"
@touchcancel="onBubbleTouchEnd"
>
<template v-if="msg.deleted">
<p class="deleted-text">این پیام حذف شد</p>
</template>
<template v-else-if="msg.type === 'image'">
<template v-if="msg.type === 'image'">
<button
type="button"
class="media-image"
@@ -907,7 +903,7 @@ onUnmounted(() => {
</template>
<p v-else>{{ msg.content }}</p>
<div class="bubble-meta">
<span v-if="msg.edited && !msg.deleted" class="edited-mark">ویرایش‌شده</span>
<span v-if="msg.edited" class="edited-mark">ویرایش‌شده</span>
<time>{{ formatListTime(msg.createdAt) }}</time>
<span
v-if="msg.senderId === chatUserId || msg.optimistic"