Fixing the issue with complete deletion from the database
This commit is contained in:
+146
-51
@@ -37,18 +37,28 @@ const PAGE_SIZE = 50;
|
||||
const IMAGE_MAX_EDGE = 1600;
|
||||
const IMAGE_WEBP_QUALITY = 0.82;
|
||||
const MAX_UPLOAD_BYTES = 40 * 1024 * 1024;
|
||||
/** Synced hard-delete marker (edit channel). Clients purge the message; never shown in UI. */
|
||||
/** Synced hard-delete marker (legacy edit attempts). */
|
||||
const HARD_DELETE_MARKER = '__MENTO_DEL__';
|
||||
/** Realtime hide command delivered as a normal text message (external WS delivers text reliably). */
|
||||
const HIDE_CMD_PREFIX = '__MENTO_HIDE__:';
|
||||
|
||||
function isDeletedContent(content: string): boolean {
|
||||
const t = content.trim();
|
||||
return (
|
||||
/^(?:\[?\s*deleted\s*\]?|این پیام حذف شد|پیام حذف شد)$/i.test(t) ||
|
||||
t.includes('MENTO_DEL') ||
|
||||
t === HARD_DELETE_MARKER
|
||||
t === HARD_DELETE_MARKER ||
|
||||
t.startsWith(HIDE_CMD_PREFIX)
|
||||
);
|
||||
}
|
||||
|
||||
function parseHideCommand(content: string): string | null {
|
||||
const t = content.trim();
|
||||
if (!t.startsWith(HIDE_CMD_PREFIX)) return null;
|
||||
const id = t.slice(HIDE_CMD_PREFIX.length).trim();
|
||||
return id || null;
|
||||
}
|
||||
|
||||
function purgeMessageById(id: string) {
|
||||
const key = String(id || '').trim();
|
||||
if (!key) return;
|
||||
@@ -56,6 +66,81 @@ function purgeMessageById(id: string) {
|
||||
if (next.length !== messages.value.length) messages.value = next;
|
||||
}
|
||||
|
||||
function purgeMessageIds(ids: Iterable<string>) {
|
||||
const set = new Set([...ids].map((id) => String(id || '').trim()).filter(Boolean));
|
||||
if (!set.size) return;
|
||||
const next = messages.value.filter((m) => !set.has(m.id));
|
||||
if (next.length !== messages.value.length) messages.value = next;
|
||||
}
|
||||
|
||||
const hiddenIdsByRoom = new Map<string, Set<string>>();
|
||||
let hiddenPollTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
function rememberHidden(roomId: string, ids: string[]) {
|
||||
if (!roomId || !ids.length) return;
|
||||
let set = hiddenIdsByRoom.get(roomId);
|
||||
if (!set) {
|
||||
set = new Set();
|
||||
hiddenIdsByRoom.set(roomId, set);
|
||||
}
|
||||
for (const id of ids) {
|
||||
const key = String(id || '').trim();
|
||||
if (key) set.add(key);
|
||||
}
|
||||
}
|
||||
|
||||
function isRememberedHidden(roomId: string, messageId: string): boolean {
|
||||
return Boolean(hiddenIdsByRoom.get(roomId)?.has(messageId));
|
||||
}
|
||||
|
||||
async function persistHiddenOnServer(externalRoomId: string, messageIds: string[]) {
|
||||
const ids = [...new Set(messageIds.map((id) => String(id || '').trim()).filter(Boolean))];
|
||||
if (!externalRoomId || !ids.length) return ids;
|
||||
rememberHidden(externalRoomId, ids);
|
||||
purgeMessageIds(ids);
|
||||
try {
|
||||
await api.post('/conversations/messages/hide', {
|
||||
externalRoomId,
|
||||
messageIds: ids,
|
||||
});
|
||||
} catch {
|
||||
// local purge already applied; sync may retry via poll
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
async function fetchHiddenFromServer(externalRoomId: string): Promise<string[]> {
|
||||
if (!externalRoomId) return [];
|
||||
try {
|
||||
const { data } = await api.get<{ messageIds: string[] }>('/conversations/messages/hidden', {
|
||||
params: { externalRoomId },
|
||||
});
|
||||
const ids = Array.isArray(data?.messageIds) ? data.messageIds : [];
|
||||
rememberHidden(externalRoomId, ids);
|
||||
return ids;
|
||||
} catch {
|
||||
return [...(hiddenIdsByRoom.get(externalRoomId) ?? [])];
|
||||
}
|
||||
}
|
||||
|
||||
function stopHiddenPoll() {
|
||||
if (hiddenPollTimer) {
|
||||
clearInterval(hiddenPollTimer);
|
||||
hiddenPollTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function startHiddenPoll(roomId: string) {
|
||||
stopHiddenPoll();
|
||||
if (!roomId) return;
|
||||
hiddenPollTimer = setInterval(() => {
|
||||
void (async () => {
|
||||
const ids = await fetchHiddenFromServer(roomId);
|
||||
if (ids.length) purgeMessageIds(ids);
|
||||
})();
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
let socket: WebSocket | null = null;
|
||||
let sessionCache: ConversationSessionDto | null = null;
|
||||
let connectPromise: Promise<void> | null = null;
|
||||
@@ -316,19 +401,25 @@ function normalizeIncoming(
|
||||
String(base.file_name ?? base.fileName ?? base.name ?? '').trim() ||
|
||||
content.split('/').pop() ||
|
||||
undefined;
|
||||
const roomId = String(base.room_id ?? raw.room_id ?? roomFallback);
|
||||
const type = inferMediaType(declared, content, fileName);
|
||||
const isMedia = type !== 'text';
|
||||
const mediaUrl = isMedia ? buildChatFileUrl(content, downloadUrl) : undefined;
|
||||
const deletedFlag = Boolean(base.deleted || base.is_deleted || base.isDeleted);
|
||||
if (deletedFlag || isDeletedContent(content)) {
|
||||
// Hard-delete: drop from history / ignore tombstones
|
||||
const hideTarget = parseHideCommand(content);
|
||||
if (
|
||||
deletedFlag ||
|
||||
isDeletedContent(content) ||
|
||||
hideTarget ||
|
||||
(id && isRememberedHidden(roomId, id))
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const edited = Boolean(base.edited || base.is_edited || base.isEdited || base.edited_at);
|
||||
|
||||
return {
|
||||
id: id || `tmp-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
roomId: String(base.room_id ?? raw.room_id ?? roomFallback),
|
||||
roomId,
|
||||
senderId: String(
|
||||
base.sender_id ?? base.user_id ?? base.from_user_id ?? raw.sender_id ?? '',
|
||||
),
|
||||
@@ -625,12 +716,26 @@ function onSocketMessage(event: MessageEvent) {
|
||||
}
|
||||
const purgeId = extractPayloadId(parsed);
|
||||
const purgeContent = extractPayloadContent(parsed);
|
||||
const hideTarget = parseHideCommand(purgeContent);
|
||||
if (hideTarget) {
|
||||
const roomId = String(
|
||||
(parsed.room_id as string) ||
|
||||
((parsed.message as Record<string, unknown> | undefined)?.room_id as string) ||
|
||||
activeRoomId.value,
|
||||
);
|
||||
const ids = [hideTarget, purgeId].filter(Boolean);
|
||||
purgeMessageIds(ids);
|
||||
rememberHidden(roomId, ids);
|
||||
void persistHiddenOnServer(roomId, ids);
|
||||
return;
|
||||
}
|
||||
if (purgeId && isDeletedContent(purgeContent)) {
|
||||
purgeMessageById(purgeId);
|
||||
return;
|
||||
}
|
||||
const msg = normalizeIncoming(parsed, activeRoomId.value);
|
||||
if (!msg) return;
|
||||
if (isRememberedHidden(msg.roomId, msg.id)) return;
|
||||
handleIncomingMessage(msg);
|
||||
} catch {
|
||||
// ignore
|
||||
@@ -791,9 +896,13 @@ export function useOrgChat() {
|
||||
async function loadMessages(roomId: string) {
|
||||
activeRoomId.value = roomId;
|
||||
loadingMessages.value = true;
|
||||
startHiddenPoll(roomId);
|
||||
try {
|
||||
const session = await ensureSession();
|
||||
await connectSocket();
|
||||
const hiddenIds = await fetchHiddenFromServer(roomId);
|
||||
const hiddenSet = new Set(hiddenIds);
|
||||
|
||||
const params = new URLSearchParams({
|
||||
api_key: new URL(session.wsUrl).searchParams.get('api_key') ?? '',
|
||||
room_id: roomId,
|
||||
@@ -806,16 +915,29 @@ export function useOrgChat() {
|
||||
if (!res.ok) throw new Error('messages failed');
|
||||
const body = await res.json();
|
||||
const list = Array.isArray(body) ? body : body?.data ?? [];
|
||||
|
||||
// Collect hide-command targets from history so we filter even without CRM row yet
|
||||
for (const item of list as Record<string, unknown>[]) {
|
||||
const rawContent = String(
|
||||
item.content ?? item.text ?? item.body ?? '',
|
||||
).trim();
|
||||
const target = parseHideCommand(rawContent);
|
||||
if (target) hiddenSet.add(target);
|
||||
}
|
||||
rememberHidden(roomId, [...hiddenSet]);
|
||||
|
||||
const myId = session.chatUserId;
|
||||
const normalized = (list
|
||||
.map((item: Record<string, unknown>) => normalizeIncoming(item, roomId))
|
||||
.filter(Boolean) as ChatMessage[]).map((m) => {
|
||||
if (m.senderId !== myId) return m;
|
||||
return {
|
||||
...m,
|
||||
delivery: (m.readAt ? 'read' : 'sent') as ChatDelivery,
|
||||
};
|
||||
});
|
||||
.filter((m): m is ChatMessage => Boolean(m) && !hiddenSet.has(m!.id)) as ChatMessage[]).map(
|
||||
(m) => {
|
||||
if (m.senderId !== myId) return m;
|
||||
return {
|
||||
...m,
|
||||
delivery: (m.readAt ? 'read' : 'sent') as ChatDelivery,
|
||||
};
|
||||
},
|
||||
);
|
||||
messages.value = sortAsc(normalized);
|
||||
sendRead(roomId);
|
||||
} finally {
|
||||
@@ -1080,51 +1202,22 @@ export function useOrgChat() {
|
||||
await connectSocket();
|
||||
if (!socket || socket.readyState !== WebSocket.OPEN) throw new Error('ws not open');
|
||||
|
||||
// Remove locally immediately (no tombstone)
|
||||
purgeMessageById(messageId);
|
||||
// 1) Persist hide in CRM (survives refresh for everyone)
|
||||
await persistHiddenOnServer(roomId, [messageId]);
|
||||
|
||||
// Sync to peers via edit channel (proven to broadcast) + best-effort delete
|
||||
// 2) Realtime: send a normal text command (text delivery is reliable on chat WS)
|
||||
const controlId = crypto.randomUUID();
|
||||
rememberHidden(roomId, [controlId]);
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
type: 'edit',
|
||||
id: messageId,
|
||||
id: controlId,
|
||||
type: 'text',
|
||||
room_id: roomId,
|
||||
content: HARD_DELETE_MARKER,
|
||||
content: `${HIDE_CMD_PREFIX}${messageId}`,
|
||||
}),
|
||||
);
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
type: 'delete',
|
||||
id: messageId,
|
||||
message_id: messageId,
|
||||
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
|
||||
}
|
||||
// Hide the control message itself so it never appears in history UI
|
||||
void persistHiddenOnServer(roomId, [controlId]);
|
||||
|
||||
const last = [...messages.value].reverse().find((m) => m.roomId === roomId);
|
||||
emitRoomLive({
|
||||
@@ -1162,6 +1255,7 @@ export function useOrgChat() {
|
||||
}
|
||||
|
||||
function clearActiveRoom() {
|
||||
stopHiddenPoll();
|
||||
activeRoomId.value = '';
|
||||
messages.value = [];
|
||||
}
|
||||
@@ -1169,6 +1263,7 @@ export function useOrgChat() {
|
||||
function resetSession() {
|
||||
wantSocket = false;
|
||||
sessionCache = null;
|
||||
stopHiddenPoll();
|
||||
disconnectSocket();
|
||||
messages.value = [];
|
||||
activeRoomId.value = '';
|
||||
|
||||
Reference in New Issue
Block a user