Add, edit, delete, and resend messages.
This commit is contained in:
@@ -37,6 +37,11 @@ const PAGE_SIZE = 50;
|
|||||||
const IMAGE_MAX_EDGE = 1600;
|
const IMAGE_MAX_EDGE = 1600;
|
||||||
const IMAGE_WEBP_QUALITY = 0.82;
|
const IMAGE_WEBP_QUALITY = 0.82;
|
||||||
const MAX_UPLOAD_BYTES = 40 * 1024 * 1024;
|
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 socket: WebSocket | null = null;
|
||||||
let sessionCache: ConversationSessionDto | null = null;
|
let sessionCache: ConversationSessionDto | null = null;
|
||||||
@@ -305,7 +310,7 @@ function normalizeIncoming(
|
|||||||
base.deleted ||
|
base.deleted ||
|
||||||
base.is_deleted ||
|
base.is_deleted ||
|
||||||
base.isDeleted ||
|
base.isDeleted ||
|
||||||
/^(?:\[?\s*deleted\s*\]?|این پیام حذف شد|پیام حذف شد)$/i.test(content),
|
isDeletedContent(content),
|
||||||
);
|
);
|
||||||
const edited = Boolean(base.edited || base.is_edited || base.isEdited || base.edited_at);
|
const edited = Boolean(base.edited || base.is_edited || base.isEdited || base.edited_at);
|
||||||
|
|
||||||
@@ -315,7 +320,7 @@ 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: deleted ? 'این پیام حذف شد' : content,
|
content: deleted ? DELETED_CONTENT : content,
|
||||||
type: deleted ? 'text' : 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(),
|
||||||
@@ -339,20 +344,33 @@ function mergeMessages(incoming: ChatMessage, list: ChatMessage[]): ChatMessage[
|
|||||||
...prev,
|
...prev,
|
||||||
...incoming,
|
...incoming,
|
||||||
optimistic: false,
|
optimistic: false,
|
||||||
type: inferMediaType(
|
type:
|
||||||
prev.type === 'voice' || incoming.type === 'voice'
|
prev.deleted || incoming.deleted
|
||||||
? 'voice'
|
? 'text'
|
||||||
: prev.type === 'video' || incoming.type === 'video'
|
: inferMediaType(
|
||||||
? 'video'
|
prev.type === 'voice' || incoming.type === 'voice'
|
||||||
: incoming.type,
|
? 'voice'
|
||||||
incoming.content || prev.content,
|
: prev.type === 'video' || incoming.type === 'video'
|
||||||
incoming.fileName || prev.fileName,
|
? '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,
|
localPreviewUrl: incoming.localPreviewUrl ?? prev.localPreviewUrl,
|
||||||
mediaUrl: incoming.mediaUrl || prev.mediaUrl,
|
mediaUrl:
|
||||||
fileName: incoming.fileName || prev.fileName,
|
prev.deleted || incoming.deleted
|
||||||
|
? undefined
|
||||||
|
: incoming.mediaUrl || prev.mediaUrl,
|
||||||
|
fileName:
|
||||||
|
prev.deleted || incoming.deleted
|
||||||
|
? undefined
|
||||||
|
: incoming.fileName || prev.fileName,
|
||||||
edited: incoming.edited || prev.edited,
|
edited: incoming.edited || prev.edited,
|
||||||
deleted: incoming.deleted || prev.deleted,
|
deleted: prev.deleted || incoming.deleted,
|
||||||
delivery:
|
delivery:
|
||||||
incoming.delivery === 'read' || prev.delivery === 'read'
|
incoming.delivery === 'read' || prev.delivery === 'read'
|
||||||
? 'read'
|
? 'read'
|
||||||
@@ -520,6 +538,11 @@ function applyEditedMessage(payload: Record<string, unknown>) {
|
|||||||
if (!id) return;
|
if (!id) return;
|
||||||
const content = String(base.content ?? base.text ?? base.body ?? '').trim();
|
const content = String(base.content ?? base.text ?? base.body ?? '').trim();
|
||||||
if (!content) return;
|
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) =>
|
messages.value = messages.value.map((m) =>
|
||||||
m.id === id
|
m.id === id
|
||||||
? {
|
? {
|
||||||
@@ -546,7 +569,7 @@ function applyDeletedMessage(payload: Record<string, unknown>) {
|
|||||||
m.id === id
|
m.id === id
|
||||||
? {
|
? {
|
||||||
...m,
|
...m,
|
||||||
content: 'این پیام حذف شد',
|
content: DELETED_CONTENT,
|
||||||
type: 'text',
|
type: 'text',
|
||||||
deleted: true,
|
deleted: true,
|
||||||
edited: false,
|
edited: false,
|
||||||
@@ -1044,22 +1067,59 @@ export function useOrgChat() {
|
|||||||
|
|
||||||
applyDeletedMessage({ id: messageId });
|
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(
|
socket.send(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
type: 'delete',
|
type: 'delete',
|
||||||
id: messageId,
|
id: messageId,
|
||||||
|
message_id: messageId,
|
||||||
room_id: roomId,
|
room_id: roomId,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
emitRoomLive({
|
emitRoomLive({
|
||||||
roomId,
|
roomId,
|
||||||
preview: 'این پیام حذف شد',
|
preview: DELETED_CONTENT,
|
||||||
at: new Date().toISOString(),
|
at: new Date().toISOString(),
|
||||||
fromSelf: true,
|
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() {
|
function clearActiveRoom() {
|
||||||
activeRoomId.value = '';
|
activeRoomId.value = '';
|
||||||
messages.value = [];
|
messages.value = [];
|
||||||
@@ -1083,6 +1143,8 @@ export function useOrgChat() {
|
|||||||
sendFile,
|
sendFile,
|
||||||
editMessage,
|
editMessage,
|
||||||
deleteMessage,
|
deleteMessage,
|
||||||
|
deleteMessages,
|
||||||
|
forwardMessages,
|
||||||
sendRead,
|
sendRead,
|
||||||
ensureConnected,
|
ensureConnected,
|
||||||
setConversationsPageOpen,
|
setConversationsPageOpen,
|
||||||
|
|||||||
@@ -67,6 +67,8 @@ const {
|
|||||||
sendFile,
|
sendFile,
|
||||||
editMessage,
|
editMessage,
|
||||||
deleteMessage,
|
deleteMessage,
|
||||||
|
deleteMessages,
|
||||||
|
forwardMessages,
|
||||||
sendRead,
|
sendRead,
|
||||||
ensureConnected,
|
ensureConnected,
|
||||||
setConversationsPageOpen,
|
setConversationsPageOpen,
|
||||||
@@ -80,6 +82,11 @@ let recordChunks: Blob[] = [];
|
|||||||
let recordTimer: ReturnType<typeof setInterval> | null = null;
|
let recordTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
let recordStream: MediaStream | null = null;
|
let recordStream: MediaStream | null = null;
|
||||||
|
|
||||||
|
const selectMode = ref(false);
|
||||||
|
const selectedIds = ref<string[]>([]);
|
||||||
|
const showForward = ref(false);
|
||||||
|
const forwardBusy = ref(false);
|
||||||
|
|
||||||
const canCreateGroup = computed(() => access.value?.canCreateGroup ?? false);
|
const canCreateGroup = computed(() => access.value?.canCreateGroup ?? false);
|
||||||
|
|
||||||
async function scrollToBottom() {
|
async function scrollToBottom() {
|
||||||
@@ -130,8 +137,10 @@ function closeMobileChat() {
|
|||||||
activeRoom.value = null;
|
activeRoom.value = null;
|
||||||
clearPendingAttaches();
|
clearPendingAttaches();
|
||||||
cancelEdit();
|
cancelEdit();
|
||||||
|
exitSelectMode();
|
||||||
emojiOpen.value = false;
|
emojiOpen.value = false;
|
||||||
msgMenu.value = null;
|
msgMenu.value = null;
|
||||||
|
showForward.value = false;
|
||||||
if (recording.value) mediaRecorder?.stop();
|
if (recording.value) mediaRecorder?.stop();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,6 +151,86 @@ const canSend = computed(
|
|||||||
const showMicInsteadOfSend = computed(
|
const showMicInsteadOfSend = computed(
|
||||||
() => !draft.value.trim() && !pendingAttaches.value.length && !editingMessageId.value,
|
() => !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) {
|
function insertEmoji(mark: string) {
|
||||||
const el = draftInputEl.value;
|
const el = draftInputEl.value;
|
||||||
@@ -177,7 +266,7 @@ function startEditMessage(msg: ChatMessage) {
|
|||||||
async function confirmDeleteMessage(msg: ChatMessage) {
|
async function confirmDeleteMessage(msg: ChatMessage) {
|
||||||
msgMenu.value = null;
|
msgMenu.value = null;
|
||||||
if (!activeRoom.value || msg.optimistic || msg.deleted) return;
|
if (!activeRoom.value || msg.optimistic || msg.deleted) return;
|
||||||
if (!window.confirm('این پیام حذف شود؟')) return;
|
if (!window.confirm('این پیام برای همه حذف شود؟')) return;
|
||||||
try {
|
try {
|
||||||
await deleteMessage(activeRoom.value.externalRoomId, msg.id);
|
await deleteMessage(activeRoom.value.externalRoomId, msg.id);
|
||||||
if (editingMessageId.value === msg.id) cancelEdit();
|
if (editingMessageId.value === msg.id) cancelEdit();
|
||||||
@@ -187,8 +276,11 @@ async function confirmDeleteMessage(msg: ChatMessage) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function openMsgMenu(msg: ChatMessage, event: MouseEvent | TouchEvent) {
|
function openMsgMenu(msg: ChatMessage, event: MouseEvent | TouchEvent) {
|
||||||
const mine = msg.senderId === chatUserId.value || msg.optimistic;
|
if (msg.deleted || msg.optimistic) return;
|
||||||
if (!mine || msg.deleted) return;
|
if (selectMode.value) {
|
||||||
|
toggleSelect(msg);
|
||||||
|
return;
|
||||||
|
}
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
let x = 0;
|
let x = 0;
|
||||||
let y = 0;
|
let y = 0;
|
||||||
@@ -213,6 +305,11 @@ function menuMessage(): ChatMessage | null {
|
|||||||
return messages.value.find((m) => m.id === msgMenu.value!.id) ?? 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<typeof setTimeout> | null = null;
|
let longPressTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
function clearLongPress() {
|
function clearLongPress() {
|
||||||
if (longPressTimer) {
|
if (longPressTimer) {
|
||||||
@@ -563,6 +660,11 @@ function toggleGroupMember(id: string) {
|
|||||||
watch(activeRoom, () => {
|
watch(activeRoom, () => {
|
||||||
draft.value = '';
|
draft.value = '';
|
||||||
clearPendingAttaches();
|
clearPendingAttaches();
|
||||||
|
exitSelectMode();
|
||||||
|
cancelEdit();
|
||||||
|
emojiOpen.value = false;
|
||||||
|
msgMenu.value = null;
|
||||||
|
showForward.value = false;
|
||||||
if (recording.value) {
|
if (recording.value) {
|
||||||
mediaRecorder?.stop();
|
mediaRecorder?.stop();
|
||||||
}
|
}
|
||||||
@@ -667,27 +769,71 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<div class="stage-top">
|
<div class="stage-top">
|
||||||
<button type="button" class="back-btn" aria-label="بازگشت" @click="closeMobileChat">
|
<template v-if="selectMode">
|
||||||
<UiIcon name="arrow-right" :size="20" />
|
<button type="button" class="back-btn show" aria-label="انصراف" @click="exitSelectMode">
|
||||||
</button>
|
<UiIcon name="x" :size="20" />
|
||||||
<span class="stage-avatar">
|
</button>
|
||||||
<UserAvatar :name="roomTitle(activeRoom)" :size="36" />
|
<div class="stage-titles">
|
||||||
</span>
|
<h2>{{ toFaDigits(selectedCount) }} انتخابشده</h2>
|
||||||
<div class="stage-titles">
|
</div>
|
||||||
<h2>{{ roomTitle(activeRoom) }}</h2>
|
<div class="select-actions">
|
||||||
<span class="kind-tag">{{ activeRoom.kind === 'group' ? 'گروه' : 'خصوصی' }}</span>
|
<button
|
||||||
</div>
|
type="button"
|
||||||
|
class="select-act"
|
||||||
|
title="ارسال برای دیگری"
|
||||||
|
:disabled="!selectedCount"
|
||||||
|
@click="openForwardPicker()"
|
||||||
|
>
|
||||||
|
<UiIcon name="send" :size="18" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="select-act danger"
|
||||||
|
title="حذف"
|
||||||
|
:disabled="!selectedCount"
|
||||||
|
@click="deleteSelected"
|
||||||
|
>
|
||||||
|
<UiIcon name="trash" :size="18" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<button type="button" class="back-btn" aria-label="بازگشت" @click="closeMobileChat">
|
||||||
|
<UiIcon name="arrow-right" :size="20" />
|
||||||
|
</button>
|
||||||
|
<span class="stage-avatar">
|
||||||
|
<UserAvatar :name="roomTitle(activeRoom)" :size="36" />
|
||||||
|
</span>
|
||||||
|
<div class="stage-titles">
|
||||||
|
<h2>{{ roomTitle(activeRoom) }}</h2>
|
||||||
|
<span class="kind-tag">{{ activeRoom.kind === 'group' ? 'گروه' : 'خصوصی' }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="loadingMessages" class="muted center pad">در حال دریافت پیامها…</div>
|
<div v-if="loadingMessages" class="muted center pad">در حال دریافت پیامها…</div>
|
||||||
<ul v-else ref="msgStreamEl" class="msg-stream">
|
<ul v-else ref="msgStreamEl" class="msg-stream" :class="{ selecting: selectMode }">
|
||||||
<li
|
<li
|
||||||
v-for="msg in messages"
|
v-for="msg in messages"
|
||||||
: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,
|
||||||
|
selected: isSelected(msg.id),
|
||||||
|
}"
|
||||||
@contextmenu="openMsgMenu(msg, $event)"
|
@contextmenu="openMsgMenu(msg, $event)"
|
||||||
|
@click="selectMode ? toggleSelect(msg) : undefined"
|
||||||
>
|
>
|
||||||
|
<button
|
||||||
|
v-if="selectMode"
|
||||||
|
type="button"
|
||||||
|
class="select-check"
|
||||||
|
:class="{ on: isSelected(msg.id) }"
|
||||||
|
:aria-label="isSelected(msg.id) ? 'برداشتن انتخاب' : 'انتخاب'"
|
||||||
|
@click.stop="toggleSelect(msg)"
|
||||||
|
>
|
||||||
|
<UiIcon v-if="isSelected(msg.id)" name="check-plain" :size="14" />
|
||||||
|
</button>
|
||||||
<div
|
<div
|
||||||
class="bubble"
|
class="bubble"
|
||||||
:class="{
|
:class="{
|
||||||
@@ -890,7 +1036,21 @@ onUnmounted(() => {
|
|||||||
:style="{ top: `${msgMenu.y}px`, left: `${msgMenu.x}px` }"
|
:style="{ top: `${msgMenu.y}px`, left: `${msgMenu.x}px` }"
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
v-if="menuMessage()?.type === 'text' && !menuMessage()?.optimistic"
|
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"
|
type="button"
|
||||||
@click="startEditMessage(menuMessage()!)"
|
@click="startEditMessage(menuMessage()!)"
|
||||||
>
|
>
|
||||||
@@ -898,12 +1058,13 @@ onUnmounted(() => {
|
|||||||
ویرایش
|
ویرایش
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
|
v-if="isMineMessage(menuMessage())"
|
||||||
type="button"
|
type="button"
|
||||||
class="danger"
|
class="danger"
|
||||||
@click="confirmDeleteMessage(menuMessage()!)"
|
@click="confirmDeleteMessage(menuMessage()!)"
|
||||||
>
|
>
|
||||||
<UiIcon name="trash" :size="16" />
|
<UiIcon name="trash" :size="16" />
|
||||||
حذف
|
حذف برای همه
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -946,6 +1107,30 @@ onUnmounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
</dialog>
|
</dialog>
|
||||||
|
|
||||||
|
<dialog v-if="showForward" class="modal" open @click.self="showForward = false">
|
||||||
|
<div class="modal-card">
|
||||||
|
<h3>ارسال برای دیگری</h3>
|
||||||
|
<p class="hint">{{ toFaDigits(selectedCount) }} پیام انتخاب شده</p>
|
||||||
|
<div v-if="!forwardTargets.length" class="muted">گفتگوی دیگری برای ارسال نیست.</div>
|
||||||
|
<div v-else class="forward-list">
|
||||||
|
<button
|
||||||
|
v-for="room in forwardTargets"
|
||||||
|
:key="room.externalRoomId"
|
||||||
|
type="button"
|
||||||
|
class="forward-item"
|
||||||
|
:disabled="forwardBusy"
|
||||||
|
@click="confirmForward(room.externalRoomId)"
|
||||||
|
>
|
||||||
|
<UserAvatar :name="roomTitle(room)" :size="36" />
|
||||||
|
<span>{{ roomTitle(room) }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button type="button" :disabled="forwardBusy" @click="showForward = false">انصراف</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
|
|
||||||
<dialog v-if="lightboxUrl" class="lightbox" open @click.self="lightboxUrl = ''">
|
<dialog v-if="lightboxUrl" class="lightbox" open @click.self="lightboxUrl = ''">
|
||||||
<button type="button" class="lightbox-close" aria-label="بستن" @click="lightboxUrl = ''">
|
<button type="button" class="lightbox-close" aria-label="بستن" @click="lightboxUrl = ''">
|
||||||
<UiIcon name="x" :size="18" />
|
<UiIcon name="x" :size="18" />
|
||||||
@@ -1163,6 +1348,9 @@ onUnmounted(() => {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
.back-btn.show {
|
||||||
|
display: grid;
|
||||||
|
}
|
||||||
.stage-avatar {
|
.stage-avatar {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
@@ -1182,6 +1370,59 @@ onUnmounted(() => {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
.select-actions {
|
||||||
|
margin-inline-start: auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.select-act {
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: #0f766e;
|
||||||
|
width: 2.35rem;
|
||||||
|
height: 2.35rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.select-act.danger {
|
||||||
|
color: #dc2626;
|
||||||
|
}
|
||||||
|
.select-act:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
.forward-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.35rem;
|
||||||
|
max-height: 280px;
|
||||||
|
overflow-y: auto;
|
||||||
|
margin: 0.75rem 0;
|
||||||
|
}
|
||||||
|
.forward-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.65rem;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 0.55rem 0.7rem;
|
||||||
|
font: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
.forward-item:hover,
|
||||||
|
.forward-item:active {
|
||||||
|
background: rgba(13, 148, 136, 0.06);
|
||||||
|
}
|
||||||
|
.forward-item:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
.kind-tag {
|
.kind-tag {
|
||||||
font-size: 0.72rem;
|
font-size: 0.72rem;
|
||||||
padding: 0.1rem 0.45rem;
|
padding: 0.1rem 0.45rem;
|
||||||
@@ -1207,10 +1448,36 @@ onUnmounted(() => {
|
|||||||
.msg-row {
|
.msg-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 0.35rem;
|
||||||
}
|
}
|
||||||
.msg-row.mine {
|
.msg-row.mine {
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
}
|
}
|
||||||
|
.msg-row.selected .bubble {
|
||||||
|
outline: 2px solid rgba(13, 148, 136, 0.45);
|
||||||
|
}
|
||||||
|
.msg-stream.selecting .msg-row {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.select-check {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
width: 1.35rem;
|
||||||
|
height: 1.35rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 2px solid #94a3b8;
|
||||||
|
background: #fff;
|
||||||
|
color: #fff;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
margin-bottom: 0.35rem;
|
||||||
|
}
|
||||||
|
.select-check.on {
|
||||||
|
border-color: #0d9488;
|
||||||
|
background: #0d9488;
|
||||||
|
}
|
||||||
.bubble {
|
.bubble {
|
||||||
max-width: min(420px, 85%);
|
max-width: min(420px, 85%);
|
||||||
padding: 0.65rem 0.85rem;
|
padding: 0.65rem 0.85rem;
|
||||||
|
|||||||
Reference in New Issue
Block a user