From f71e78f3a7adbb58a69cf2684fb36badf2dd7b1a Mon Sep 17 00:00:00 2001 From: Mahdi Date: Wed, 30 Sep 2026 10:40:29 +0330 Subject: [PATCH] Add mentions, pin messages and conversations, and search within chats --- .../migration.sql | 42 ++ apps/api/prisma/schema.prisma | 31 + .../conversations/conversations.controller.ts | 73 +- .../conversations/conversations.service.ts | 139 +++- apps/api/src/conversations/dto.ts | 34 + apps/web/src/components/UiIcon.vue | 5 + apps/web/src/lib/useOrgChat.ts | 147 +++- apps/web/src/views/ConversationsView.vue | 703 +++++++++++++++++- packages/shared/src/index.ts | 9 + 9 files changed, 1152 insertions(+), 31 deletions(-) create mode 100644 apps/api/prisma/migrations/20260930120000_conversation_pins/migration.sql diff --git a/apps/api/prisma/migrations/20260930120000_conversation_pins/migration.sql b/apps/api/prisma/migrations/20260930120000_conversation_pins/migration.sql new file mode 100644 index 0000000..e59787b --- /dev/null +++ b/apps/api/prisma/migrations/20260930120000_conversation_pins/migration.sql @@ -0,0 +1,42 @@ +-- CreateTable +CREATE TABLE "ConversationPinnedMessage" ( + "id" TEXT NOT NULL, + "organizationId" TEXT NOT NULL, + "externalRoomId" TEXT NOT NULL, + "messageId" TEXT NOT NULL, + "preview" TEXT NOT NULL DEFAULT '', + "pinnedByUserId" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "ConversationPinnedMessage_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "ConversationRoomUserPin" ( + "id" TEXT NOT NULL, + "organizationId" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "externalRoomId" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "ConversationRoomUserPin_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "ConversationPinnedMessage_organizationId_externalRoomId_key" ON "ConversationPinnedMessage"("organizationId", "externalRoomId"); + +-- CreateIndex +CREATE INDEX "ConversationPinnedMessage_organizationId_externalRoomId_idx" ON "ConversationPinnedMessage"("organizationId", "externalRoomId"); + +-- CreateIndex +CREATE UNIQUE INDEX "ConversationRoomUserPin_organizationId_userId_externalRoomId_key" ON "ConversationRoomUserPin"("organizationId", "userId", "externalRoomId"); + +-- CreateIndex +CREATE INDEX "ConversationRoomUserPin_organizationId_userId_idx" ON "ConversationRoomUserPin"("organizationId", "userId"); + +-- AddForeignKey +ALTER TABLE "ConversationPinnedMessage" ADD CONSTRAINT "ConversationPinnedMessage_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ConversationRoomUserPin" ADD CONSTRAINT "ConversationRoomUserPin_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index a8152ee..a301540 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -210,6 +210,8 @@ model Organization { conversationSettings ConversationSettings? conversationRooms ConversationRoom[] conversationHiddenMessages ConversationHiddenMessage[] + conversationPinnedMessages ConversationPinnedMessage[] + conversationRoomUserPins ConversationRoomUserPin[] } model User { @@ -2113,3 +2115,32 @@ model ConversationHiddenMessage { @@unique([organizationId, externalRoomId, messageId, scope, hiddenByUserId]) @@index([organizationId, externalRoomId]) } + +/// یک پیام سنجاق‌شده در هر اتاق (برای همه اعضا) +model ConversationPinnedMessage { + id String @id @default(cuid()) + organizationId String + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + externalRoomId String + messageId String + preview String @default("") + pinnedByUserId String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([organizationId, externalRoomId]) + @@index([organizationId, externalRoomId]) +} + +/// سنجاق گفتگو در لیست — فقط برای همان کاربر +model ConversationRoomUserPin { + id String @id @default(cuid()) + organizationId String + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + userId String + externalRoomId String + createdAt DateTime @default(now()) + + @@unique([organizationId, userId, externalRoomId]) + @@index([organizationId, userId]) +} diff --git a/apps/api/src/conversations/conversations.controller.ts b/apps/api/src/conversations/conversations.controller.ts index 4319064..5a9ed65 100644 --- a/apps/api/src/conversations/conversations.controller.ts +++ b/apps/api/src/conversations/conversations.controller.ts @@ -1,4 +1,4 @@ -import { Body, Controller, Get, Post, Query, UseGuards } from '@nestjs/common'; +import { Body, Controller, Delete, Get, Post, Query, UseGuards } from '@nestjs/common'; import { JwtAuthGuard } from '../auth/jwt-auth.guard'; import { CurrentUser } from '../auth/current-user.decorator'; import { RequireOrgModule } from '../billing/org-module.decorator'; @@ -7,6 +7,9 @@ import { CreateConversationDmDto, CreateConversationGroupDto, HideConversationMessagesDto, + PinConversationMessageDto, + PinConversationRoomDto, + UnpinConversationMessageDto, } from './dto'; import { ConversationsService } from './conversations.service'; @@ -63,6 +66,20 @@ export class ConversationsController { ); } + @Post('rooms/pin') + pinRoom( + @CurrentUser() user: { userId: string; organizationId: string; role: string }, + @Body() dto: PinConversationRoomDto, + ) { + return this.conversations.setRoomPinned( + user.userId, + user.organizationId, + user.role, + dto.externalRoomId, + dto.pinned, + ); + } + @Post('messages/hide') hideMessages( @CurrentUser() user: { userId: string; organizationId: string; role: string }, @@ -90,4 +107,58 @@ export class ConversationsController { externalRoomId || '', ); } + + @Get('messages/pin') + getPinnedMessage( + @CurrentUser() user: { userId: string; organizationId: string; role: string }, + @Query('externalRoomId') externalRoomId: string, + ) { + return this.conversations.getPinnedMessage( + user.userId, + user.organizationId, + user.role, + externalRoomId || '', + ); + } + + @Post('messages/pin') + pinMessage( + @CurrentUser() user: { userId: string; organizationId: string; role: string }, + @Body() dto: PinConversationMessageDto, + ) { + return this.conversations.pinMessage( + user.userId, + user.organizationId, + user.role, + dto.externalRoomId, + dto.messageId, + dto.preview, + ); + } + + @Post('messages/unpin') + unpinMessage( + @CurrentUser() user: { userId: string; organizationId: string; role: string }, + @Body() dto: UnpinConversationMessageDto, + ) { + return this.conversations.unpinMessage( + user.userId, + user.organizationId, + user.role, + dto.externalRoomId, + ); + } + + @Delete('messages/pin') + unpinMessageDelete( + @CurrentUser() user: { userId: string; organizationId: string; role: string }, + @Query('externalRoomId') externalRoomId: string, + ) { + return this.conversations.unpinMessage( + user.userId, + user.organizationId, + user.role, + externalRoomId || '', + ); + } } diff --git a/apps/api/src/conversations/conversations.service.ts b/apps/api/src/conversations/conversations.service.ts index 4ae69b2..a0454a9 100644 --- a/apps/api/src/conversations/conversations.service.ts +++ b/apps/api/src/conversations/conversations.service.ts @@ -7,6 +7,7 @@ import { import type { ConversationAccessDto, ConversationPeerDto, + ConversationPinnedMessageDto, ConversationRoomDto, ConversationSessionDto, ConversationSettingsDto, @@ -188,19 +189,21 @@ export class ConversationsService { where: { organizationId }, }); const trackedByExternal = new Map(tracked.map((t) => [t.externalRoomId, t])); + const pinnedIds = await this.userPinnedRoomIds(organizationId, userId); try { const external = await this.chat.listUserRooms(creds.host, creds.apiKey, chatUserId); - return external.map((row) => - this.toRoomDto(row, trackedByExternal, organizationId, userId), - ); + return external.map((row) => { + const dto = this.toRoomDto(row, trackedByExternal, organizationId, userId); + return { ...dto, pinned: pinnedIds.has(dto.externalRoomId) }; + }); } catch (e) { // اگر سرویس چت در دسترس نبود، حداقل roomهای ثبت‌شده در CRM را نشان بده if (tracked.length) { return tracked.map((t) => ({ id: t.id, externalRoomId: t.externalRoomId, - kind: t.kind === 'group' ? 'group' : 'dm', + kind: (t.kind === 'group' ? 'group' : 'dm') as 'dm' | 'group', name: t.name || (t.kind === 'group' ? 'گروه' : 'گفتگو'), unreadCount: 0, lastMessagePreview: null, @@ -213,6 +216,7 @@ export class ConversationsService { ? t.dmUserMinId : null : null, + pinned: pinnedIds.has(t.externalRoomId), })); } throw e; @@ -443,7 +447,7 @@ export class ConversationsService { let preview = lastMsg ? String(lastMsg.content ?? lastMsg.text ?? lastMsg.body ?? '').trim() : ''; - if (/^__MENTO_HIDE__:/i.test(preview) || /MENTO_DEL/i.test(preview)) { + if (/^__MENTO_HIDE__:/i.test(preview) || /MENTO_DEL/i.test(preview) || /^__MENTO_PIN__:/i.test(preview) || /^__MENTO_UNPIN__$/i.test(preview)) { preview = ''; } if (lastType === 'image' || lastType === 'photo' || lastType === 'img') { @@ -491,9 +495,134 @@ export class ConversationsService { lastMessagePreview: preview || null, lastMessageAt: lastAtRaw ? String(lastAtRaw) : null, peerUserId, + pinned: false, }; } + private async userPinnedRoomIds(organizationId: string, userId: string): Promise> { + const rows = await this.prisma.conversationRoomUserPin.findMany({ + where: { organizationId, userId }, + select: { externalRoomId: true }, + take: 500, + }); + return new Set(rows.map((r) => r.externalRoomId)); + } + + async setRoomPinned( + userId: string, + organizationId: string, + role: string, + externalRoomId: string, + pinned: boolean, + ): Promise<{ externalRoomId: string; pinned: boolean }> { + await this.loadAccess(userId, organizationId, role); + const roomId = externalRoomId.trim(); + if (!roomId) throw new BadRequestException('شناسه اتاق خالی است'); + + if (pinned) { + await this.prisma.conversationRoomUserPin.upsert({ + where: { + organizationId_userId_externalRoomId: { + organizationId, + userId, + externalRoomId: roomId, + }, + }, + create: { organizationId, userId, externalRoomId: roomId }, + update: {}, + }); + } else { + await this.prisma.conversationRoomUserPin.deleteMany({ + where: { organizationId, userId, externalRoomId: roomId }, + }); + } + return { externalRoomId: roomId, pinned }; + } + + async getPinnedMessage( + userId: string, + organizationId: string, + role: string, + externalRoomId: string, + ): Promise { + await this.loadAccess(userId, organizationId, role); + const roomId = externalRoomId.trim(); + if (!roomId) throw new BadRequestException('شناسه اتاق خالی است'); + + const row = await this.prisma.conversationPinnedMessage.findUnique({ + where: { + organizationId_externalRoomId: { organizationId, externalRoomId: roomId }, + }, + }); + if (!row) return null; + return { + messageId: row.messageId, + preview: row.preview, + pinnedByUserId: row.pinnedByUserId, + createdAt: row.createdAt.toISOString(), + }; + } + + async pinMessage( + userId: string, + organizationId: string, + role: string, + externalRoomId: string, + messageId: string, + preview?: string, + ): Promise { + await this.loadAccess(userId, organizationId, role); + const roomId = externalRoomId.trim(); + const msgId = messageId.trim(); + if (!roomId) throw new BadRequestException('شناسه اتاق خالی است'); + if (!msgId) throw new BadRequestException('شناسه پیام خالی است'); + + const cleanPreview = String(preview || '') + .replace(/\s+/g, ' ') + .trim() + .slice(0, 240); + + const row = await this.prisma.conversationPinnedMessage.upsert({ + where: { + organizationId_externalRoomId: { organizationId, externalRoomId: roomId }, + }, + create: { + organizationId, + externalRoomId: roomId, + messageId: msgId, + preview: cleanPreview, + pinnedByUserId: userId, + }, + update: { + messageId: msgId, + preview: cleanPreview, + pinnedByUserId: userId, + }, + }); + + return { + messageId: row.messageId, + preview: row.preview, + pinnedByUserId: row.pinnedByUserId, + createdAt: row.createdAt.toISOString(), + }; + } + + async unpinMessage( + userId: string, + organizationId: string, + role: string, + externalRoomId: string, + ): Promise<{ ok: true }> { + await this.loadAccess(userId, organizationId, role); + const roomId = externalRoomId.trim(); + if (!roomId) throw new BadRequestException('شناسه اتاق خالی است'); + await this.prisma.conversationPinnedMessage.deleteMany({ + where: { organizationId, externalRoomId: roomId }, + }); + return { ok: true }; + } + async hideMessages( userId: string, organizationId: string, diff --git a/apps/api/src/conversations/dto.ts b/apps/api/src/conversations/dto.ts index d02b1ef..4349955 100644 --- a/apps/api/src/conversations/dto.ts +++ b/apps/api/src/conversations/dto.ts @@ -68,3 +68,37 @@ export class HideConversationMessagesDto { @IsBoolean() forEveryone?: boolean; } + +export class PinConversationMessageDto { + @IsString() + @MinLength(1) + @MaxLength(200) + externalRoomId!: string; + + @IsString() + @MinLength(1) + @MaxLength(200) + messageId!: string; + + @IsOptional() + @IsString() + @MaxLength(240) + preview?: string; +} + +export class UnpinConversationMessageDto { + @IsString() + @MinLength(1) + @MaxLength(200) + externalRoomId!: string; +} + +export class PinConversationRoomDto { + @IsString() + @MinLength(1) + @MaxLength(200) + externalRoomId!: string; + + @IsBoolean() + pinned!: boolean; +} diff --git a/apps/web/src/components/UiIcon.vue b/apps/web/src/components/UiIcon.vue index fdfe0be..03f62c6 100644 --- a/apps/web/src/components/UiIcon.vue +++ b/apps/web/src/components/UiIcon.vue @@ -31,6 +31,7 @@ defineProps<{ | 'plus' | 'chevron' | 'star' + | 'pin' | 'filter' | 'funnel' | 'lightbulb' @@ -247,6 +248,10 @@ defineProps<{ + diff --git a/apps/web/src/lib/useOrgChat.ts b/apps/web/src/lib/useOrgChat.ts index d974bde..9f1cc2c 100644 --- a/apps/web/src/lib/useOrgChat.ts +++ b/apps/web/src/lib/useOrgChat.ts @@ -33,7 +33,49 @@ export type RoomLiveEvent = { fromSelf: boolean; }; -const PAGE_SIZE = 50; +export type MessagePinEvent = { + roomId: string; + /** null = unpinned */ + messageId: string | null; + preview: string; + fromSelf: boolean; +}; + +type RoomListener = (event: RoomLiveEvent) => void; +const roomListeners = new Set(); + +export function onRoomLiveEvent(listener: RoomListener) { + roomListeners.add(listener); + return () => roomListeners.delete(listener); +} + +function emitRoomLive(event: RoomLiveEvent) { + for (const listener of roomListeners) { + try { + listener(event); + } catch { + // ignore listener errors + } + } +} + +type PinListener = (event: MessagePinEvent) => void; +const pinListeners = new Set(); + +export function onMessagePinEvent(listener: PinListener) { + pinListeners.add(listener); + return () => pinListeners.delete(listener); +} + +function emitMessagePin(event: MessagePinEvent) { + for (const listener of pinListeners) { + try { + listener(event); + } catch { + // ignore + } + } +} const IMAGE_MAX_EDGE = 1600; const IMAGE_WEBP_QUALITY = 0.82; const MAX_UPLOAD_BYTES = 40 * 1024 * 1024; @@ -41,6 +83,9 @@ const MAX_UPLOAD_BYTES = 40 * 1024 * 1024; const HARD_DELETE_MARKER = '__MENTO_DEL__'; /** Realtime hide command delivered as a normal text message (external WS delivers text reliably). */ const HIDE_CMD_PREFIX = '__MENTO_HIDE__:'; +/** Realtime pin / unpin control messages. */ +const PIN_CMD_PREFIX = '__MENTO_PIN__:'; +const UNPIN_CMD = '__MENTO_UNPIN__'; function isDeletedContent(content: string): boolean { const t = content.trim(); @@ -48,7 +93,9 @@ function isDeletedContent(content: string): boolean { /^(?:\[?\s*deleted\s*\]?|این پیام حذف شد|پیام حذف شد)$/i.test(t) || t.includes('MENTO_DEL') || t === HARD_DELETE_MARKER || - t.startsWith(HIDE_CMD_PREFIX) + t.startsWith(HIDE_CMD_PREFIX) || + t.startsWith(PIN_CMD_PREFIX) || + t === UNPIN_CMD ); } @@ -59,6 +106,15 @@ function parseHideCommand(content: string): string | null { return id || null; } +/** null = not a pin cmd; '' = unpin; otherwise messageId */ +function parsePinCommand(content: string): string | null { + const t = content.trim(); + if (t === UNPIN_CMD) return ''; + if (!t.startsWith(PIN_CMD_PREFIX)) return null; + const id = t.slice(PIN_CMD_PREFIX.length).trim(); + return id || null; +} + function purgeMessageById(id: string) { const key = String(id || '').trim(); if (!key) return; @@ -159,23 +215,7 @@ const activeRoomId = ref(''); const unreadTotal = ref(0); const pageOpen = ref(false); -type RoomListener = (event: RoomLiveEvent) => void; -const roomListeners = new Set(); - -export function onRoomLiveEvent(listener: RoomListener) { - roomListeners.add(listener); - return () => roomListeners.delete(listener); -} - -function emitRoomLive(event: RoomLiveEvent) { - for (const listener of roomListeners) { - try { - listener(event); - } catch { - // ignore listener errors - } - } -} +const PAGE_SIZE = 200; function messageTs(item: ChatMessage): number { const t = new Date(item.createdAt).getTime(); @@ -734,6 +774,27 @@ function onSocketMessage(event: MessageEvent) { void persistHiddenOnServer(roomId, ids, true); return; } + const pinTarget = parsePinCommand(purgeContent); + if (pinTarget !== null) { + const roomId = String( + (parsed.room_id as string) || + ((parsed.message as Record | undefined)?.room_id as string) || + activeRoomId.value, + ); + if (purgeId) rememberHidden(roomId, [purgeId]); + if (pinTarget === '') { + emitMessagePin({ roomId, messageId: null, preview: '', fromSelf: false }); + } else { + const found = messages.value.find((m) => m.id === pinTarget); + emitMessagePin({ + roomId, + messageId: pinTarget, + preview: found ? previewLabelForMessage(found) : '', + fromSelf: false, + }); + } + return; + } if (purgeId && isDeletedContent(purgeContent)) { purgeMessageById(purgeId); return; @@ -1271,6 +1332,52 @@ export function useOrgChat() { } } + async function pinChatMessage(roomId: string, msg: ChatMessage) { + const messageId = String(msg.id || '').trim(); + if (!roomId || !messageId || msg.optimistic) return; + const preview = previewLabelForMessage(msg).slice(0, 240); + await api.post('/conversations/messages/pin', { + externalRoomId: roomId, + messageId, + preview, + }); + emitMessagePin({ roomId, messageId, preview, fromSelf: true }); + 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: `${PIN_CMD_PREFIX}${messageId}`, + }), + ); + void persistHiddenOnServer(roomId, [controlId], true); + } + } + + async function unpinChatMessage(roomId: string) { + if (!roomId) return; + await api.post('/conversations/messages/unpin', { externalRoomId: roomId }); + emitMessagePin({ roomId, messageId: null, preview: '', fromSelf: true }); + 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: UNPIN_CMD, + }), + ); + void persistHiddenOnServer(roomId, [controlId], true); + } + } + function clearActiveRoom() { stopHiddenPoll(); activeRoomId.value = ''; @@ -1298,6 +1405,8 @@ export function useOrgChat() { deleteMessage, deleteMessages, forwardMessages, + pinChatMessage, + unpinChatMessage, sendRead, ensureConnected, setConversationsPageOpen, diff --git a/apps/web/src/views/ConversationsView.vue b/apps/web/src/views/ConversationsView.vue index 7dbcd9c..7b04f81 100644 --- a/apps/web/src/views/ConversationsView.vue +++ b/apps/web/src/views/ConversationsView.vue @@ -5,16 +5,19 @@ import { api } from '@/lib/api'; import type { ConversationAccessDto, ConversationPeerDto, + ConversationPinnedMessageDto, ConversationRoomDto, } from '@mento/shared'; import { formatClockFa, toFaDigits } from '@/lib/jalali'; import { classifyChatFile, + onMessagePinEvent, onRoomLiveEvent, previewLabelForMessage, useOrgChat, type ChatDelivery, type ChatMessage, + type MessagePinEvent, type RoomLiveEvent, } from '@/lib/useOrgChat'; import UiIcon from '@/components/UiIcon.vue'; @@ -72,6 +75,8 @@ const { deleteMessage, deleteMessages, forwardMessages, + pinChatMessage, + unpinChatMessage, sendRead, ensureConnected, setConversationsPageOpen, @@ -80,6 +85,7 @@ const { } = useOrgChat(); const msgStreamEl = ref(null); let stopRoomLive: (() => boolean) | undefined; +let stopPinLive: (() => boolean) | undefined; let mediaRecorder: MediaRecorder | null = null; let recordChunks: Blob[] = []; let recordTimer: ReturnType | null = null; @@ -89,6 +95,14 @@ const selectMode = ref(false); const selectedIds = ref([]); const showForward = ref(false); const forwardBusy = ref(false); +const roomListQuery = ref(''); +const roomListTab = ref<'all' | 'dm' | 'group'>('all'); +const pinnedMessage = ref(null); +const chatSearchOpen = ref(false); +const chatSearchQuery = ref(''); +const chatSearchIndex = ref(0); +const mentionState = ref<{ start: number; end: number; query: string } | null>(null); +const mentionIndex = ref(0); const canCreateGroup = computed(() => access.value?.canCreateGroup ?? false); @@ -101,18 +115,112 @@ async function scrollToBottom() { const sortedRooms = computed(() => [...rooms.value].sort((a, b) => { + const pinA = a.pinned ? 1 : 0; + const pinB = b.pinned ? 1 : 0; + if (pinA !== pinB) return pinB - pinA; const ta = a.lastMessageAt ? new Date(a.lastMessageAt).getTime() : 0; const tb = b.lastMessageAt ? new Date(b.lastMessageAt).getTime() : 0; return tb - ta; }), ); +const filteredRooms = computed(() => { + const q = roomListQuery.value.trim().toLowerCase(); + return sortedRooms.value.filter((room) => { + if (roomListTab.value === 'dm' && room.kind !== 'dm') return false; + if (roomListTab.value === 'group' && room.kind !== 'group') return false; + if (!q) return true; + const title = roomTitle(room).toLowerCase(); + const preview = (room.lastMessagePreview || '').toLowerCase(); + return title.includes(q) || preview.includes(q); + }); +}); + +const mentionCandidates = computed(() => { + const state = mentionState.value; + if (!state) return [] as ConversationPeerDto[]; + const q = state.query.trim().toLowerCase(); + return peers.value + .filter((p) => !q || p.fullName.toLowerCase().includes(q)) + .slice(0, 8); +}); + +const chatSearchHits = computed(() => { + const q = chatSearchQuery.value.trim().toLowerCase(); + if (!q || !chatSearchOpen.value) return [] as ChatMessage[]; + return messages.value.filter((m) => { + if (m.deleted) return false; + if (m.type === 'text') return (m.content || '').toLowerCase().includes(q); + return previewLabelForMessage(m).toLowerCase().includes(q); + }); +}); + +const chatSearchHitCount = computed(() => chatSearchHits.value.length); +const activeSearchHitId = computed(() => { + const hits = chatSearchHits.value; + if (!hits.length) return ''; + const idx = ((chatSearchIndex.value % hits.length) + hits.length) % hits.length; + return hits[idx]?.id ?? ''; +}); + function roomTitle(room: ConversationRoomDto) { if (room.kind === 'group') return room.name; const peer = peers.value.find((p) => p.id === room.peerUserId); return peer?.fullName ?? room.name; } +type TextSeg = { text: string; mention?: boolean; hit?: boolean }; + +function messageTextSegments(content: string, highlightQuery = ''): TextSeg[] { + const raw = String(content || ''); + if (!raw) return []; + const names = [...peers.value] + .map((p) => p.fullName.trim()) + .filter(Boolean) + .sort((a, b) => b.length - a.length); + const mentionRe = names.length + ? new RegExp(`(@(?:${names.map((n) => n.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|')}))`, 'g') + : /(@[^\s@]{2,40})/g; + + const parts: TextSeg[] = []; + let last = 0; + for (const match of raw.matchAll(mentionRe)) { + const idx = match.index ?? 0; + if (idx > last) parts.push({ text: raw.slice(last, idx) }); + parts.push({ text: match[0], mention: true }); + last = idx + match[0].length; + } + if (last < raw.length) parts.push({ text: raw.slice(last) }); + + const q = highlightQuery.trim(); + if (!q) return parts.length ? parts : [{ text: raw }]; + + const out: TextSeg[] = []; + const qLower = q.toLowerCase(); + for (const part of parts) { + if (part.mention) { + out.push(part); + continue; + } + const text = part.text; + const lower = text.toLowerCase(); + let cursor = 0; + let found = lower.indexOf(qLower, cursor); + if (found < 0) { + out.push(part); + continue; + } + while (found >= 0) { + if (found > cursor) out.push({ text: text.slice(cursor, found) }); + out.push({ text: text.slice(found, found + q.length), hit: true }); + cursor = found + q.length; + found = lower.indexOf(qLower, cursor); + } + if (cursor < text.length) out.push({ text: text.slice(cursor) }); + } + return out.length ? out : [{ text: raw }]; +} + function formatListTime(iso: string | null) { if (!iso) return ''; try { @@ -145,6 +253,9 @@ function closeMobileChat() { emojiOpen.value = false; msgMenu.value = null; showForward.value = false; + closeChatSearch(); + mentionState.value = null; + pinnedMessage.value = null; if (recording.value) mediaRecorder?.stop(); } @@ -207,10 +318,12 @@ async function deleteSelected() { if (otherCount) parts.push(`${otherCount} پیام دریافتی فقط برای شما`); if (!window.confirm(`حذف شود؟\n${parts.join('\n')}`)) return; try { + const pinnedId = pinnedMessage.value?.messageId; await deleteMessages( activeRoom.value.externalRoomId, list.map((m) => ({ id: m.id, forEveryone: isMineMessage(m) })), ); + if (pinnedId && list.some((m) => m.id === pinnedId)) pinnedMessage.value = null; exitSelectMode(); } catch (e) { error.value = e instanceof Error ? e.message : 'حذف پیام ناموفق بود.'; @@ -309,6 +422,147 @@ function scrollToMessage(id: string | null | undefined) { window.setTimeout(() => row.classList.remove('flash'), 1200); } +async function refreshPinnedMessage(roomId: string) { + if (!roomId) { + pinnedMessage.value = null; + return; + } + try { + const { data } = await api.get( + '/conversations/messages/pin', + { params: { externalRoomId: roomId } }, + ); + pinnedMessage.value = data?.messageId ? data : null; + } catch { + pinnedMessage.value = null; + } +} + +function applyMessagePin(event: MessagePinEvent) { + if (activeRoom.value?.externalRoomId !== event.roomId) return; + if (!event.messageId) { + pinnedMessage.value = null; + return; + } + pinnedMessage.value = { + messageId: event.messageId, + preview: event.preview || pinnedMessage.value?.preview || 'پیام سنجاق‌شده', + pinnedByUserId: pinnedMessage.value?.pinnedByUserId || '', + createdAt: new Date().toISOString(), + }; +} + +async function togglePinMessage(msg: ChatMessage) { + msgMenu.value = null; + if (!activeRoom.value || msg.optimistic || msg.deleted) return; + const roomId = activeRoom.value.externalRoomId; + try { + if (pinnedMessage.value?.messageId === msg.id) { + await unpinChatMessage(roomId); + pinnedMessage.value = null; + } else { + await pinChatMessage(roomId, msg); + pinnedMessage.value = { + messageId: msg.id, + preview: previewLabelForMessage(msg).slice(0, 240), + pinnedByUserId: '', + createdAt: new Date().toISOString(), + }; + } + } catch (e) { + error.value = e instanceof Error ? e.message : 'سنجاق پیام ناموفق بود.'; + } +} + +async function clearPinnedMessage() { + if (!activeRoom.value) return; + try { + await unpinChatMessage(activeRoom.value.externalRoomId); + pinnedMessage.value = null; + } catch (e) { + error.value = e instanceof Error ? e.message : 'برداشتن سنجاق ناموفق بود.'; + } +} + +async function toggleRoomPinned() { + if (!activeRoom.value) return; + const room = activeRoom.value; + const next = !room.pinned; + try { + await api.post('/conversations/rooms/pin', { + externalRoomId: room.externalRoomId, + pinned: next, + }); + const updated = { ...room, pinned: next }; + activeRoom.value = updated; + rooms.value = rooms.value.map((r) => + r.externalRoomId === room.externalRoomId ? { ...r, pinned: next } : r, + ); + } catch (e) { + error.value = e instanceof Error ? e.message : 'سنجاق گفتگو ناموفق بود.'; + } +} + +function openChatSearch() { + chatSearchOpen.value = true; + chatSearchQuery.value = ''; + chatSearchIndex.value = 0; + emojiOpen.value = false; + mentionState.value = null; + msgMenu.value = null; +} + +function closeChatSearch() { + chatSearchOpen.value = false; + chatSearchQuery.value = ''; + chatSearchIndex.value = 0; +} + +function goSearchHit(delta: number) { + const total = chatSearchHitCount.value; + if (!total) return; + chatSearchIndex.value = (chatSearchIndex.value + delta + total) % total; + nextTick(() => scrollToMessage(activeSearchHitId.value)); +} + +function updateMentionState() { + const el = draftInputEl.value; + if (!el) { + mentionState.value = null; + return; + } + const caret = el.selectionStart ?? draft.value.length; + const before = draft.value.slice(0, caret); + const match = before.match(/(^|[\s\n])@([^\s@]*)$/); + if (!match) { + mentionState.value = null; + return; + } + const query = match[2] ?? ''; + const start = caret - query.length - 1; + mentionState.value = { start, end: caret, query }; + mentionIndex.value = 0; +} + +function insertMention(peer: ConversationPeerDto) { + const state = mentionState.value; + if (!state) return; + const token = `@${peer.fullName} `; + draft.value = draft.value.slice(0, state.start) + token + draft.value.slice(state.end); + mentionState.value = null; + nextTick(() => { + const el = draftInputEl.value; + if (!el) return; + const pos = state.start + token.length; + el.focus(); + el.setSelectionRange(pos, pos); + }); +} + +function onDraftInput() { + updateMentionState(); +} + async function confirmDeleteMessage(msg: ChatMessage) { msgMenu.value = null; if (!activeRoom.value || msg.optimistic || msg.deleted) return; @@ -324,6 +578,7 @@ async function confirmDeleteMessage(msg: ChatMessage) { forEveryone: mine, }); if (editingMessageId.value === msg.id) cancelEdit(); + if (pinnedMessage.value?.messageId === msg.id) pinnedMessage.value = null; } catch (e) { error.value = e instanceof Error ? e.message : 'حذف پیام ناموفق بود.'; } @@ -350,6 +605,30 @@ function insertComposerNewline() { } function onComposerKeydown(event: KeyboardEvent) { + if (mentionState.value && mentionCandidates.value.length) { + if (event.key === 'ArrowDown') { + event.preventDefault(); + mentionIndex.value = (mentionIndex.value + 1) % mentionCandidates.value.length; + return; + } + if (event.key === 'ArrowUp') { + event.preventDefault(); + mentionIndex.value = + (mentionIndex.value - 1 + mentionCandidates.value.length) % mentionCandidates.value.length; + return; + } + if (event.key === 'Enter' || event.key === 'Tab') { + event.preventDefault(); + const peer = mentionCandidates.value[mentionIndex.value]; + if (peer) insertMention(peer); + return; + } + if (event.key === 'Escape') { + event.preventDefault(); + mentionState.value = null; + return; + } + } if (event.key !== 'Enter') return; // موبایل: Enter = خط بعد؛ ارسال فقط با دکمه ارسال if (isMobileComposer()) return; @@ -652,7 +931,10 @@ async function openRoom(room: ConversationRoomDto) { rooms.value = rooms.value.map((r) => r.externalRoomId === room.externalRoomId ? cleared : r, ); + closeChatSearch(); + mentionState.value = null; await loadMessages(room.externalRoomId); + await refreshPinnedMessage(room.externalRoomId); sendRead(room.externalRoomId); void refreshUnreadTotal(); } @@ -769,6 +1051,8 @@ watch(activeRoom, () => { emojiOpen.value = false; msgMenu.value = null; showForward.value = false; + closeChatSearch(); + mentionState.value = null; if (recording.value) { mediaRecorder?.stop(); } @@ -777,23 +1061,36 @@ watch(activeRoom, () => { watch( () => messages.value.length, () => { + if (chatSearchOpen.value) return; void scrollToBottom(); }, ); watch(loadingMessages, (v) => { - if (!v) void scrollToBottom(); + if (!v && !chatSearchOpen.value) void scrollToBottom(); +}); + +watch(chatSearchHits, (hits) => { + if (!chatSearchOpen.value) return; + if (!hits.length) { + chatSearchIndex.value = 0; + return; + } + if (chatSearchIndex.value >= hits.length) chatSearchIndex.value = 0; + nextTick(() => scrollToMessage(activeSearchHitId.value)); }); onMounted(() => { setConversationsPageOpen(true); stopRoomLive = onRoomLiveEvent(applyRoomLive); + stopPinLive = onMessagePinEvent(applyMessagePin); void bootstrap(); }); onUnmounted(() => { setConversationsPageOpen(false); stopRoomLive?.(); + stopPinLive?.(); clearActiveRoom(); clearPendingAttaches(); if (recording.value) mediaRecorder?.stop(); @@ -838,19 +1135,65 @@ onUnmounted(() => { گروه +
+ +
+ + + +
+
    -
  • +
  • + {{ roomListQuery.trim() ? 'گفتگویی پیدا نشد' : 'گفتگویی در این بخش نیست' }} +
  • +
  • + +
    + + +
    +
    در حال دریافت پیام‌ها…
    • { :class="{ mine: msg.senderId === chatUserId || msg.optimistic, selected: isSelected(msg.id), + 'search-hit': activeSearchHitId === msg.id, }" @contextmenu="openMsgMenu(msg, $event)" @click="selectMode ? toggleSelect(msg) : undefined" @@ -1015,7 +1437,19 @@ onUnmounted(() => { آپلود {{ toFaDigits(msg.uploadProgress) }}٪ -

      {{ msg.content }}

      +

      + +

      ویرایش‌شده @@ -1037,6 +1471,19 @@ onUnmounted(() => {
    +
    + +
    @@ -1165,6 +1615,14 @@ onUnmounted(() => { پاسخ +