Fixed message deletion error and layout changes for the message input box.
This commit is contained in:
@@ -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");
|
||||
@@ -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))] };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user