From e1d456ad117290f62200f0c5341891146bd3e7ad Mon Sep 17 00:00:00 2001 From: Mahdi Date: Wed, 30 Sep 2026 11:53:04 +0330 Subject: [PATCH] Adding the typing... indicator, loading older messages, and managing members/admins after creation. --- .../migration.sql | 21 ++ apps/api/prisma/schema.prisma | 17 + .../src/conversations/chat-external.client.ts | 77 ++++ .../conversations/conversations.controller.ts | 60 +++ .../conversations/conversations.service.ts | 260 ++++++++++++- apps/api/src/conversations/dto.ts | 47 +++ apps/web/src/lib/useOrgChat.ts | 179 +++++++-- apps/web/src/views/ConversationsView.vue | 349 +++++++++++++++++- packages/shared/src/index.ts | 10 + 9 files changed, 992 insertions(+), 28 deletions(-) create mode 100644 apps/api/prisma/migrations/20260930140000_conversation_room_members/migration.sql diff --git a/apps/api/prisma/migrations/20260930140000_conversation_room_members/migration.sql b/apps/api/prisma/migrations/20260930140000_conversation_room_members/migration.sql new file mode 100644 index 0000000..80c4f79 --- /dev/null +++ b/apps/api/prisma/migrations/20260930140000_conversation_room_members/migration.sql @@ -0,0 +1,21 @@ +-- CreateTable +CREATE TABLE "ConversationRoomMember" ( + "id" TEXT NOT NULL, + "organizationId" TEXT NOT NULL, + "externalRoomId" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "role" TEXT NOT NULL DEFAULT 'member', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "ConversationRoomMember_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "ConversationRoomMember_organizationId_externalRoomId_userId_key" ON "ConversationRoomMember"("organizationId", "externalRoomId", "userId"); + +-- CreateIndex +CREATE INDEX "ConversationRoomMember_organizationId_externalRoomId_idx" ON "ConversationRoomMember"("organizationId", "externalRoomId"); + +-- AddForeignKey +ALTER TABLE "ConversationRoomMember" ADD CONSTRAINT "ConversationRoomMember_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index a301540..87c54f5 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -212,6 +212,7 @@ model Organization { conversationHiddenMessages ConversationHiddenMessage[] conversationPinnedMessages ConversationPinnedMessage[] conversationRoomUserPins ConversationRoomUserPin[] + conversationRoomMembers ConversationRoomMember[] } model User { @@ -2144,3 +2145,19 @@ model ConversationRoomUserPin { @@unique([organizationId, userId, externalRoomId]) @@index([organizationId, userId]) } + +/// اعضای گروه/کانال در CRM (نقش ادمین برای ارسال در کانال و مدیریت اعضا) +model ConversationRoomMember { + id String @id @default(cuid()) + organizationId String + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + externalRoomId String + userId String + /// admin | member + role String @default("member") + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([organizationId, externalRoomId, userId]) + @@index([organizationId, externalRoomId]) +} diff --git a/apps/api/src/conversations/chat-external.client.ts b/apps/api/src/conversations/chat-external.client.ts index 0a3254e..47f9ff8 100644 --- a/apps/api/src/conversations/chat-external.client.ts +++ b/apps/api/src/conversations/chat-external.client.ts @@ -128,4 +128,81 @@ export class ChatExternalClient { return false; } } + + /** تلاش برای افزودن عضو به اتاق روی سرویس خارجی (endpointهای رایج). */ + async addRoomMembers( + host: string, + apiKey: string, + roomId: string, + members: string[], + ): Promise { + const base = this.normalizeHost(host); + if (!base || !roomId || !members.length) return false; + const attempts: Array<{ path: string; body: Record }> = [ + { + path: '/api/rooms/add_members', + body: { api_key: apiKey, room_id: roomId, members }, + }, + { + path: '/api/rooms/members/add', + body: { api_key: apiKey, room_id: roomId, members }, + }, + { + path: '/api/rooms/members', + body: { api_key: apiKey, room_id: roomId, members, action: 'add' }, + }, + ]; + for (const attempt of attempts) { + try { + const { ok, status } = await this.fetchJson(`https://${base}${attempt.path}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, + body: JSON.stringify(attempt.body), + }); + if (ok || status === 204) return true; + if (status === 404 || status === 405) continue; + } catch { + // try next + } + } + return false; + } + + async removeRoomMembers( + host: string, + apiKey: string, + roomId: string, + members: string[], + ): Promise { + const base = this.normalizeHost(host); + if (!base || !roomId || !members.length) return false; + const attempts: Array<{ path: string; body: Record }> = [ + { + path: '/api/rooms/remove_members', + body: { api_key: apiKey, room_id: roomId, members }, + }, + { + path: '/api/rooms/members/remove', + body: { api_key: apiKey, room_id: roomId, members }, + }, + { + path: '/api/rooms/members', + body: { api_key: apiKey, room_id: roomId, members, action: 'remove' }, + }, + ]; + for (const attempt of attempts) { + try { + const { ok, status } = await this.fetchJson(`https://${base}${attempt.path}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, + body: JSON.stringify(attempt.body), + }); + if (ok || status === 204) return true; + if (status === 404 || status === 405) continue; + } catch { + // try next + } + } + return false; + } } diff --git a/apps/api/src/conversations/conversations.controller.ts b/apps/api/src/conversations/conversations.controller.ts index 2a1d54a..f853acc 100644 --- a/apps/api/src/conversations/conversations.controller.ts +++ b/apps/api/src/conversations/conversations.controller.ts @@ -11,6 +11,9 @@ import { PinConversationMessageDto, PinConversationRoomDto, UnpinConversationMessageDto, + AddConversationRoomMemberDto, + RemoveConversationRoomMemberDto, + UpdateConversationRoomMemberRoleDto, } from './dto'; import { ConversationsService } from './conversations.service'; @@ -81,6 +84,63 @@ export class ConversationsController { ); } + @Get('rooms/members') + listMembers( + @CurrentUser() user: { userId: string; organizationId: string; role: string }, + @Query('externalRoomId') externalRoomId: string, + ) { + return this.conversations.listRoomMembers( + user.userId, + user.organizationId, + user.role, + externalRoomId || '', + ); + } + + @Post('rooms/members') + addMember( + @CurrentUser() user: { userId: string; organizationId: string; role: string }, + @Body() dto: AddConversationRoomMemberDto, + ) { + return this.conversations.addRoomMember( + user.userId, + user.organizationId, + user.role, + dto.externalRoomId, + dto.userId, + dto.role === 'admin' ? 'admin' : 'member', + ); + } + + @Post('rooms/members/remove') + removeMember( + @CurrentUser() user: { userId: string; organizationId: string; role: string }, + @Body() dto: RemoveConversationRoomMemberDto, + ) { + return this.conversations.removeRoomMember( + user.userId, + user.organizationId, + user.role, + dto.externalRoomId, + dto.userId, + ); + } + + @Post('rooms/members/role') + updateMemberRole( + @CurrentUser() user: { userId: string; organizationId: string; role: string }, + @Body() dto: UpdateConversationRoomMemberRoleDto, + ) { + return this.conversations.updateRoomMemberRole( + user.userId, + user.organizationId, + user.role, + dto.externalRoomId, + dto.userId, + dto.role === 'admin' ? 'admin' : 'member', + ); + } + @Post('rooms/pin') pinRoom( @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 6faa4e2..5c822ea 100644 --- a/apps/api/src/conversations/conversations.service.ts +++ b/apps/api/src/conversations/conversations.service.ts @@ -9,6 +9,7 @@ import type { ConversationPeerDto, ConversationPinnedMessageDto, ConversationRoomDto, + ConversationRoomMemberDto, ConversationSessionDto, ConversationSettingsDto, PlatformConversationStatsDto, @@ -195,18 +196,32 @@ export class ConversationsService { select: { role: true }, }); const effectiveRole = String(membership?.role ?? role); + const orgAdmin = effectiveRole === 'owner' || effectiveRole === 'admin'; + const roomAdminIds = await this.userAdminRoomIds(organizationId, userId); try { const external = await this.chat.listUserRooms(creds.host, creds.apiKey, chatUserId); return external.map((row) => { const dto = this.toRoomDto(row, trackedByExternal, organizationId, userId, effectiveRole); - return { ...dto, pinned: pinnedIds.has(dto.externalRoomId) }; + const isRoomAdmin = + roomAdminIds.has(dto.externalRoomId) || + trackedByExternal.get(dto.externalRoomId)?.createdByUserId === userId; + return { + ...dto, + pinned: pinnedIds.has(dto.externalRoomId), + canPost: + dto.kind !== 'channel' ? true : Boolean(isRoomAdmin || orgAdmin), + canManageMembers: + dto.kind === 'dm' ? false : Boolean(isRoomAdmin || orgAdmin), + }; }); } catch (e) { // اگر سرویس چت در دسترس نبود، حداقل roomهای ثبت‌شده در CRM را نشان بده if (tracked.length) { return tracked.map((t) => { const kind = this.normalizeKind(t.kind); + const isRoomAdmin = + roomAdminIds.has(t.externalRoomId) || t.createdByUserId === userId; return { id: t.id, externalRoomId: t.externalRoomId, @@ -226,7 +241,8 @@ export class ConversationsService { : null : null, pinned: pinnedIds.has(t.externalRoomId), - canPost: this.canPostInRoom(kind, t.createdByUserId, userId, effectiveRole), + canPost: kind !== 'channel' ? true : Boolean(isRoomAdmin || orgAdmin), + canManageMembers: kind === 'dm' ? false : Boolean(isRoomAdmin || orgAdmin), }; }); } @@ -355,6 +371,8 @@ export class ConversationsService { }, }); + await this.seedRoomMembers(organizationId, row.externalRoomId, userId, uniqueIds); + return { id: row.id, externalRoomId: row.externalRoomId, @@ -365,6 +383,7 @@ export class ConversationsService { lastMessageAt: null, peerUserId: null, canPost: true, + canManageMembers: true, }; } @@ -417,6 +436,8 @@ export class ConversationsService { }, }); + await this.seedRoomMembers(organizationId, row.externalRoomId, userId, uniqueIds); + return { id: row.id, externalRoomId: row.externalRoomId, @@ -427,6 +448,7 @@ export class ConversationsService { lastMessageAt: null, peerUserId: null, canPost: true, + canManageMembers: true, }; } @@ -602,6 +624,240 @@ export class ConversationsService { return new Set(rows.map((r) => r.externalRoomId)); } + private async userAdminRoomIds(organizationId: string, userId: string): Promise> { + const rows = await this.prisma.conversationRoomMember.findMany({ + where: { organizationId, userId, role: 'admin' }, + select: { externalRoomId: true }, + take: 1000, + }); + return new Set(rows.map((r) => r.externalRoomId)); + } + + private async seedRoomMembers( + organizationId: string, + externalRoomId: string, + adminUserId: string, + memberUserIds: string[], + ) { + const rows = [ + { + organizationId, + externalRoomId, + userId: adminUserId, + role: 'admin', + }, + ...memberUserIds.map((id) => ({ + organizationId, + externalRoomId, + userId: id, + role: 'member', + })), + ]; + await this.prisma.conversationRoomMember.createMany({ + data: rows, + skipDuplicates: true, + }); + } + + private async assertCanManageRoom( + userId: string, + organizationId: string, + role: string, + externalRoomId: string, + ) { + await this.loadAccess(userId, organizationId, role); + const room = await this.prisma.conversationRoom.findFirst({ + where: { organizationId, externalRoomId }, + }); + if (!room) throw new NotFoundException('گفتگو پیدا نشد'); + if (room.kind === 'dm') { + throw new BadRequestException('مدیریت اعضا برای گفتگوی خصوصی نیست'); + } + const orgAdmin = role === 'owner' || role === 'admin'; + if (orgAdmin || room.createdByUserId === userId) return room; + const mine = await this.prisma.conversationRoomMember.findFirst({ + where: { organizationId, externalRoomId, userId, role: 'admin' }, + }); + if (!mine) throw new ForbiddenException('اجازه مدیریت اعضای این گفتگو را ندارید'); + return room; + } + + async listRoomMembers( + userId: string, + organizationId: string, + role: string, + externalRoomId: string, + ): Promise { + await this.loadAccess(userId, organizationId, role); + const roomId = externalRoomId.trim(); + if (!roomId) throw new BadRequestException('شناسه اتاق خالی است'); + + const room = await this.prisma.conversationRoom.findFirst({ + where: { organizationId, externalRoomId: roomId }, + }); + if (!room) throw new NotFoundException('گفتگو پیدا نشد'); + + let rows = await this.prisma.conversationRoomMember.findMany({ + where: { organizationId, externalRoomId: roomId }, + }); + + // اتاق‌های قدیمی بدون ردیف عضو — حداقل سازنده را ادمین نشان بده + if (!rows.length && room.createdByUserId) { + await this.seedRoomMembers(organizationId, roomId, room.createdByUserId, []); + rows = await this.prisma.conversationRoomMember.findMany({ + where: { organizationId, externalRoomId: roomId }, + }); + } + + const userIds = rows.map((r) => r.userId); + const users = await this.prisma.user.findMany({ + where: { id: { in: userIds } }, + select: { id: true, fullName: true, avatarStorageKey: true }, + }); + const byId = new Map(users.map((u) => [u.id, u])); + + return rows + .map((r) => { + const u = byId.get(r.userId); + return { + userId: r.userId, + fullName: u?.fullName || 'کاربر', + role: (r.role === 'admin' ? 'admin' : 'member') as 'admin' | 'member', + hasAvatar: Boolean(u?.avatarStorageKey), + isMe: r.userId === userId, + }; + }) + .sort((a, b) => { + if (a.role !== b.role) return a.role === 'admin' ? -1 : 1; + return a.fullName.localeCompare(b.fullName, 'fa'); + }); + } + + async addRoomMember( + userId: string, + organizationId: string, + role: string, + externalRoomId: string, + memberUserId: string, + memberRole: 'admin' | 'member' = 'member', + ): Promise { + const room = await this.assertCanManageRoom(userId, organizationId, role, externalRoomId.trim()); + const targetId = memberUserId.trim(); + if (!targetId || targetId === userId) { + // اجازه افزودن خود لازم نیست + } + if (!targetId) throw new BadRequestException('شناسه عضو خالی است'); + + const membership = await this.prisma.membership.findFirst({ + where: { + organizationId, + userId: targetId, + isActive: true, + user: { isActive: true }, + }, + }); + if (!membership) throw new NotFoundException('همکار در این سازمان پیدا نشد'); + + await this.prisma.conversationRoomMember.upsert({ + where: { + organizationId_externalRoomId_userId: { + organizationId, + externalRoomId: room.externalRoomId, + userId: targetId, + }, + }, + create: { + organizationId, + externalRoomId: room.externalRoomId, + userId: targetId, + role: memberRole === 'admin' ? 'admin' : 'member', + }, + update: { + role: memberRole === 'admin' ? 'admin' : 'member', + }, + }); + + try { + const creds = await this.getCredentials(organizationId); + await this.chat.addRoomMembers(creds.host, creds.apiKey, room.externalRoomId, [ + buildChatUserId(organizationId, targetId), + ]); + } catch { + // CRM به‌روز شده؛ همگام‌سازی خارجی best-effort است + } + + return this.listRoomMembers(userId, organizationId, role, room.externalRoomId); + } + + async removeRoomMember( + userId: string, + organizationId: string, + role: string, + externalRoomId: string, + memberUserId: string, + ): Promise { + const room = await this.assertCanManageRoom(userId, organizationId, role, externalRoomId.trim()); + const targetId = memberUserId.trim(); + if (!targetId) throw new BadRequestException('شناسه عضو خالی است'); + if (targetId === room.createdByUserId) { + throw new BadRequestException('نمی‌توان سازنده گفتگو را حذف کرد'); + } + if (targetId === userId) { + throw new BadRequestException('نمی‌توانید خودتان را حذف کنید'); + } + + await this.prisma.conversationRoomMember.deleteMany({ + where: { + organizationId, + externalRoomId: room.externalRoomId, + userId: targetId, + }, + }); + + try { + const creds = await this.getCredentials(organizationId); + await this.chat.removeRoomMembers(creds.host, creds.apiKey, room.externalRoomId, [ + buildChatUserId(organizationId, targetId), + ]); + } catch { + // best-effort + } + + return this.listRoomMembers(userId, organizationId, role, room.externalRoomId); + } + + async updateRoomMemberRole( + userId: string, + organizationId: string, + role: string, + externalRoomId: string, + memberUserId: string, + memberRole: 'admin' | 'member', + ): Promise { + const room = await this.assertCanManageRoom(userId, organizationId, role, externalRoomId.trim()); + const targetId = memberUserId.trim(); + if (!targetId) throw new BadRequestException('شناسه عضو خالی است'); + if (targetId === room.createdByUserId && memberRole !== 'admin') { + throw new BadRequestException('نقش سازنده گفتگو را نمی‌توان از ادمین برداشت'); + } + + const existing = await this.prisma.conversationRoomMember.findFirst({ + where: { + organizationId, + externalRoomId: room.externalRoomId, + userId: targetId, + }, + }); + if (!existing) throw new NotFoundException('عضو پیدا نشد'); + + await this.prisma.conversationRoomMember.update({ + where: { id: existing.id }, + data: { role: memberRole === 'admin' ? 'admin' : 'member' }, + }); + + return this.listRoomMembers(userId, organizationId, role, room.externalRoomId); + } + async setRoomPinned( userId: string, organizationId: string, diff --git a/apps/api/src/conversations/dto.ts b/apps/api/src/conversations/dto.ts index b9562c8..4c55f6c 100644 --- a/apps/api/src/conversations/dto.ts +++ b/apps/api/src/conversations/dto.ts @@ -115,3 +115,50 @@ export class PinConversationRoomDto { @IsBoolean() pinned!: boolean; } + +export class ConversationRoomMembersQueryDto { + @IsString() + @MinLength(1) + @MaxLength(200) + externalRoomId!: string; +} + +export class AddConversationRoomMemberDto { + @IsString() + @MinLength(1) + @MaxLength(200) + externalRoomId!: string; + + @IsString() + @MinLength(1) + userId!: string; + + @IsOptional() + @IsString() + role?: 'admin' | 'member'; +} + +export class RemoveConversationRoomMemberDto { + @IsString() + @MinLength(1) + @MaxLength(200) + externalRoomId!: string; + + @IsString() + @MinLength(1) + userId!: string; +} + +export class UpdateConversationRoomMemberRoleDto { + @IsString() + @MinLength(1) + @MaxLength(200) + externalRoomId!: string; + + @IsString() + @MinLength(1) + userId!: string; + + @IsString() + role!: 'admin' | 'member'; +} diff --git a/apps/web/src/lib/useOrgChat.ts b/apps/web/src/lib/useOrgChat.ts index 9f1cc2c..720492f 100644 --- a/apps/web/src/lib/useOrgChat.ts +++ b/apps/web/src/lib/useOrgChat.ts @@ -214,8 +214,47 @@ const messages = shallowRef([]); const activeRoomId = ref(''); const unreadTotal = ref(0); const pageOpen = ref(false); +const hasMoreMessages = ref(false); +const loadingOlderMessages = ref(false); +/** تعداد خام پیام‌های گرفته‌شده از API (برای offset) */ +let fetchedRawCount = 0; +const typingMap = ref>>({}); +let typingSweepTimer: ReturnType | null = null; -const PAGE_SIZE = 200; +function startTypingSweep() { + if (typingSweepTimer) return; + typingSweepTimer = setInterval(() => { + const now = Date.now(); + let changed = false; + const next: Record> = {}; + for (const [roomId, list] of Object.entries(typingMap.value)) { + const kept = list.filter((t) => now - t.at < 3500); + if (kept.length) next[roomId] = kept; + if (kept.length !== list.length) changed = true; + } + if (changed) typingMap.value = next; + }, 1000); +} + +function applyTypingEvent(roomId: string, userId: string) { + const rid = String(roomId || '').trim(); + const uid = String(userId || '').trim(); + if (!rid || !uid) 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() }); + typingMap.value = { ...typingMap.value, [rid]: list }; +} + +function clearTypingForRoom(roomId: string) { + if (!typingMap.value[roomId]) return; + const next = { ...typingMap.value }; + delete next[roomId]; + typingMap.value = next; +} + +const PAGE_SIZE = 40; function messageTs(item: ChatMessage): number { const t = new Date(item.createdAt).getTime(); @@ -732,7 +771,24 @@ function onSocketMessage(event: MessageEvent) { try { const parsed = JSON.parse(raw) as Record; const type = String(parsed.type ?? parsed.event ?? '').toLowerCase(); - if (type === 'pong' || type === 'ping' || type === 'typing' || type === 'read') return; + if (type === 'pong' || type === 'ping' || type === 'read') return; + if (type === 'typing') { + const roomId = String( + parsed.room_id ?? + parsed.roomId ?? + ((parsed.message as Record | undefined)?.room_id as string) ?? + activeRoomId.value, + ); + const userId = String( + parsed.user_id ?? + parsed.sender_id ?? + parsed.from_user_id ?? + parsed.userId ?? + '', + ); + applyTypingEvent(roomId, userId); + return; + } if (type === 'seen') { applySeenPayload(parsed); return; @@ -959,9 +1015,39 @@ export function useOrgChat() { pageOpen.value = open; } + function normalizeMessageList( + list: Record[], + roomId: string, + hiddenSet: Set, + myId: string, + ): ChatMessage[] { + for (const item of list) { + const rawContent = String(item.content ?? item.text ?? item.body ?? '').trim(); + const target = parseHideCommand(rawContent); + if (target) hiddenSet.add(target); + } + rememberHidden(roomId, [...hiddenSet]); + + const normalized: ChatMessage[] = []; + for (const item of list) { + const m = normalizeIncoming(item, roomId); + if (!m || hiddenSet.has(m.id)) continue; + normalized.push( + m.senderId === myId + ? { ...m, delivery: (m.readAt ? 'read' : 'sent') as ChatDelivery } + : m, + ); + } + return sortAsc(normalized); + } + async function loadMessages(roomId: string) { activeRoomId.value = roomId; loadingMessages.value = true; + loadingOlderMessages.value = false; + fetchedRawCount = 0; + hasMoreMessages.value = false; + clearTypingForRoom(roomId); startHiddenPoll(roomId); try { const session = await ensureSession(); @@ -981,35 +1067,72 @@ export function useOrgChat() { if (!res.ok) throw new Error('messages failed'); const body = await res.json(); const list = Array.isArray(body) ? body : body?.data ?? []; - - // Collect hide-command targets from history so we filter even without CRM row yet - for (const item of list as Record[]) { - const rawContent = String( - item.content ?? item.text ?? item.body ?? '', - ).trim(); - const target = parseHideCommand(rawContent); - if (target) hiddenSet.add(target); - } - rememberHidden(roomId, [...hiddenSet]); - - const myId = session.chatUserId; - const normalized: ChatMessage[] = []; - for (const item of list as Record[]) { - const m = normalizeIncoming(item, roomId); - if (!m || hiddenSet.has(m.id)) continue; - normalized.push( - m.senderId === myId - ? { ...m, delivery: (m.readAt ? 'read' : 'sent') as ChatDelivery } - : m, - ); - } - messages.value = sortAsc(normalized); + const rows = list as Record[]; + fetchedRawCount = rows.length; + hasMoreMessages.value = rows.length >= PAGE_SIZE; + messages.value = normalizeMessageList(rows, roomId, hiddenSet, session.chatUserId); sendRead(roomId); } finally { loadingMessages.value = false; } } + async function loadOlderMessages(roomId: string): Promise { + if (!roomId || loadingOlderMessages.value || loadingMessages.value) return false; + if (!hasMoreMessages.value) return false; + loadingOlderMessages.value = true; + try { + const session = await ensureSession(); + const hiddenIds = await fetchHiddenFromServer(roomId); + const hiddenSet = new Set(hiddenIds); + const params = new URLSearchParams({ + api_key: new URL(session.wsUrl).searchParams.get('api_key') ?? '', + room_id: roomId, + user_id: session.chatUserId, + limit: String(PAGE_SIZE), + offset: String(fetchedRawCount), + }); + const url = `${session.messagesBaseUrl}/api/messages?${params.toString()}`; + const res = await fetch(url, { headers: { Accept: 'application/json' }, cache: 'no-store' }); + if (!res.ok) throw new Error('older messages failed'); + const body = await res.json(); + const list = Array.isArray(body) ? body : body?.data ?? []; + const rows = list as Record[]; + fetchedRawCount += rows.length; + hasMoreMessages.value = rows.length >= PAGE_SIZE; + if (!rows.length) return false; + const older = normalizeMessageList(rows, roomId, hiddenSet, session.chatUserId); + const existingIds = new Set(messages.value.map((m) => m.id)); + const fresh = older.filter((m) => !existingIds.has(m.id)); + messages.value = sortAsc([...fresh, ...messages.value]); + return true; + } finally { + loadingOlderMessages.value = false; + } + } + + let lastTypingSentAt = 0; + async function sendTyping(roomId: string) { + const rid = String(roomId || '').trim(); + if (!rid) return; + const now = Date.now(); + if (now - lastTypingSentAt < 1800) return; + lastTypingSentAt = now; + try { + await connectSocket(); + if (!socket || socket.readyState !== WebSocket.OPEN) return; + socket.send(JSON.stringify({ type: 'typing', room_id: rid })); + } catch { + // ignore + } + } + + function typingUserIdsForRoom(roomId: string): string[] { + const list = typingMap.value[roomId] || []; + const now = Date.now(); + return list.filter((t) => now - t.at < 3500).map((t) => t.userId); + } + async function sendText(roomId: string, text: string, reply: string | null = null) { const trimmed = text.trim(); if (!trimmed) return; @@ -1398,7 +1521,13 @@ export function useOrgChat() { messages: computed(() => messages.value), unreadTotal: computed(() => unreadTotal.value), loadingMessages, + hasMoreMessages: computed(() => hasMoreMessages.value), + loadingOlderMessages: computed(() => loadingOlderMessages.value), + typingMap: computed(() => typingMap.value), loadMessages, + loadOlderMessages, + sendTyping, + typingUserIdsForRoom, sendText, sendFile, editMessage, diff --git a/apps/web/src/views/ConversationsView.vue b/apps/web/src/views/ConversationsView.vue index 2c7c055..21f8881 100644 --- a/apps/web/src/views/ConversationsView.vue +++ b/apps/web/src/views/ConversationsView.vue @@ -10,6 +10,7 @@ import type { ConversationPeerDto, ConversationPinnedMessageDto, ConversationRoomDto, + ConversationRoomMemberDto, CreateActivityRequest, } from '@mento/shared'; import { formatClockFa, toFaDigits } from '@/lib/jalali'; @@ -78,8 +79,13 @@ const CHAT_EMOJIS = [ const { messages, loadingMessages, + hasMoreMessages, + loadingOlderMessages, status, loadMessages, + loadOlderMessages, + sendTyping, + typingUserIdsForRoom, sendText, sendFile, editMessage, @@ -121,6 +127,12 @@ const activityType = ref('task'); const activityBusy = ref(false); const activityError = ref(''); const activityCreatedId = ref(''); +const showMembers = ref(false); +const roomMembers = ref([]); +const membersBusy = ref(false); +const membersError = ref(''); +const memberAddId = ref(''); +const typingTick = ref(0); const ACTIVITY_TYPE_OPTS: Array<{ value: ActivityType; label: string }> = [ { value: 'task', label: 'وظیفه' }, @@ -153,6 +165,32 @@ const selectedChannelPeers = computed(() => peers.value.filter((p) => channelMemberIds.value.includes(p.id)), ); +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)); + if (!names.length) return 'در حال نوشتن…'; + if (names.length === 1) return `${names[0]} در حال نوشتن…`; + if (names.length === 2) return `${names[0]} و ${names[1]} در حال نوشتن…`; + return `${toFaDigits(names.length)} نفر در حال نوشتن…`; +}); + +const canManageActiveMembers = computed(() => { + const room = activeRoom.value; + if (!room || room.kind === 'dm') return false; + return room.canManageMembers === true; +}); + +const membersAddCandidates = computed(() => { + const taken = new Set(roomMembers.value.map((m) => m.userId)); + return peers.value.filter((p) => !taken.has(p.id)); +}); + async function scrollToBottom() { await nextTick(); const el = msgStreamEl.value; @@ -616,6 +654,128 @@ function insertMention(peer: ConversationPeerDto) { function onDraftInput() { updateMentionState(); + if (activeRoom.value && draft.value.trim()) { + void sendTyping(activeRoom.value.externalRoomId); + } +} + +async function onMsgStreamScroll() { + const el = msgStreamEl.value; + if (!el || !activeRoom.value) return; + if (el.scrollTop > 80) return; + if (!hasMoreMessages.value || loadingOlderMessages.value) return; + const prevHeight = el.scrollHeight; + const prevTop = el.scrollTop; + const loaded = await loadOlderMessages(activeRoom.value.externalRoomId); + if (!loaded) return; + await nextTick(); + el.scrollTop = el.scrollHeight - prevHeight + prevTop; +} + +async function openMembersPanel() { + if (!activeRoom.value || activeRoom.value.kind === 'dm') return; + showMembers.value = true; + membersError.value = ''; + memberAddId.value = ''; + await refreshRoomMembers(); +} + +async function refreshRoomMembers() { + if (!activeRoom.value) return; + 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 : []; + } catch (e) { + membersError.value = + e instanceof Error ? e.message : 'بارگذاری اعضا ناموفق بود.'; + } finally { + membersBusy.value = false; + } +} + +async function addRoomMember() { + if (!activeRoom.value || !memberAddId.value) return; + membersBusy.value = true; + membersError.value = ''; + try { + const { data } = await api.post('/conversations/rooms/members', { + externalRoomId: activeRoom.value.externalRoomId, + userId: memberAddId.value, + role: 'member', + }); + roomMembers.value = Array.isArray(data) ? data : []; + memberAddId.value = ''; + // refresh canPost/canManage for current user list + void refreshRoomsSoft(); + } catch (e) { + if (axios.isAxiosError(e)) { + const msg = (e.response?.data as { message?: string | string[] })?.message; + membersError.value = Array.isArray(msg) ? msg.join('، ') : msg || 'افزودن عضو ناموفق بود.'; + } else { + membersError.value = 'افزودن عضو ناموفق بود.'; + } + } finally { + membersBusy.value = false; + } +} + +async function removeRoomMember(userId: string) { + if (!activeRoom.value || !canManageActiveMembers.value) return; + if (!window.confirm('این عضو از گفتگو حذف شود؟')) return; + membersBusy.value = true; + membersError.value = ''; + try { + const { data } = await api.post( + '/conversations/rooms/members/remove', + { + externalRoomId: activeRoom.value.externalRoomId, + userId, + }, + ); + roomMembers.value = Array.isArray(data) ? data : []; + void refreshRoomsSoft(); + } catch (e) { + if (axios.isAxiosError(e)) { + const msg = (e.response?.data as { message?: string | string[] })?.message; + membersError.value = Array.isArray(msg) ? msg.join('، ') : msg || 'حذف عضو ناموفق بود.'; + } else { + membersError.value = 'حذف عضو ناموفق بود.'; + } + } finally { + membersBusy.value = false; + } +} + +async function toggleMemberAdmin(member: ConversationRoomMemberDto) { + if (!activeRoom.value || !canManageActiveMembers.value) return; + const nextRole = member.role === 'admin' ? 'member' : 'admin'; + membersBusy.value = true; + membersError.value = ''; + try { + const { data } = await api.post( + '/conversations/rooms/members/role', + { + externalRoomId: activeRoom.value.externalRoomId, + userId: member.userId, + role: nextRole, + }, + ); + roomMembers.value = Array.isArray(data) ? data : []; + void refreshRoomsSoft(); + } catch (e) { + if (axios.isAxiosError(e)) { + const msg = (e.response?.data as { message?: string | string[] })?.message; + membersError.value = Array.isArray(msg) ? msg.join('، ') : msg || 'تغییر نقش ناموفق بود.'; + } else { + membersError.value = 'تغییر نقش ناموفق بود.'; + } + } finally { + membersBusy.value = false; + } } async function confirmDeleteMessage(msg: ChatMessage) { @@ -916,6 +1076,10 @@ async function refreshRoomsSoft() { try { const { data } = await api.get('/conversations/rooms'); rooms.value = data; + if (activeRoom.value) { + const next = data.find((r) => r.externalRoomId === activeRoom.value!.externalRoomId); + if (next) activeRoom.value = { ...next, unreadCount: 0 }; + } await refreshUnreadTotal(); } catch { // keep previous @@ -1274,17 +1438,23 @@ watch(chatSearchHits, (hits) => { nextTick(() => scrollToMessage(activeSearchHitId.value)); }); +let typingUiTimer: ReturnType | null = null; + onMounted(() => { setConversationsPageOpen(true); stopRoomLive = onRoomLiveEvent(applyRoomLive); stopPinLive = onMessagePinEvent(applyMessagePin); void bootstrap(); + typingUiTimer = setInterval(() => { + typingTick.value += 1; + }, 1000); }); onUnmounted(() => { setConversationsPageOpen(false); stopRoomLive?.(); stopPinLive?.(); + if (typingUiTimer) clearInterval(typingUiTimer); clearActiveRoom(); clearPendingAttaches(); if (recording.value) mediaRecorder?.stop(); @@ -1511,6 +1681,15 @@ onUnmounted(() => { }}
+ @@ -1550,7 +1729,15 @@ onUnmounted(() => {
در حال دریافت پیام‌ها…
-
    +
      +
    • در حال بارگذاری پیام‌های قبلی…
    • +
    • برای دیدن پیام‌های قدیمی‌تر بالا بکشید
    • {
    +
    + + {{ typingLabel }} +
    فقط مدیران کانال می‌توانند پیام بفرستند @@ -2112,6 +2303,64 @@ onUnmounted(() => {
    + + + +