From 3c0ed3369e44dff0edbb671fe055865a8a8435eb Mon Sep 17 00:00:00 2001 From: Mahdi Date: Wed, 30 Sep 2026 12:43:19 +0330 Subject: [PATCH] Editing and error correction (in progress) --- .../conversations/conversations.controller.ts | 27 ++++ .../conversations/conversations.service.ts | 66 ++++++++++ apps/api/src/conversations/dto.ts | 7 + apps/web/src/lib/useOrgChat.ts | 121 +++++++++++++----- apps/web/src/views/ConversationsView.vue | 9 +- 5 files changed, 199 insertions(+), 31 deletions(-) diff --git a/apps/api/src/conversations/conversations.controller.ts b/apps/api/src/conversations/conversations.controller.ts index f853acc..1206268 100644 --- a/apps/api/src/conversations/conversations.controller.ts +++ b/apps/api/src/conversations/conversations.controller.ts @@ -14,6 +14,7 @@ import { AddConversationRoomMemberDto, RemoveConversationRoomMemberDto, UpdateConversationRoomMemberRoleDto, + ConversationTypingDto, } from './dto'; import { ConversationsService } from './conversations.service'; @@ -43,6 +44,32 @@ export class ConversationsController { return this.conversations.listRooms(user.userId, user.organizationId, user.role); } + @Post('rooms/typing') + signalTyping( + @CurrentUser() user: { userId: string; organizationId: string; role: string }, + @Body() dto: ConversationTypingDto, + ) { + return this.conversations.signalTyping( + user.userId, + user.organizationId, + user.role, + dto.externalRoomId, + ); + } + + @Get('rooms/typing') + listTyping( + @CurrentUser() user: { userId: string; organizationId: string; role: string }, + @Query('externalRoomId') externalRoomId: string, + ) { + return this.conversations.listTyping( + user.userId, + user.organizationId, + user.role, + externalRoomId || '', + ); + } + @Post('rooms/dm') createDm( @CurrentUser() user: { userId: string; organizationId: string; role: string }, diff --git a/apps/api/src/conversations/conversations.service.ts b/apps/api/src/conversations/conversations.service.ts index 1a1a532..b0ad100 100644 --- a/apps/api/src/conversations/conversations.service.ts +++ b/apps/api/src/conversations/conversations.service.ts @@ -26,12 +26,78 @@ import { @Injectable() export class ConversationsService { + /** presence: org|room|userId → epoch ms (بدون DB؛ کافی برای typing کوتاه‌عمر) */ + private readonly typingPresence = new Map(); + private typingSweepAt = 0; + constructor( private readonly prisma: PrismaService, private readonly crypto: TahesabCryptoService, private readonly chat: ChatExternalClient, ) {} + private typingKey(organizationId: string, roomId: string, userId: string) { + return `${organizationId}|${roomId}|${userId}`; + } + + private sweepTypingPresence(now = Date.now()) { + if (now - this.typingSweepAt < 10_000) return; + this.typingSweepAt = now; + for (const [k, at] of this.typingPresence) { + if (now - at > 8000) this.typingPresence.delete(k); + } + } + + async signalTyping( + userId: string, + organizationId: string, + role: string, + externalRoomId: string, + ): Promise<{ ok: true }> { + await this.loadAccess(userId, organizationId, role); + const roomId = externalRoomId.trim(); + if (!roomId) throw new BadRequestException('شناسه اتاق خالی است'); + const now = Date.now(); + this.typingPresence.set(this.typingKey(organizationId, roomId, userId), now); + this.sweepTypingPresence(now); + return { ok: true }; + } + + async listTyping( + userId: string, + organizationId: string, + role: string, + externalRoomId: string, + ): Promise> { + await this.loadAccess(userId, organizationId, role); + const roomId = externalRoomId.trim(); + if (!roomId) return []; + const now = Date.now(); + this.sweepTypingPresence(now); + const prefix = `${organizationId}|${roomId}|`; + const ids: string[] = []; + for (const [k, at] of this.typingPresence) { + if (!k.startsWith(prefix)) continue; + if (now - at > 4000) { + this.typingPresence.delete(k); + continue; + } + const uid = k.slice(prefix.length); + if (!uid || uid === userId) continue; + ids.push(uid); + } + if (!ids.length) return []; + const users = await this.prisma.user.findMany({ + where: { id: { in: ids } }, + select: { id: true, fullName: true }, + }); + return users.map((u) => ({ + userId: u.id, + fullName: u.fullName || 'کاربر', + chatUserId: buildChatUserId(organizationId, u.id), + })); + } + async getAccess(userId: string, organizationId: string, role: string): Promise { const access = await this.loadAccess(userId, organizationId, role); return access; diff --git a/apps/api/src/conversations/dto.ts b/apps/api/src/conversations/dto.ts index 4c55f6c..5b38238 100644 --- a/apps/api/src/conversations/dto.ts +++ b/apps/api/src/conversations/dto.ts @@ -162,3 +162,10 @@ export class UpdateConversationRoomMemberRoleDto { @IsString() role!: 'admin' | 'member'; } + +export class ConversationTypingDto { + @IsString() + @MinLength(1) + @MaxLength(200) + externalRoomId!: string; +} diff --git a/apps/web/src/lib/useOrgChat.ts b/apps/web/src/lib/useOrgChat.ts index f116d54..059336c 100644 --- a/apps/web/src/lib/useOrgChat.ts +++ b/apps/web/src/lib/useOrgChat.ts @@ -221,17 +221,20 @@ const hasMoreMessages = ref(false); const loadingOlderMessages = ref(false); /** تعداد خام پیام‌های گرفته‌شده از API (برای offset) */ let fetchedRawCount = 0; -const typingMap = ref>>({}); +const typingMap = ref< + Record> +>({}); let typingSweepTimer: ReturnType | null = null; +let typingPollTimer: ReturnType | null = null; function startTypingSweep() { if (typingSweepTimer) return; typingSweepTimer = setInterval(() => { const now = Date.now(); let changed = false; - const next: Record> = {}; + const next: Record> = {}; for (const [roomId, list] of Object.entries(typingMap.value)) { - const kept = list.filter((t) => now - t.at < 3500); + const kept = list.filter((t) => now - t.at < 4000); if (kept.length) next[roomId] = kept; if (kept.length !== list.length) changed = true; } @@ -239,17 +242,51 @@ function startTypingSweep() { }, 1000); } -function applyTypingEvent(roomId: string, userId: string) { +function applyTypingEvent(roomId: string, userId: string, name?: string) { 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); - list.push({ userId: uid, at: Date.now() }); + list.push({ userId: uid, name: name || undefined, at: Date.now() }); typingMap.value = { ...typingMap.value, [rid]: list }; } +function applyTypingFromServer( + roomId: string, + typers: Array<{ userId: string; fullName: string; chatUserId: string }>, +) { + const rid = String(roomId || '').trim(); + if (!rid) return; + const now = Date.now(); + const list = typers + .map((t) => ({ + userId: t.chatUserId || t.userId, + name: t.fullName || undefined, + at: now, + })) + .filter((t) => t.userId && t.userId !== sessionCache?.chatUserId); + const prev = typingMap.value[rid] || []; + // نگه داشتن رویدادهای خیلی تازه از WS اگر سرور هنوز خالی است + const merged = new Map(); + for (const t of prev) { + if (now - t.at < 4000) merged.set(t.userId, t); + } + for (const t of list) merged.set(t.userId, t); + const nextList = [...merged.values()]; + if (!nextList.length) { + if (prev.length) { + const next = { ...typingMap.value }; + delete next[rid]; + typingMap.value = next; + } + return; + } + startTypingSweep(); + typingMap.value = { ...typingMap.value, [rid]: nextList }; +} + function parseTypingCommand(content: string): string | null { const t = content.trim(); if (!t.startsWith(TYPING_CMD_PREFIX)) return null; @@ -274,6 +311,34 @@ function clearTypingUser(roomId: string, userId: string) { typingMap.value = next; } +function stopTypingPoll() { + if (typingPollTimer) { + clearInterval(typingPollTimer); + typingPollTimer = null; + } +} + +function startTypingPoll(roomId: string) { + stopTypingPoll(); + const rid = String(roomId || '').trim(); + if (!rid) return; + const tick = async () => { + if (activeRoomId.value !== rid) return; + try { + const { data } = await api.get< + Array<{ userId: string; fullName: string; chatUserId: string }> + >('/conversations/rooms/typing', { params: { externalRoomId: rid } }); + applyTypingFromServer(rid, Array.isArray(data) ? data : []); + } catch { + // ignore + } + }; + void tick(); + typingPollTimer = setInterval(() => { + void tick(); + }, 1400); +} + const PAGE_SIZE = 40; function messageTs(item: ChatMessage): number { @@ -1101,6 +1166,7 @@ export function useOrgChat() { hasMoreMessages.value = false; clearTypingForRoom(roomId); startHiddenPoll(roomId); + startTypingPoll(roomId); try { const session = await ensureSession(); await connectSocket(); @@ -1168,33 +1234,28 @@ export function useOrgChat() { const rid = String(roomId || '').trim(); if (!rid) return; const now = Date.now(); - if (now - lastTypingSentAt < 1200) return; + if (now - lastTypingSentAt < 1100) return; lastTypingSentAt = now; + // مسیر اصلی: CRM presence (سرویس خارجی typing را relay نمی‌کند) + try { + await api.post('/conversations/rooms/typing', { externalRoomId: rid }); + } catch { + // ignore + } + // best-effort native WS typing try { await connectSocket(); - if (!socket || socket.readyState !== WebSocket.OPEN) return; - 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); + if (socket && socket.readyState === WebSocket.OPEN) { + const session = await ensureSession(); + socket.send( + JSON.stringify({ + type: 'typing', + room_id: rid, + user_id: session.chatUserId, + sender_id: session.chatUserId, + }), + ); + } } catch { // ignore } @@ -1576,6 +1637,7 @@ export function useOrgChat() { function clearActiveRoom() { stopHiddenPoll(); + stopTypingPoll(); activeRoomId.value = ''; messages.value = []; } @@ -1584,6 +1646,7 @@ export function useOrgChat() { wantSocket = false; sessionCache = null; stopHiddenPoll(); + stopTypingPoll(); disconnectSocket(); messages.value = []; activeRoomId.value = ''; diff --git a/apps/web/src/views/ConversationsView.vue b/apps/web/src/views/ConversationsView.vue index c573b94..25f9c5c 100644 --- a/apps/web/src/views/ConversationsView.vue +++ b/apps/web/src/views/ConversationsView.vue @@ -170,13 +170,18 @@ const typingLabel = computed(() => { const roomId = activeRoom.value?.externalRoomId; if (!roomId) return ''; const list = (typingMap.value?.[roomId] || []).filter( - (t) => Date.now() - t.at < 3500, + (t) => Date.now() - t.at < 4000, ); if (!list.length) return ''; const names = list .map((t) => { + if (t.name) return t.name; if (t.userId === '__someone__') return ''; - return peers.value.find((p) => p.chatUserId === t.userId)?.fullName || ''; + return ( + peers.value.find((p) => p.chatUserId === t.userId)?.fullName || + peers.value.find((p) => p.id === t.userId)?.fullName || + '' + ); }) .filter(Boolean); if (!names.length) return 'در حال نوشتن…';