From c20dc0a7caed723d9d92d9016a02e371533d36ec Mon Sep 17 00:00:00 2001 From: Mahdi Date: Tue, 29 Sep 2026 21:06:04 +0330 Subject: [PATCH] Aligning the chat bar with Telegram --- apps/web/src/lib/useOrgChat.ts | 154 +++++++- apps/web/src/views/ConversationsView.vue | 424 +++++++++++++++++++++-- 2 files changed, 536 insertions(+), 42 deletions(-) diff --git a/apps/web/src/lib/useOrgChat.ts b/apps/web/src/lib/useOrgChat.ts index 9ec1c40..25faece 100644 --- a/apps/web/src/lib/useOrgChat.ts +++ b/apps/web/src/lib/useOrgChat.ts @@ -22,6 +22,8 @@ export type ChatMessage = { fileName?: string; uploadProgress?: number; localPreviewUrl?: string; + edited?: boolean; + deleted?: boolean; }; export type RoomLiveEvent = { @@ -299,6 +301,13 @@ 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 || + /^(?:\[?\s*deleted\s*\]?|این پیام حذف شد|پیام حذف شد)$/i.test(content), + ); + 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)}`, @@ -306,16 +315,18 @@ function normalizeIncoming( senderId: String( base.sender_id ?? base.user_id ?? base.from_user_id ?? raw.sender_id ?? '', ), - content, - type, + content: deleted ? 'این پیام حذف شد' : content, + type: deleted ? 'text' : 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, - fileName, + mediaUrl: deleted ? undefined : mediaUrl, + fileName: deleted ? undefined : fileName, + edited: deleted ? false : edited, + deleted, }; } @@ -340,6 +351,8 @@ function mergeMessages(incoming: ChatMessage, list: ChatMessage[]): ChatMessage[ localPreviewUrl: incoming.localPreviewUrl ?? prev.localPreviewUrl, mediaUrl: incoming.mediaUrl || prev.mediaUrl, fileName: incoming.fileName || prev.fileName, + edited: incoming.edited || prev.edited, + deleted: incoming.deleted || prev.deleted, delivery: incoming.delivery === 'read' || prev.delivery === 'read' ? 'read' @@ -379,6 +392,8 @@ function mergeMessages(incoming: ChatMessage, list: ChatMessage[]): ChatMessage[ uploadProgress: undefined, mediaUrl: incoming.mediaUrl || prev.mediaUrl, fileName: incoming.fileName || prev.fileName, + edited: incoming.edited || prev.edited, + deleted: incoming.deleted || prev.deleted, delivery: incoming.readAt ? 'read' : 'sent', }; if (prev.localPreviewUrl?.startsWith('blob:')) { @@ -496,6 +511,53 @@ function handleIncomingMessage(msg: ChatMessage) { }); } +function applyEditedMessage(payload: Record) { + const base = + payload.message && typeof payload.message === 'object' + ? (payload.message as Record) + : payload; + const id = String(base.id ?? base.message_id ?? payload.message_id ?? '').trim(); + if (!id) return; + const content = String(base.content ?? base.text ?? base.body ?? '').trim(); + if (!content) return; + messages.value = messages.value.map((m) => + m.id === id + ? { + ...m, + content, + type: 'text', + edited: true, + deleted: false, + mediaUrl: undefined, + fileName: undefined, + } + : m, + ); +} + +function applyDeletedMessage(payload: Record) { + const base = + payload.message && typeof payload.message === 'object' + ? (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: 'این پیام حذف شد', + type: 'text', + deleted: true, + edited: false, + mediaUrl: undefined, + fileName: undefined, + localPreviewUrl: undefined, + } + : m, + ); +} + function onSocketMessage(event: MessageEvent) { const raw = String(event.data ?? '').trim(); if (!raw || raw === 'pong' || raw === 'ping') return; @@ -507,6 +569,28 @@ function onSocketMessage(event: MessageEvent) { applySeenPayload(parsed); return; } + if ( + type === 'edit' || + type === 'edited' || + type === 'update' || + type === 'message_edit' || + type === 'message:edit' || + type === 'message_edited' + ) { + applyEditedMessage(parsed); + return; + } + if ( + type === 'delete' || + type === 'deleted' || + type === 'remove' || + type === 'message_delete' || + type === 'message:delete' || + type === 'message_deleted' + ) { + applyDeletedMessage(parsed); + return; + } const msg = normalizeIncoming(parsed, activeRoomId.value); if (!msg) return; handleIncomingMessage(msg); @@ -916,6 +1000,66 @@ export function useOrgChat() { } } + async function editMessage(roomId: string, messageId: string, text: string) { + const trimmed = text.trim(); + if (!trimmed || !messageId) return; + await connectSocket(); + if (!socket || socket.readyState !== WebSocket.OPEN) throw new Error('ws not open'); + + messages.value = messages.value.map((m) => + m.id === messageId + ? { + ...m, + content: trimmed, + type: 'text', + edited: true, + deleted: false, + mediaUrl: undefined, + fileName: undefined, + } + : m, + ); + + socket.send( + JSON.stringify({ + type: 'edit', + id: messageId, + room_id: roomId, + content: trimmed, + }), + ); + + emitRoomLive({ + roomId, + preview: trimmed, + at: new Date().toISOString(), + fromSelf: true, + }); + } + + async function deleteMessage(roomId: string, messageId: string) { + if (!messageId) return; + await connectSocket(); + if (!socket || socket.readyState !== WebSocket.OPEN) throw new Error('ws not open'); + + applyDeletedMessage({ id: messageId }); + + socket.send( + JSON.stringify({ + type: 'delete', + id: messageId, + room_id: roomId, + }), + ); + + emitRoomLive({ + roomId, + preview: 'این پیام حذف شد', + at: new Date().toISOString(), + fromSelf: true, + }); + } + function clearActiveRoom() { activeRoomId.value = ''; messages.value = []; @@ -937,6 +1081,8 @@ export function useOrgChat() { loadMessages, sendText, sendFile, + editMessage, + deleteMessage, sendRead, ensureConnected, setConversationsPageOpen, diff --git a/apps/web/src/views/ConversationsView.vue b/apps/web/src/views/ConversationsView.vue index 5294709..24b6275 100644 --- a/apps/web/src/views/ConversationsView.vue +++ b/apps/web/src/views/ConversationsView.vue @@ -46,6 +46,17 @@ const recording = ref(false); const recordSecs = ref(0); const lightboxUrl = ref(''); const fileInputEl = ref(null); +const draftInputEl = ref(null); +const emojiOpen = ref(false); +const editingMessageId = ref(null); +const msgMenu = ref<{ id: string; x: number; y: number } | null>(null); + +const CHAT_EMOJIS = [ + '😀', '😁', '😂', '🤣', '😊', '😍', '😘', '😎', '🤔', '😅', + '😢', '😭', '😡', '👍', '👎', '👏', '🙏', '🔥', '❤️', '💔', + '✨', '🎉', '🌸', '🌹', '⭐', '✅', '❌', '🤝', '👋', '💪', + '🍀', '☕', '🎂', '🏆', '📌', '💡', '📎', '🕐', '😴', '🤩', +]; const { messages, @@ -54,6 +65,8 @@ const { loadMessages, sendText, sendFile, + editMessage, + deleteMessage, sendRead, ensureConnected, setConversationsPageOpen, @@ -116,10 +129,107 @@ function closeMobileChat() { clearActiveRoom(); activeRoom.value = null; clearPendingAttaches(); + cancelEdit(); + emojiOpen.value = false; + msgMenu.value = null; if (recording.value) mediaRecorder?.stop(); } const mobileChatOpen = computed(() => Boolean(activeRoom.value)); +const canSend = computed( + () => Boolean(draft.value.trim() || pendingAttaches.value.length || editingMessageId.value), +); +const showMicInsteadOfSend = computed( + () => !draft.value.trim() && !pendingAttaches.value.length && !editingMessageId.value, +); + +function insertEmoji(mark: string) { + const el = draftInputEl.value; + const cur = draft.value; + if (el && typeof el.selectionStart === 'number') { + const start = el.selectionStart; + const end = el.selectionEnd ?? start; + draft.value = cur.slice(0, start) + mark + cur.slice(end); + nextTick(() => { + el.focus(); + const pos = start + mark.length; + el.setSelectionRange(pos, pos); + }); + } else { + draft.value = cur + mark; + } +} + +function cancelEdit() { + editingMessageId.value = null; +} + +function startEditMessage(msg: ChatMessage) { + if (msg.deleted || msg.type !== 'text' || msg.optimistic) return; + editingMessageId.value = msg.id; + draft.value = msg.content; + emojiOpen.value = false; + msgMenu.value = null; + clearPendingAttaches(); + nextTick(() => draftInputEl.value?.focus()); +} + +async function confirmDeleteMessage(msg: ChatMessage) { + msgMenu.value = null; + if (!activeRoom.value || msg.optimistic || msg.deleted) return; + if (!window.confirm('این پیام حذف شود؟')) return; + try { + await deleteMessage(activeRoom.value.externalRoomId, msg.id); + if (editingMessageId.value === msg.id) cancelEdit(); + } catch (e) { + error.value = e instanceof Error ? e.message : 'حذف پیام ناموفق بود.'; + } +} + +function openMsgMenu(msg: ChatMessage, event: MouseEvent | TouchEvent) { + const mine = msg.senderId === chatUserId.value || msg.optimistic; + if (!mine || msg.deleted) return; + event.preventDefault(); + let x = 0; + let y = 0; + if ('changedTouches' in event && event.changedTouches[0]) { + x = event.changedTouches[0].clientX; + y = event.changedTouches[0].clientY; + } else if ('touches' in event && event.touches[0]) { + x = event.touches[0].clientX; + y = event.touches[0].clientY; + } else { + x = (event as MouseEvent).clientX; + y = (event as MouseEvent).clientY; + } + const pad = 12; + x = Math.min(Math.max(x, pad + 80), window.innerWidth - pad - 80); + y = Math.min(Math.max(y, pad + 40), window.innerHeight - pad); + msgMenu.value = { id: msg.id, x, y }; +} + +function menuMessage(): ChatMessage | null { + if (!msgMenu.value) return null; + return messages.value.find((m) => m.id === msgMenu.value!.id) ?? null; +} + +let longPressTimer: ReturnType | null = null; +function clearLongPress() { + if (longPressTimer) { + clearTimeout(longPressTimer); + longPressTimer = null; + } +} +function onBubbleTouchStart(msg: ChatMessage, event: TouchEvent) { + clearLongPress(); + longPressTimer = setTimeout(() => { + longPressTimer = null; + openMsgMenu(msg, event); + }, 480); +} +function onBubbleTouchEnd() { + clearLongPress(); +} function classifyPending(file: File): PendingAttach['kind'] { return classifyChatFile(file); @@ -354,6 +464,25 @@ async function submitMessage() { if (sending.value || recording.value) return; const text = draft.value.trim(); const attaches = [...pendingAttaches.value]; + const editingId = editingMessageId.value; + + if (editingId) { + if (!text) return; + sending.value = true; + error.value = ''; + try { + await editMessage(activeRoom.value.externalRoomId, editingId, text); + draft.value = ''; + editingMessageId.value = null; + emojiOpen.value = false; + } catch (e) { + error.value = e instanceof Error ? e.message : 'ویرایش پیام ناموفق بود.'; + } finally { + sending.value = false; + } + return; + } + if (!text && !attaches.length) return; sending.value = true; @@ -361,6 +490,7 @@ async function submitMessage() { const roomId = activeRoom.value.externalRoomId; draft.value = ''; pendingAttaches.value = []; + emojiOpen.value = false; try { if (text) { @@ -556,9 +686,23 @@ onUnmounted(() => { :key="msg.id" class="msg-row" :class="{ mine: msg.senderId === chatUserId || msg.optimistic }" + @contextmenu="openMsgMenu(msg, $event)" > -
-
@@ -1118,42 +1325,121 @@ onUnmounted(() => { .composer { display: flex; flex-direction: column; - gap: 0.45rem; - padding: 0.65rem 0.75rem calc(0.65rem + env(safe-area-inset-bottom, 0px)); + gap: 0.4rem; + padding: 0.55rem 0.65rem calc(0.55rem + env(safe-area-inset-bottom, 0px)); border-top: 1px solid var(--line); - background: rgba(255, 255, 255, 0.92); + background: #f0f2f5; flex-shrink: 0; } .composer-row { display: flex; - gap: 0.4rem; - align-items: center; + gap: 0.35rem; + align-items: flex-end; min-width: 0; } +.input-shell { + flex: 1 1 auto; + min-width: 0; + display: flex; + align-items: center; + gap: 0.15rem; + background: #fff; + border: 1px solid #e2e8f0; + border-radius: 22px; + padding: 0.15rem 0.35rem 0.15rem 0.55rem; + min-height: 2.75rem; +} .composer-input { flex: 1 1 auto; min-width: 0; - border: 1px solid var(--line); - border-radius: 14px; - padding: 0.65rem 0.85rem; + border: none; + outline: none; + border-radius: 18px; + padding: 0.55rem 0.35rem; font: inherit; + font-size: 0.95rem; + background: transparent; +} +.emoji-toggle { + flex: 0 0 auto; + border: none; + background: transparent; + width: 2.1rem; + height: 2.1rem; + border-radius: 999px; + font-size: 1.15rem; + line-height: 1; + cursor: pointer; + display: grid; + place-items: center; +} +.emoji-toggle.on { + background: rgba(13, 148, 136, 0.12); +} +.emoji-panel { + display: grid; + grid-template-columns: repeat(8, minmax(0, 1fr)); + gap: 0.2rem; + max-height: 9.5rem; + overflow-y: auto; + padding: 0.45rem; + border-radius: 14px; background: #fff; + border: 1px solid #e2e8f0; +} +.emoji-btn { + border: none; + background: transparent; + border-radius: 10px; + font-size: 1.35rem; + line-height: 1.4; + cursor: pointer; + padding: 0.2rem; +} +.emoji-btn:hover, +.emoji-btn:active { + background: #f1f5f9; +} +.edit-banner { + display: flex; + align-items: center; + gap: 0.45rem; + padding: 0.45rem 0.65rem; + border-radius: 12px; + background: rgba(13, 148, 136, 0.1); + color: #0f766e; + font-size: 0.85rem; +} +.edit-cancel { + margin-inline-start: auto; + border: none; + background: transparent; + color: #0f766e; + font: inherit; + cursor: pointer; + font-weight: 600; } .tool-btn, .send-btn { flex: 0 0 auto; - border-radius: 12px; - width: 2.6rem; - height: 2.6rem; - min-width: 2.6rem; + border-radius: 999px; + width: 2.75rem; + height: 2.75rem; + min-width: 2.75rem; display: grid; place-items: center; cursor: pointer; } .tool-btn { - border: 1px solid var(--line); - background: var(--surface, #fff); - color: var(--text); + border: none; + background: transparent; + color: #64748b; +} +.tool-btn.attach-btn, +.tool-btn.mic-btn { + background: #fff; + border: 1px solid #e2e8f0; + color: #334155; } .tool-btn.on { border-color: #ef4444; @@ -1169,6 +1455,60 @@ onUnmounted(() => { border: none; background: #0d9488; color: #fff; + box-shadow: 0 4px 12px rgba(13, 148, 136, 0.28); +} +.bubble.deleted { + opacity: 0.72; + font-style: italic; +} +.deleted-text { + margin: 0; + color: #64748b; + font-size: 0.88rem; +} +.edited-mark { + font-size: 0.68rem; + color: #94a3b8; +} +.msg-menu-backdrop { + position: fixed; + inset: 0; + z-index: 40; + background: transparent; +} +.msg-menu { + position: fixed; + z-index: 50; + transform: translate(-50%, -110%); + min-width: 10rem; + display: flex; + flex-direction: column; + gap: 0.15rem; + padding: 0.35rem; + border-radius: 14px; + background: #fff; + border: 1px solid #e2e8f0; + box-shadow: 0 12px 32px rgba(15, 23, 42, 0.16); +} +.msg-menu button { + display: flex; + align-items: center; + gap: 0.45rem; + border: none; + background: transparent; + padding: 0.55rem 0.7rem; + border-radius: 10px; + font: inherit; + cursor: pointer; + color: #0f172a; + text-align: right; +} +.msg-menu button:hover, +.msg-menu button:active { + background: #f8fafc; +} +.msg-menu button.danger { + color: #dc2626; } .attach-preview { display: flex; @@ -1484,19 +1824,27 @@ onUnmounted(() => { max-width: min(300px, 82%); } .composer { - padding: 0.5rem 0.55rem calc(0.45rem + env(safe-area-inset-bottom, 0px)); - box-shadow: 0 -6px 18px rgba(15, 23, 42, 0.04); + padding: 0.4rem 0.45rem calc(0.4rem + env(safe-area-inset-bottom, 0px)); + background: #f0f2f5; + box-shadow: 0 -8px 24px rgba(15, 23, 42, 0.05); } .tool-btn, .send-btn { - width: 2.75rem; - height: 2.75rem; - min-width: 2.75rem; + width: 2.85rem; + height: 2.85rem; + min-width: 2.85rem; + } + .input-shell { + min-height: 2.85rem; } .composer-input { - padding: 0.7rem 0.8rem; + padding: 0.6rem 0.3rem; font-size: 1rem; } + .emoji-panel { + grid-template-columns: repeat(8, minmax(0, 1fr)); + max-height: 11rem; + } }