Adding the typing... indicator, loading older messages, and managing members/admins after creation.
This commit is contained in:
@@ -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;
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
@@ -128,4 +128,81 @@ export class ChatExternalClient {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** تلاش برای افزودن عضو به اتاق روی سرویس خارجی (endpointهای رایج). */
|
||||
async addRoomMembers(
|
||||
host: string,
|
||||
apiKey: string,
|
||||
roomId: string,
|
||||
members: string[],
|
||||
): Promise<boolean> {
|
||||
const base = this.normalizeHost(host);
|
||||
if (!base || !roomId || !members.length) return false;
|
||||
const attempts: Array<{ path: string; body: Record<string, unknown> }> = [
|
||||
{
|
||||
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<boolean> {
|
||||
const base = this.normalizeHost(host);
|
||||
if (!base || !roomId || !members.length) return false;
|
||||
const attempts: Array<{ path: string; body: Record<string, unknown> }> = [
|
||||
{
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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<Set<string>> {
|
||||
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<ConversationRoomMemberDto[]> {
|
||||
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<ConversationRoomMemberDto[]> {
|
||||
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<ConversationRoomMemberDto[]> {
|
||||
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<ConversationRoomMemberDto[]> {
|
||||
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,
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
+154
-25
@@ -214,8 +214,47 @@ const messages = shallowRef<ChatMessage[]>([]);
|
||||
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<Record<string, Array<{ userId: string; at: number }>>>({});
|
||||
let typingSweepTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
const PAGE_SIZE = 200;
|
||||
function startTypingSweep() {
|
||||
if (typingSweepTimer) return;
|
||||
typingSweepTimer = setInterval(() => {
|
||||
const now = Date.now();
|
||||
let changed = false;
|
||||
const next: Record<string, Array<{ userId: string; at: number }>> = {};
|
||||
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<string, unknown>;
|
||||
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<string, unknown> | 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<string, unknown>[],
|
||||
roomId: string,
|
||||
hiddenSet: Set<string>,
|
||||
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<string, unknown>[]) {
|
||||
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<string, unknown>[]) {
|
||||
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<string, unknown>[];
|
||||
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<boolean> {
|
||||
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<string, unknown>[];
|
||||
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,
|
||||
|
||||
@@ -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<ActivityType>('task');
|
||||
const activityBusy = ref(false);
|
||||
const activityError = ref('');
|
||||
const activityCreatedId = ref('');
|
||||
const showMembers = ref(false);
|
||||
const roomMembers = ref<ConversationRoomMemberDto[]>([]);
|
||||
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<ConversationRoomMemberDto[]>('/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<ConversationRoomMemberDto[]>('/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<ConversationRoomMemberDto[]>(
|
||||
'/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<ConversationRoomMemberDto[]>(
|
||||
'/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<ConversationRoomDto[]>('/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<typeof setInterval> | 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(() => {
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="stage-tools">
|
||||
<button
|
||||
v-if="activeRoom.kind !== 'dm'"
|
||||
type="button"
|
||||
class="select-act"
|
||||
title="اعضا"
|
||||
@click="openMembersPanel"
|
||||
>
|
||||
<UiIcon name="users" :size="18" />
|
||||
</button>
|
||||
<button type="button" class="select-act" title="جستجو" @click="openChatSearch">
|
||||
<UiIcon name="search" :size="18" />
|
||||
</button>
|
||||
@@ -1550,7 +1729,15 @@ onUnmounted(() => {
|
||||
</div>
|
||||
|
||||
<div v-if="loadingMessages" class="muted center pad">در حال دریافت پیامها…</div>
|
||||
<ul v-else ref="msgStreamEl" class="msg-stream" :class="{ selecting: selectMode }">
|
||||
<ul
|
||||
v-else
|
||||
ref="msgStreamEl"
|
||||
class="msg-stream"
|
||||
:class="{ selecting: selectMode }"
|
||||
@scroll.passive="onMsgStreamScroll"
|
||||
>
|
||||
<li v-if="loadingOlderMessages" class="history-loading">در حال بارگذاری پیامهای قبلی…</li>
|
||||
<li v-else-if="hasMoreMessages" class="history-hint">برای دیدن پیامهای قدیمیتر بالا بکشید</li>
|
||||
<li
|
||||
v-for="msg in messages"
|
||||
:key="msg.id"
|
||||
@@ -1683,6 +1870,10 @@ onUnmounted(() => {
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div v-if="typingLabel" class="typing-bar">
|
||||
<span class="typing-dots" aria-hidden="true"><i /><i /><i /></span>
|
||||
{{ typingLabel }}
|
||||
</div>
|
||||
<div v-if="!canPostInActive" class="channel-readonly">
|
||||
<UiIcon name="megaphone" :size="18" />
|
||||
<span>فقط مدیران کانال میتوانند پیام بفرستند</span>
|
||||
@@ -2112,6 +2303,64 @@ onUnmounted(() => {
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<dialog v-if="showMembers" class="modal" open @click.self="showMembers = false">
|
||||
<div class="modal-card wide members-sheet">
|
||||
<h3>اعضای {{ activeRoom ? roomTitle(activeRoom) : 'گفتگو' }}</h3>
|
||||
<p v-if="activeRoom?.kind === 'channel'" class="hint channel-hint">
|
||||
ادمینها میتوانند در کانال پیام بفرستند؛ بقیه فقط مشاهده میکنند.
|
||||
</p>
|
||||
<div v-if="canManageActiveMembers" class="member-add-row">
|
||||
<select v-model="memberAddId" :disabled="membersBusy || !membersAddCandidates.length">
|
||||
<option value="">افزودن همکار…</option>
|
||||
<option v-for="p in membersAddCandidates" :key="p.id" :value="p.id">
|
||||
{{ p.fullName }}
|
||||
</option>
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
class="primary"
|
||||
:disabled="membersBusy || !memberAddId"
|
||||
@click="addRoomMember"
|
||||
>
|
||||
افزودن
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="membersError" class="act-err">{{ membersError }}</p>
|
||||
<div v-if="membersBusy && !roomMembers.length" class="muted center pad">در حال بارگذاری…</div>
|
||||
<div v-else class="peer-list members-list">
|
||||
<div v-for="m in roomMembers" :key="m.userId" class="peer-row member-static">
|
||||
<UserAvatar :name="m.fullName" :size="40" />
|
||||
<span class="peer-row-meta">
|
||||
<strong>{{ m.fullName }}{{ m.isMe ? ' (شما)' : '' }}</strong>
|
||||
<small>{{ m.role === 'admin' ? 'ادمین' : 'عضو' }}</small>
|
||||
</span>
|
||||
<div v-if="canManageActiveMembers && !m.isMe" class="member-acts">
|
||||
<button
|
||||
type="button"
|
||||
class="member-act"
|
||||
:disabled="membersBusy"
|
||||
@click="toggleMemberAdmin(m)"
|
||||
>
|
||||
{{ m.role === 'admin' ? 'برداشتن ادمین' : 'ادمین کردن' }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="member-act danger"
|
||||
:disabled="membersBusy"
|
||||
@click="removeRoomMember(m.userId)"
|
||||
>
|
||||
حذف
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="!roomMembers.length" class="peer-empty">عضوی ثبت نشده</p>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button type="button" @click="showMembers = false">بستن</button>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<dialog v-if="showForward" class="modal" open @click.self="showForward = false">
|
||||
<div class="modal-card">
|
||||
<h3>ارسال برای دیگری</h3>
|
||||
@@ -2672,6 +2921,104 @@ onUnmounted(() => {
|
||||
flex-shrink: 0;
|
||||
text-align: center;
|
||||
}
|
||||
.typing-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
padding: 0.35rem 1rem;
|
||||
font-size: 0.8rem;
|
||||
color: #0f766e;
|
||||
background: rgba(13, 148, 136, 0.06);
|
||||
border-top: 1px solid rgba(13, 148, 136, 0.12);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.typing-dots {
|
||||
display: inline-flex;
|
||||
gap: 3px;
|
||||
align-items: center;
|
||||
}
|
||||
.typing-dots i {
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-radius: 999px;
|
||||
background: #0d9488;
|
||||
animation: typingPulse 1.2s infinite ease-in-out;
|
||||
}
|
||||
.typing-dots i:nth-child(2) {
|
||||
animation-delay: 0.15s;
|
||||
}
|
||||
.typing-dots i:nth-child(3) {
|
||||
animation-delay: 0.3s;
|
||||
}
|
||||
@keyframes typingPulse {
|
||||
0%,
|
||||
80%,
|
||||
100% {
|
||||
opacity: 0.3;
|
||||
transform: translateY(0);
|
||||
}
|
||||
40% {
|
||||
opacity: 1;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
}
|
||||
.history-loading,
|
||||
.history-hint {
|
||||
text-align: center;
|
||||
color: var(--muted);
|
||||
font-size: 0.78rem;
|
||||
padding: 0.35rem 0.5rem 0.75rem;
|
||||
list-style: none;
|
||||
}
|
||||
.member-add-row {
|
||||
display: flex;
|
||||
gap: 0.45rem;
|
||||
align-items: center;
|
||||
}
|
||||
.member-add-row select {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 0.55rem 0.65rem;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--line);
|
||||
font: inherit;
|
||||
}
|
||||
.member-add-row .primary {
|
||||
border: none;
|
||||
background: #0d9488;
|
||||
color: #fff;
|
||||
border-radius: 10px;
|
||||
padding: 0.55rem 0.85rem;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.member-static {
|
||||
cursor: default;
|
||||
}
|
||||
.member-acts {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
align-items: stretch;
|
||||
}
|
||||
.member-act {
|
||||
border: 1px solid var(--line);
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
padding: 0.25rem 0.45rem;
|
||||
font: inherit;
|
||||
font-size: 0.72rem;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
color: #0f766e;
|
||||
}
|
||||
.member-act.danger {
|
||||
color: #b91c1c;
|
||||
}
|
||||
.members-list {
|
||||
max-height: 280px;
|
||||
}
|
||||
.msg-stream {
|
||||
flex: 1;
|
||||
list-style: none;
|
||||
|
||||
@@ -3978,6 +3978,16 @@ export interface ConversationRoomDto {
|
||||
pinned?: boolean;
|
||||
/** در کانال: آیا کاربر جاری میتواند پیام بفرستد */
|
||||
canPost?: boolean;
|
||||
/** آیا کاربر جاری میتواند اعضا را مدیریت کند */
|
||||
canManageMembers?: boolean;
|
||||
}
|
||||
|
||||
export interface ConversationRoomMemberDto {
|
||||
userId: string;
|
||||
fullName: string;
|
||||
role: "admin" | "member";
|
||||
hasAvatar: boolean;
|
||||
isMe?: boolean;
|
||||
}
|
||||
|
||||
export interface ConversationPinnedMessageDto {
|
||||
|
||||
Reference in New Issue
Block a user