Add, edit, delete, and resend messages.
This commit is contained in:
@@ -67,6 +67,8 @@ const {
|
||||
sendFile,
|
||||
editMessage,
|
||||
deleteMessage,
|
||||
deleteMessages,
|
||||
forwardMessages,
|
||||
sendRead,
|
||||
ensureConnected,
|
||||
setConversationsPageOpen,
|
||||
@@ -80,6 +82,11 @@ let recordChunks: Blob[] = [];
|
||||
let recordTimer: ReturnType<typeof setInterval> | 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);
|
||||
|
||||
async function scrollToBottom() {
|
||||
@@ -130,8 +137,10 @@ function closeMobileChat() {
|
||||
activeRoom.value = null;
|
||||
clearPendingAttaches();
|
||||
cancelEdit();
|
||||
exitSelectMode();
|
||||
emojiOpen.value = false;
|
||||
msgMenu.value = null;
|
||||
showForward.value = false;
|
||||
if (recording.value) mediaRecorder?.stop();
|
||||
}
|
||||
|
||||
@@ -142,6 +151,86 @@ const canSend = computed(
|
||||
const showMicInsteadOfSend = computed(
|
||||
() => !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) {
|
||||
const el = draftInputEl.value;
|
||||
@@ -177,7 +266,7 @@ function startEditMessage(msg: ChatMessage) {
|
||||
async function confirmDeleteMessage(msg: ChatMessage) {
|
||||
msgMenu.value = null;
|
||||
if (!activeRoom.value || msg.optimistic || msg.deleted) return;
|
||||
if (!window.confirm('این پیام حذف شود؟')) return;
|
||||
if (!window.confirm('این پیام برای همه حذف شود؟')) return;
|
||||
try {
|
||||
await deleteMessage(activeRoom.value.externalRoomId, msg.id);
|
||||
if (editingMessageId.value === msg.id) cancelEdit();
|
||||
@@ -187,8 +276,11 @@ async function confirmDeleteMessage(msg: ChatMessage) {
|
||||
}
|
||||
|
||||
function openMsgMenu(msg: ChatMessage, event: MouseEvent | TouchEvent) {
|
||||
const mine = msg.senderId === chatUserId.value || msg.optimistic;
|
||||
if (!mine || msg.deleted) return;
|
||||
if (msg.deleted || msg.optimistic) return;
|
||||
if (selectMode.value) {
|
||||
toggleSelect(msg);
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
let x = 0;
|
||||
let y = 0;
|
||||
@@ -213,6 +305,11 @@ function menuMessage(): ChatMessage | 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;
|
||||
function clearLongPress() {
|
||||
if (longPressTimer) {
|
||||
@@ -563,6 +660,11 @@ function toggleGroupMember(id: string) {
|
||||
watch(activeRoom, () => {
|
||||
draft.value = '';
|
||||
clearPendingAttaches();
|
||||
exitSelectMode();
|
||||
cancelEdit();
|
||||
emojiOpen.value = false;
|
||||
msgMenu.value = null;
|
||||
showForward.value = false;
|
||||
if (recording.value) {
|
||||
mediaRecorder?.stop();
|
||||
}
|
||||
@@ -667,27 +769,71 @@ onUnmounted(() => {
|
||||
|
||||
<template v-else>
|
||||
<div class="stage-top">
|
||||
<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 v-if="selectMode">
|
||||
<button type="button" class="back-btn show" aria-label="انصراف" @click="exitSelectMode">
|
||||
<UiIcon name="x" :size="20" />
|
||||
</button>
|
||||
<div class="stage-titles">
|
||||
<h2>{{ toFaDigits(selectedCount) }} انتخابشده</h2>
|
||||
</div>
|
||||
<div class="select-actions">
|
||||
<button
|
||||
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 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
|
||||
v-for="msg in messages"
|
||||
:key="msg.id"
|
||||
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)"
|
||||
@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
|
||||
class="bubble"
|
||||
:class="{
|
||||
@@ -890,7 +1036,21 @@ onUnmounted(() => {
|
||||
:style="{ top: `${msgMenu.y}px`, left: `${msgMenu.x}px` }"
|
||||
>
|
||||
<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"
|
||||
@click="startEditMessage(menuMessage()!)"
|
||||
>
|
||||
@@ -898,12 +1058,13 @@ onUnmounted(() => {
|
||||
ویرایش
|
||||
</button>
|
||||
<button
|
||||
v-if="isMineMessage(menuMessage())"
|
||||
type="button"
|
||||
class="danger"
|
||||
@click="confirmDeleteMessage(menuMessage()!)"
|
||||
>
|
||||
<UiIcon name="trash" :size="16" />
|
||||
حذف
|
||||
حذف برای همه
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -946,6 +1107,30 @@ onUnmounted(() => {
|
||||
</div>
|
||||
</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 = ''">
|
||||
<button type="button" class="lightbox-close" aria-label="بستن" @click="lightboxUrl = ''">
|
||||
<UiIcon name="x" :size="18" />
|
||||
@@ -1163,6 +1348,9 @@ onUnmounted(() => {
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.back-btn.show {
|
||||
display: grid;
|
||||
}
|
||||
.stage-avatar {
|
||||
flex-shrink: 0;
|
||||
border-radius: 999px;
|
||||
@@ -1182,6 +1370,59 @@ onUnmounted(() => {
|
||||
overflow: hidden;
|
||||
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 {
|
||||
font-size: 0.72rem;
|
||||
padding: 0.1rem 0.45rem;
|
||||
@@ -1207,10 +1448,36 @@ onUnmounted(() => {
|
||||
.msg-row {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
align-items: flex-end;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
.msg-row.mine {
|
||||
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 {
|
||||
max-width: min(420px, 85%);
|
||||
padding: 0.65rem 0.85rem;
|
||||
|
||||
Reference in New Issue
Block a user