Edit text and links, mute and unmute chat
This commit is contained in:
@@ -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;
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user