diff --git a/apps/api/prisma/migrations/20260930160000_conversation_mute_reactions/migration.sql b/apps/api/prisma/migrations/20260930160000_conversation_mute_reactions/migration.sql new file mode 100644 index 0000000..5757c73 --- /dev/null +++ b/apps/api/prisma/migrations/20260930160000_conversation_mute_reactions/migration.sql @@ -0,0 +1,33 @@ +-- Mute per-user (smart: mentions can still notify) +CREATE TABLE "ConversationRoomUserMute" ( + "id" TEXT NOT NULL, + "organizationId" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "externalRoomId" TEXT NOT NULL, + "allowMentions" BOOLEAN NOT NULL DEFAULT true, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "ConversationRoomUserMute_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "ConversationRoomUserMute_organizationId_userId_externalRoomId_key" ON "ConversationRoomUserMute"("organizationId", "userId", "externalRoomId"); +CREATE INDEX "ConversationRoomUserMute_organizationId_userId_idx" ON "ConversationRoomUserMute"("organizationId", "userId"); +ALTER TABLE "ConversationRoomUserMute" ADD CONSTRAINT "ConversationRoomUserMute_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- Message emoji reactions +CREATE TABLE "ConversationMessageReaction" ( + "id" TEXT NOT NULL, + "organizationId" TEXT NOT NULL, + "externalRoomId" TEXT NOT NULL, + "messageId" TEXT NOT NULL, + "emoji" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "ConversationMessageReaction_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "ConversationMessageReaction_organizationId_externalRoomId_messageId_userId_emoji_key" ON "ConversationMessageReaction"("organizationId", "externalRoomId", "messageId", "userId", "emoji"); +CREATE INDEX "ConversationMessageReaction_organizationId_externalRoomId_idx" ON "ConversationMessageReaction"("organizationId", "externalRoomId"); +ALTER TABLE "ConversationMessageReaction" ADD CONSTRAINT "ConversationMessageReaction_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 87c54f5..65a0f4d 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -213,6 +213,8 @@ model Organization { conversationPinnedMessages ConversationPinnedMessage[] conversationRoomUserPins ConversationRoomUserPin[] conversationRoomMembers ConversationRoomMember[] + conversationRoomUserMutes ConversationRoomUserMute[] + conversationMessageReactions ConversationMessageReaction[] } model User { @@ -2161,3 +2163,34 @@ model ConversationRoomMember { @@unique([organizationId, externalRoomId, userId]) @@index([organizationId, externalRoomId]) } + +/// بی‌صدا کردن گفتگو برای کاربر — با استثنای منشن +model ConversationRoomUserMute { + id String @id @default(cuid()) + organizationId String + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + userId String + externalRoomId String + /// اگر true باشد منشن همچنان اعلان/خوانده‌نشده می‌سازد + allowMentions Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([organizationId, userId, externalRoomId]) + @@index([organizationId, userId]) +} + +/// واکنش ایموجی روی پیام +model ConversationMessageReaction { + id String @id @default(cuid()) + organizationId String + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + externalRoomId String + messageId String + emoji String + userId String + createdAt DateTime @default(now()) + + @@unique([organizationId, externalRoomId, messageId, userId, emoji]) + @@index([organizationId, externalRoomId]) +} diff --git a/apps/api/src/conversations/conversations.controller.ts b/apps/api/src/conversations/conversations.controller.ts index 11e226f..406e398 100644 --- a/apps/api/src/conversations/conversations.controller.ts +++ b/apps/api/src/conversations/conversations.controller.ts @@ -15,6 +15,8 @@ import { RemoveConversationRoomMemberDto, UpdateConversationRoomMemberRoleDto, ConversationTypingDto, + MuteConversationRoomDto, + ToggleMessageReactionDto, } from './dto'; import { ConversationsService } from './conversations.service'; @@ -186,6 +188,49 @@ export class ConversationsController { ); } + @Post('rooms/mute') + muteRoom( + @CurrentUser() user: { userId: string; organizationId: string; role: string }, + @Body() dto: MuteConversationRoomDto, + ) { + return this.conversations.setRoomMuted( + user.userId, + user.organizationId, + user.role, + dto.externalRoomId, + dto.muted, + dto.allowMentions !== false, + ); + } + + @Get('messages/reactions') + listReactions( + @CurrentUser() user: { userId: string; organizationId: string; role: string }, + @Query('externalRoomId') externalRoomId: string, + ) { + return this.conversations.listMessageReactions( + user.userId, + user.organizationId, + user.role, + externalRoomId || '', + ); + } + + @Post('messages/reactions') + toggleReaction( + @CurrentUser() user: { userId: string; organizationId: string; role: string }, + @Body() dto: ToggleMessageReactionDto, + ) { + return this.conversations.toggleMessageReaction( + user.userId, + user.organizationId, + user.role, + dto.externalRoomId, + dto.messageId, + dto.emoji, + ); + } + @Post('messages/hide') hideMessages( @CurrentUser() user: { userId: string; organizationId: string; role: string }, diff --git a/apps/api/src/conversations/conversations.service.ts b/apps/api/src/conversations/conversations.service.ts index da5dadf..caa1e17 100644 --- a/apps/api/src/conversations/conversations.service.ts +++ b/apps/api/src/conversations/conversations.service.ts @@ -312,6 +312,7 @@ export class ConversationsService { }); const trackedByExternal = new Map(tracked.map((t) => [t.externalRoomId, t])); const pinnedIds = await this.userPinnedRoomIds(organizationId, userId); + const muteMap = await this.userMuteMap(organizationId, userId); const membership = await this.prisma.membership.findFirst({ where: { userId, organizationId, isActive: true }, select: { role: true }, @@ -327,9 +328,12 @@ export class ConversationsService { const isRoomAdmin = roomAdminIds.has(dto.externalRoomId) || trackedByExternal.get(dto.externalRoomId)?.createdByUserId === userId; + const mute = muteMap.get(dto.externalRoomId); return { ...dto, pinned: pinnedIds.has(dto.externalRoomId), + muted: Boolean(mute), + muteAllowMentions: mute ? mute.allowMentions : true, canPost: dto.kind !== 'channel' ? true : Boolean(isRoomAdmin || orgAdmin), canManageMembers: @@ -343,6 +347,7 @@ export class ConversationsService { const kind = this.normalizeKind(t.kind); const isRoomAdmin = roomAdminIds.has(t.externalRoomId) || t.createdByUserId === userId; + const mute = muteMap.get(t.externalRoomId); return { id: t.id, externalRoomId: t.externalRoomId, @@ -362,6 +367,8 @@ export class ConversationsService { : null : null, pinned: pinnedIds.has(t.externalRoomId), + muted: Boolean(mute), + muteAllowMentions: mute ? mute.allowMentions : true, canPost: kind !== 'channel' ? true : Boolean(isRoomAdmin || orgAdmin), canManageMembers: kind === 'dm' ? false : Boolean(isRoomAdmin || orgAdmin), }; @@ -683,7 +690,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) || /^__MENTO_PIN__:/i.test(preview) || /^__MENTO_UNPIN__$/i.test(preview) || /^__MENTO_TYPING__:/i.test(preview)) { + if (/^__MENTO_HIDE__:/i.test(preview) || /MENTO_DEL/i.test(preview) || /^__MENTO_PIN__:/i.test(preview) || /^__MENTO_UNPIN__$/i.test(preview) || /^__MENTO_TYPING__:/i.test(preview) || /^__MENTO_REACT__:/i.test(preview)) { preview = ''; } if (lastType === 'image' || lastType === 'photo' || lastType === 'img') { @@ -745,6 +752,18 @@ export class ConversationsService { return new Set(rows.map((r) => r.externalRoomId)); } + private async userMuteMap( + organizationId: string, + userId: string, + ): Promise> { + const rows = await this.prisma.conversationRoomUserMute.findMany({ + where: { organizationId, userId }, + select: { externalRoomId: true, allowMentions: true }, + take: 2000, + }); + return new Map(rows.map((r) => [r.externalRoomId, { allowMentions: r.allowMentions }])); + } + private async userAdminRoomIds(organizationId: string, userId: string): Promise> { const rows = await this.prisma.conversationRoomMember.findMany({ where: { organizationId, userId, role: 'admin' }, @@ -1048,6 +1067,129 @@ export class ConversationsService { return { externalRoomId: roomId, pinned }; } + async setRoomMuted( + userId: string, + organizationId: string, + role: string, + externalRoomId: string, + muted: boolean, + allowMentions = true, + ): Promise<{ externalRoomId: string; muted: boolean; muteAllowMentions: boolean }> { + await this.loadAccess(userId, organizationId, role); + const roomId = externalRoomId.trim(); + if (!roomId) throw new BadRequestException('شناسه اتاق خالی است'); + + if (muted) { + await this.prisma.conversationRoomUserMute.upsert({ + where: { + organizationId_userId_externalRoomId: { + organizationId, + userId, + externalRoomId: roomId, + }, + }, + create: { + organizationId, + userId, + externalRoomId: roomId, + allowMentions: allowMentions !== false, + }, + update: { allowMentions: allowMentions !== false }, + }); + } else { + await this.prisma.conversationRoomUserMute.deleteMany({ + where: { organizationId, userId, externalRoomId: roomId }, + }); + } + return { + externalRoomId: roomId, + muted, + muteAllowMentions: muted ? allowMentions !== false : true, + }; + } + + async listMessageReactions( + userId: string, + organizationId: string, + role: string, + externalRoomId: string, + ): Promise< + Array<{ messageId: string; emoji: string; count: number; me: boolean }> + > { + await this.loadAccess(userId, organizationId, role); + const roomId = externalRoomId.trim(); + if (!roomId) return []; + const rows = await this.prisma.conversationMessageReaction.findMany({ + where: { organizationId, externalRoomId: roomId }, + select: { messageId: true, emoji: true, userId: true }, + take: 5000, + }); + const map = new Map(); + for (const r of rows) { + const key = `${r.messageId}\0${r.emoji}`; + const cur = map.get(key) || { + messageId: r.messageId, + emoji: r.emoji, + count: 0, + me: false, + }; + cur.count += 1; + if (r.userId === userId) cur.me = true; + map.set(key, cur); + } + return [...map.values()]; + } + + async toggleMessageReaction( + userId: string, + organizationId: string, + role: string, + externalRoomId: string, + messageId: string, + emoji: string, + ): Promise<{ + added: boolean; + reactions: Array<{ messageId: string; emoji: string; count: number; me: boolean }>; + }> { + await this.loadAccess(userId, organizationId, role); + const roomId = externalRoomId.trim(); + const mid = messageId.trim(); + const em = emoji.trim().slice(0, 16); + if (!roomId || !mid || !em) throw new BadRequestException('پارامتر واکنش ناقص است'); + + const existing = await this.prisma.conversationMessageReaction.findFirst({ + where: { + organizationId, + externalRoomId: roomId, + messageId: mid, + userId, + emoji: em, + }, + }); + let added = false; + if (existing) { + await this.prisma.conversationMessageReaction.delete({ where: { id: existing.id } }); + } else { + await this.prisma.conversationMessageReaction.create({ + data: { + organizationId, + externalRoomId: roomId, + messageId: mid, + userId, + emoji: em, + }, + }); + added = true; + } + const reactions = await this.listMessageReactions( + userId, + organizationId, + role, + roomId, + ); + return { added, reactions }; + } + async getPinnedMessage( userId: string, organizationId: string, diff --git a/apps/api/src/conversations/dto.ts b/apps/api/src/conversations/dto.ts index 5b38238..e3dadfa 100644 --- a/apps/api/src/conversations/dto.ts +++ b/apps/api/src/conversations/dto.ts @@ -169,3 +169,34 @@ export class ConversationTypingDto { @MaxLength(200) externalRoomId!: string; } + +export class MuteConversationRoomDto { + @IsString() + @MinLength(1) + @MaxLength(200) + externalRoomId!: string; + + @IsBoolean() + muted!: boolean; + + @IsOptional() + @IsBoolean() + allowMentions?: boolean; +} + +export class ToggleMessageReactionDto { + @IsString() + @MinLength(1) + @MaxLength(200) + externalRoomId!: string; + + @IsString() + @MinLength(1) + @MaxLength(200) + messageId!: string; + + @IsString() + @MinLength(1) + @MaxLength(16) + emoji!: string; +} diff --git a/apps/web/src/lib/chatMessageFormat.ts b/apps/web/src/lib/chatMessageFormat.ts new file mode 100644 index 0000000..837163f --- /dev/null +++ b/apps/web/src/lib/chatMessageFormat.ts @@ -0,0 +1,185 @@ +export type ChatTextSeg = { + text: string; + mention?: boolean; + hit?: boolean; + bold?: boolean; + link?: string; + /** deep CRM link */ + crm?: { kind: 'person' | 'deal' | 'ticket'; id: string }; + listItem?: boolean; +}; + +const URL_RE = + /https?:\/\/[^\s<>"'`]+|(?:www\.)[^\s<>"'`]+/gi; +const CRM_RE = /mento:\/\/(person|deal|ticket)\/([A-Za-z0-9_-]+)/gi; +const BOLD_RE = /\*([^*\n]+)\*/g; +const LIST_LINE_RE = /^\s*[-•]\s+/; + +function escapeRegExp(s: string) { + return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +function pushPlain(parts: ChatTextSeg[], text: string) { + if (text) parts.push({ text }); +} + +/** Parse one inline run (no newlines) into segments. */ +function parseInline( + raw: string, + mentionNames: string[], +): ChatTextSeg[] { + if (!raw) return []; + + type Mark = { start: number; end: number; seg: ChatTextSeg }; + const marks: Mark[] = []; + + const mentionRe = mentionNames.length + ? new RegExp( + `(@(?:${mentionNames.map(escapeRegExp).join('|')}))`, + 'g', + ) + : /(@[^\s@]{2,40})/g; + + for (const m of raw.matchAll(mentionRe)) { + const start = m.index ?? 0; + marks.push({ + start, + end: start + m[0].length, + seg: { text: m[0], mention: true }, + }); + } + + for (const m of raw.matchAll(CRM_RE)) { + const start = m.index ?? 0; + const kind = m[1] as 'person' | 'deal' | 'ticket'; + const id = m[2]; + const label = + kind === 'person' ? 'شخص' : kind === 'deal' ? 'معامله' : 'تیکت'; + marks.push({ + start, + end: start + m[0].length, + seg: { text: label, link: m[0], crm: { kind, id } }, + }); + } + + for (const m of raw.matchAll(URL_RE)) { + const start = m.index ?? 0; + let url = m[0]; + // trim trailing punctuation common in Persian/English + url = url.replace(/[)،.,;:!?]+$/g, ''); + const end = start + url.length; + const href = url.startsWith('http') ? url : `https://${url}`; + marks.push({ + start, + end, + seg: { text: url, link: href }, + }); + } + + for (const m of raw.matchAll(BOLD_RE)) { + const start = m.index ?? 0; + marks.push({ + start, + end: start + m[0].length, + seg: { text: m[1], bold: true }, + }); + } + + marks.sort((a, b) => a.start - b.start || b.end - a.end); + const chosen: Mark[] = []; + let cursor = 0; + for (const mark of marks) { + if (mark.start < cursor) continue; + chosen.push(mark); + cursor = mark.end; + } + + const out: ChatTextSeg[] = []; + let last = 0; + for (const mark of chosen) { + if (mark.start > last) pushPlain(out, raw.slice(last, mark.start)); + out.push(mark.seg); + last = mark.end; + } + if (last < raw.length) pushPlain(out, raw.slice(last)); + return out.length ? out : [{ text: raw }]; +} + +function applyHighlight(parts: ChatTextSeg[], q: string): ChatTextSeg[] { + const qLower = q.trim().toLowerCase(); + if (!qLower) return parts; + const out: ChatTextSeg[] = []; + for (const part of parts) { + if (part.mention || part.link || part.crm || part.bold) { + 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 : parts; +} + +/** + * فرمت سبک شبیه تلگرام: *پررنگ*، لیست با - ، لینک http، mento://person|deal|ticket/id + */ +export function parseChatMessageText( + content: string, + opts?: { mentionNames?: string[]; highlightQuery?: string }, +): ChatTextSeg[] { + const raw = String(content || ''); + if (!raw) return []; + const names = [...(opts?.mentionNames || [])] + .map((n) => n.trim()) + .filter(Boolean) + .sort((a, b) => b.length - a.length); + + const lines = raw.split('\n'); + const parts: ChatTextSeg[] = []; + lines.forEach((line, i) => { + if (i > 0) parts.push({ text: '\n' }); + const isList = LIST_LINE_RE.test(line); + const body = isList ? line.replace(LIST_LINE_RE, '') : line; + const inline = parseInline(body, names); + if (isList) { + if (!inline.length) { + parts.push({ text: '• ', listItem: true }); + } else { + parts.push({ text: '• ', listItem: true }); + for (const seg of inline) parts.push({ ...seg, listItem: true }); + } + } else { + parts.push(...inline); + } + }); + + return applyHighlight(parts.length ? parts : [{ text: raw }], opts?.highlightQuery || ''); +} + +export function crmPathFor(kind: 'person' | 'deal' | 'ticket', id: string): string { + if (kind === 'person') return `/contacts/people/${id}`; + if (kind === 'deal') return `/deals/${id}`; + return `/tickets/${id}`; +} + +export function insertCrmLink( + kind: 'person' | 'deal' | 'ticket', + id: string, + title?: string, +): string { + const link = `mento://${kind}/${id}`; + return title ? `${title} ${link}` : link; +} diff --git a/apps/web/src/lib/useOrgChat.ts b/apps/web/src/lib/useOrgChat.ts index 778a6c2..d8cf352 100644 --- a/apps/web/src/lib/useOrgChat.ts +++ b/apps/web/src/lib/useOrgChat.ts @@ -31,6 +31,8 @@ export type RoomLiveEvent = { preview: string; at: string; fromSelf: boolean; + /** متن خام برای تشخیص منشن در mute هوشمند */ + content?: string; }; export type MessagePinEvent = { @@ -41,6 +43,21 @@ export type MessagePinEvent = { fromSelf: boolean; }; +export type MessageReactionEvent = { + roomId: string; + messageId: string; + emoji: string; + added: boolean; + fromSelf: boolean; +}; + +export type MessageReactionAgg = { + messageId: string; + emoji: string; + count: number; + me: boolean; +}; + type RoomListener = (event: RoomLiveEvent) => void; const roomListeners = new Set(); @@ -76,6 +93,47 @@ function emitMessagePin(event: MessagePinEvent) { } } } + +type ReactionListener = (event: MessageReactionEvent) => void; +const reactionListeners = new Set(); + +export function onMessageReactionEvent(listener: ReactionListener) { + reactionListeners.add(listener); + return () => reactionListeners.delete(listener); +} + +function emitMessageReaction(event: MessageReactionEvent) { + for (const listener of reactionListeners) { + try { + listener(event); + } catch { + // ignore + } + } +} + +/** mute map for unread suppression (filled by ConversationsView) */ +const mutedRoomPrefs = ref>({}); +const mentionSelfName = ref(''); + +export function setChatMutePrefs( + prefs: Record, + selfFullName = '', +) { + mutedRoomPrefs.value = prefs; + mentionSelfName.value = selfFullName.trim(); +} + +function shouldCountUnread(roomId: string, content: string, fromSelf: boolean): boolean { + if (fromSelf) return false; + const mute = mutedRoomPrefs.value[roomId]; + if (!mute) return true; + if (mute.allowMentions && mentionSelfName.value) { + const needle = `@${mentionSelfName.value}`; + if (content.includes(needle)) return true; + } + return false; +} const IMAGE_MAX_EDGE = 1600; const IMAGE_WEBP_QUALITY = 0.82; const MAX_UPLOAD_BYTES = 40 * 1024 * 1024; @@ -88,6 +146,8 @@ const PIN_CMD_PREFIX = '__MENTO_PIN__:'; const UNPIN_CMD = '__MENTO_UNPIN__'; /** Typing fallback when WS `typing` event is not relayed by external chat. */ const TYPING_CMD_PREFIX = '__MENTO_TYPING__:'; +/** Reaction sync: __MENTO_REACT__:messageId:emoji:1|0 */ +const REACT_CMD_PREFIX = '__MENTO_REACT__:'; function isDeletedContent(content: string): boolean { const t = content.trim(); @@ -98,7 +158,8 @@ function isDeletedContent(content: string): boolean { t.startsWith(HIDE_CMD_PREFIX) || t.startsWith(PIN_CMD_PREFIX) || t === UNPIN_CMD || - t.startsWith(TYPING_CMD_PREFIX) + t.startsWith(TYPING_CMD_PREFIX) || + t.startsWith(REACT_CMD_PREFIX) ); } @@ -118,6 +179,21 @@ function parsePinCommand(content: string): string | null { return id || null; } +/** null = not react; otherwise { messageId, emoji, added } */ +function parseReactCommand( + content: string, +): { messageId: string; emoji: string; added: boolean } | null { + const t = content.trim(); + if (!t.startsWith(REACT_CMD_PREFIX)) return null; + const parts = t.slice(REACT_CMD_PREFIX.length).split(':'); + if (parts.length < 3) return null; + const messageId = parts[0]?.trim(); + const emoji = parts[1]?.trim(); + const flag = parts[2]?.trim(); + if (!messageId || !emoji) return null; + return { messageId, emoji, added: flag !== '0' }; +} + function purgeMessageById(id: string) { const key = String(id || '').trim(); if (!key) return; @@ -772,7 +848,9 @@ function handleIncomingMessage(msg: ChatMessage) { sendRead(msg.roomId); } } else if (!fromSelf) { - unreadTotal.value += 1; + if (shouldCountUnread(msg.roomId, msg.content || '', fromSelf)) { + unreadTotal.value += 1; + } } emitRoomLive({ @@ -780,6 +858,7 @@ function handleIncomingMessage(msg: ChatMessage) { preview: previewLabelForMessage(msg), at: msg.createdAt, fromSelf, + content: msg.type === 'text' ? msg.content : '', }); } @@ -955,6 +1034,26 @@ function onSocketMessage(event: MessageEvent) { } return; } + const reactCmd = parseReactCommand(purgeContent); + if (reactCmd) { + const roomId = String( + (parsed.room_id as string) || + ((parsed.message as Record | undefined)?.room_id as string) || + activeRoomId.value, + ); + if (purgeId) { + rememberHidden(roomId, [purgeId]); + void persistHiddenOnServer(roomId, [purgeId], true); + } + emitMessageReaction({ + roomId, + messageId: reactCmd.messageId, + emoji: reactCmd.emoji, + added: reactCmd.added, + fromSelf: false, + }); + return; + } if (purgeId && isDeletedContent(purgeContent)) { purgeMessageById(purgeId); return; @@ -1626,6 +1725,47 @@ export function useOrgChat() { } } + async function toggleMessageReaction(roomId: string, messageId: string, emoji: string) { + const rid = String(roomId || '').trim(); + const mid = String(messageId || '').trim(); + const em = String(emoji || '').trim().slice(0, 16); + if (!rid || !mid || !em) return null; + const { data } = await api.post<{ + added: boolean; + reactions: MessageReactionAgg[]; + }>('/conversations/messages/reactions', { + externalRoomId: rid, + messageId: mid, + emoji: em, + }); + emitMessageReaction({ + roomId: rid, + messageId: mid, + emoji: em, + added: Boolean(data?.added), + fromSelf: true, + }); + try { + await connectSocket(); + if (socket && socket.readyState === WebSocket.OPEN) { + const controlId = crypto.randomUUID(); + rememberHidden(rid, [controlId]); + socket.send( + JSON.stringify({ + id: controlId, + type: 'text', + room_id: rid, + content: `${REACT_CMD_PREFIX}${mid}:${em}:${data?.added ? '1' : '0'}`, + }), + ); + void persistHiddenOnServer(rid, [controlId], true); + } + } catch { + // ignore + } + return Array.isArray(data?.reactions) ? data.reactions : null; + } + function clearActiveRoom() { stopHiddenPoll(); activeRoomId.value = ''; @@ -1662,6 +1802,7 @@ export function useOrgChat() { forwardMessages, pinChatMessage, unpinChatMessage, + toggleMessageReaction, sendRead, ensureConnected, setConversationsPageOpen, diff --git a/apps/web/src/views/ConversationsView.vue b/apps/web/src/views/ConversationsView.vue index bb2b6e6..4f515f5 100644 --- a/apps/web/src/views/ConversationsView.vue +++ b/apps/web/src/views/ConversationsView.vue @@ -17,17 +17,32 @@ import { formatClockFa, toFaDigits } from '@/lib/jalali'; import { classifyChatFile, onMessagePinEvent, + onMessageReactionEvent, onRoomLiveEvent, previewLabelForMessage, + setChatMutePrefs, useOrgChat, type ChatDelivery, type ChatMessage, type MessagePinEvent, + type MessageReactionAgg, + type MessageReactionEvent, type RoomLiveEvent, } from '@/lib/useOrgChat'; +import { + crmPathFor, + insertCrmLink, + parseChatMessageText, +} from '@/lib/chatMessageFormat'; import { useAuthStore } from '@/stores/auth'; import UiIcon from '@/components/UiIcon.vue'; import UserAvatar from '@/components/UserAvatar.vue'; +import type { + PaginatedResponse, + PersonDto, + PipelineBoardDto, + TicketDto, +} from '@mento/shared'; type PendingAttach = { id: string; @@ -94,6 +109,7 @@ const { forwardMessages, pinChatMessage, unpinChatMessage, + toggleMessageReaction, sendRead, ensureConnected, setConversationsPageOpen, @@ -103,6 +119,7 @@ const { const msgStreamEl = ref(null); let stopRoomLive: (() => boolean) | undefined; let stopPinLive: (() => boolean) | undefined; +let stopReactLive: (() => boolean) | undefined; let mediaRecorder: MediaRecorder | null = null; let recordChunks: Blob[] = []; let recordTimer: ReturnType | null = null; @@ -115,6 +132,13 @@ const forwardBusy = ref(false); const roomListQuery = ref(''); const roomListTab = ref<'all' | 'dm' | 'group' | 'channel'>('all'); const pinnedMessage = ref(null); +const messageReactions = ref([]); +const QUICK_REACTIONS = ['👍', '❤️', '😂', '😮', '😢', '🙏'] as const; +const showCrmLink = ref(false); +const crmLinkKind = ref<'person' | 'deal' | 'ticket'>('person'); +const crmLinkQuery = ref(''); +const crmLinkResults = ref>([]); +const crmLinkBusy = ref(false); const chatSearchOpen = ref(false); const chatSearchQuery = ref(''); const chatSearchIndex = ref(0); @@ -274,56 +298,27 @@ function roomKindLabel(kind: ConversationRoomDto['kind']) { return 'خصوصی'; } -type TextSeg = { text: string; mention?: boolean; hit?: boolean }; +type TextSeg = ReturnType[number]; 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; + return parseChatMessageText(content, { + mentionNames: peers.value.map((p) => p.fullName), + highlightQuery, + }); +} - 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; +function reactionsForMessage(messageId: string) { + return messageReactions.value.filter((r) => r.messageId === messageId); +} + +function syncMutePrefs() { + const prefs: Record = {}; + for (const r of rooms.value) { + if (r.muted) { + prefs[r.externalRoomId] = { allowMentions: r.muteAllowMentions !== false }; + } } - 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 }]; + setChatMutePrefs(prefs, auth.user?.fullName || ''); } function formatListTime(iso: string | null) { @@ -633,6 +628,170 @@ async function toggleRoomPinned() { } } +async function toggleRoomMuted() { + if (!activeRoom.value) return; + const room = activeRoom.value; + const nextMuted = !room.muted; + try { + const { data } = await api.post<{ + muted: boolean; + muteAllowMentions: boolean; + }>('/conversations/rooms/mute', { + externalRoomId: room.externalRoomId, + muted: nextMuted, + allowMentions: true, + }); + const updated: ConversationRoomDto = { + ...room, + muted: data.muted, + muteAllowMentions: data.muteAllowMentions, + }; + activeRoom.value = updated; + rooms.value = rooms.value.map((r) => + r.externalRoomId === room.externalRoomId ? { ...r, ...updated } : r, + ); + syncMutePrefs(); + } catch (e) { + error.value = e instanceof Error ? e.message : 'تغییر اعلان گفتگو ناموفق بود.'; + } +} + +async function refreshReactions(roomId: string) { + try { + const { data } = await api.get('/conversations/messages/reactions', { + params: { externalRoomId: roomId }, + }); + messageReactions.value = Array.isArray(data) ? data : []; + } catch { + messageReactions.value = []; + } +} + +function applyReactionLive(event: MessageReactionEvent) { + if (event.fromSelf) return; + if (activeRoom.value?.externalRoomId !== event.roomId) return; + void refreshReactions(event.roomId); +} + +async function reactToMessage(msg: ChatMessage, emoji: string) { + if (!activeRoom.value || msg.optimistic) return; + msgMenu.value = null; + try { + const list = await toggleMessageReaction( + activeRoom.value.externalRoomId, + msg.id, + emoji, + ); + if (list) messageReactions.value = list; + else await refreshReactions(activeRoom.value.externalRoomId); + } catch (e) { + error.value = e instanceof Error ? e.message : 'واکنش ناموفق بود.'; + } +} + +function wrapDraftSelection(before: string, after = before) { + const el = draftInputEl.value; + const value = draft.value; + if (!el) { + draft.value = `${before}${value}${after}`; + return; + } + const start = el.selectionStart ?? value.length; + const end = el.selectionEnd ?? value.length; + const selected = value.slice(start, end) || 'متن'; + const next = value.slice(0, start) + before + selected + after + value.slice(end); + draft.value = next; + nextTick(() => { + el.focus(); + const pos = start + before.length + selected.length + after.length; + el.setSelectionRange(pos, pos); + }); +} + +function formatDraftBold() { + wrapDraftSelection('*', '*'); +} + +function formatDraftList() { + const el = draftInputEl.value; + const value = draft.value; + const start = el?.selectionStart ?? value.length; + const lineStart = value.lastIndexOf('\n', Math.max(0, start - 1)) + 1; + const prefix = value.slice(lineStart, lineStart + 2) === '- ' ? '' : '- '; + draft.value = value.slice(0, lineStart) + prefix + value.slice(lineStart); + nextTick(() => { + el?.focus(); + const pos = start + prefix.length; + el?.setSelectionRange(pos, pos); + }); +} + +async function searchCrmLinks() { + const q = crmLinkQuery.value.trim(); + crmLinkBusy.value = true; + crmLinkResults.value = []; + try { + if (crmLinkKind.value === 'person') { + const { data } = await api.get>('/people', { + params: { q: q || undefined, limit: 20 }, + }); + crmLinkResults.value = (data.items || []).map((p) => ({ + id: p.id, + title: p.fullName || 'شخص', + })); + } else if (crmLinkKind.value === 'ticket') { + const { data } = await api.get>('/tickets', { + params: { q: q || undefined, limit: 20 }, + }); + crmLinkResults.value = (data.items || []).map((t) => ({ + id: t.id, + title: t.subject || `تیکت ${t.id.slice(0, 6)}`, + })); + } else { + const { data } = await api.get('/pipelines/board'); + const deals = (data.stages || []).flatMap((s) => s.deals || []); + const ql = q.toLowerCase(); + crmLinkResults.value = deals + .filter((d) => !ql || (d.title || '').toLowerCase().includes(ql) || d.id.includes(q)) + .slice(0, 20) + .map((d) => ({ id: d.id, title: d.title || 'معامله' })); + } + } catch { + crmLinkResults.value = []; + } finally { + crmLinkBusy.value = false; + } +} + +function openCrmLinkPicker() { + showCrmLink.value = true; + crmLinkQuery.value = ''; + crmLinkResults.value = []; + void searchCrmLinks(); +} + +function insertCrmEntity(item: { id: string; title: string }) { + const chunk = insertCrmLink(crmLinkKind.value, item.id, item.title); + const el = draftInputEl.value; + const value = draft.value; + const start = el?.selectionStart ?? value.length; + const end = el?.selectionEnd ?? value.length; + const pad = start > 0 && !/\s$/.test(value.slice(0, start)) ? ' ' : ''; + draft.value = value.slice(0, start) + pad + chunk + ' ' + value.slice(end); + showCrmLink.value = false; + nextTick(() => draftInputEl.value?.focus()); +} + +function openCrmSeg(seg: TextSeg) { + if (seg.crm) { + void router.push(crmPathFor(seg.crm.kind, seg.crm.id)); + return; + } + if (seg.link && /^https?:/i.test(seg.link)) { + window.open(seg.link, '_blank', 'noopener'); + } +} + function openChatSearch() { chatSearchOpen.value = true; chatSearchQuery.value = ''; @@ -1155,12 +1314,22 @@ function applyRoomLive(event: RoomLiveEvent) { } const room = rooms.value[idx]; const isActive = activeRoom.value?.externalRoomId === event.roomId; + let unreadBump = 0; + if (!event.fromSelf && !isActive) { + const muted = Boolean(room.muted); + const allowMentions = room.muteAllowMentions !== false; + const myName = (auth.user?.fullName || '').trim(); + const mentionsMe = + Boolean(myName) && Boolean(event.content) && event.content!.includes(`@${myName}`); + if (!muted || (allowMentions && mentionsMe)) unreadBump = 1; + } const next: ConversationRoomDto = { ...room, lastMessagePreview: event.preview, lastMessageAt: event.at, - unreadCount: event.fromSelf || isActive ? 0 : (room.unreadCount || 0) + 1, + unreadCount: (room.unreadCount || 0) + unreadBump, }; + if (isActive) next.unreadCount = 0; const nextRooms = [...rooms.value]; nextRooms[idx] = next; rooms.value = nextRooms; @@ -1173,6 +1342,7 @@ async function refreshRoomsSoft() { try { const { data } = await api.get('/conversations/rooms'); rooms.value = data; + syncMutePrefs(); if (activeRoom.value) { const next = data.find((r) => r.externalRoomId === activeRoom.value!.externalRoomId); if (next) activeRoom.value = { ...next, unreadCount: 0 }; @@ -1208,6 +1378,7 @@ async function bootstrap() { } if (roomsOutcome.status === 'fulfilled') { rooms.value = roomsOutcome.value.data; + syncMutePrefs(); await refreshUnreadTotal(); } else { const e = roomsOutcome.reason; @@ -1251,6 +1422,7 @@ async function openRoom(room: ConversationRoomDto) { mentionState.value = null; await loadMessages(room.externalRoomId); await refreshPinnedMessage(room.externalRoomId); + await refreshReactions(room.externalRoomId); sendRead(room.externalRoomId); void refreshUnreadTotal(); } @@ -1541,6 +1713,7 @@ onMounted(() => { setConversationsPageOpen(true); stopRoomLive = onRoomLiveEvent(applyRoomLive); stopPinLive = onMessagePinEvent(applyMessagePin); + stopReactLive = onMessageReactionEvent(applyReactionLive); void bootstrap(); typingUiTimer = setInterval(() => { typingTick.value += 1; @@ -1551,6 +1724,7 @@ onUnmounted(() => { setConversationsPageOpen(false); stopRoomLive?.(); stopPinLive?.(); + stopReactLive?.(); if (typingUiTimer) clearInterval(typingUiTimer); clearActiveRoom(); clearPendingAttaches(); @@ -1661,7 +1835,7 @@ onUnmounted(() => { + + {{ seg.text }}

+
+ +
ویرایش‌شده @@ -2033,6 +2245,17 @@ onUnmounted(() => { در حال ضبط {{ toFaDigits(recordLabel()) }}
+
+ + + +
{ : { top: `${msgMenu.y}px`, left: `${msgMenu.x}px` } " > +
+ +
+ + + +