From a3ed6187e9894abfe7a901cc19e9adafaf2af2ea Mon Sep 17 00:00:00 2001 From: Mahdi Date: Wed, 30 Sep 2026 12:20:49 +0330 Subject: [PATCH] Edit typing... indicator, loading older message. --- .../conversations/conversations.service.ts | 52 ++++++++-- apps/web/src/lib/useOrgChat.ts | 87 +++++++++++++++-- apps/web/src/views/ConversationsView.vue | 94 ++++++++++++++++--- 3 files changed, 206 insertions(+), 27 deletions(-) diff --git a/apps/api/src/conversations/conversations.service.ts b/apps/api/src/conversations/conversations.service.ts index 5c822ea..1a1a532 100644 --- a/apps/api/src/conversations/conversations.service.ts +++ b/apps/api/src/conversations/conversations.service.ts @@ -562,7 +562,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)) { + 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 = ''; } if (lastType === 'image' || lastType === 'photo' || lastType === 'img') { @@ -697,16 +697,54 @@ export class ConversationsService { }); 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; + } + } - // اتاق‌های قدیمی بدون ردیف عضو — حداقل سازنده را ادمین نشان بده - if (!rows.length && room.createdByUserId) { - await this.seedRoomMembers(organizationId, roomId, room.createdByUserId, []); + let rows: Array<{ userId: string; role: string }> = []; + try { rows = await this.prisma.conversationRoomMember.findMany({ 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); diff --git a/apps/web/src/lib/useOrgChat.ts b/apps/web/src/lib/useOrgChat.ts index 720492f..f116d54 100644 --- a/apps/web/src/lib/useOrgChat.ts +++ b/apps/web/src/lib/useOrgChat.ts @@ -86,6 +86,8 @@ const HIDE_CMD_PREFIX = '__MENTO_HIDE__:'; /** Realtime pin / unpin control messages. */ 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__:'; function isDeletedContent(content: string): boolean { const t = content.trim(); @@ -95,7 +97,8 @@ function isDeletedContent(content: string): boolean { t === HARD_DELETE_MARKER || t.startsWith(HIDE_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) { - const rid = String(roomId || '').trim(); - const uid = String(userId || '').trim(); - if (!rid || !uid) return; + const rid = String(roomId || activeRoomId.value || '').trim(); + const uid = String(userId || '').trim() || '__someone__'; + if (!rid) return; if (sessionCache?.chatUserId && uid === sessionCache.chatUserId) return; startTypingSweep(); 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 }; } +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) { if (!typingMap.value[roomId]) return; const next = { ...typingMap.value }; @@ -254,6 +263,17 @@ function clearTypingForRoom(roomId: string) { 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; function messageTs(item: ChatMessage): number { @@ -686,6 +706,8 @@ function handleIncomingMessage(msg: ChatMessage) { const myId = sessionCache?.chatUserId ?? ''; const fromSelf = Boolean(myId && msg.senderId === myId); + if (msg.senderId) clearTypingUser(msg.roomId, msg.senderId); + if (msg.roomId === activeRoomId.value) { messages.value = mergeMessages( { @@ -784,6 +806,7 @@ function onSocketMessage(event: MessageEvent) { parsed.sender_id ?? parsed.from_user_id ?? parsed.userId ?? + ((parsed.message as Record | undefined)?.sender_id as string) ?? '', ); applyTypingEvent(roomId, userId); @@ -830,6 +853,35 @@ function onSocketMessage(event: MessageEvent) { void persistHiddenOnServer(roomId, ids, true); return; } + const typingFromCmd = parseTypingCommand(purgeContent); + if (typingFromCmd) { + const nested = + parsed.message && typeof parsed.message === 'object' + ? (parsed.message as Record) + : 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); if (pinTarget !== null) { const roomId = String( @@ -1116,12 +1168,33 @@ export function useOrgChat() { const rid = String(roomId || '').trim(); if (!rid) return; const now = Date.now(); - if (now - lastTypingSentAt < 1800) return; + if (now - lastTypingSentAt < 1200) return; lastTypingSentAt = now; try { await connectSocket(); 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 { // ignore } @@ -1523,7 +1596,7 @@ export function useOrgChat() { loadingMessages, hasMoreMessages: computed(() => hasMoreMessages.value), loadingOlderMessages: computed(() => loadingOlderMessages.value), - typingMap: computed(() => typingMap.value), + typingMap, loadMessages, loadOlderMessages, sendTyping, diff --git a/apps/web/src/views/ConversationsView.vue b/apps/web/src/views/ConversationsView.vue index 21f8881..c573b94 100644 --- a/apps/web/src/views/ConversationsView.vue +++ b/apps/web/src/views/ConversationsView.vue @@ -85,7 +85,7 @@ const { loadMessages, loadOlderMessages, sendTyping, - typingUserIdsForRoom, + typingMap, sendText, sendFile, editMessage, @@ -169,11 +169,16 @@ const typingLabel = computed(() => { void typingTick.value; const roomId = activeRoom.value?.externalRoomId; if (!roomId) return ''; - const ids = typingUserIdsForRoom(roomId); - if (!ids.length) return ''; - const names = ids - .map((id) => peers.value.find((p) => p.chatUserId === id)?.fullName) - .filter((n): n is string => Boolean(n)); + const list = (typingMap.value?.[roomId] || []).filter( + (t) => Date.now() - t.at < 3500, + ); + if (!list.length) return ''; + 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 === 1) return `${names[0]} در حال نوشتن…`; if (names.length === 2) return `${names[0]} و ${names[1]} در حال نوشتن…`; @@ -183,7 +188,10 @@ const typingLabel = computed(() => { const canManageActiveMembers = computed(() => { const room = activeRoom.value; 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(() => { @@ -685,13 +693,73 @@ async function refreshRoomMembers() { membersBusy.value = true; membersError.value = ''; try { - const { data } = await api.get('/conversations/rooms/members', { - params: { externalRoomId: activeRoom.value.externalRoomId }, - }); - roomMembers.value = Array.isArray(data) ? data : []; + const { data } = await api.get( + '/conversations/rooms/members', + { params: { externalRoomId: activeRoom.value.externalRoomId } }, + ); + 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( + '/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) { - membersError.value = - e instanceof Error ? e.message : 'بارگذاری اعضا ناموفق بود.'; + roomMembers.value = []; + 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 { membersBusy.value = false; }