diff --git a/apps/web/src/lib/useOrgChat.ts b/apps/web/src/lib/useOrgChat.ts index 25faece..1286f39 100644 --- a/apps/web/src/lib/useOrgChat.ts +++ b/apps/web/src/lib/useOrgChat.ts @@ -37,6 +37,11 @@ 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 = 'این پیام حذف شد'; + +function isDeletedContent(content: string): boolean { + return /^(?:\[?\s*deleted\s*\]?|این پیام حذف شد|پیام حذف شد)$/i.test(content.trim()); +} let socket: WebSocket | null = null; let sessionCache: ConversationSessionDto | null = null; @@ -305,7 +310,7 @@ function normalizeIncoming( base.deleted || base.is_deleted || base.isDeleted || - /^(?:\[?\s*deleted\s*\]?|این پیام حذف شد|پیام حذف شد)$/i.test(content), + isDeletedContent(content), ); const edited = Boolean(base.edited || base.is_edited || base.isEdited || base.edited_at); @@ -315,7 +320,7 @@ function normalizeIncoming( senderId: String( base.sender_id ?? base.user_id ?? base.from_user_id ?? raw.sender_id ?? '', ), - content: deleted ? 'این پیام حذف شد' : content, + content: deleted ? DELETED_CONTENT : content, type: deleted ? 'text' : type, createdAt: String( base.created_at ?? base.createdAt ?? raw.created_at ?? new Date().toISOString(), @@ -339,20 +344,33 @@ function mergeMessages(incoming: ChatMessage, list: ChatMessage[]): ChatMessage[ ...prev, ...incoming, optimistic: false, - 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, - ), + 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, localPreviewUrl: incoming.localPreviewUrl ?? prev.localPreviewUrl, - mediaUrl: incoming.mediaUrl || prev.mediaUrl, - fileName: incoming.fileName || prev.fileName, + mediaUrl: + prev.deleted || incoming.deleted + ? undefined + : incoming.mediaUrl || prev.mediaUrl, + fileName: + prev.deleted || incoming.deleted + ? undefined + : incoming.fileName || prev.fileName, edited: incoming.edited || prev.edited, - deleted: incoming.deleted || prev.deleted, + deleted: prev.deleted || incoming.deleted, delivery: incoming.delivery === 'read' || prev.delivery === 'read' ? 'read' @@ -520,6 +538,11 @@ 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) + if (isDeletedContent(content)) { + applyDeletedMessage({ id }); + return; + } messages.value = messages.value.map((m) => m.id === id ? { @@ -546,7 +569,7 @@ function applyDeletedMessage(payload: Record) { m.id === id ? { ...m, - content: 'این پیام حذف شد', + content: DELETED_CONTENT, type: 'text', deleted: true, edited: false, @@ -1044,22 +1067,59 @@ export function useOrgChat() { applyDeletedMessage({ id: messageId }); + // Chat WS does not reliably broadcast type:delete — sync via edit (proven channel). + socket.send( + JSON.stringify({ + type: 'edit', + id: messageId, + room_id: roomId, + content: DELETED_CONTENT, + }), + ); + // Best-effort native delete for backends that support it socket.send( JSON.stringify({ type: 'delete', id: messageId, + message_id: messageId, room_id: roomId, }), ); emitRoomLive({ roomId, - preview: 'این پیام حذف شد', + preview: DELETED_CONTENT, at: new Date().toISOString(), fromSelf: true, }); } + async function deleteMessages(roomId: string, messageIds: string[]) { + const ids = [...new Set(messageIds.filter(Boolean))]; + for (const id of ids) { + await deleteMessage(roomId, id); + } + } + + async function forwardMessages(targetRoomId: string, sourceMessages: ChatMessage[]) { + const roomId = targetRoomId.trim(); + if (!roomId) return; + await connectSocket(); + for (const msg of sourceMessages) { + if (!msg || msg.deleted || msg.optimistic) continue; + if (msg.type === 'text') { + const text = msg.content.trim(); + if (text && !isDeletedContent(text)) await sendText(roomId, text); + continue; + } + const path = String(msg.content || '').trim(); + if (!path) continue; + await sendMediaPath(roomId, path, msg.type, { + fileName: msg.fileName, + }); + } + } + function clearActiveRoom() { activeRoomId.value = ''; messages.value = []; @@ -1083,6 +1143,8 @@ export function useOrgChat() { sendFile, editMessage, deleteMessage, + deleteMessages, + forwardMessages, sendRead, ensureConnected, setConversationsPageOpen, diff --git a/apps/web/src/views/ConversationsView.vue b/apps/web/src/views/ConversationsView.vue index 24b6275..fd2bc97 100644 --- a/apps/web/src/views/ConversationsView.vue +++ b/apps/web/src/views/ConversationsView.vue @@ -67,6 +67,8 @@ const { sendFile, editMessage, deleteMessage, + deleteMessages, + forwardMessages, sendRead, ensureConnected, setConversationsPageOpen, @@ -80,6 +82,11 @@ let recordChunks: Blob[] = []; let recordTimer: ReturnType | null = null; let recordStream: MediaStream | null = null; +const selectMode = ref(false); +const selectedIds = ref([]); +const showForward = ref(false); +const forwardBusy = ref(false); + const canCreateGroup = computed(() => access.value?.canCreateGroup ?? false); async function scrollToBottom() { @@ -130,8 +137,10 @@ function closeMobileChat() { activeRoom.value = null; clearPendingAttaches(); cancelEdit(); + exitSelectMode(); emojiOpen.value = false; msgMenu.value = null; + showForward.value = false; if (recording.value) mediaRecorder?.stop(); } @@ -142,6 +151,86 @@ const canSend = computed( const showMicInsteadOfSend = computed( () => !draft.value.trim() && !pendingAttaches.value.length && !editingMessageId.value, ); +const selectedCount = computed(() => selectedIds.value.length); +const forwardTargets = computed(() => + sortedRooms.value.filter((r) => r.externalRoomId !== activeRoom.value?.externalRoomId), +); + +function isSelected(id: string) { + return selectedIds.value.includes(id); +} + +function exitSelectMode() { + selectMode.value = false; + selectedIds.value = []; +} + +function enterSelectMode(msg?: ChatMessage) { + selectMode.value = true; + msgMenu.value = null; + emojiOpen.value = false; + cancelEdit(); + if (msg && !msg.deleted) selectedIds.value = [msg.id]; +} + +function toggleSelect(msg: ChatMessage) { + if (msg.deleted || msg.optimistic) return; + if (!selectMode.value) { + enterSelectMode(msg); + return; + } + if (isSelected(msg.id)) { + selectedIds.value = selectedIds.value.filter((id) => id !== msg.id); + if (!selectedIds.value.length) exitSelectMode(); + } else { + selectedIds.value = [...selectedIds.value, msg.id]; + } +} + +function selectedMessages(): ChatMessage[] { + const set = new Set(selectedIds.value); + return messages.value.filter((m) => set.has(m.id) && !m.deleted); +} + +async function deleteSelected() { + if (!activeRoom.value || !selectedIds.value.length) return; + const n = selectedIds.value.length; + if (!window.confirm(n > 1 ? `${n} پیام حذف شوند؟` : 'این پیام حذف شود؟')) return; + const ids = [...selectedIds.value]; + try { + await deleteMessages(activeRoom.value.externalRoomId, ids); + exitSelectMode(); + } catch (e) { + error.value = e instanceof Error ? e.message : 'حذف پیام ناموفق بود.'; + } +} + +function openForwardPicker(msgs?: ChatMessage[]) { + msgMenu.value = null; + if (msgs?.length) { + selectedIds.value = msgs.map((m) => m.id); + selectMode.value = true; + } + if (!selectedIds.value.length) return; + showForward.value = true; +} + +async function confirmForward(targetRoomId: string) { + if (!targetRoomId || forwardBusy.value) return; + const list = selectedMessages(); + if (!list.length) return; + forwardBusy.value = true; + error.value = ''; + try { + await forwardMessages(targetRoomId, list); + showForward.value = false; + exitSelectMode(); + } catch (e) { + error.value = e instanceof Error ? e.message : 'ارسال برای دیگری ناموفق بود.'; + } finally { + forwardBusy.value = false; + } +} function insertEmoji(mark: string) { const el = draftInputEl.value; @@ -177,7 +266,7 @@ function startEditMessage(msg: ChatMessage) { async function confirmDeleteMessage(msg: ChatMessage) { msgMenu.value = null; if (!activeRoom.value || msg.optimistic || msg.deleted) return; - if (!window.confirm('این پیام حذف شود؟')) return; + if (!window.confirm('این پیام برای همه حذف شود؟')) return; try { await deleteMessage(activeRoom.value.externalRoomId, msg.id); if (editingMessageId.value === msg.id) cancelEdit(); @@ -187,8 +276,11 @@ async function confirmDeleteMessage(msg: ChatMessage) { } function openMsgMenu(msg: ChatMessage, event: MouseEvent | TouchEvent) { - const mine = msg.senderId === chatUserId.value || msg.optimistic; - if (!mine || msg.deleted) return; + if (msg.deleted || msg.optimistic) return; + if (selectMode.value) { + toggleSelect(msg); + return; + } event.preventDefault(); let x = 0; let y = 0; @@ -213,6 +305,11 @@ function menuMessage(): ChatMessage | null { return messages.value.find((m) => m.id === msgMenu.value!.id) ?? null; } +function isMineMessage(msg: ChatMessage | null | undefined) { + if (!msg) return false; + return msg.senderId === chatUserId.value || Boolean(msg.optimistic); +} + let longPressTimer: ReturnType | null = null; function clearLongPress() { if (longPressTimer) { @@ -563,6 +660,11 @@ function toggleGroupMember(id: string) { watch(activeRoom, () => { draft.value = ''; clearPendingAttaches(); + exitSelectMode(); + cancelEdit(); + emojiOpen.value = false; + msgMenu.value = null; + showForward.value = false; if (recording.value) { mediaRecorder?.stop(); } @@ -667,27 +769,71 @@ onUnmounted(() => { @@ -946,6 +1107,30 @@ onUnmounted(() => { + + + +