diff --git a/apps/web/src/lib/useOrgChat.ts b/apps/web/src/lib/useOrgChat.ts index 1286f39..e49c2c7 100644 --- a/apps/web/src/lib/useOrgChat.ts +++ b/apps/web/src/lib/useOrgChat.ts @@ -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) { 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) { ? (payload.message as Record) : 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 { + const base = + parsed.message && typeof parsed.message === 'object' + ? (parsed.message as Record) + : parsed; + return String( + base.content ?? base.text ?? base.body ?? parsed.content ?? '', + ).trim(); +} + +function extractPayloadId(parsed: Record): string { + const base = + parsed.message && typeof parsed.message === 'object' + ? (parsed.message as Record) + : 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, }); diff --git a/apps/web/src/views/ConversationsView.vue b/apps/web/src/views/ConversationsView.vue index fd2bc97..93b4829 100644 --- a/apps/web/src/views/ConversationsView.vue +++ b/apps/web/src/views/ConversationsView.vue @@ -837,18 +837,14 @@ onUnmounted(() => {
- -