diff --git a/apps/web/src/views/ConversationsView.vue b/apps/web/src/views/ConversationsView.vue index 10da298..94a9364 100644 --- a/apps/web/src/views/ConversationsView.vue +++ b/apps/web/src/views/ConversationsView.vue @@ -11,6 +11,7 @@ import { formatClockFa, toFaDigits } from '@/lib/jalali'; import { classifyChatFile, onRoomLiveEvent, + previewLabelForMessage, useOrgChat, type ChatDelivery, type ChatMessage, @@ -49,7 +50,9 @@ 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 replyTo = ref(null); +const msgMenu = ref<{ id: string; x: number; y: number; sheet: boolean } | null>(null); +const msgMenuEl = ref(null); const CHAT_EMOJIS = [ '😀', '😁', '😂', '🤣', '😊', '😍', '😘', '😎', '🤔', '😅', @@ -137,6 +140,7 @@ function closeMobileChat() { activeRoom.value = null; clearPendingAttaches(); cancelEdit(); + cancelReply(); exitSelectMode(); emojiOpen.value = false; msgMenu.value = null; @@ -261,9 +265,23 @@ function cancelEdit() { editingMessageId.value = null; } +function cancelReply() { + replyTo.value = null; +} + +function startReplyMessage(msg: ChatMessage) { + if (msg.deleted || msg.optimistic) return; + replyTo.value = msg; + editingMessageId.value = null; + emojiOpen.value = false; + msgMenu.value = null; + nextTick(() => draftInputEl.value?.focus()); +} + function startEditMessage(msg: ChatMessage) { if (msg.deleted || msg.type !== 'text' || msg.optimistic) return; editingMessageId.value = msg.id; + replyTo.value = null; draft.value = msg.content; emojiOpen.value = false; msgMenu.value = null; @@ -271,6 +289,26 @@ function startEditMessage(msg: ChatMessage) { nextTick(() => draftInputEl.value?.focus()); } +function replyPreviewText(replyId: string | null | undefined) { + if (!replyId) return 'پیام'; + const m = messages.value.find((item) => item.id === replyId); + if (!m) return 'پیام'; + if (m.type === 'text') { + const t = (m.content || '').trim(); + return t.length > 90 ? `${t.slice(0, 90)}…` : t || 'پیام'; + } + return previewLabelForMessage(m); +} + +function scrollToMessage(id: string | null | undefined) { + if (!id || !msgStreamEl.value) return; + const row = msgStreamEl.value.querySelector(`[data-msg-id="${CSS.escape(id)}"]`); + if (!(row instanceof HTMLElement)) return; + row.scrollIntoView({ behavior: 'smooth', block: 'center' }); + row.classList.add('flash'); + window.setTimeout(() => row.classList.remove('flash'), 1200); +} + async function confirmDeleteMessage(msg: ChatMessage) { msgMenu.value = null; if (!activeRoom.value || msg.optimistic || msg.deleted) return; @@ -336,10 +374,23 @@ function openMsgMenu(msg: ChatMessage, event: MouseEvent | TouchEvent) { 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 }; + const sheet = window.matchMedia('(max-width: 900px)').matches; + msgMenu.value = { id: msg.id, x, y, sheet }; + if (sheet) return; + nextTick(() => { + const el = msgMenuEl.value; + if (!el || !msgMenu.value) return; + const rect = el.getBoundingClientRect(); + const pad = 12; + let left = x - rect.width / 2; + let top = y - rect.height - 10; + if (top < pad) top = y + 10; + if (top + rect.height > window.innerHeight - pad) { + top = Math.max(pad, window.innerHeight - pad - rect.height); + } + left = Math.min(Math.max(left, pad), window.innerWidth - pad - rect.width); + msgMenu.value = { ...msgMenu.value, x: left, y: top }; + }); } function menuMessage(): ChatMessage | null { @@ -627,17 +678,19 @@ async function submitMessage() { sending.value = true; error.value = ''; const roomId = activeRoom.value.externalRoomId; + const replyId = replyTo.value?.id ?? null; draft.value = ''; pendingAttaches.value = []; emojiOpen.value = false; + replyTo.value = null; try { if (text) { - await sendText(roomId, text); + await sendText(roomId, text, replyId); } for (const item of attaches) { try { - await sendFile(roomId, item.file); + await sendFile(roomId, item.file, replyId); } finally { if (item.previewUrl.startsWith('blob:')) { try { @@ -704,6 +757,7 @@ watch(activeRoom, () => { clearPendingAttaches(); exitSelectMode(); cancelEdit(); + cancelReply(); emojiOpen.value = false; msgMenu.value = null; showForward.value = false; @@ -859,6 +913,7 @@ onUnmounted(() => { v-for="msg in messages" :key="msg.id" class="msg-row" + :data-msg-id="msg.id" :class="{ mine: msg.senderId === chatUserId || msg.optimistic, selected: isSelected(msg.id), @@ -886,6 +941,15 @@ onUnmounted(() => { @touchmove="onBubbleTouchEnd" @touchcancel="onBubbleTouchEnd" > + @@ -1793,14 +1871,14 @@ onUnmounted(() => { .msg-menu-backdrop { position: fixed; inset: 0; - z-index: 40; - background: transparent; + z-index: 1200; + background: rgba(15, 23, 42, 0.28); } .msg-menu { position: fixed; - z-index: 50; - transform: translate(-50%, -110%); - min-width: 10rem; + z-index: 1210; + min-width: 11rem; + max-width: min(18rem, calc(100vw - 1.5rem)); display: flex; flex-direction: column; gap: 0.15rem; @@ -1808,7 +1886,18 @@ onUnmounted(() => { border-radius: 14px; background: #fff; border: 1px solid #e2e8f0; - box-shadow: 0 12px 32px rgba(15, 23, 42, 0.16); + box-shadow: 0 12px 32px rgba(15, 23, 42, 0.18); +} +.msg-menu.sheet { + left: 50%; + right: auto; + top: auto; + bottom: calc(0.85rem + env(safe-area-inset-bottom, 0px)); + transform: translateX(-50%); + width: min(22rem, calc(100vw - 1.5rem)); + max-width: none; + padding: 0.45rem; + border-radius: 18px; } .msg-menu button { display: flex; @@ -1816,7 +1905,7 @@ onUnmounted(() => { gap: 0.45rem; border: none; background: transparent; - padding: 0.55rem 0.7rem; + padding: 0.65rem 0.75rem; border-radius: 10px; font: inherit; cursor: pointer; @@ -1830,6 +1919,57 @@ onUnmounted(() => { .msg-menu button.danger { color: #dc2626; } +.reply-quote { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 0.15rem; + width: 100%; + margin: 0 0 0.4rem; + padding: 0.4rem 0.55rem; + border: none; + border-inline-start: 3px solid #0d9488; + border-radius: 10px; + background: rgba(15, 23, 42, 0.05); + color: inherit; + font: inherit; + cursor: pointer; + text-align: right; +} +.reply-quote-label { + font-size: 0.72rem; + color: #0f766e; + font-weight: 700; +} +.reply-quote-text { + font-size: 0.82rem; + color: #475569; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 100%; +} +.reply-banner-body { + display: flex; + flex-direction: column; + gap: 0.1rem; + min-width: 0; + flex: 1; +} +.reply-banner-body strong { + font-size: 0.82rem; +} +.reply-banner-body span { + font-size: 0.78rem; + color: #64748b; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.msg-row.flash .bubble { + outline: 2px solid rgba(13, 148, 136, 0.55); + transition: outline-color 0.2s ease; +} .attach-preview { display: flex; gap: 0.45rem;