Edit typing... indicator, loading older message.

This commit is contained in:
2026-09-30 12:20:49 +03:30
parent e1d456ad11
commit a3ed6187e9
3 changed files with 206 additions and 27 deletions
@@ -562,7 +562,7 @@ export class ConversationsService {
let preview = lastMsg let preview = lastMsg
? String(lastMsg.content ?? lastMsg.text ?? lastMsg.body ?? '').trim() ? 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)) { 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)) {
preview = ''; preview = '';
} }
if (lastType === 'image' || lastType === 'photo' || lastType === 'img') { if (lastType === 'image' || lastType === 'photo' || lastType === 'img') {
@@ -697,16 +697,54 @@ export class ConversationsService {
}); });
if (!room) throw new NotFoundException('گفتگو پیدا نشد'); if (!room) throw new NotFoundException('گفتگو پیدا نشد');
let rows = await this.prisma.conversationRoomMember.findMany({ // همیشه سازنده + کاربر فعلی را در لیست عضو داشته باش (اتاق‌های قدیمی بدون ردیف)
where: { organizationId, externalRoomId: roomId }, const seedIds: Array<{ userId: string; role: string }> = [];
}); if (room.createdByUserId) {
seedIds.push({ userId: room.createdByUserId, role: 'admin' });
}
if (!seedIds.some((s) => s.userId === userId)) {
const orgAdmin = role === 'owner' || role === 'admin';
seedIds.push({
userId,
role: orgAdmin || room.createdByUserId === userId ? 'admin' : 'member',
});
}
if (seedIds.length) {
try {
await this.prisma.conversationRoomMember.createMany({
data: seedIds.map((s) => ({
organizationId,
externalRoomId: roomId,
userId: s.userId,
role: s.role,
})),
skipDuplicates: true,
});
} catch (e) {
const code = (e as { code?: string })?.code;
if (code === 'P2021' || code === 'P2022') {
throw new BadRequestException(
'جدول اعضای گفتگو هنوز migrate نشده است. از apps/api دستور prisma migrate deploy را اجرا کنید.',
);
}
throw e;
}
}
// اتاق‌های قدیمی بدون ردیف عضو — حداقل سازنده را ادمین نشان بده let rows: Array<{ userId: string; role: string }> = [];
if (!rows.length && room.createdByUserId) { try {
await this.seedRoomMembers(organizationId, roomId, room.createdByUserId, []);
rows = await this.prisma.conversationRoomMember.findMany({ rows = await this.prisma.conversationRoomMember.findMany({
where: { organizationId, externalRoomId: roomId }, where: { organizationId, externalRoomId: roomId },
select: { userId: true, role: true },
}); });
} catch (e) {
const code = (e as { code?: string })?.code;
if (code === 'P2021' || code === 'P2022') {
throw new BadRequestException(
'جدول اعضای گفتگو هنوز migrate نشده است. از apps/api دستور prisma migrate deploy را اجرا کنید.',
);
}
throw e;
} }
const userIds = rows.map((r) => r.userId); const userIds = rows.map((r) => r.userId);
+80 -7
View File
@@ -86,6 +86,8 @@ const HIDE_CMD_PREFIX = '__MENTO_HIDE__:';
/** Realtime pin / unpin control messages. */ /** Realtime pin / unpin control messages. */
const PIN_CMD_PREFIX = '__MENTO_PIN__:'; const PIN_CMD_PREFIX = '__MENTO_PIN__:';
const UNPIN_CMD = '__MENTO_UNPIN__'; const UNPIN_CMD = '__MENTO_UNPIN__';
/** Typing fallback when WS `typing` event is not relayed by external chat. */
const TYPING_CMD_PREFIX = '__MENTO_TYPING__:';
function isDeletedContent(content: string): boolean { function isDeletedContent(content: string): boolean {
const t = content.trim(); const t = content.trim();
@@ -95,7 +97,8 @@ function isDeletedContent(content: string): boolean {
t === HARD_DELETE_MARKER || t === HARD_DELETE_MARKER ||
t.startsWith(HIDE_CMD_PREFIX) || t.startsWith(HIDE_CMD_PREFIX) ||
t.startsWith(PIN_CMD_PREFIX) || t.startsWith(PIN_CMD_PREFIX) ||
t === UNPIN_CMD t === UNPIN_CMD ||
t.startsWith(TYPING_CMD_PREFIX)
); );
} }
@@ -237,9 +240,9 @@ function startTypingSweep() {
} }
function applyTypingEvent(roomId: string, userId: string) { function applyTypingEvent(roomId: string, userId: string) {
const rid = String(roomId || '').trim(); const rid = String(roomId || activeRoomId.value || '').trim();
const uid = String(userId || '').trim(); const uid = String(userId || '').trim() || '__someone__';
if (!rid || !uid) return; if (!rid) return;
if (sessionCache?.chatUserId && uid === sessionCache.chatUserId) return; if (sessionCache?.chatUserId && uid === sessionCache.chatUserId) return;
startTypingSweep(); startTypingSweep();
const list = [...(typingMap.value[rid] || [])].filter((t) => t.userId !== uid); const list = [...(typingMap.value[rid] || [])].filter((t) => t.userId !== uid);
@@ -247,6 +250,12 @@ function applyTypingEvent(roomId: string, userId: string) {
typingMap.value = { ...typingMap.value, [rid]: list }; typingMap.value = { ...typingMap.value, [rid]: list };
} }
function parseTypingCommand(content: string): string | null {
const t = content.trim();
if (!t.startsWith(TYPING_CMD_PREFIX)) return null;
return t.slice(TYPING_CMD_PREFIX.length).trim() || '__someone__';
}
function clearTypingForRoom(roomId: string) { function clearTypingForRoom(roomId: string) {
if (!typingMap.value[roomId]) return; if (!typingMap.value[roomId]) return;
const next = { ...typingMap.value }; const next = { ...typingMap.value };
@@ -254,6 +263,17 @@ function clearTypingForRoom(roomId: string) {
typingMap.value = next; typingMap.value = next;
} }
function clearTypingUser(roomId: string, userId: string) {
const rid = String(roomId || '').trim();
const uid = String(userId || '').trim();
if (!rid || !uid || !typingMap.value[rid]) return;
const kept = typingMap.value[rid].filter((t) => t.userId !== uid);
const next = { ...typingMap.value };
if (kept.length) next[rid] = kept;
else delete next[rid];
typingMap.value = next;
}
const PAGE_SIZE = 40; const PAGE_SIZE = 40;
function messageTs(item: ChatMessage): number { function messageTs(item: ChatMessage): number {
@@ -686,6 +706,8 @@ function handleIncomingMessage(msg: ChatMessage) {
const myId = sessionCache?.chatUserId ?? ''; const myId = sessionCache?.chatUserId ?? '';
const fromSelf = Boolean(myId && msg.senderId === myId); const fromSelf = Boolean(myId && msg.senderId === myId);
if (msg.senderId) clearTypingUser(msg.roomId, msg.senderId);
if (msg.roomId === activeRoomId.value) { if (msg.roomId === activeRoomId.value) {
messages.value = mergeMessages( messages.value = mergeMessages(
{ {
@@ -784,6 +806,7 @@ function onSocketMessage(event: MessageEvent) {
parsed.sender_id ?? parsed.sender_id ??
parsed.from_user_id ?? parsed.from_user_id ??
parsed.userId ?? parsed.userId ??
((parsed.message as Record<string, unknown> | undefined)?.sender_id as string) ??
'', '',
); );
applyTypingEvent(roomId, userId); applyTypingEvent(roomId, userId);
@@ -830,6 +853,35 @@ function onSocketMessage(event: MessageEvent) {
void persistHiddenOnServer(roomId, ids, true); void persistHiddenOnServer(roomId, ids, true);
return; return;
} }
const typingFromCmd = parseTypingCommand(purgeContent);
if (typingFromCmd) {
const nested =
parsed.message && typeof parsed.message === 'object'
? (parsed.message as Record<string, unknown>)
: undefined;
const roomId = String(
(parsed.room_id as string) ||
(parsed.roomId as string) ||
(nested?.room_id as string) ||
activeRoomId.value,
);
const sender =
typingFromCmd !== '__someone__'
? typingFromCmd
: String(
(parsed as { sender_id?: string }).sender_id ||
(parsed as { user_id?: string }).user_id ||
(nested?.sender_id as string) ||
(nested?.user_id as string) ||
'',
);
if (purgeId) {
rememberHidden(roomId, [purgeId]);
void persistHiddenOnServer(roomId, [purgeId], true);
}
applyTypingEvent(roomId, sender);
return;
}
const pinTarget = parsePinCommand(purgeContent); const pinTarget = parsePinCommand(purgeContent);
if (pinTarget !== null) { if (pinTarget !== null) {
const roomId = String( const roomId = String(
@@ -1116,12 +1168,33 @@ export function useOrgChat() {
const rid = String(roomId || '').trim(); const rid = String(roomId || '').trim();
if (!rid) return; if (!rid) return;
const now = Date.now(); const now = Date.now();
if (now - lastTypingSentAt < 1800) return; if (now - lastTypingSentAt < 1200) return;
lastTypingSentAt = now; lastTypingSentAt = now;
try { try {
await connectSocket(); await connectSocket();
if (!socket || socket.readyState !== WebSocket.OPEN) return; if (!socket || socket.readyState !== WebSocket.OPEN) return;
socket.send(JSON.stringify({ type: 'typing', room_id: rid })); const session = await ensureSession();
// Native typing event (if server relays it)
socket.send(
JSON.stringify({
type: 'typing',
room_id: rid,
user_id: session.chatUserId,
sender_id: session.chatUserId,
}),
);
// Fallback control text — external chat reliably delivers text; filtered from UI
const controlId = crypto.randomUUID();
rememberHidden(rid, [controlId]);
socket.send(
JSON.stringify({
id: controlId,
type: 'text',
room_id: rid,
content: `${TYPING_CMD_PREFIX}${session.chatUserId}`,
}),
);
void persistHiddenOnServer(rid, [controlId], true);
} catch { } catch {
// ignore // ignore
} }
@@ -1523,7 +1596,7 @@ export function useOrgChat() {
loadingMessages, loadingMessages,
hasMoreMessages: computed(() => hasMoreMessages.value), hasMoreMessages: computed(() => hasMoreMessages.value),
loadingOlderMessages: computed(() => loadingOlderMessages.value), loadingOlderMessages: computed(() => loadingOlderMessages.value),
typingMap: computed(() => typingMap.value), typingMap,
loadMessages, loadMessages,
loadOlderMessages, loadOlderMessages,
sendTyping, sendTyping,
+81 -13
View File
@@ -85,7 +85,7 @@ const {
loadMessages, loadMessages,
loadOlderMessages, loadOlderMessages,
sendTyping, sendTyping,
typingUserIdsForRoom, typingMap,
sendText, sendText,
sendFile, sendFile,
editMessage, editMessage,
@@ -169,11 +169,16 @@ const typingLabel = computed(() => {
void typingTick.value; void typingTick.value;
const roomId = activeRoom.value?.externalRoomId; const roomId = activeRoom.value?.externalRoomId;
if (!roomId) return ''; if (!roomId) return '';
const ids = typingUserIdsForRoom(roomId); const list = (typingMap.value?.[roomId] || []).filter(
if (!ids.length) return ''; (t) => Date.now() - t.at < 3500,
const names = ids );
.map((id) => peers.value.find((p) => p.chatUserId === id)?.fullName) if (!list.length) return '';
.filter((n): n is string => Boolean(n)); const names = list
.map((t) => {
if (t.userId === '__someone__') return '';
return peers.value.find((p) => p.chatUserId === t.userId)?.fullName || '';
})
.filter(Boolean);
if (!names.length) return 'در حال نوشتن…'; if (!names.length) return 'در حال نوشتن…';
if (names.length === 1) return `${names[0]} در حال نوشتن…`; if (names.length === 1) return `${names[0]} در حال نوشتن…`;
if (names.length === 2) return `${names[0]} و ${names[1]} در حال نوشتن…`; if (names.length === 2) return `${names[0]} و ${names[1]} در حال نوشتن…`;
@@ -183,7 +188,10 @@ const typingLabel = computed(() => {
const canManageActiveMembers = computed(() => { const canManageActiveMembers = computed(() => {
const room = activeRoom.value; const room = activeRoom.value;
if (!room || room.kind === 'dm') return false; if (!room || room.kind === 'dm') return false;
return room.canManageMembers === true; // اگر فلگ از API نیامده ولی کاربر سازنده/ادمین سازمان است، مدیریت را نشان بده
if (room.canManageMembers === true) return true;
if (room.canManageMembers === false) return false;
return room.kind === 'group' || room.kind === 'channel';
}); });
const membersAddCandidates = computed(() => { const membersAddCandidates = computed(() => {
@@ -685,13 +693,73 @@ async function refreshRoomMembers() {
membersBusy.value = true; membersBusy.value = true;
membersError.value = ''; membersError.value = '';
try { try {
const { data } = await api.get<ConversationRoomMemberDto[]>('/conversations/rooms/members', { const { data } = await api.get<ConversationRoomMemberDto[] | { data?: ConversationRoomMemberDto[] }>(
params: { externalRoomId: activeRoom.value.externalRoomId }, '/conversations/rooms/members',
}); { params: { externalRoomId: activeRoom.value.externalRoomId } },
roomMembers.value = Array.isArray(data) ? data : []; );
let list = Array.isArray(data)
? data
: Array.isArray((data as { data?: ConversationRoomMemberDto[] })?.data)
? (data as { data: ConversationRoomMemberDto[] }).data
: [];
// بازیابی اعضای فعال از روی فرستنده‌های پیام (اتاق‌های قدیمی بدون ردیف CRM)
const taken = new Set(list.map((m) => m.userId));
const senderChatIds = new Set(
messages.value
.filter((m) => m.roomId === activeRoom.value?.externalRoomId)
.map((m) => m.senderId)
.filter(Boolean),
);
const inferredPeers = peers.value.filter(
(p) => senderChatIds.has(p.chatUserId) && !taken.has(p.id),
);
if (inferredPeers.length && canManageActiveMembers.value) {
for (const p of inferredPeers.slice(0, 30)) {
try {
const { data: synced } = await api.post<ConversationRoomMemberDto[]>(
'/conversations/rooms/members',
{
externalRoomId: activeRoom.value.externalRoomId,
userId: p.id,
role: 'member',
},
);
if (Array.isArray(synced)) list = synced;
} catch {
// best-effort
}
}
} else if (inferredPeers.length) {
list = [
...list,
...inferredPeers.map((p) => ({
userId: p.id,
fullName: p.fullName,
role: 'member' as const,
hasAvatar: Boolean(p.hasAvatar),
isMe: false,
})),
];
}
roomMembers.value = list;
if (!list.length) {
membersError.value = 'عضوی برای این گفتگو ثبت نشده. با «افزودن» اعضا را اضافه کنید.';
}
} catch (e) { } catch (e) {
membersError.value = roomMembers.value = [];
e instanceof Error ? e.message : 'بارگذاری اعضا ناموفق بود.'; if (axios.isAxiosError(e)) {
const msg = (e.response?.data as { message?: string | string[] })?.message;
membersError.value = Array.isArray(msg)
? msg.join('، ')
: msg ||
(e.response?.status === 404
? 'گفتگو در CRM پیدا نشد یا جدول اعضا migrate نشده است.'
: 'بارگذاری اعضا ناموفق بود.');
} else {
membersError.value = e instanceof Error ? e.message : 'بارگذاری اعضا ناموفق بود.';
}
} finally { } finally {
membersBusy.value = false; membersBusy.value = false;
} }