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
+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;