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