Aligning the chat bar with Telegram
This commit is contained in:
@@ -22,6 +22,8 @@ export type ChatMessage = {
|
|||||||
fileName?: string;
|
fileName?: string;
|
||||||
uploadProgress?: number;
|
uploadProgress?: number;
|
||||||
localPreviewUrl?: string;
|
localPreviewUrl?: string;
|
||||||
|
edited?: boolean;
|
||||||
|
deleted?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type RoomLiveEvent = {
|
export type RoomLiveEvent = {
|
||||||
@@ -299,6 +301,13 @@ function normalizeIncoming(
|
|||||||
const type = inferMediaType(declared, content, fileName);
|
const type = inferMediaType(declared, content, fileName);
|
||||||
const isMedia = type !== 'text';
|
const isMedia = type !== 'text';
|
||||||
const mediaUrl = isMedia ? buildChatFileUrl(content, downloadUrl) : undefined;
|
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 {
|
return {
|
||||||
id: id || `tmp-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
id: id || `tmp-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||||
@@ -306,16 +315,18 @@ function normalizeIncoming(
|
|||||||
senderId: String(
|
senderId: String(
|
||||||
base.sender_id ?? base.user_id ?? base.from_user_id ?? raw.sender_id ?? '',
|
base.sender_id ?? base.user_id ?? base.from_user_id ?? raw.sender_id ?? '',
|
||||||
),
|
),
|
||||||
content,
|
content: deleted ? 'این پیام حذف شد' : content,
|
||||||
type,
|
type: deleted ? 'text' : type,
|
||||||
createdAt: String(
|
createdAt: String(
|
||||||
base.created_at ?? base.createdAt ?? raw.created_at ?? new Date().toISOString(),
|
base.created_at ?? base.createdAt ?? raw.created_at ?? new Date().toISOString(),
|
||||||
),
|
),
|
||||||
reply: base.reply ? String(base.reply) : null,
|
reply: base.reply ? String(base.reply) : null,
|
||||||
readAt: readAt ? String(readAt) : null,
|
readAt: readAt ? String(readAt) : null,
|
||||||
delivery: readAt ? 'read' : 'sent',
|
delivery: readAt ? 'read' : 'sent',
|
||||||
mediaUrl,
|
mediaUrl: deleted ? undefined : mediaUrl,
|
||||||
fileName,
|
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,
|
localPreviewUrl: incoming.localPreviewUrl ?? prev.localPreviewUrl,
|
||||||
mediaUrl: incoming.mediaUrl || prev.mediaUrl,
|
mediaUrl: incoming.mediaUrl || prev.mediaUrl,
|
||||||
fileName: incoming.fileName || prev.fileName,
|
fileName: incoming.fileName || prev.fileName,
|
||||||
|
edited: incoming.edited || prev.edited,
|
||||||
|
deleted: incoming.deleted || prev.deleted,
|
||||||
delivery:
|
delivery:
|
||||||
incoming.delivery === 'read' || prev.delivery === 'read'
|
incoming.delivery === 'read' || prev.delivery === 'read'
|
||||||
? 'read'
|
? 'read'
|
||||||
@@ -379,6 +392,8 @@ function mergeMessages(incoming: ChatMessage, list: ChatMessage[]): ChatMessage[
|
|||||||
uploadProgress: undefined,
|
uploadProgress: undefined,
|
||||||
mediaUrl: incoming.mediaUrl || prev.mediaUrl,
|
mediaUrl: incoming.mediaUrl || prev.mediaUrl,
|
||||||
fileName: incoming.fileName || prev.fileName,
|
fileName: incoming.fileName || prev.fileName,
|
||||||
|
edited: incoming.edited || prev.edited,
|
||||||
|
deleted: incoming.deleted || prev.deleted,
|
||||||
delivery: incoming.readAt ? 'read' : 'sent',
|
delivery: incoming.readAt ? 'read' : 'sent',
|
||||||
};
|
};
|
||||||
if (prev.localPreviewUrl?.startsWith('blob:')) {
|
if (prev.localPreviewUrl?.startsWith('blob:')) {
|
||||||
@@ -496,6 +511,53 @@ function handleIncomingMessage(msg: ChatMessage) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function applyEditedMessage(payload: Record<string, unknown>) {
|
||||||
|
const base =
|
||||||
|
payload.message && typeof payload.message === 'object'
|
||||||
|
? (payload.message as Record<string, unknown>)
|
||||||
|
: 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<string, unknown>) {
|
||||||
|
const base =
|
||||||
|
payload.message && typeof payload.message === 'object'
|
||||||
|
? (payload.message as Record<string, unknown>)
|
||||||
|
: 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) {
|
function onSocketMessage(event: MessageEvent) {
|
||||||
const raw = String(event.data ?? '').trim();
|
const raw = String(event.data ?? '').trim();
|
||||||
if (!raw || raw === 'pong' || raw === 'ping') return;
|
if (!raw || raw === 'pong' || raw === 'ping') return;
|
||||||
@@ -507,6 +569,28 @@ function onSocketMessage(event: MessageEvent) {
|
|||||||
applySeenPayload(parsed);
|
applySeenPayload(parsed);
|
||||||
return;
|
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);
|
const msg = normalizeIncoming(parsed, activeRoomId.value);
|
||||||
if (!msg) return;
|
if (!msg) return;
|
||||||
handleIncomingMessage(msg);
|
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() {
|
function clearActiveRoom() {
|
||||||
activeRoomId.value = '';
|
activeRoomId.value = '';
|
||||||
messages.value = [];
|
messages.value = [];
|
||||||
@@ -937,6 +1081,8 @@ export function useOrgChat() {
|
|||||||
loadMessages,
|
loadMessages,
|
||||||
sendText,
|
sendText,
|
||||||
sendFile,
|
sendFile,
|
||||||
|
editMessage,
|
||||||
|
deleteMessage,
|
||||||
sendRead,
|
sendRead,
|
||||||
ensureConnected,
|
ensureConnected,
|
||||||
setConversationsPageOpen,
|
setConversationsPageOpen,
|
||||||
|
|||||||
@@ -46,6 +46,17 @@ const recording = ref(false);
|
|||||||
const recordSecs = ref(0);
|
const recordSecs = ref(0);
|
||||||
const lightboxUrl = ref('');
|
const lightboxUrl = ref('');
|
||||||
const fileInputEl = ref<HTMLInputElement | null>(null);
|
const fileInputEl = ref<HTMLInputElement | null>(null);
|
||||||
|
const draftInputEl = ref<HTMLInputElement | null>(null);
|
||||||
|
const emojiOpen = ref(false);
|
||||||
|
const editingMessageId = ref<string | null>(null);
|
||||||
|
const msgMenu = ref<{ id: string; x: number; y: number } | null>(null);
|
||||||
|
|
||||||
|
const CHAT_EMOJIS = [
|
||||||
|
'😀', '😁', '😂', '🤣', '😊', '😍', '😘', '😎', '🤔', '😅',
|
||||||
|
'😢', '😭', '😡', '👍', '👎', '👏', '🙏', '🔥', '❤️', '💔',
|
||||||
|
'✨', '🎉', '🌸', '🌹', '⭐', '✅', '❌', '🤝', '👋', '💪',
|
||||||
|
'🍀', '☕', '🎂', '🏆', '📌', '💡', '📎', '🕐', '😴', '🤩',
|
||||||
|
];
|
||||||
|
|
||||||
const {
|
const {
|
||||||
messages,
|
messages,
|
||||||
@@ -54,6 +65,8 @@ const {
|
|||||||
loadMessages,
|
loadMessages,
|
||||||
sendText,
|
sendText,
|
||||||
sendFile,
|
sendFile,
|
||||||
|
editMessage,
|
||||||
|
deleteMessage,
|
||||||
sendRead,
|
sendRead,
|
||||||
ensureConnected,
|
ensureConnected,
|
||||||
setConversationsPageOpen,
|
setConversationsPageOpen,
|
||||||
@@ -116,10 +129,107 @@ function closeMobileChat() {
|
|||||||
clearActiveRoom();
|
clearActiveRoom();
|
||||||
activeRoom.value = null;
|
activeRoom.value = null;
|
||||||
clearPendingAttaches();
|
clearPendingAttaches();
|
||||||
|
cancelEdit();
|
||||||
|
emojiOpen.value = false;
|
||||||
|
msgMenu.value = null;
|
||||||
if (recording.value) mediaRecorder?.stop();
|
if (recording.value) mediaRecorder?.stop();
|
||||||
}
|
}
|
||||||
|
|
||||||
const mobileChatOpen = computed(() => Boolean(activeRoom.value));
|
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<typeof setTimeout> | 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'] {
|
function classifyPending(file: File): PendingAttach['kind'] {
|
||||||
return classifyChatFile(file);
|
return classifyChatFile(file);
|
||||||
@@ -354,6 +464,25 @@ async function submitMessage() {
|
|||||||
if (sending.value || recording.value) return;
|
if (sending.value || recording.value) return;
|
||||||
const text = draft.value.trim();
|
const text = draft.value.trim();
|
||||||
const attaches = [...pendingAttaches.value];
|
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;
|
if (!text && !attaches.length) return;
|
||||||
|
|
||||||
sending.value = true;
|
sending.value = true;
|
||||||
@@ -361,6 +490,7 @@ async function submitMessage() {
|
|||||||
const roomId = activeRoom.value.externalRoomId;
|
const roomId = activeRoom.value.externalRoomId;
|
||||||
draft.value = '';
|
draft.value = '';
|
||||||
pendingAttaches.value = [];
|
pendingAttaches.value = [];
|
||||||
|
emojiOpen.value = false;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (text) {
|
if (text) {
|
||||||
@@ -556,9 +686,23 @@ onUnmounted(() => {
|
|||||||
:key="msg.id"
|
:key="msg.id"
|
||||||
class="msg-row"
|
class="msg-row"
|
||||||
:class="{ mine: msg.senderId === chatUserId || msg.optimistic }"
|
:class="{ mine: msg.senderId === chatUserId || msg.optimistic }"
|
||||||
|
@contextmenu="openMsgMenu(msg, $event)"
|
||||||
>
|
>
|
||||||
<div class="bubble" :class="{ media: msg.type !== 'text' }">
|
<div
|
||||||
<template v-if="msg.type === 'image'">
|
class="bubble"
|
||||||
|
:class="{
|
||||||
|
media: msg.type !== 'text' && !msg.deleted,
|
||||||
|
deleted: msg.deleted,
|
||||||
|
}"
|
||||||
|
@touchstart.passive="onBubbleTouchStart(msg, $event)"
|
||||||
|
@touchend="onBubbleTouchEnd"
|
||||||
|
@touchmove="onBubbleTouchEnd"
|
||||||
|
@touchcancel="onBubbleTouchEnd"
|
||||||
|
>
|
||||||
|
<template v-if="msg.deleted">
|
||||||
|
<p class="deleted-text">این پیام حذف شد</p>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="msg.type === 'image'">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="media-image"
|
class="media-image"
|
||||||
@@ -617,6 +761,7 @@ onUnmounted(() => {
|
|||||||
</template>
|
</template>
|
||||||
<p v-else>{{ msg.content }}</p>
|
<p v-else>{{ msg.content }}</p>
|
||||||
<div class="bubble-meta">
|
<div class="bubble-meta">
|
||||||
|
<span v-if="msg.edited && !msg.deleted" class="edited-mark">ویرایششده</span>
|
||||||
<time>{{ formatListTime(msg.createdAt) }}</time>
|
<time>{{ formatListTime(msg.createdAt) }}</time>
|
||||||
<span
|
<span
|
||||||
v-if="msg.senderId === chatUserId || msg.optimistic"
|
v-if="msg.senderId === chatUserId || msg.optimistic"
|
||||||
@@ -636,6 +781,22 @@ onUnmounted(() => {
|
|||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<form class="composer" @submit.prevent="submitMessage">
|
<form class="composer" @submit.prevent="submitMessage">
|
||||||
|
<div v-if="emojiOpen" class="emoji-panel">
|
||||||
|
<button
|
||||||
|
v-for="mark in CHAT_EMOJIS"
|
||||||
|
:key="mark"
|
||||||
|
type="button"
|
||||||
|
class="emoji-btn"
|
||||||
|
@click="insertEmoji(mark)"
|
||||||
|
>
|
||||||
|
{{ mark }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div v-if="editingMessageId" class="edit-banner">
|
||||||
|
<UiIcon name="edit" :size="16" />
|
||||||
|
<span>ویرایش پیام</span>
|
||||||
|
<button type="button" class="edit-cancel" @click="cancelEdit(); draft = ''">انصراف</button>
|
||||||
|
</div>
|
||||||
<div v-if="pendingAttaches.length" class="attach-preview">
|
<div v-if="pendingAttaches.length" class="attach-preview">
|
||||||
<div v-for="item in pendingAttaches" :key="item.id" class="attach-chip" :class="item.kind">
|
<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" />
|
<img v-if="item.kind === 'image'" :src="item.previewUrl" :alt="item.name" />
|
||||||
@@ -665,40 +826,86 @@ onUnmounted(() => {
|
|||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="tool-btn"
|
class="tool-btn attach-btn"
|
||||||
title="پیوست فایل / عکس / فیلم"
|
title="پیوست"
|
||||||
:disabled="sending || recording"
|
:disabled="sending || recording || Boolean(editingMessageId)"
|
||||||
@click="fileInputEl?.click()"
|
@click="fileInputEl?.click()"
|
||||||
>
|
>
|
||||||
<UiIcon name="document" :size="18" />
|
<UiIcon name="plus" :size="20" />
|
||||||
</button>
|
</button>
|
||||||
|
<div class="input-shell">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="emoji-toggle"
|
||||||
|
title="ایموجی"
|
||||||
|
:class="{ on: emojiOpen }"
|
||||||
|
:disabled="sending || recording"
|
||||||
|
@click="emojiOpen = !emojiOpen"
|
||||||
|
>
|
||||||
|
😊
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
ref="draftInputEl"
|
||||||
|
v-model="draft"
|
||||||
|
type="text"
|
||||||
|
class="composer-input"
|
||||||
|
:placeholder="editingMessageId ? 'ویرایش پیام…' : 'پیام بنویسید…'"
|
||||||
|
autocomplete="off"
|
||||||
|
enterkeyhint="send"
|
||||||
|
:disabled="sending || recording"
|
||||||
|
@focus="emojiOpen = false"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
|
v-if="showMicInsteadOfSend"
|
||||||
type="button"
|
type="button"
|
||||||
class="tool-btn"
|
class="tool-btn mic-btn"
|
||||||
:class="{ on: recording }"
|
:class="{ on: recording }"
|
||||||
title="ضبط پیام صوتی"
|
title="ضبط پیام صوتی"
|
||||||
:disabled="sending"
|
:disabled="sending"
|
||||||
@click="toggleVoiceRecord"
|
@click="toggleVoiceRecord"
|
||||||
>
|
>
|
||||||
<UiIcon :name="recording ? 'stop' : 'mic'" :size="18" />
|
<UiIcon :name="recording ? 'stop' : 'mic'" :size="20" />
|
||||||
</button>
|
</button>
|
||||||
<input
|
|
||||||
v-model="draft"
|
|
||||||
type="text"
|
|
||||||
class="composer-input"
|
|
||||||
placeholder="پیام…"
|
|
||||||
autocomplete="off"
|
|
||||||
:disabled="sending || recording"
|
|
||||||
/>
|
|
||||||
<button
|
<button
|
||||||
|
v-else
|
||||||
type="submit"
|
type="submit"
|
||||||
class="send-btn"
|
class="send-btn"
|
||||||
:disabled="sending || recording || (!draft.trim() && !pendingAttaches.length)"
|
:disabled="sending || recording || !canSend"
|
||||||
>
|
>
|
||||||
<UiIcon name="send" :size="18" />
|
<UiIcon name="send" :size="18" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</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
|
||||||
|
v-if="menuMessage()?.type === 'text' && !menuMessage()?.optimistic"
|
||||||
|
type="button"
|
||||||
|
@click="startEditMessage(menuMessage()!)"
|
||||||
|
>
|
||||||
|
<UiIcon name="edit" :size="16" />
|
||||||
|
ویرایش
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="danger"
|
||||||
|
@click="confirmDeleteMessage(menuMessage()!)"
|
||||||
|
>
|
||||||
|
<UiIcon name="trash" :size="16" />
|
||||||
|
حذف
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
@@ -1118,42 +1325,121 @@ onUnmounted(() => {
|
|||||||
.composer {
|
.composer {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.45rem;
|
gap: 0.4rem;
|
||||||
padding: 0.65rem 0.75rem calc(0.65rem + env(safe-area-inset-bottom, 0px));
|
padding: 0.55rem 0.65rem calc(0.55rem + env(safe-area-inset-bottom, 0px));
|
||||||
border-top: 1px solid var(--line);
|
border-top: 1px solid var(--line);
|
||||||
background: rgba(255, 255, 255, 0.92);
|
background: #f0f2f5;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.composer-row {
|
.composer-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.4rem;
|
gap: 0.35rem;
|
||||||
align-items: center;
|
align-items: flex-end;
|
||||||
min-width: 0;
|
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 {
|
.composer-input {
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
border: 1px solid var(--line);
|
border: none;
|
||||||
border-radius: 14px;
|
outline: none;
|
||||||
padding: 0.65rem 0.85rem;
|
border-radius: 18px;
|
||||||
|
padding: 0.55rem 0.35rem;
|
||||||
font: inherit;
|
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;
|
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,
|
.tool-btn,
|
||||||
.send-btn {
|
.send-btn {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
border-radius: 12px;
|
border-radius: 999px;
|
||||||
width: 2.6rem;
|
width: 2.75rem;
|
||||||
height: 2.6rem;
|
height: 2.75rem;
|
||||||
min-width: 2.6rem;
|
min-width: 2.75rem;
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
.tool-btn {
|
.tool-btn {
|
||||||
border: 1px solid var(--line);
|
border: none;
|
||||||
background: var(--surface, #fff);
|
background: transparent;
|
||||||
color: var(--text);
|
color: #64748b;
|
||||||
|
}
|
||||||
|
.tool-btn.attach-btn,
|
||||||
|
.tool-btn.mic-btn {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
color: #334155;
|
||||||
}
|
}
|
||||||
.tool-btn.on {
|
.tool-btn.on {
|
||||||
border-color: #ef4444;
|
border-color: #ef4444;
|
||||||
@@ -1169,6 +1455,60 @@ onUnmounted(() => {
|
|||||||
border: none;
|
border: none;
|
||||||
background: #0d9488;
|
background: #0d9488;
|
||||||
color: #fff;
|
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 {
|
.attach-preview {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -1484,19 +1824,27 @@ onUnmounted(() => {
|
|||||||
max-width: min(300px, 82%);
|
max-width: min(300px, 82%);
|
||||||
}
|
}
|
||||||
.composer {
|
.composer {
|
||||||
padding: 0.5rem 0.55rem calc(0.45rem + env(safe-area-inset-bottom, 0px));
|
padding: 0.4rem 0.45rem calc(0.4rem + env(safe-area-inset-bottom, 0px));
|
||||||
box-shadow: 0 -6px 18px rgba(15, 23, 42, 0.04);
|
background: #f0f2f5;
|
||||||
|
box-shadow: 0 -8px 24px rgba(15, 23, 42, 0.05);
|
||||||
}
|
}
|
||||||
.tool-btn,
|
.tool-btn,
|
||||||
.send-btn {
|
.send-btn {
|
||||||
width: 2.75rem;
|
width: 2.85rem;
|
||||||
height: 2.75rem;
|
height: 2.85rem;
|
||||||
min-width: 2.75rem;
|
min-width: 2.85rem;
|
||||||
|
}
|
||||||
|
.input-shell {
|
||||||
|
min-height: 2.85rem;
|
||||||
}
|
}
|
||||||
.composer-input {
|
.composer-input {
|
||||||
padding: 0.7rem 0.8rem;
|
padding: 0.6rem 0.3rem;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
}
|
}
|
||||||
|
.emoji-panel {
|
||||||
|
grid-template-columns: repeat(8, minmax(0, 1fr));
|
||||||
|
max-height: 11rem;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user