Add, edit, delete, and resend messages.

This commit is contained in:
2026-09-29 21:26:07 +03:30
parent c20dc0a7ca
commit 83504816c5
2 changed files with 362 additions and 33 deletions
+284 -17
View File
@@ -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;