Fixing the pop-up error and enabling the ability to reply to messages.

This commit is contained in:
2026-09-30 09:41:49 +03:30
parent 298b3316b4
commit e6fb5c3700
+205 -65
View File
@@ -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<HTMLInputElement | null>(null);
const draftInputEl = ref<HTMLTextAreaElement | null>(null);
const emojiOpen = ref(false);
const editingMessageId = ref<string | null>(null);
const msgMenu = ref<{ id: string; x: number; y: number } | null>(null);
const replyTo = ref<ChatMessage | null>(null);
const msgMenu = ref<{ id: string; x: number; y: number; sheet: boolean } | null>(null);
const msgMenuEl = ref<HTMLElement | null>(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"
>
<button
v-if="msg.reply"
type="button"
class="reply-quote"
@click.stop="scrollToMessage(msg.reply)"
>
<span class="reply-quote-label">پاسخ به</span>
<span class="reply-quote-text">{{ replyPreviewText(msg.reply) }}</span>
</button>
<template v-if="msg.type === 'image'">
<button
type="button"
@@ -981,6 +1045,14 @@ onUnmounted(() => {
<span>ویرایش پیام</span>
<button type="button" class="edit-cancel" @click="cancelEdit(); draft = ''">انصراف</button>
</div>
<div v-else-if="replyTo" class="edit-banner reply-banner">
<UiIcon name="chat" :size="16" />
<div class="reply-banner-body">
<strong>پاسخ به پیام</strong>
<span>{{ replyPreviewText(replyTo.id) }}</span>
</div>
<button type="button" class="edit-cancel" @click="cancelReply">انصراف</button>
</div>
<div v-if="pendingAttaches.length" class="attach-preview">
<div v-for="item in pendingAttaches" :key="item.id" class="attach-chip" :class="item.kind">
<img v-if="item.kind === 'image'" :src="item.previewUrl" :alt="item.name" />
@@ -1063,58 +1135,64 @@ onUnmounted(() => {
</div>
</form>
<div
v-if="msgMenu"
class="msg-menu-backdrop"
@click="msgMenu = null"
@touchstart.passive="msgMenu = null"
/>
<div
v-if="msgMenu && menuMessage()"
class="msg-menu"
:style="{ top: `${msgMenu.y}px`, left: `${msgMenu.x}px` }"
>
<button
type="button"
@click="enterSelectMode(menuMessage()!)"
<Teleport to="body">
<div
v-if="msgMenu"
class="msg-menu-backdrop"
@click="msgMenu = null"
@touchstart.passive="msgMenu = null"
/>
<div
v-if="msgMenu && menuMessage()"
ref="msgMenuEl"
class="msg-menu"
:class="{ sheet: msgMenu.sheet }"
:style="
msgMenu.sheet
? undefined
: { top: `${msgMenu.y}px`, left: `${msgMenu.x}px` }
"
>
<UiIcon name="check-plain" :size="16" />
انتخاب
</button>
<button
type="button"
@click="openForwardPicker([menuMessage()!])"
>
<UiIcon name="send" :size="16" />
ارسال برای دیگری
</button>
<button
v-if="isMineMessage(menuMessage()) && menuMessage()?.type === 'text'"
type="button"
@click="startEditMessage(menuMessage()!)"
>
<UiIcon name="edit" :size="16" />
ویرایش
</button>
<button
v-if="isMineMessage(menuMessage())"
type="button"
class="danger"
@click="confirmDeleteMessage(menuMessage()!)"
>
<UiIcon name="trash" :size="16" />
حذف برای همه
</button>
<button
v-else
type="button"
class="danger"
@click="confirmDeleteMessage(menuMessage()!)"
>
<UiIcon name="trash" :size="16" />
حذف فقط برای من
</button>
</div>
<button type="button" @click="startReplyMessage(menuMessage()!)">
<UiIcon name="chat" :size="16" />
پاسخ
</button>
<button type="button" @click="enterSelectMode(menuMessage()!)">
<UiIcon name="check-plain" :size="16" />
انتخاب
</button>
<button type="button" @click="openForwardPicker([menuMessage()!])">
<UiIcon name="send" :size="16" />
ارسال برای دیگری
</button>
<button
v-if="isMineMessage(menuMessage()) && menuMessage()?.type === 'text'"
type="button"
@click="startEditMessage(menuMessage()!)"
>
<UiIcon name="edit" :size="16" />
ویرایش
</button>
<button
v-if="isMineMessage(menuMessage())"
type="button"
class="danger"
@click="confirmDeleteMessage(menuMessage()!)"
>
<UiIcon name="trash" :size="16" />
حذف برای همه
</button>
<button
v-else
type="button"
class="danger"
@click="confirmDeleteMessage(menuMessage()!)"
>
<UiIcon name="trash" :size="16" />
حذف فقط برای من
</button>
</div>
</Teleport>
</template>
</section>
</div>
@@ -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;