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
+78 -16
View File
@@ -37,6 +37,11 @@ const PAGE_SIZE = 50;
const IMAGE_MAX_EDGE = 1600;
const IMAGE_WEBP_QUALITY = 0.82;
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 sessionCache: ConversationSessionDto | null = null;
@@ -305,7 +310,7 @@ function normalizeIncoming(
base.deleted ||
base.is_deleted ||
base.isDeleted ||
/^(?:\[?\s*deleted\s*\]?|این پیام حذف شد|پیام حذف شد)$/i.test(content),
isDeletedContent(content),
);
const edited = Boolean(base.edited || base.is_edited || base.isEdited || base.edited_at);
@@ -315,7 +320,7 @@ function normalizeIncoming(
senderId: String(
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,
createdAt: String(
base.created_at ?? base.createdAt ?? raw.created_at ?? new Date().toISOString(),
@@ -339,20 +344,33 @@ function mergeMessages(incoming: ChatMessage, list: ChatMessage[]): ChatMessage[
...prev,
...incoming,
optimistic: false,
type: inferMediaType(
prev.type === 'voice' || incoming.type === 'voice'
? 'voice'
: prev.type === 'video' || incoming.type === 'video'
? 'video'
: incoming.type,
incoming.content || prev.content,
incoming.fileName || prev.fileName,
),
type:
prev.deleted || incoming.deleted
? 'text'
: inferMediaType(
prev.type === 'voice' || incoming.type === 'voice'
? 'voice'
: prev.type === 'video' || incoming.type === 'video'
? '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,
mediaUrl: incoming.mediaUrl || prev.mediaUrl,
fileName: incoming.fileName || prev.fileName,
mediaUrl:
prev.deleted || incoming.deleted
? undefined
: incoming.mediaUrl || prev.mediaUrl,
fileName:
prev.deleted || incoming.deleted
? undefined
: incoming.fileName || prev.fileName,
edited: incoming.edited || prev.edited,
deleted: incoming.deleted || prev.deleted,
deleted: prev.deleted || incoming.deleted,
delivery:
incoming.delivery === 'read' || prev.delivery === 'read'
? 'read'
@@ -520,6 +538,11 @@ function applyEditedMessage(payload: Record<string, unknown>) {
if (!id) return;
const content = String(base.content ?? base.text ?? base.body ?? '').trim();
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) =>
m.id === id
? {
@@ -546,7 +569,7 @@ function applyDeletedMessage(payload: Record<string, unknown>) {
m.id === id
? {
...m,
content: 'این پیام حذف شد',
content: DELETED_CONTENT,
type: 'text',
deleted: true,
edited: false,
@@ -1044,22 +1067,59 @@ export function useOrgChat() {
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(
JSON.stringify({
type: 'delete',
id: messageId,
message_id: messageId,
room_id: roomId,
}),
);
emitRoomLive({
roomId,
preview: 'این پیام حذف شد',
preview: DELETED_CONTENT,
at: new Date().toISOString(),
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() {
activeRoomId.value = '';
messages.value = [];
@@ -1083,6 +1143,8 @@ export function useOrgChat() {
sendFile,
editMessage,
deleteMessage,
deleteMessages,
forwardMessages,
sendRead,
ensureConnected,
setConversationsPageOpen,
+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;