Edit text and links, mute and unmute chat

This commit is contained in:
2026-09-30 14:58:44 +03:30
parent d3986da28b
commit 75b83b7f1b
9 changed files with 1070 additions and 51 deletions
@@ -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;
+33
View File
@@ -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])
}
@@ -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 },
@@ -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<Map<string, { allowMentions: boolean }>> {
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<Set<string>> {
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<string, { messageId: string; emoji: string; count: number; me: boolean }>();
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,
+31
View File
@@ -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;
}
+185
View File
@@ -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;
}
+143 -2
View File
@@ -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<RoomListener>();
@@ -76,6 +93,47 @@ function emitMessagePin(event: MessagePinEvent) {
}
}
}
type ReactionListener = (event: MessageReactionEvent) => void;
const reactionListeners = new Set<ReactionListener>();
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<Record<string, { allowMentions: boolean }>>({});
const mentionSelfName = ref('');
export function setChatMutePrefs(
prefs: Record<string, { allowMentions: boolean }>,
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<string, unknown> | 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,
+446 -48
View File
@@ -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<HTMLElement | null>(null);
let stopRoomLive: (() => boolean) | undefined;
let stopPinLive: (() => boolean) | undefined;
let stopReactLive: (() => boolean) | undefined;
let mediaRecorder: MediaRecorder | null = null;
let recordChunks: Blob[] = [];
let recordTimer: ReturnType<typeof setInterval> | null = null;
@@ -115,6 +132,13 @@ const forwardBusy = ref(false);
const roomListQuery = ref('');
const roomListTab = ref<'all' | 'dm' | 'group' | 'channel'>('all');
const pinnedMessage = ref<ConversationPinnedMessageDto | null>(null);
const messageReactions = ref<MessageReactionAgg[]>([]);
const QUICK_REACTIONS = ['👍', '❤️', '😂', '😮', '😢', '🙏'] as const;
const showCrmLink = ref(false);
const crmLinkKind = ref<'person' | 'deal' | 'ticket'>('person');
const crmLinkQuery = ref('');
const crmLinkResults = ref<Array<{ id: string; title: string }>>([]);
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<typeof parseChatMessageText>[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<string, { allowMentions: boolean }> = {};
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<MessageReactionAgg[]>('/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<PaginatedResponse<PersonDto>>('/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<PaginatedResponse<TicketDto>>('/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<PipelineBoardDto>('/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<ConversationRoomDto[]>('/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(() => {
<button
type="button"
class="room-chip"
:class="{ active: activeRoom?.externalRoomId === room.externalRoomId, pinned: room.pinned }"
:class="{ active: activeRoom?.externalRoomId === room.externalRoomId, pinned: room.pinned, muted: room.muted }"
@click="openRoom(room)"
>
<span class="avatar-ring">
@@ -1670,6 +1844,7 @@ onUnmounted(() => {
<span class="room-meta">
<strong>
<UiIcon v-if="room.pinned" name="pin" :size="12" class="room-pin-ic" />
<UiIcon v-if="room.muted" name="eye-off" :size="12" class="room-mute-ic" />
{{ roomTitle(room) }}
</strong>
<small :class="{ 'room-typing': Boolean(roomTypingPreview(room.externalRoomId)) }">{{
@@ -1792,6 +1967,19 @@ onUnmounted(() => {
<button type="button" class="select-act" title="جستجو" @click="openChatSearch">
<UiIcon name="search" :size="18" />
</button>
<button
type="button"
class="select-act"
:class="{ on: activeRoom.muted }"
:title="
activeRoom.muted
? 'روشن کردن اعلان (منشن همچنان مستثنی است)'
: 'بی‌صدا کردن گفتگو (منشن مستثنی است)'
"
@click="toggleRoomMuted"
>
<UiIcon :name="activeRoom.muted ? 'eye-off' : 'bell'" :size="18" />
</button>
<button
type="button"
class="select-act"
@@ -1946,9 +2134,33 @@ onUnmounted(() => {
>
<span v-if="seg.mention" class="mention">{{ seg.text }}</span>
<mark v-else-if="seg.hit" class="search-mark">{{ seg.text }}</mark>
<button
v-else-if="seg.crm || seg.link"
type="button"
class="msg-link"
:class="{ crm: Boolean(seg.crm) }"
@click.stop="openCrmSeg(seg)"
>
<strong v-if="seg.bold">{{ seg.text }}</strong>
<template v-else>{{ seg.text }}</template>
</button>
<strong v-else-if="seg.bold">{{ seg.text }}</strong>
<template v-else>{{ seg.text }}</template>
</template>
</p>
<div v-if="reactionsForMessage(msg.id).length" class="react-row">
<button
v-for="r in reactionsForMessage(msg.id)"
:key="`${r.messageId}-${r.emoji}`"
type="button"
class="react-chip"
:class="{ me: r.me }"
@click.stop="reactToMessage(msg, r.emoji)"
>
<span>{{ r.emoji }}</span>
<span>{{ toFaDigits(r.count) }}</span>
</button>
</div>
<div class="bubble-meta">
<span v-if="msg.edited" class="edited-mark">ویرایش‌شده</span>
<time>{{ formatListTime(msg.createdAt) }}</time>
@@ -2033,6 +2245,17 @@ onUnmounted(() => {
در حال ضبط {{ toFaDigits(recordLabel()) }}
<button type="button" class="rec-stop" @click="toggleVoiceRecord">توقف</button>
</div>
<div class="fmt-bar">
<button type="button" class="fmt-btn" title="پررنگ (*متن*)" @click="formatDraftBold">
<strong>ب</strong>
</button>
<button type="button" class="fmt-btn" title="لیست" @click="formatDraftList">
<UiIcon name="list" :size="14" />
</button>
<button type="button" class="fmt-btn" title="لینک CRM" @click="openCrmLinkPicker">
<UiIcon name="briefcase" :size="14" />
</button>
</div>
<div class="composer-row">
<input
ref="fileInputEl"
@@ -2118,6 +2341,17 @@ onUnmounted(() => {
: { top: `${msgMenu.y}px`, left: `${msgMenu.x}px` }
"
>
<div class="react-quick">
<button
v-for="em in QUICK_REACTIONS"
:key="em"
type="button"
class="react-quick-btn"
@click="reactToMessage(menuMessage()!, em)"
>
{{ em }}
</button>
</div>
<button type="button" @click="startReplyMessage(menuMessage()!)">
<UiIcon name="chat" :size="16" />
پاسخ
@@ -2402,6 +2636,65 @@ onUnmounted(() => {
</div>
</dialog>
<dialog v-if="showCrmLink" class="modal" open @click.self="showCrmLink = false">
<div class="modal-card wide">
<h3>لینک CRM</h3>
<p class="hint">شخص، معامله یا تیکت را انتخاب کنید تا در پیام درج شود.</p>
<div class="crm-kind-tabs" role="tablist">
<button
type="button"
:class="{ active: crmLinkKind === 'person' }"
@click="crmLinkKind = 'person'; searchCrmLinks()"
>
شخص
</button>
<button
type="button"
:class="{ active: crmLinkKind === 'deal' }"
@click="crmLinkKind = 'deal'; searchCrmLinks()"
>
معامله
</button>
<button
type="button"
:class="{ active: crmLinkKind === 'ticket' }"
@click="crmLinkKind = 'ticket'; searchCrmLinks()"
>
تیکت
</button>
</div>
<label class="peer-search">
<UiIcon name="search" :size="16" />
<input
v-model="crmLinkQuery"
type="search"
placeholder="جستجو…"
autocomplete="off"
@input="searchCrmLinks"
/>
</label>
<div class="peer-list">
<div v-if="crmLinkBusy" class="muted center pad">در حال جستجو…</div>
<button
v-for="item in crmLinkResults"
:key="item.id"
type="button"
class="peer-row"
@click="insertCrmEntity(item)"
>
<span class="peer-row-meta">
<strong>{{ item.title }}</strong>
<small>{{ item.id.slice(0, 8) }}…</small>
</span>
</button>
<p v-if="!crmLinkBusy && !crmLinkResults.length" class="peer-empty">موردی پیدا نشد</p>
</div>
<div class="modal-actions">
<button type="button" @click="showCrmLink = false">بستن</button>
</div>
</div>
</dialog>
<dialog v-if="showMembers" class="modal" open @click.self="showMembers = false">
<div class="modal-card wide members-sheet">
<h3>اعضای {{ activeRoom ? roomTitle(activeRoom) : 'گفتگو' }}</h3>
@@ -2711,6 +3004,10 @@ onUnmounted(() => {
color: #0d9488;
flex-shrink: 0;
}
.room-mute-ic {
color: #64748b;
flex-shrink: 0;
}
.room-meta small {
color: var(--muted);
font-size: 0.78rem;
@@ -2724,6 +3021,107 @@ onUnmounted(() => {
color: #0f766e;
font-weight: 600;
}
.msg-text .msg-link {
border: none;
background: transparent;
color: #0f766e;
text-decoration: underline;
text-underline-offset: 2px;
font: inherit;
padding: 0;
cursor: pointer;
}
.msg-text .msg-link.crm {
color: #0369a1;
font-weight: 600;
text-decoration: none;
background: rgba(3, 105, 161, 0.08);
border-radius: 6px;
padding: 0 0.25rem;
}
.msg-text strong {
font-weight: 700;
}
.react-row {
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
margin-top: 0.35rem;
}
.react-chip {
border: 1px solid rgba(15, 118, 110, 0.2);
background: rgba(13, 148, 136, 0.08);
border-radius: 999px;
padding: 0.1rem 0.4rem;
font: inherit;
font-size: 0.75rem;
cursor: pointer;
display: inline-flex;
gap: 0.2rem;
align-items: center;
}
.react-chip.me {
border-color: #0d9488;
background: rgba(13, 148, 136, 0.18);
}
.react-quick {
display: flex;
gap: 0.25rem;
padding: 0.35rem 0.5rem 0.15rem;
border-bottom: 1px solid var(--line);
margin-bottom: 0.15rem;
}
.react-quick-btn {
border: none;
background: transparent;
font-size: 1.15rem;
cursor: pointer;
padding: 0.2rem;
border-radius: 8px;
}
.react-quick-btn:hover {
background: rgba(13, 148, 136, 0.12);
}
.fmt-bar {
display: flex;
gap: 0.3rem;
padding: 0 0.65rem 0.25rem;
}
.fmt-btn {
border: 1px solid var(--line);
background: #fff;
border-radius: 8px;
width: 1.85rem;
height: 1.85rem;
display: grid;
place-items: center;
cursor: pointer;
color: #0f766e;
font: inherit;
font-size: 0.8rem;
}
.crm-kind-tabs {
display: flex;
gap: 0.35rem;
margin-bottom: 0.65rem;
}
.crm-kind-tabs button {
flex: 1;
border: 1px solid var(--line);
background: #fff;
border-radius: 10px;
padding: 0.45rem;
font: inherit;
cursor: pointer;
}
.crm-kind-tabs button.active {
background: #0d9488;
border-color: #0d9488;
color: #fff;
}
.select-act.on {
color: #0d9488;
}
.unread {
grid-column: 3;
grid-row: 1;
+11
View File
@@ -3976,6 +3976,10 @@ export interface ConversationRoomDto {
peerUserId: string | null;
/** سنجاق در لیست گفتگو — فقط برای کاربر جاری */
pinned?: boolean;
/** بی‌صدا برای کاربر جاری */
muted?: boolean;
/** در حالت mute، منشن همچنان اعلان می‌دهد */
muteAllowMentions?: boolean;
/** در کانال: آیا کاربر جاری می‌تواند پیام بفرستد */
canPost?: boolean;
/** آیا کاربر جاری می‌تواند اعضا را مدیریت کند */
@@ -3997,6 +4001,13 @@ export interface ConversationPinnedMessageDto {
createdAt: string;
}
export interface ConversationMessageReactionDto {
messageId: string;
emoji: string;
count: number;
me: boolean;
}
export interface CreateConversationDmRequest {
peerUserId: string;
}