Fixed message deletion error and layout changes for the message input box.

This commit is contained in:
2026-09-30 09:19:36 +03:30
parent 58e89246d9
commit 298b3316b4
7 changed files with 176 additions and 76 deletions
@@ -0,0 +1,8 @@
-- AlterTable
ALTER TABLE "ConversationHiddenMessage" ADD COLUMN "scope" TEXT NOT NULL DEFAULT 'all';
-- DropIndex
DROP INDEX "ConversationHiddenMessage_organizationId_externalRoomId_messageId_key";
-- CreateIndex
CREATE UNIQUE INDEX "ConversationHiddenMessage_organizationId_externalRoomId_messageId_scope_hiddenByUserId_key" ON "ConversationHiddenMessage"("organizationId", "externalRoomId", "messageId", "scope", "hiddenByUserId");
+5 -2
View File
@@ -2097,7 +2097,8 @@ model ConversationRoom {
@@index([organizationId, kind])
}
/// پیام‌های حذف‌شده برای همه در CRM (سرویس چت خارجی حذف پایدار ندارد)
/// پیام‌های حذف‌شده در CRM (سرویس چت خارجی حذف پایدار ندارد)
/// scope=all → برای همه؛ scope=self → فقط برای hiddenByUserId
model ConversationHiddenMessage {
id String @id @default(cuid())
organizationId String
@@ -2105,8 +2106,10 @@ model ConversationHiddenMessage {
externalRoomId String
messageId String
hiddenByUserId String
/// all = حذف برای همه | self = حذف فقط برای کاربر
scope String @default("all")
createdAt DateTime @default(now())
@@unique([organizationId, externalRoomId, messageId])
@@unique([organizationId, externalRoomId, messageId, scope, hiddenByUserId])
@@index([organizationId, externalRoomId])
}
@@ -74,6 +74,7 @@ export class ConversationsController {
user.role,
dto.externalRoomId,
dto.messageIds,
dto.forEveryone !== false,
);
}
@@ -500,7 +500,8 @@ export class ConversationsService {
role: string,
externalRoomId: string,
messageIds: string[],
): Promise<{ messageIds: string[] }> {
forEveryone = true,
): Promise<{ messageIds: string[]; scope: 'all' | 'self' }> {
await this.loadAccess(userId, organizationId, role);
const roomId = externalRoomId.trim();
if (!roomId) throw new BadRequestException('شناسه اتاق خالی است');
@@ -513,17 +514,19 @@ export class ConversationsService {
].slice(0, 100);
if (!ids.length) throw new BadRequestException('شناسه پیام خالی است');
const scope = forEveryone ? 'all' : 'self';
await this.prisma.conversationHiddenMessage.createMany({
data: ids.map((messageId) => ({
organizationId,
externalRoomId: roomId,
messageId,
hiddenByUserId: userId,
scope,
})),
skipDuplicates: true,
});
return { messageIds: ids };
return { messageIds: ids, scope };
}
async listHiddenMessageIds(
@@ -537,10 +540,14 @@ export class ConversationsService {
if (!roomId) throw new BadRequestException('شناسه اتاق خالی است');
const rows = await this.prisma.conversationHiddenMessage.findMany({
where: { organizationId, externalRoomId: roomId },
where: {
organizationId,
externalRoomId: roomId,
OR: [{ scope: 'all' }, { scope: 'self', hiddenByUserId: userId }],
},
select: { messageId: true },
take: 5000,
});
return { messageIds: rows.map((r) => r.messageId) };
return { messageIds: [...new Set(rows.map((r) => r.messageId))] };
}
}
+6
View File
@@ -1,5 +1,6 @@
import {
IsArray,
IsBoolean,
IsOptional,
IsString,
MaxLength,
@@ -61,4 +62,9 @@ export class HideConversationMessagesDto {
@ArrayMaxSize(100)
@IsString({ each: true })
messageIds!: string[];
/** true = حذف برای همه؛ false = فقط برای کاربر جاری */
@IsOptional()
@IsBoolean()
forEveryone?: boolean;
}
+42 -24
View File
@@ -93,7 +93,11 @@ function isRememberedHidden(roomId: string, messageId: string): boolean {
return Boolean(hiddenIdsByRoom.get(roomId)?.has(messageId));
}
async function persistHiddenOnServer(externalRoomId: string, messageIds: string[]) {
async function persistHiddenOnServer(
externalRoomId: string,
messageIds: string[],
forEveryone = true,
) {
const ids = [...new Set(messageIds.map((id) => String(id || '').trim()).filter(Boolean))];
if (!externalRoomId || !ids.length) return ids;
rememberHidden(externalRoomId, ids);
@@ -102,6 +106,7 @@ async function persistHiddenOnServer(externalRoomId: string, messageIds: string[
await api.post('/conversations/messages/hide', {
externalRoomId,
messageIds: ids,
forEveryone,
});
} catch {
// local purge already applied; sync may retry via poll
@@ -726,7 +731,7 @@ function onSocketMessage(event: MessageEvent) {
const ids = [hideTarget, purgeId].filter(Boolean);
purgeMessageIds(ids);
rememberHidden(roomId, ids);
void persistHiddenOnServer(roomId, ids);
void persistHiddenOnServer(roomId, ids, true);
return;
}
if (purgeId && isDeletedContent(purgeContent)) {
@@ -1196,27 +1201,34 @@ export function useOrgChat() {
});
}
async function deleteMessage(roomId: string, messageId: string) {
async function deleteMessage(
roomId: string,
messageId: string,
opts?: { forEveryone?: boolean },
) {
if (!messageId) return;
await connectSocket();
if (!socket || socket.readyState !== WebSocket.OPEN) throw new Error('ws not open');
const forEveryone = opts?.forEveryone !== false;
// 1) Persist hide in CRM (survives refresh for everyone)
await persistHiddenOnServer(roomId, [messageId]);
// 1) Persist hide in CRM
await persistHiddenOnServer(roomId, [messageId], forEveryone);
// 2) Realtime: send a normal text command (text delivery is reliable on chat WS)
const controlId = crypto.randomUUID();
rememberHidden(roomId, [controlId]);
socket.send(
JSON.stringify({
id: controlId,
type: 'text',
room_id: roomId,
content: `${HIDE_CMD_PREFIX}${messageId}`,
}),
);
// Hide the control message itself so it never appears in history UI
void persistHiddenOnServer(roomId, [controlId]);
// 2) Realtime sync only when deleting for everyone
if (forEveryone) {
await connectSocket();
if (socket && socket.readyState === WebSocket.OPEN) {
const controlId = crypto.randomUUID();
rememberHidden(roomId, [controlId]);
socket.send(
JSON.stringify({
id: controlId,
type: 'text',
room_id: roomId,
content: `${HIDE_CMD_PREFIX}${messageId}`,
}),
);
void persistHiddenOnServer(roomId, [controlId], true);
}
}
const last = [...messages.value].reverse().find((m) => m.roomId === roomId);
emitRoomLive({
@@ -1227,10 +1239,16 @@ export function useOrgChat() {
});
}
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 deleteMessages(
roomId: string,
items: Array<{ id: string; forEveryone?: boolean }>,
) {
const seen = new Set<string>();
for (const item of items) {
const id = String(item.id || '').trim();
if (!id || seen.has(id)) continue;
seen.add(id);
await deleteMessage(roomId, id, { forEveryone: item.forEveryone !== false });
}
}
+103 -46
View File
@@ -46,7 +46,7 @@ const recording = ref(false);
const recordSecs = ref(0);
const lightboxUrl = ref('');
const fileInputEl = ref<HTMLInputElement | null>(null);
const draftInputEl = ref<HTMLInputElement | null>(null);
const draftInputEl = ref<HTMLTextAreaElement | null>(null);
const emojiOpen = ref(false);
const editingMessageId = ref<string | null>(null);
const msgMenu = ref<{ id: string; x: number; y: number } | null>(null);
@@ -194,11 +194,19 @@ function selectedMessages(): ChatMessage[] {
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];
const list = selectedMessages();
if (!list.length) return;
const mineCount = list.filter((m) => isMineMessage(m)).length;
const otherCount = list.length - mineCount;
const parts: string[] = [];
if (mineCount) parts.push(`${mineCount} پیام خودتان برای همه`);
if (otherCount) parts.push(`${otherCount} پیام دریافتی فقط برای شما`);
if (!window.confirm(`حذف شود؟\n${parts.join('\n')}`)) return;
try {
await deleteMessages(activeRoom.value.externalRoomId, ids);
await deleteMessages(
activeRoom.value.externalRoomId,
list.map((m) => ({ id: m.id, forEveryone: isMineMessage(m) })),
);
exitSelectMode();
} catch (e) {
error.value = e instanceof Error ? e.message : 'حذف پیام ناموفق بود.';
@@ -266,15 +274,49 @@ function startEditMessage(msg: ChatMessage) {
async function confirmDeleteMessage(msg: ChatMessage) {
msgMenu.value = null;
if (!activeRoom.value || msg.optimistic || msg.deleted) return;
if (!window.confirm('این پیام برای همه حذف شود؟')) return;
const mine = isMineMessage(msg);
const ok = window.confirm(
mine
? 'این پیام برای همه حذف شود؟'
: 'این پیام فقط برای شما حذف شود؟ (برای طرف مقابل باقی می‌ماند)',
);
if (!ok) return;
try {
await deleteMessage(activeRoom.value.externalRoomId, msg.id);
await deleteMessage(activeRoom.value.externalRoomId, msg.id, {
forEveryone: mine,
});
if (editingMessageId.value === msg.id) cancelEdit();
} catch (e) {
error.value = e instanceof Error ? e.message : 'حذف پیام ناموفق بود.';
}
}
function onComposerKeydown(event: KeyboardEvent) {
if (event.key !== 'Enter') return;
if (event.ctrlKey || event.metaKey) {
// Ctrl/Cmd+Enter → newline
event.preventDefault();
const el = draftInputEl.value;
if (!el) {
draft.value += '\n';
return;
}
const start = el.selectionStart ?? draft.value.length;
const end = el.selectionEnd ?? start;
const next = draft.value.slice(0, start) + '\n' + draft.value.slice(end);
draft.value = next;
nextTick(() => {
const pos = start + 1;
el.focus();
el.setSelectionRange(pos, pos);
});
return;
}
if (event.shiftKey) return;
event.preventDefault();
void submitMessage();
}
function openMsgMenu(msg: ChatMessage, event: MouseEvent | TouchEvent) {
if (msg.deleted || msg.optimistic) return;
if (selectMode.value) {
@@ -966,38 +1008,6 @@ onUnmounted(() => {
accept="image/*,video/*,audio/*,.pdf,.doc,.docx,.xls,.xlsx,.zip,.rar,.txt"
@change="onFilePicked"
/>
<button
type="button"
class="tool-btn attach-btn"
title="پیوست"
:disabled="sending || recording || Boolean(editingMessageId)"
@click="fileInputEl?.click()"
>
<UiIcon name="plus" :size="20" />
</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
v-if="showMicInsteadOfSend"
type="button"
@@ -1017,6 +1027,39 @@ onUnmounted(() => {
>
<UiIcon name="send" :size="18" />
</button>
<div class="input-shell">
<textarea
ref="draftInputEl"
v-model="draft"
class="composer-input"
rows="1"
:placeholder="editingMessageId ? 'ویرایش پیام…' : 'پیام بنویسید…'"
autocomplete="off"
enterkeyhint="send"
:disabled="sending || recording"
@focus="emojiOpen = false"
@keydown="onComposerKeydown"
/>
</div>
<button
type="button"
class="emoji-toggle"
title="ایموجی"
:class="{ on: emojiOpen }"
:disabled="sending || recording"
@click="emojiOpen = !emojiOpen"
>
😊
</button>
<button
type="button"
class="tool-btn attach-btn"
title="پیوست"
:disabled="sending || recording || Boolean(editingMessageId)"
@click="fileInputEl?.click()"
>
<UiIcon name="plus" :size="20" />
</button>
</div>
</form>
@@ -1062,6 +1105,15 @@ onUnmounted(() => {
<UiIcon name="trash" :size="16" />
حذف برای همه
</button>
<button
v-else
type="button"
class="danger"
@click="confirmDeleteMessage(menuMessage()!)"
>
<UiIcon name="trash" :size="16" />
حذف فقط برای من
</button>
</div>
</template>
</section>
@@ -1604,12 +1656,11 @@ onUnmounted(() => {
flex: 1 1 auto;
min-width: 0;
display: flex;
align-items: center;
gap: 0.15rem;
align-items: flex-end;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 22px;
padding: 0.15rem 0.35rem 0.15rem 0.55rem;
padding: 0.35rem 0.75rem;
min-height: 2.75rem;
}
.composer-input {
@@ -1618,17 +1669,22 @@ onUnmounted(() => {
border: none;
outline: none;
border-radius: 18px;
padding: 0.55rem 0.35rem;
padding: 0.35rem 0;
font: inherit;
font-size: 0.95rem;
background: transparent;
resize: none;
max-height: 7.5rem;
line-height: 1.45;
field-sizing: content;
}
.emoji-toggle {
flex: 0 0 auto;
border: none;
background: transparent;
width: 2.1rem;
height: 2.1rem;
background: #fff;
border: 1px solid #e2e8f0;
width: 2.75rem;
height: 2.75rem;
border-radius: 999px;
font-size: 1.15rem;
line-height: 1;
@@ -1638,6 +1694,7 @@ onUnmounted(() => {
}
.emoji-toggle.on {
background: rgba(13, 148, 136, 0.12);
border-color: rgba(13, 148, 136, 0.35);
}
.emoji-panel {
display: grid;