Add mentions, pin messages and conversations, and search within chats
This commit is contained in:
@@ -0,0 +1,42 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "ConversationPinnedMessage" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"organizationId" TEXT NOT NULL,
|
||||||
|
"externalRoomId" TEXT NOT NULL,
|
||||||
|
"messageId" TEXT NOT NULL,
|
||||||
|
"preview" TEXT NOT NULL DEFAULT '',
|
||||||
|
"pinnedByUserId" TEXT NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "ConversationPinnedMessage_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "ConversationRoomUserPin" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"organizationId" TEXT NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"externalRoomId" TEXT NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "ConversationRoomUserPin_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "ConversationPinnedMessage_organizationId_externalRoomId_key" ON "ConversationPinnedMessage"("organizationId", "externalRoomId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "ConversationPinnedMessage_organizationId_externalRoomId_idx" ON "ConversationPinnedMessage"("organizationId", "externalRoomId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "ConversationRoomUserPin_organizationId_userId_externalRoomId_key" ON "ConversationRoomUserPin"("organizationId", "userId", "externalRoomId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "ConversationRoomUserPin_organizationId_userId_idx" ON "ConversationRoomUserPin"("organizationId", "userId");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "ConversationPinnedMessage" ADD CONSTRAINT "ConversationPinnedMessage_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "ConversationRoomUserPin" ADD CONSTRAINT "ConversationRoomUserPin_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@@ -210,6 +210,8 @@ model Organization {
|
|||||||
conversationSettings ConversationSettings?
|
conversationSettings ConversationSettings?
|
||||||
conversationRooms ConversationRoom[]
|
conversationRooms ConversationRoom[]
|
||||||
conversationHiddenMessages ConversationHiddenMessage[]
|
conversationHiddenMessages ConversationHiddenMessage[]
|
||||||
|
conversationPinnedMessages ConversationPinnedMessage[]
|
||||||
|
conversationRoomUserPins ConversationRoomUserPin[]
|
||||||
}
|
}
|
||||||
|
|
||||||
model User {
|
model User {
|
||||||
@@ -2113,3 +2115,32 @@ model ConversationHiddenMessage {
|
|||||||
@@unique([organizationId, externalRoomId, messageId, scope, hiddenByUserId])
|
@@unique([organizationId, externalRoomId, messageId, scope, hiddenByUserId])
|
||||||
@@index([organizationId, externalRoomId])
|
@@index([organizationId, externalRoomId])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// یک پیام سنجاقشده در هر اتاق (برای همه اعضا)
|
||||||
|
model ConversationPinnedMessage {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
organizationId String
|
||||||
|
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
||||||
|
externalRoomId String
|
||||||
|
messageId String
|
||||||
|
preview String @default("")
|
||||||
|
pinnedByUserId String
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@unique([organizationId, externalRoomId])
|
||||||
|
@@index([organizationId, externalRoomId])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// سنجاق گفتگو در لیست — فقط برای همان کاربر
|
||||||
|
model ConversationRoomUserPin {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
organizationId String
|
||||||
|
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
||||||
|
userId String
|
||||||
|
externalRoomId String
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@unique([organizationId, userId, externalRoomId])
|
||||||
|
@@index([organizationId, userId])
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Body, Controller, Get, Post, Query, UseGuards } from '@nestjs/common';
|
import { Body, Controller, Delete, Get, Post, Query, UseGuards } from '@nestjs/common';
|
||||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||||
import { CurrentUser } from '../auth/current-user.decorator';
|
import { CurrentUser } from '../auth/current-user.decorator';
|
||||||
import { RequireOrgModule } from '../billing/org-module.decorator';
|
import { RequireOrgModule } from '../billing/org-module.decorator';
|
||||||
@@ -7,6 +7,9 @@ import {
|
|||||||
CreateConversationDmDto,
|
CreateConversationDmDto,
|
||||||
CreateConversationGroupDto,
|
CreateConversationGroupDto,
|
||||||
HideConversationMessagesDto,
|
HideConversationMessagesDto,
|
||||||
|
PinConversationMessageDto,
|
||||||
|
PinConversationRoomDto,
|
||||||
|
UnpinConversationMessageDto,
|
||||||
} from './dto';
|
} from './dto';
|
||||||
import { ConversationsService } from './conversations.service';
|
import { ConversationsService } from './conversations.service';
|
||||||
|
|
||||||
@@ -63,6 +66,20 @@ export class ConversationsController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post('rooms/pin')
|
||||||
|
pinRoom(
|
||||||
|
@CurrentUser() user: { userId: string; organizationId: string; role: string },
|
||||||
|
@Body() dto: PinConversationRoomDto,
|
||||||
|
) {
|
||||||
|
return this.conversations.setRoomPinned(
|
||||||
|
user.userId,
|
||||||
|
user.organizationId,
|
||||||
|
user.role,
|
||||||
|
dto.externalRoomId,
|
||||||
|
dto.pinned,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@Post('messages/hide')
|
@Post('messages/hide')
|
||||||
hideMessages(
|
hideMessages(
|
||||||
@CurrentUser() user: { userId: string; organizationId: string; role: string },
|
@CurrentUser() user: { userId: string; organizationId: string; role: string },
|
||||||
@@ -90,4 +107,58 @@ export class ConversationsController {
|
|||||||
externalRoomId || '',
|
externalRoomId || '',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('messages/pin')
|
||||||
|
getPinnedMessage(
|
||||||
|
@CurrentUser() user: { userId: string; organizationId: string; role: string },
|
||||||
|
@Query('externalRoomId') externalRoomId: string,
|
||||||
|
) {
|
||||||
|
return this.conversations.getPinnedMessage(
|
||||||
|
user.userId,
|
||||||
|
user.organizationId,
|
||||||
|
user.role,
|
||||||
|
externalRoomId || '',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('messages/pin')
|
||||||
|
pinMessage(
|
||||||
|
@CurrentUser() user: { userId: string; organizationId: string; role: string },
|
||||||
|
@Body() dto: PinConversationMessageDto,
|
||||||
|
) {
|
||||||
|
return this.conversations.pinMessage(
|
||||||
|
user.userId,
|
||||||
|
user.organizationId,
|
||||||
|
user.role,
|
||||||
|
dto.externalRoomId,
|
||||||
|
dto.messageId,
|
||||||
|
dto.preview,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('messages/unpin')
|
||||||
|
unpinMessage(
|
||||||
|
@CurrentUser() user: { userId: string; organizationId: string; role: string },
|
||||||
|
@Body() dto: UnpinConversationMessageDto,
|
||||||
|
) {
|
||||||
|
return this.conversations.unpinMessage(
|
||||||
|
user.userId,
|
||||||
|
user.organizationId,
|
||||||
|
user.role,
|
||||||
|
dto.externalRoomId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete('messages/pin')
|
||||||
|
unpinMessageDelete(
|
||||||
|
@CurrentUser() user: { userId: string; organizationId: string; role: string },
|
||||||
|
@Query('externalRoomId') externalRoomId: string,
|
||||||
|
) {
|
||||||
|
return this.conversations.unpinMessage(
|
||||||
|
user.userId,
|
||||||
|
user.organizationId,
|
||||||
|
user.role,
|
||||||
|
externalRoomId || '',
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
import type {
|
import type {
|
||||||
ConversationAccessDto,
|
ConversationAccessDto,
|
||||||
ConversationPeerDto,
|
ConversationPeerDto,
|
||||||
|
ConversationPinnedMessageDto,
|
||||||
ConversationRoomDto,
|
ConversationRoomDto,
|
||||||
ConversationSessionDto,
|
ConversationSessionDto,
|
||||||
ConversationSettingsDto,
|
ConversationSettingsDto,
|
||||||
@@ -188,19 +189,21 @@ export class ConversationsService {
|
|||||||
where: { organizationId },
|
where: { organizationId },
|
||||||
});
|
});
|
||||||
const trackedByExternal = new Map(tracked.map((t) => [t.externalRoomId, t]));
|
const trackedByExternal = new Map(tracked.map((t) => [t.externalRoomId, t]));
|
||||||
|
const pinnedIds = await this.userPinnedRoomIds(organizationId, userId);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const external = await this.chat.listUserRooms(creds.host, creds.apiKey, chatUserId);
|
const external = await this.chat.listUserRooms(creds.host, creds.apiKey, chatUserId);
|
||||||
return external.map((row) =>
|
return external.map((row) => {
|
||||||
this.toRoomDto(row, trackedByExternal, organizationId, userId),
|
const dto = this.toRoomDto(row, trackedByExternal, organizationId, userId);
|
||||||
);
|
return { ...dto, pinned: pinnedIds.has(dto.externalRoomId) };
|
||||||
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// اگر سرویس چت در دسترس نبود، حداقل roomهای ثبتشده در CRM را نشان بده
|
// اگر سرویس چت در دسترس نبود، حداقل roomهای ثبتشده در CRM را نشان بده
|
||||||
if (tracked.length) {
|
if (tracked.length) {
|
||||||
return tracked.map((t) => ({
|
return tracked.map((t) => ({
|
||||||
id: t.id,
|
id: t.id,
|
||||||
externalRoomId: t.externalRoomId,
|
externalRoomId: t.externalRoomId,
|
||||||
kind: t.kind === 'group' ? 'group' : 'dm',
|
kind: (t.kind === 'group' ? 'group' : 'dm') as 'dm' | 'group',
|
||||||
name: t.name || (t.kind === 'group' ? 'گروه' : 'گفتگو'),
|
name: t.name || (t.kind === 'group' ? 'گروه' : 'گفتگو'),
|
||||||
unreadCount: 0,
|
unreadCount: 0,
|
||||||
lastMessagePreview: null,
|
lastMessagePreview: null,
|
||||||
@@ -213,6 +216,7 @@ export class ConversationsService {
|
|||||||
? t.dmUserMinId
|
? t.dmUserMinId
|
||||||
: null
|
: null
|
||||||
: null,
|
: null,
|
||||||
|
pinned: pinnedIds.has(t.externalRoomId),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
throw e;
|
throw e;
|
||||||
@@ -443,7 +447,7 @@ export class ConversationsService {
|
|||||||
let preview = lastMsg
|
let preview = lastMsg
|
||||||
? String(lastMsg.content ?? lastMsg.text ?? lastMsg.body ?? '').trim()
|
? String(lastMsg.content ?? lastMsg.text ?? lastMsg.body ?? '').trim()
|
||||||
: '';
|
: '';
|
||||||
if (/^__MENTO_HIDE__:/i.test(preview) || /MENTO_DEL/i.test(preview)) {
|
if (/^__MENTO_HIDE__:/i.test(preview) || /MENTO_DEL/i.test(preview) || /^__MENTO_PIN__:/i.test(preview) || /^__MENTO_UNPIN__$/i.test(preview)) {
|
||||||
preview = '';
|
preview = '';
|
||||||
}
|
}
|
||||||
if (lastType === 'image' || lastType === 'photo' || lastType === 'img') {
|
if (lastType === 'image' || lastType === 'photo' || lastType === 'img') {
|
||||||
@@ -491,9 +495,134 @@ export class ConversationsService {
|
|||||||
lastMessagePreview: preview || null,
|
lastMessagePreview: preview || null,
|
||||||
lastMessageAt: lastAtRaw ? String(lastAtRaw) : null,
|
lastMessageAt: lastAtRaw ? String(lastAtRaw) : null,
|
||||||
peerUserId,
|
peerUserId,
|
||||||
|
pinned: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async userPinnedRoomIds(organizationId: string, userId: string): Promise<Set<string>> {
|
||||||
|
const rows = await this.prisma.conversationRoomUserPin.findMany({
|
||||||
|
where: { organizationId, userId },
|
||||||
|
select: { externalRoomId: true },
|
||||||
|
take: 500,
|
||||||
|
});
|
||||||
|
return new Set(rows.map((r) => r.externalRoomId));
|
||||||
|
}
|
||||||
|
|
||||||
|
async setRoomPinned(
|
||||||
|
userId: string,
|
||||||
|
organizationId: string,
|
||||||
|
role: string,
|
||||||
|
externalRoomId: string,
|
||||||
|
pinned: boolean,
|
||||||
|
): Promise<{ externalRoomId: string; pinned: boolean }> {
|
||||||
|
await this.loadAccess(userId, organizationId, role);
|
||||||
|
const roomId = externalRoomId.trim();
|
||||||
|
if (!roomId) throw new BadRequestException('شناسه اتاق خالی است');
|
||||||
|
|
||||||
|
if (pinned) {
|
||||||
|
await this.prisma.conversationRoomUserPin.upsert({
|
||||||
|
where: {
|
||||||
|
organizationId_userId_externalRoomId: {
|
||||||
|
organizationId,
|
||||||
|
userId,
|
||||||
|
externalRoomId: roomId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
create: { organizationId, userId, externalRoomId: roomId },
|
||||||
|
update: {},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await this.prisma.conversationRoomUserPin.deleteMany({
|
||||||
|
where: { organizationId, userId, externalRoomId: roomId },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return { externalRoomId: roomId, pinned };
|
||||||
|
}
|
||||||
|
|
||||||
|
async getPinnedMessage(
|
||||||
|
userId: string,
|
||||||
|
organizationId: string,
|
||||||
|
role: string,
|
||||||
|
externalRoomId: string,
|
||||||
|
): Promise<ConversationPinnedMessageDto | null> {
|
||||||
|
await this.loadAccess(userId, organizationId, role);
|
||||||
|
const roomId = externalRoomId.trim();
|
||||||
|
if (!roomId) throw new BadRequestException('شناسه اتاق خالی است');
|
||||||
|
|
||||||
|
const row = await this.prisma.conversationPinnedMessage.findUnique({
|
||||||
|
where: {
|
||||||
|
organizationId_externalRoomId: { organizationId, externalRoomId: roomId },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!row) return null;
|
||||||
|
return {
|
||||||
|
messageId: row.messageId,
|
||||||
|
preview: row.preview,
|
||||||
|
pinnedByUserId: row.pinnedByUserId,
|
||||||
|
createdAt: row.createdAt.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async pinMessage(
|
||||||
|
userId: string,
|
||||||
|
organizationId: string,
|
||||||
|
role: string,
|
||||||
|
externalRoomId: string,
|
||||||
|
messageId: string,
|
||||||
|
preview?: string,
|
||||||
|
): Promise<ConversationPinnedMessageDto> {
|
||||||
|
await this.loadAccess(userId, organizationId, role);
|
||||||
|
const roomId = externalRoomId.trim();
|
||||||
|
const msgId = messageId.trim();
|
||||||
|
if (!roomId) throw new BadRequestException('شناسه اتاق خالی است');
|
||||||
|
if (!msgId) throw new BadRequestException('شناسه پیام خالی است');
|
||||||
|
|
||||||
|
const cleanPreview = String(preview || '')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim()
|
||||||
|
.slice(0, 240);
|
||||||
|
|
||||||
|
const row = await this.prisma.conversationPinnedMessage.upsert({
|
||||||
|
where: {
|
||||||
|
organizationId_externalRoomId: { organizationId, externalRoomId: roomId },
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
organizationId,
|
||||||
|
externalRoomId: roomId,
|
||||||
|
messageId: msgId,
|
||||||
|
preview: cleanPreview,
|
||||||
|
pinnedByUserId: userId,
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
messageId: msgId,
|
||||||
|
preview: cleanPreview,
|
||||||
|
pinnedByUserId: userId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
messageId: row.messageId,
|
||||||
|
preview: row.preview,
|
||||||
|
pinnedByUserId: row.pinnedByUserId,
|
||||||
|
createdAt: row.createdAt.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async unpinMessage(
|
||||||
|
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('شناسه اتاق خالی است');
|
||||||
|
await this.prisma.conversationPinnedMessage.deleteMany({
|
||||||
|
where: { organizationId, externalRoomId: roomId },
|
||||||
|
});
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
async hideMessages(
|
async hideMessages(
|
||||||
userId: string,
|
userId: string,
|
||||||
organizationId: string,
|
organizationId: string,
|
||||||
|
|||||||
@@ -68,3 +68,37 @@ export class HideConversationMessagesDto {
|
|||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
forEveryone?: boolean;
|
forEveryone?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class PinConversationMessageDto {
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
@MaxLength(200)
|
||||||
|
externalRoomId!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
@MaxLength(200)
|
||||||
|
messageId!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(240)
|
||||||
|
preview?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UnpinConversationMessageDto {
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
@MaxLength(200)
|
||||||
|
externalRoomId!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PinConversationRoomDto {
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
@MaxLength(200)
|
||||||
|
externalRoomId!: string;
|
||||||
|
|
||||||
|
@IsBoolean()
|
||||||
|
pinned!: boolean;
|
||||||
|
}
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ defineProps<{
|
|||||||
| 'plus'
|
| 'plus'
|
||||||
| 'chevron'
|
| 'chevron'
|
||||||
| 'star'
|
| 'star'
|
||||||
|
| 'pin'
|
||||||
| 'filter'
|
| 'filter'
|
||||||
| 'funnel'
|
| 'funnel'
|
||||||
| 'lightbulb'
|
| 'lightbulb'
|
||||||
@@ -247,6 +248,10 @@ defineProps<{
|
|||||||
<template v-else-if="name === 'star'">
|
<template v-else-if="name === 'star'">
|
||||||
<path d="m12 3.4 2.4 4.9 5.4.8-3.9 3.8.9 5.4L12 15.8 7.2 18.3l.9-5.4-3.9-3.8 5.4-.8L12 3.4Z" />
|
<path d="m12 3.4 2.4 4.9 5.4.8-3.9 3.8.9 5.4L12 15.8 7.2 18.3l.9-5.4-3.9-3.8 5.4-.8L12 3.4Z" />
|
||||||
</template>
|
</template>
|
||||||
|
<template v-else-if="name === 'pin'">
|
||||||
|
<path d="M15.5 4.5 19.5 8.5 14 14v5l-2 1-2-1v-5L4.5 8.5 8.5 4.5l2.2 2.2a3 3 0 0 0 2.6 0L15.5 4.5Z" />
|
||||||
|
<path d="M12 20v-5" />
|
||||||
|
</template>
|
||||||
<template v-else-if="name === 'filter'">
|
<template v-else-if="name === 'filter'">
|
||||||
<path d="M4 6h16M7 12h10M10 18h4" />
|
<path d="M4 6h16M7 12h10M10 18h4" />
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
+128
-19
@@ -33,7 +33,49 @@ export type RoomLiveEvent = {
|
|||||||
fromSelf: boolean;
|
fromSelf: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const PAGE_SIZE = 50;
|
export type MessagePinEvent = {
|
||||||
|
roomId: string;
|
||||||
|
/** null = unpinned */
|
||||||
|
messageId: string | null;
|
||||||
|
preview: string;
|
||||||
|
fromSelf: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type RoomListener = (event: RoomLiveEvent) => void;
|
||||||
|
const roomListeners = new Set<RoomListener>();
|
||||||
|
|
||||||
|
export function onRoomLiveEvent(listener: RoomListener) {
|
||||||
|
roomListeners.add(listener);
|
||||||
|
return () => roomListeners.delete(listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
function emitRoomLive(event: RoomLiveEvent) {
|
||||||
|
for (const listener of roomListeners) {
|
||||||
|
try {
|
||||||
|
listener(event);
|
||||||
|
} catch {
|
||||||
|
// ignore listener errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type PinListener = (event: MessagePinEvent) => void;
|
||||||
|
const pinListeners = new Set<PinListener>();
|
||||||
|
|
||||||
|
export function onMessagePinEvent(listener: PinListener) {
|
||||||
|
pinListeners.add(listener);
|
||||||
|
return () => pinListeners.delete(listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
function emitMessagePin(event: MessagePinEvent) {
|
||||||
|
for (const listener of pinListeners) {
|
||||||
|
try {
|
||||||
|
listener(event);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
const IMAGE_MAX_EDGE = 1600;
|
const IMAGE_MAX_EDGE = 1600;
|
||||||
const IMAGE_WEBP_QUALITY = 0.82;
|
const IMAGE_WEBP_QUALITY = 0.82;
|
||||||
const MAX_UPLOAD_BYTES = 40 * 1024 * 1024;
|
const MAX_UPLOAD_BYTES = 40 * 1024 * 1024;
|
||||||
@@ -41,6 +83,9 @@ const MAX_UPLOAD_BYTES = 40 * 1024 * 1024;
|
|||||||
const HARD_DELETE_MARKER = '__MENTO_DEL__';
|
const HARD_DELETE_MARKER = '__MENTO_DEL__';
|
||||||
/** Realtime hide command delivered as a normal text message (external WS delivers text reliably). */
|
/** Realtime hide command delivered as a normal text message (external WS delivers text reliably). */
|
||||||
const HIDE_CMD_PREFIX = '__MENTO_HIDE__:';
|
const HIDE_CMD_PREFIX = '__MENTO_HIDE__:';
|
||||||
|
/** Realtime pin / unpin control messages. */
|
||||||
|
const PIN_CMD_PREFIX = '__MENTO_PIN__:';
|
||||||
|
const UNPIN_CMD = '__MENTO_UNPIN__';
|
||||||
|
|
||||||
function isDeletedContent(content: string): boolean {
|
function isDeletedContent(content: string): boolean {
|
||||||
const t = content.trim();
|
const t = content.trim();
|
||||||
@@ -48,7 +93,9 @@ function isDeletedContent(content: string): boolean {
|
|||||||
/^(?:\[?\s*deleted\s*\]?|این پیام حذف شد|پیام حذف شد)$/i.test(t) ||
|
/^(?:\[?\s*deleted\s*\]?|این پیام حذف شد|پیام حذف شد)$/i.test(t) ||
|
||||||
t.includes('MENTO_DEL') ||
|
t.includes('MENTO_DEL') ||
|
||||||
t === HARD_DELETE_MARKER ||
|
t === HARD_DELETE_MARKER ||
|
||||||
t.startsWith(HIDE_CMD_PREFIX)
|
t.startsWith(HIDE_CMD_PREFIX) ||
|
||||||
|
t.startsWith(PIN_CMD_PREFIX) ||
|
||||||
|
t === UNPIN_CMD
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,6 +106,15 @@ function parseHideCommand(content: string): string | null {
|
|||||||
return id || null;
|
return id || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** null = not a pin cmd; '' = unpin; otherwise messageId */
|
||||||
|
function parsePinCommand(content: string): string | null {
|
||||||
|
const t = content.trim();
|
||||||
|
if (t === UNPIN_CMD) return '';
|
||||||
|
if (!t.startsWith(PIN_CMD_PREFIX)) return null;
|
||||||
|
const id = t.slice(PIN_CMD_PREFIX.length).trim();
|
||||||
|
return id || null;
|
||||||
|
}
|
||||||
|
|
||||||
function purgeMessageById(id: string) {
|
function purgeMessageById(id: string) {
|
||||||
const key = String(id || '').trim();
|
const key = String(id || '').trim();
|
||||||
if (!key) return;
|
if (!key) return;
|
||||||
@@ -159,23 +215,7 @@ const activeRoomId = ref('');
|
|||||||
const unreadTotal = ref(0);
|
const unreadTotal = ref(0);
|
||||||
const pageOpen = ref(false);
|
const pageOpen = ref(false);
|
||||||
|
|
||||||
type RoomListener = (event: RoomLiveEvent) => void;
|
const PAGE_SIZE = 200;
|
||||||
const roomListeners = new Set<RoomListener>();
|
|
||||||
|
|
||||||
export function onRoomLiveEvent(listener: RoomListener) {
|
|
||||||
roomListeners.add(listener);
|
|
||||||
return () => roomListeners.delete(listener);
|
|
||||||
}
|
|
||||||
|
|
||||||
function emitRoomLive(event: RoomLiveEvent) {
|
|
||||||
for (const listener of roomListeners) {
|
|
||||||
try {
|
|
||||||
listener(event);
|
|
||||||
} catch {
|
|
||||||
// ignore listener errors
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function messageTs(item: ChatMessage): number {
|
function messageTs(item: ChatMessage): number {
|
||||||
const t = new Date(item.createdAt).getTime();
|
const t = new Date(item.createdAt).getTime();
|
||||||
@@ -734,6 +774,27 @@ function onSocketMessage(event: MessageEvent) {
|
|||||||
void persistHiddenOnServer(roomId, ids, true);
|
void persistHiddenOnServer(roomId, ids, true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const pinTarget = parsePinCommand(purgeContent);
|
||||||
|
if (pinTarget !== null) {
|
||||||
|
const roomId = String(
|
||||||
|
(parsed.room_id as string) ||
|
||||||
|
((parsed.message as Record<string, unknown> | undefined)?.room_id as string) ||
|
||||||
|
activeRoomId.value,
|
||||||
|
);
|
||||||
|
if (purgeId) rememberHidden(roomId, [purgeId]);
|
||||||
|
if (pinTarget === '') {
|
||||||
|
emitMessagePin({ roomId, messageId: null, preview: '', fromSelf: false });
|
||||||
|
} else {
|
||||||
|
const found = messages.value.find((m) => m.id === pinTarget);
|
||||||
|
emitMessagePin({
|
||||||
|
roomId,
|
||||||
|
messageId: pinTarget,
|
||||||
|
preview: found ? previewLabelForMessage(found) : '',
|
||||||
|
fromSelf: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (purgeId && isDeletedContent(purgeContent)) {
|
if (purgeId && isDeletedContent(purgeContent)) {
|
||||||
purgeMessageById(purgeId);
|
purgeMessageById(purgeId);
|
||||||
return;
|
return;
|
||||||
@@ -1271,6 +1332,52 @@ export function useOrgChat() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function pinChatMessage(roomId: string, msg: ChatMessage) {
|
||||||
|
const messageId = String(msg.id || '').trim();
|
||||||
|
if (!roomId || !messageId || msg.optimistic) return;
|
||||||
|
const preview = previewLabelForMessage(msg).slice(0, 240);
|
||||||
|
await api.post('/conversations/messages/pin', {
|
||||||
|
externalRoomId: roomId,
|
||||||
|
messageId,
|
||||||
|
preview,
|
||||||
|
});
|
||||||
|
emitMessagePin({ roomId, messageId, preview, fromSelf: true });
|
||||||
|
await connectSocket();
|
||||||
|
if (socket && socket.readyState === WebSocket.OPEN) {
|
||||||
|
const controlId = crypto.randomUUID();
|
||||||
|
rememberHidden(roomId, [controlId]);
|
||||||
|
socket.send(
|
||||||
|
JSON.stringify({
|
||||||
|
id: controlId,
|
||||||
|
type: 'text',
|
||||||
|
room_id: roomId,
|
||||||
|
content: `${PIN_CMD_PREFIX}${messageId}`,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
void persistHiddenOnServer(roomId, [controlId], true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function unpinChatMessage(roomId: string) {
|
||||||
|
if (!roomId) return;
|
||||||
|
await api.post('/conversations/messages/unpin', { externalRoomId: roomId });
|
||||||
|
emitMessagePin({ roomId, messageId: null, preview: '', fromSelf: true });
|
||||||
|
await connectSocket();
|
||||||
|
if (socket && socket.readyState === WebSocket.OPEN) {
|
||||||
|
const controlId = crypto.randomUUID();
|
||||||
|
rememberHidden(roomId, [controlId]);
|
||||||
|
socket.send(
|
||||||
|
JSON.stringify({
|
||||||
|
id: controlId,
|
||||||
|
type: 'text',
|
||||||
|
room_id: roomId,
|
||||||
|
content: UNPIN_CMD,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
void persistHiddenOnServer(roomId, [controlId], true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function clearActiveRoom() {
|
function clearActiveRoom() {
|
||||||
stopHiddenPoll();
|
stopHiddenPoll();
|
||||||
activeRoomId.value = '';
|
activeRoomId.value = '';
|
||||||
@@ -1298,6 +1405,8 @@ export function useOrgChat() {
|
|||||||
deleteMessage,
|
deleteMessage,
|
||||||
deleteMessages,
|
deleteMessages,
|
||||||
forwardMessages,
|
forwardMessages,
|
||||||
|
pinChatMessage,
|
||||||
|
unpinChatMessage,
|
||||||
sendRead,
|
sendRead,
|
||||||
ensureConnected,
|
ensureConnected,
|
||||||
setConversationsPageOpen,
|
setConversationsPageOpen,
|
||||||
|
|||||||
@@ -5,16 +5,19 @@ import { api } from '@/lib/api';
|
|||||||
import type {
|
import type {
|
||||||
ConversationAccessDto,
|
ConversationAccessDto,
|
||||||
ConversationPeerDto,
|
ConversationPeerDto,
|
||||||
|
ConversationPinnedMessageDto,
|
||||||
ConversationRoomDto,
|
ConversationRoomDto,
|
||||||
} from '@mento/shared';
|
} from '@mento/shared';
|
||||||
import { formatClockFa, toFaDigits } from '@/lib/jalali';
|
import { formatClockFa, toFaDigits } from '@/lib/jalali';
|
||||||
import {
|
import {
|
||||||
classifyChatFile,
|
classifyChatFile,
|
||||||
|
onMessagePinEvent,
|
||||||
onRoomLiveEvent,
|
onRoomLiveEvent,
|
||||||
previewLabelForMessage,
|
previewLabelForMessage,
|
||||||
useOrgChat,
|
useOrgChat,
|
||||||
type ChatDelivery,
|
type ChatDelivery,
|
||||||
type ChatMessage,
|
type ChatMessage,
|
||||||
|
type MessagePinEvent,
|
||||||
type RoomLiveEvent,
|
type RoomLiveEvent,
|
||||||
} from '@/lib/useOrgChat';
|
} from '@/lib/useOrgChat';
|
||||||
import UiIcon from '@/components/UiIcon.vue';
|
import UiIcon from '@/components/UiIcon.vue';
|
||||||
@@ -72,6 +75,8 @@ const {
|
|||||||
deleteMessage,
|
deleteMessage,
|
||||||
deleteMessages,
|
deleteMessages,
|
||||||
forwardMessages,
|
forwardMessages,
|
||||||
|
pinChatMessage,
|
||||||
|
unpinChatMessage,
|
||||||
sendRead,
|
sendRead,
|
||||||
ensureConnected,
|
ensureConnected,
|
||||||
setConversationsPageOpen,
|
setConversationsPageOpen,
|
||||||
@@ -80,6 +85,7 @@ const {
|
|||||||
} = useOrgChat();
|
} = useOrgChat();
|
||||||
const msgStreamEl = ref<HTMLElement | null>(null);
|
const msgStreamEl = ref<HTMLElement | null>(null);
|
||||||
let stopRoomLive: (() => boolean) | undefined;
|
let stopRoomLive: (() => boolean) | undefined;
|
||||||
|
let stopPinLive: (() => boolean) | undefined;
|
||||||
let mediaRecorder: MediaRecorder | null = null;
|
let mediaRecorder: MediaRecorder | null = null;
|
||||||
let recordChunks: Blob[] = [];
|
let recordChunks: Blob[] = [];
|
||||||
let recordTimer: ReturnType<typeof setInterval> | null = null;
|
let recordTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
@@ -89,6 +95,14 @@ const selectMode = ref(false);
|
|||||||
const selectedIds = ref<string[]>([]);
|
const selectedIds = ref<string[]>([]);
|
||||||
const showForward = ref(false);
|
const showForward = ref(false);
|
||||||
const forwardBusy = ref(false);
|
const forwardBusy = ref(false);
|
||||||
|
const roomListQuery = ref('');
|
||||||
|
const roomListTab = ref<'all' | 'dm' | 'group'>('all');
|
||||||
|
const pinnedMessage = ref<ConversationPinnedMessageDto | null>(null);
|
||||||
|
const chatSearchOpen = ref(false);
|
||||||
|
const chatSearchQuery = ref('');
|
||||||
|
const chatSearchIndex = ref(0);
|
||||||
|
const mentionState = ref<{ start: number; end: number; query: string } | null>(null);
|
||||||
|
const mentionIndex = ref(0);
|
||||||
|
|
||||||
const canCreateGroup = computed(() => access.value?.canCreateGroup ?? false);
|
const canCreateGroup = computed(() => access.value?.canCreateGroup ?? false);
|
||||||
|
|
||||||
@@ -101,18 +115,112 @@ async function scrollToBottom() {
|
|||||||
|
|
||||||
const sortedRooms = computed(() =>
|
const sortedRooms = computed(() =>
|
||||||
[...rooms.value].sort((a, b) => {
|
[...rooms.value].sort((a, b) => {
|
||||||
|
const pinA = a.pinned ? 1 : 0;
|
||||||
|
const pinB = b.pinned ? 1 : 0;
|
||||||
|
if (pinA !== pinB) return pinB - pinA;
|
||||||
const ta = a.lastMessageAt ? new Date(a.lastMessageAt).getTime() : 0;
|
const ta = a.lastMessageAt ? new Date(a.lastMessageAt).getTime() : 0;
|
||||||
const tb = b.lastMessageAt ? new Date(b.lastMessageAt).getTime() : 0;
|
const tb = b.lastMessageAt ? new Date(b.lastMessageAt).getTime() : 0;
|
||||||
return tb - ta;
|
return tb - ta;
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const filteredRooms = computed(() => {
|
||||||
|
const q = roomListQuery.value.trim().toLowerCase();
|
||||||
|
return sortedRooms.value.filter((room) => {
|
||||||
|
if (roomListTab.value === 'dm' && room.kind !== 'dm') return false;
|
||||||
|
if (roomListTab.value === 'group' && room.kind !== 'group') return false;
|
||||||
|
if (!q) return true;
|
||||||
|
const title = roomTitle(room).toLowerCase();
|
||||||
|
const preview = (room.lastMessagePreview || '').toLowerCase();
|
||||||
|
return title.includes(q) || preview.includes(q);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const mentionCandidates = computed(() => {
|
||||||
|
const state = mentionState.value;
|
||||||
|
if (!state) return [] as ConversationPeerDto[];
|
||||||
|
const q = state.query.trim().toLowerCase();
|
||||||
|
return peers.value
|
||||||
|
.filter((p) => !q || p.fullName.toLowerCase().includes(q))
|
||||||
|
.slice(0, 8);
|
||||||
|
});
|
||||||
|
|
||||||
|
const chatSearchHits = computed(() => {
|
||||||
|
const q = chatSearchQuery.value.trim().toLowerCase();
|
||||||
|
if (!q || !chatSearchOpen.value) return [] as ChatMessage[];
|
||||||
|
return messages.value.filter((m) => {
|
||||||
|
if (m.deleted) return false;
|
||||||
|
if (m.type === 'text') return (m.content || '').toLowerCase().includes(q);
|
||||||
|
return previewLabelForMessage(m).toLowerCase().includes(q);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const chatSearchHitCount = computed(() => chatSearchHits.value.length);
|
||||||
|
const activeSearchHitId = computed(() => {
|
||||||
|
const hits = chatSearchHits.value;
|
||||||
|
if (!hits.length) return '';
|
||||||
|
const idx = ((chatSearchIndex.value % hits.length) + hits.length) % hits.length;
|
||||||
|
return hits[idx]?.id ?? '';
|
||||||
|
});
|
||||||
|
|
||||||
function roomTitle(room: ConversationRoomDto) {
|
function roomTitle(room: ConversationRoomDto) {
|
||||||
if (room.kind === 'group') return room.name;
|
if (room.kind === 'group') return room.name;
|
||||||
const peer = peers.value.find((p) => p.id === room.peerUserId);
|
const peer = peers.value.find((p) => p.id === room.peerUserId);
|
||||||
return peer?.fullName ?? room.name;
|
return peer?.fullName ?? room.name;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type TextSeg = { text: string; mention?: boolean; hit?: boolean };
|
||||||
|
|
||||||
|
function messageTextSegments(content: string, highlightQuery = ''): TextSeg[] {
|
||||||
|
const raw = String(content || '');
|
||||||
|
if (!raw) return [];
|
||||||
|
const names = [...peers.value]
|
||||||
|
.map((p) => p.fullName.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.sort((a, b) => b.length - a.length);
|
||||||
|
const mentionRe = names.length
|
||||||
|
? new RegExp(`(@(?:${names.map((n) => n.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|')}))`, 'g')
|
||||||
|
: /(@[^\s@]{2,40})/g;
|
||||||
|
|
||||||
|
const parts: TextSeg[] = [];
|
||||||
|
let last = 0;
|
||||||
|
for (const match of raw.matchAll(mentionRe)) {
|
||||||
|
const idx = match.index ?? 0;
|
||||||
|
if (idx > last) parts.push({ text: raw.slice(last, idx) });
|
||||||
|
parts.push({ text: match[0], mention: true });
|
||||||
|
last = idx + match[0].length;
|
||||||
|
}
|
||||||
|
if (last < raw.length) parts.push({ text: raw.slice(last) });
|
||||||
|
|
||||||
|
const q = highlightQuery.trim();
|
||||||
|
if (!q) return parts.length ? parts : [{ text: raw }];
|
||||||
|
|
||||||
|
const out: TextSeg[] = [];
|
||||||
|
const qLower = q.toLowerCase();
|
||||||
|
for (const part of parts) {
|
||||||
|
if (part.mention) {
|
||||||
|
out.push(part);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const text = part.text;
|
||||||
|
const lower = text.toLowerCase();
|
||||||
|
let cursor = 0;
|
||||||
|
let found = lower.indexOf(qLower, cursor);
|
||||||
|
if (found < 0) {
|
||||||
|
out.push(part);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
while (found >= 0) {
|
||||||
|
if (found > cursor) out.push({ text: text.slice(cursor, found) });
|
||||||
|
out.push({ text: text.slice(found, found + q.length), hit: true });
|
||||||
|
cursor = found + q.length;
|
||||||
|
found = lower.indexOf(qLower, cursor);
|
||||||
|
}
|
||||||
|
if (cursor < text.length) out.push({ text: text.slice(cursor) });
|
||||||
|
}
|
||||||
|
return out.length ? out : [{ text: raw }];
|
||||||
|
}
|
||||||
|
|
||||||
function formatListTime(iso: string | null) {
|
function formatListTime(iso: string | null) {
|
||||||
if (!iso) return '';
|
if (!iso) return '';
|
||||||
try {
|
try {
|
||||||
@@ -145,6 +253,9 @@ function closeMobileChat() {
|
|||||||
emojiOpen.value = false;
|
emojiOpen.value = false;
|
||||||
msgMenu.value = null;
|
msgMenu.value = null;
|
||||||
showForward.value = false;
|
showForward.value = false;
|
||||||
|
closeChatSearch();
|
||||||
|
mentionState.value = null;
|
||||||
|
pinnedMessage.value = null;
|
||||||
if (recording.value) mediaRecorder?.stop();
|
if (recording.value) mediaRecorder?.stop();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -207,10 +318,12 @@ async function deleteSelected() {
|
|||||||
if (otherCount) parts.push(`${otherCount} پیام دریافتی فقط برای شما`);
|
if (otherCount) parts.push(`${otherCount} پیام دریافتی فقط برای شما`);
|
||||||
if (!window.confirm(`حذف شود؟\n${parts.join('\n')}`)) return;
|
if (!window.confirm(`حذف شود؟\n${parts.join('\n')}`)) return;
|
||||||
try {
|
try {
|
||||||
|
const pinnedId = pinnedMessage.value?.messageId;
|
||||||
await deleteMessages(
|
await deleteMessages(
|
||||||
activeRoom.value.externalRoomId,
|
activeRoom.value.externalRoomId,
|
||||||
list.map((m) => ({ id: m.id, forEveryone: isMineMessage(m) })),
|
list.map((m) => ({ id: m.id, forEveryone: isMineMessage(m) })),
|
||||||
);
|
);
|
||||||
|
if (pinnedId && list.some((m) => m.id === pinnedId)) pinnedMessage.value = null;
|
||||||
exitSelectMode();
|
exitSelectMode();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error.value = e instanceof Error ? e.message : 'حذف پیام ناموفق بود.';
|
error.value = e instanceof Error ? e.message : 'حذف پیام ناموفق بود.';
|
||||||
@@ -309,6 +422,147 @@ function scrollToMessage(id: string | null | undefined) {
|
|||||||
window.setTimeout(() => row.classList.remove('flash'), 1200);
|
window.setTimeout(() => row.classList.remove('flash'), 1200);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function refreshPinnedMessage(roomId: string) {
|
||||||
|
if (!roomId) {
|
||||||
|
pinnedMessage.value = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const { data } = await api.get<ConversationPinnedMessageDto | null>(
|
||||||
|
'/conversations/messages/pin',
|
||||||
|
{ params: { externalRoomId: roomId } },
|
||||||
|
);
|
||||||
|
pinnedMessage.value = data?.messageId ? data : null;
|
||||||
|
} catch {
|
||||||
|
pinnedMessage.value = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyMessagePin(event: MessagePinEvent) {
|
||||||
|
if (activeRoom.value?.externalRoomId !== event.roomId) return;
|
||||||
|
if (!event.messageId) {
|
||||||
|
pinnedMessage.value = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pinnedMessage.value = {
|
||||||
|
messageId: event.messageId,
|
||||||
|
preview: event.preview || pinnedMessage.value?.preview || 'پیام سنجاقشده',
|
||||||
|
pinnedByUserId: pinnedMessage.value?.pinnedByUserId || '',
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function togglePinMessage(msg: ChatMessage) {
|
||||||
|
msgMenu.value = null;
|
||||||
|
if (!activeRoom.value || msg.optimistic || msg.deleted) return;
|
||||||
|
const roomId = activeRoom.value.externalRoomId;
|
||||||
|
try {
|
||||||
|
if (pinnedMessage.value?.messageId === msg.id) {
|
||||||
|
await unpinChatMessage(roomId);
|
||||||
|
pinnedMessage.value = null;
|
||||||
|
} else {
|
||||||
|
await pinChatMessage(roomId, msg);
|
||||||
|
pinnedMessage.value = {
|
||||||
|
messageId: msg.id,
|
||||||
|
preview: previewLabelForMessage(msg).slice(0, 240),
|
||||||
|
pinnedByUserId: '',
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e instanceof Error ? e.message : 'سنجاق پیام ناموفق بود.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clearPinnedMessage() {
|
||||||
|
if (!activeRoom.value) return;
|
||||||
|
try {
|
||||||
|
await unpinChatMessage(activeRoom.value.externalRoomId);
|
||||||
|
pinnedMessage.value = null;
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e instanceof Error ? e.message : 'برداشتن سنجاق ناموفق بود.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleRoomPinned() {
|
||||||
|
if (!activeRoom.value) return;
|
||||||
|
const room = activeRoom.value;
|
||||||
|
const next = !room.pinned;
|
||||||
|
try {
|
||||||
|
await api.post('/conversations/rooms/pin', {
|
||||||
|
externalRoomId: room.externalRoomId,
|
||||||
|
pinned: next,
|
||||||
|
});
|
||||||
|
const updated = { ...room, pinned: next };
|
||||||
|
activeRoom.value = updated;
|
||||||
|
rooms.value = rooms.value.map((r) =>
|
||||||
|
r.externalRoomId === room.externalRoomId ? { ...r, pinned: next } : r,
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e instanceof Error ? e.message : 'سنجاق گفتگو ناموفق بود.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openChatSearch() {
|
||||||
|
chatSearchOpen.value = true;
|
||||||
|
chatSearchQuery.value = '';
|
||||||
|
chatSearchIndex.value = 0;
|
||||||
|
emojiOpen.value = false;
|
||||||
|
mentionState.value = null;
|
||||||
|
msgMenu.value = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeChatSearch() {
|
||||||
|
chatSearchOpen.value = false;
|
||||||
|
chatSearchQuery.value = '';
|
||||||
|
chatSearchIndex.value = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function goSearchHit(delta: number) {
|
||||||
|
const total = chatSearchHitCount.value;
|
||||||
|
if (!total) return;
|
||||||
|
chatSearchIndex.value = (chatSearchIndex.value + delta + total) % total;
|
||||||
|
nextTick(() => scrollToMessage(activeSearchHitId.value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateMentionState() {
|
||||||
|
const el = draftInputEl.value;
|
||||||
|
if (!el) {
|
||||||
|
mentionState.value = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const caret = el.selectionStart ?? draft.value.length;
|
||||||
|
const before = draft.value.slice(0, caret);
|
||||||
|
const match = before.match(/(^|[\s\n])@([^\s@]*)$/);
|
||||||
|
if (!match) {
|
||||||
|
mentionState.value = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const query = match[2] ?? '';
|
||||||
|
const start = caret - query.length - 1;
|
||||||
|
mentionState.value = { start, end: caret, query };
|
||||||
|
mentionIndex.value = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function insertMention(peer: ConversationPeerDto) {
|
||||||
|
const state = mentionState.value;
|
||||||
|
if (!state) return;
|
||||||
|
const token = `@${peer.fullName} `;
|
||||||
|
draft.value = draft.value.slice(0, state.start) + token + draft.value.slice(state.end);
|
||||||
|
mentionState.value = null;
|
||||||
|
nextTick(() => {
|
||||||
|
const el = draftInputEl.value;
|
||||||
|
if (!el) return;
|
||||||
|
const pos = state.start + token.length;
|
||||||
|
el.focus();
|
||||||
|
el.setSelectionRange(pos, pos);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDraftInput() {
|
||||||
|
updateMentionState();
|
||||||
|
}
|
||||||
|
|
||||||
async function confirmDeleteMessage(msg: ChatMessage) {
|
async function confirmDeleteMessage(msg: ChatMessage) {
|
||||||
msgMenu.value = null;
|
msgMenu.value = null;
|
||||||
if (!activeRoom.value || msg.optimistic || msg.deleted) return;
|
if (!activeRoom.value || msg.optimistic || msg.deleted) return;
|
||||||
@@ -324,6 +578,7 @@ async function confirmDeleteMessage(msg: ChatMessage) {
|
|||||||
forEveryone: mine,
|
forEveryone: mine,
|
||||||
});
|
});
|
||||||
if (editingMessageId.value === msg.id) cancelEdit();
|
if (editingMessageId.value === msg.id) cancelEdit();
|
||||||
|
if (pinnedMessage.value?.messageId === msg.id) pinnedMessage.value = null;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error.value = e instanceof Error ? e.message : 'حذف پیام ناموفق بود.';
|
error.value = e instanceof Error ? e.message : 'حذف پیام ناموفق بود.';
|
||||||
}
|
}
|
||||||
@@ -350,6 +605,30 @@ function insertComposerNewline() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function onComposerKeydown(event: KeyboardEvent) {
|
function onComposerKeydown(event: KeyboardEvent) {
|
||||||
|
if (mentionState.value && mentionCandidates.value.length) {
|
||||||
|
if (event.key === 'ArrowDown') {
|
||||||
|
event.preventDefault();
|
||||||
|
mentionIndex.value = (mentionIndex.value + 1) % mentionCandidates.value.length;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.key === 'ArrowUp') {
|
||||||
|
event.preventDefault();
|
||||||
|
mentionIndex.value =
|
||||||
|
(mentionIndex.value - 1 + mentionCandidates.value.length) % mentionCandidates.value.length;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.key === 'Enter' || event.key === 'Tab') {
|
||||||
|
event.preventDefault();
|
||||||
|
const peer = mentionCandidates.value[mentionIndex.value];
|
||||||
|
if (peer) insertMention(peer);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
event.preventDefault();
|
||||||
|
mentionState.value = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
if (event.key !== 'Enter') return;
|
if (event.key !== 'Enter') return;
|
||||||
// موبایل: Enter = خط بعد؛ ارسال فقط با دکمه ارسال
|
// موبایل: Enter = خط بعد؛ ارسال فقط با دکمه ارسال
|
||||||
if (isMobileComposer()) return;
|
if (isMobileComposer()) return;
|
||||||
@@ -652,7 +931,10 @@ async function openRoom(room: ConversationRoomDto) {
|
|||||||
rooms.value = rooms.value.map((r) =>
|
rooms.value = rooms.value.map((r) =>
|
||||||
r.externalRoomId === room.externalRoomId ? cleared : r,
|
r.externalRoomId === room.externalRoomId ? cleared : r,
|
||||||
);
|
);
|
||||||
|
closeChatSearch();
|
||||||
|
mentionState.value = null;
|
||||||
await loadMessages(room.externalRoomId);
|
await loadMessages(room.externalRoomId);
|
||||||
|
await refreshPinnedMessage(room.externalRoomId);
|
||||||
sendRead(room.externalRoomId);
|
sendRead(room.externalRoomId);
|
||||||
void refreshUnreadTotal();
|
void refreshUnreadTotal();
|
||||||
}
|
}
|
||||||
@@ -769,6 +1051,8 @@ watch(activeRoom, () => {
|
|||||||
emojiOpen.value = false;
|
emojiOpen.value = false;
|
||||||
msgMenu.value = null;
|
msgMenu.value = null;
|
||||||
showForward.value = false;
|
showForward.value = false;
|
||||||
|
closeChatSearch();
|
||||||
|
mentionState.value = null;
|
||||||
if (recording.value) {
|
if (recording.value) {
|
||||||
mediaRecorder?.stop();
|
mediaRecorder?.stop();
|
||||||
}
|
}
|
||||||
@@ -777,23 +1061,36 @@ watch(activeRoom, () => {
|
|||||||
watch(
|
watch(
|
||||||
() => messages.value.length,
|
() => messages.value.length,
|
||||||
() => {
|
() => {
|
||||||
|
if (chatSearchOpen.value) return;
|
||||||
void scrollToBottom();
|
void scrollToBottom();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
watch(loadingMessages, (v) => {
|
watch(loadingMessages, (v) => {
|
||||||
if (!v) void scrollToBottom();
|
if (!v && !chatSearchOpen.value) void scrollToBottom();
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(chatSearchHits, (hits) => {
|
||||||
|
if (!chatSearchOpen.value) return;
|
||||||
|
if (!hits.length) {
|
||||||
|
chatSearchIndex.value = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (chatSearchIndex.value >= hits.length) chatSearchIndex.value = 0;
|
||||||
|
nextTick(() => scrollToMessage(activeSearchHitId.value));
|
||||||
});
|
});
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
setConversationsPageOpen(true);
|
setConversationsPageOpen(true);
|
||||||
stopRoomLive = onRoomLiveEvent(applyRoomLive);
|
stopRoomLive = onRoomLiveEvent(applyRoomLive);
|
||||||
|
stopPinLive = onMessagePinEvent(applyMessagePin);
|
||||||
void bootstrap();
|
void bootstrap();
|
||||||
});
|
});
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
setConversationsPageOpen(false);
|
setConversationsPageOpen(false);
|
||||||
stopRoomLive?.();
|
stopRoomLive?.();
|
||||||
|
stopPinLive?.();
|
||||||
clearActiveRoom();
|
clearActiveRoom();
|
||||||
clearPendingAttaches();
|
clearPendingAttaches();
|
||||||
if (recording.value) mediaRecorder?.stop();
|
if (recording.value) mediaRecorder?.stop();
|
||||||
@@ -838,19 +1135,65 @@ onUnmounted(() => {
|
|||||||
گروه
|
گروه
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="rail-filters">
|
||||||
|
<label class="rail-search">
|
||||||
|
<UiIcon name="search" :size="16" />
|
||||||
|
<input
|
||||||
|
v-model="roomListQuery"
|
||||||
|
type="search"
|
||||||
|
placeholder="جستجوی گفتگو…"
|
||||||
|
autocomplete="off"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div class="rail-tabs" role="tablist" aria-label="نوع گفتگو">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
:aria-selected="roomListTab === 'all'"
|
||||||
|
:class="{ active: roomListTab === 'all' }"
|
||||||
|
@click="roomListTab = 'all'"
|
||||||
|
>
|
||||||
|
همه
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
:aria-selected="roomListTab === 'dm'"
|
||||||
|
:class="{ active: roomListTab === 'dm' }"
|
||||||
|
@click="roomListTab = 'dm'"
|
||||||
|
>
|
||||||
|
خصوصی
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
:aria-selected="roomListTab === 'group'"
|
||||||
|
:class="{ active: roomListTab === 'group' }"
|
||||||
|
@click="roomListTab = 'group'"
|
||||||
|
>
|
||||||
|
گروه
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<ul class="room-list">
|
<ul class="room-list">
|
||||||
<li v-for="room in sortedRooms" :key="room.externalRoomId">
|
<li v-if="!filteredRooms.length" class="rail-empty">
|
||||||
|
{{ roomListQuery.trim() ? 'گفتگویی پیدا نشد' : 'گفتگویی در این بخش نیست' }}
|
||||||
|
</li>
|
||||||
|
<li v-for="room in filteredRooms" :key="room.externalRoomId">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="room-chip"
|
class="room-chip"
|
||||||
:class="{ active: activeRoom?.externalRoomId === room.externalRoomId }"
|
:class="{ active: activeRoom?.externalRoomId === room.externalRoomId, pinned: room.pinned }"
|
||||||
@click="openRoom(room)"
|
@click="openRoom(room)"
|
||||||
>
|
>
|
||||||
<span class="avatar-ring">
|
<span class="avatar-ring">
|
||||||
<UserAvatar :name="roomTitle(room)" :size="40" />
|
<UserAvatar :name="roomTitle(room)" :size="40" />
|
||||||
</span>
|
</span>
|
||||||
<span class="room-meta">
|
<span class="room-meta">
|
||||||
<strong>{{ roomTitle(room) }}</strong>
|
<strong>
|
||||||
|
<UiIcon v-if="room.pinned" name="pin" :size="12" class="room-pin-ic" />
|
||||||
|
{{ roomTitle(room) }}
|
||||||
|
</strong>
|
||||||
<small>{{ roomPreviewText(room) }}</small>
|
<small>{{ roomPreviewText(room) }}</small>
|
||||||
</span>
|
</span>
|
||||||
<span v-if="room.unreadCount" class="unread">{{ toFaDigits(room.unreadCount) }}</span>
|
<span v-if="room.unreadCount" class="unread">{{ toFaDigits(room.unreadCount) }}</span>
|
||||||
@@ -901,6 +1244,48 @@ onUnmounted(() => {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
<template v-else-if="chatSearchOpen">
|
||||||
|
<button type="button" class="back-btn show" aria-label="بستن جستجو" @click="closeChatSearch">
|
||||||
|
<UiIcon name="arrow-right" :size="20" />
|
||||||
|
</button>
|
||||||
|
<label class="chat-search-field">
|
||||||
|
<UiIcon name="search" :size="16" />
|
||||||
|
<input
|
||||||
|
v-model="chatSearchQuery"
|
||||||
|
type="search"
|
||||||
|
placeholder="جستجو در گفتگو…"
|
||||||
|
autocomplete="off"
|
||||||
|
autofocus
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div class="chat-search-nav">
|
||||||
|
<span class="chat-search-count">
|
||||||
|
{{
|
||||||
|
chatSearchHitCount
|
||||||
|
? `${toFaDigits(chatSearchIndex + 1)}/${toFaDigits(chatSearchHitCount)}`
|
||||||
|
: '۰'
|
||||||
|
}}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="select-act"
|
||||||
|
title="قبلی"
|
||||||
|
:disabled="!chatSearchHitCount"
|
||||||
|
@click="goSearchHit(-1)"
|
||||||
|
>
|
||||||
|
<UiIcon name="chevron" :size="16" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="select-act search-next"
|
||||||
|
title="بعدی"
|
||||||
|
:disabled="!chatSearchHitCount"
|
||||||
|
@click="goSearchHit(1)"
|
||||||
|
>
|
||||||
|
<UiIcon name="chevron" :size="16" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<button type="button" class="back-btn" aria-label="بازگشت" @click="closeMobileChat">
|
<button type="button" class="back-btn" aria-label="بازگشت" @click="closeMobileChat">
|
||||||
<UiIcon name="arrow-right" :size="20" />
|
<UiIcon name="arrow-right" :size="20" />
|
||||||
@@ -912,9 +1297,45 @@ onUnmounted(() => {
|
|||||||
<h2>{{ roomTitle(activeRoom) }}</h2>
|
<h2>{{ roomTitle(activeRoom) }}</h2>
|
||||||
<span class="kind-tag">{{ activeRoom.kind === 'group' ? 'گروه' : 'خصوصی' }}</span>
|
<span class="kind-tag">{{ activeRoom.kind === 'group' ? 'گروه' : 'خصوصی' }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="stage-tools">
|
||||||
|
<button type="button" class="select-act" title="جستجو" @click="openChatSearch">
|
||||||
|
<UiIcon name="search" :size="18" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="select-act"
|
||||||
|
:class="{ on: activeRoom.pinned }"
|
||||||
|
:title="activeRoom.pinned ? 'برداشتن سنجاق گفتگو' : 'سنجاق گفتگو'"
|
||||||
|
@click="toggleRoomPinned"
|
||||||
|
>
|
||||||
|
<UiIcon name="pin" :size="18" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="pinnedMessage && !chatSearchOpen && !selectMode"
|
||||||
|
class="pin-banner"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="pin-banner-main"
|
||||||
|
@click="scrollToMessage(pinnedMessage.messageId)"
|
||||||
|
>
|
||||||
|
<UiIcon name="pin" :size="16" />
|
||||||
|
<span class="pin-banner-text">{{ pinnedMessage.preview || 'پیام سنجاقشده' }}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="pin-banner-x"
|
||||||
|
aria-label="برداشتن سنجاق"
|
||||||
|
@click="clearPinnedMessage"
|
||||||
|
>
|
||||||
|
<UiIcon name="x" :size="14" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div v-if="loadingMessages" class="muted center pad">در حال دریافت پیامها…</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 }">
|
||||||
<li
|
<li
|
||||||
@@ -925,6 +1346,7 @@ onUnmounted(() => {
|
|||||||
:class="{
|
:class="{
|
||||||
mine: msg.senderId === chatUserId || msg.optimistic,
|
mine: msg.senderId === chatUserId || msg.optimistic,
|
||||||
selected: isSelected(msg.id),
|
selected: isSelected(msg.id),
|
||||||
|
'search-hit': activeSearchHitId === msg.id,
|
||||||
}"
|
}"
|
||||||
@contextmenu="openMsgMenu(msg, $event)"
|
@contextmenu="openMsgMenu(msg, $event)"
|
||||||
@click="selectMode ? toggleSelect(msg) : undefined"
|
@click="selectMode ? toggleSelect(msg) : undefined"
|
||||||
@@ -1015,7 +1437,19 @@ onUnmounted(() => {
|
|||||||
آپلود {{ toFaDigits(msg.uploadProgress) }}٪
|
آپلود {{ toFaDigits(msg.uploadProgress) }}٪
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
<p v-else>{{ msg.content }}</p>
|
<p v-else class="msg-text">
|
||||||
|
<template
|
||||||
|
v-for="(seg, si) in messageTextSegments(
|
||||||
|
msg.content,
|
||||||
|
chatSearchOpen ? chatSearchQuery : '',
|
||||||
|
)"
|
||||||
|
:key="si"
|
||||||
|
>
|
||||||
|
<span v-if="seg.mention" class="mention">{{ seg.text }}</span>
|
||||||
|
<mark v-else-if="seg.hit" class="search-mark">{{ seg.text }}</mark>
|
||||||
|
<template v-else>{{ seg.text }}</template>
|
||||||
|
</template>
|
||||||
|
</p>
|
||||||
<div class="bubble-meta">
|
<div class="bubble-meta">
|
||||||
<span v-if="msg.edited" class="edited-mark">ویرایششده</span>
|
<span v-if="msg.edited" class="edited-mark">ویرایششده</span>
|
||||||
<time>{{ formatListTime(msg.createdAt) }}</time>
|
<time>{{ formatListTime(msg.createdAt) }}</time>
|
||||||
@@ -1037,6 +1471,19 @@ onUnmounted(() => {
|
|||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<form class="composer" @submit.prevent="submitMessage">
|
<form class="composer" @submit.prevent="submitMessage">
|
||||||
|
<div v-if="mentionState && mentionCandidates.length" class="mention-sheet">
|
||||||
|
<button
|
||||||
|
v-for="(peer, idx) in mentionCandidates"
|
||||||
|
:key="peer.id"
|
||||||
|
type="button"
|
||||||
|
class="mention-item"
|
||||||
|
:class="{ active: idx === mentionIndex }"
|
||||||
|
@click="insertMention(peer)"
|
||||||
|
>
|
||||||
|
<UserAvatar :name="peer.fullName" :size="28" />
|
||||||
|
<span>{{ peer.fullName }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<div v-if="emojiOpen" class="emoji-panel">
|
<div v-if="emojiOpen" class="emoji-panel">
|
||||||
<button
|
<button
|
||||||
v-for="mark in CHAT_EMOJIS"
|
v-for="mark in CHAT_EMOJIS"
|
||||||
@@ -1118,6 +1565,9 @@ onUnmounted(() => {
|
|||||||
enterkeyhint="enter"
|
enterkeyhint="enter"
|
||||||
:disabled="sending || recording"
|
:disabled="sending || recording"
|
||||||
@focus="emojiOpen = false"
|
@focus="emojiOpen = false"
|
||||||
|
@input="onDraftInput"
|
||||||
|
@click="onDraftInput"
|
||||||
|
@keyup="onDraftInput"
|
||||||
@keydown="onComposerKeydown"
|
@keydown="onComposerKeydown"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -1165,6 +1615,14 @@ onUnmounted(() => {
|
|||||||
<UiIcon name="chat" :size="16" />
|
<UiIcon name="chat" :size="16" />
|
||||||
پاسخ
|
پاسخ
|
||||||
</button>
|
</button>
|
||||||
|
<button type="button" @click="togglePinMessage(menuMessage()!)">
|
||||||
|
<UiIcon name="pin" :size="16" />
|
||||||
|
{{
|
||||||
|
pinnedMessage?.messageId === menuMessage()?.id
|
||||||
|
? 'برداشتن سنجاق'
|
||||||
|
: 'سنجاق کردن'
|
||||||
|
}}
|
||||||
|
</button>
|
||||||
<button type="button" @click="enterSelectMode(menuMessage()!)">
|
<button type="button" @click="enterSelectMode(menuMessage()!)">
|
||||||
<UiIcon name="check-plain" :size="16" />
|
<UiIcon name="check-plain" :size="16" />
|
||||||
انتخاب
|
انتخاب
|
||||||
@@ -1370,6 +1828,66 @@ onUnmounted(() => {
|
|||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.4rem;
|
gap: 0.4rem;
|
||||||
}
|
}
|
||||||
|
.rail-filters {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.45rem;
|
||||||
|
padding: 0 0.1rem;
|
||||||
|
}
|
||||||
|
.rail-search {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
padding: 0.4rem 0.65rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: rgba(148, 163, 184, 0.12);
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.2);
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.rail-search input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
outline: none;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.rail-search input::placeholder {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.rail-tabs {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 0.25rem;
|
||||||
|
padding: 0.2rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: rgba(148, 163, 184, 0.1);
|
||||||
|
}
|
||||||
|
.rail-tabs button {
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
border-radius: 9px;
|
||||||
|
padding: 0.35rem 0.25rem;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--muted);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s ease, color 0.15s ease;
|
||||||
|
}
|
||||||
|
.rail-tabs button.active {
|
||||||
|
background: #fff;
|
||||||
|
color: #0f766e;
|
||||||
|
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.06);
|
||||||
|
}
|
||||||
|
.rail-empty {
|
||||||
|
padding: 1.25rem 0.75rem;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
.fab {
|
.fab {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
border: none;
|
border: none;
|
||||||
@@ -1422,9 +1940,15 @@ onUnmounted(() => {
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
.room-meta strong {
|
.room-meta strong {
|
||||||
display: block;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.3rem;
|
||||||
font-size: 0.92rem;
|
font-size: 0.92rem;
|
||||||
}
|
}
|
||||||
|
.room-pin-ic {
|
||||||
|
color: #0d9488;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
.room-meta small {
|
.room-meta small {
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
@@ -1529,6 +2053,148 @@ onUnmounted(() => {
|
|||||||
opacity: 0.4;
|
opacity: 0.4;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
.select-act.on {
|
||||||
|
background: rgba(13, 148, 136, 0.12);
|
||||||
|
color: #0f766e;
|
||||||
|
}
|
||||||
|
.stage-tools {
|
||||||
|
margin-inline-start: auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.15rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.chat-search-field {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
padding: 0.4rem 0.7rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(148, 163, 184, 0.14);
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.chat-search-field input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
outline: none;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.chat-search-nav {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.1rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.chat-search-count {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--muted);
|
||||||
|
min-width: 2.4rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.chat-search-nav .select-act svg {
|
||||||
|
transform: rotate(-90deg);
|
||||||
|
}
|
||||||
|
.chat-search-nav .search-next svg {
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
.pin-banner {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
padding: 0.45rem 0.75rem;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
background: linear-gradient(90deg, rgba(13, 148, 136, 0.1), rgba(99, 102, 241, 0.06));
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.pin-banner-main {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.45rem;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: #0f766e;
|
||||||
|
text-align: right;
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.pin-banner-text {
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.pin-banner-x {
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--muted);
|
||||||
|
width: 1.75rem;
|
||||||
|
height: 1.75rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.msg-row.search-hit .bubble {
|
||||||
|
outline: 2px solid rgba(13, 148, 136, 0.55);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
.msg-text {
|
||||||
|
margin: 0;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
.mention {
|
||||||
|
color: #0f766e;
|
||||||
|
font-weight: 700;
|
||||||
|
background: rgba(13, 148, 136, 0.12);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 0 0.15rem;
|
||||||
|
}
|
||||||
|
.search-mark {
|
||||||
|
background: #fde68a;
|
||||||
|
color: inherit;
|
||||||
|
border-radius: 3px;
|
||||||
|
padding: 0 0.05rem;
|
||||||
|
}
|
||||||
|
.mention-sheet {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.15rem;
|
||||||
|
max-height: 220px;
|
||||||
|
overflow: auto;
|
||||||
|
margin-bottom: 0.35rem;
|
||||||
|
border-radius: 14px;
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.28);
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.1);
|
||||||
|
}
|
||||||
|
.mention-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.55rem;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
padding: 0.55rem 0.7rem;
|
||||||
|
text-align: right;
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.mention-item.active,
|
||||||
|
.mention-item:hover {
|
||||||
|
background: rgba(13, 148, 136, 0.08);
|
||||||
|
}
|
||||||
.forward-list {
|
.forward-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -1731,6 +2397,7 @@ onUnmounted(() => {
|
|||||||
border-top: 1px solid var(--line);
|
border-top: 1px solid var(--line);
|
||||||
background: #f0f2f5;
|
background: #f0f2f5;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
.composer-row {
|
.composer-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -2248,6 +2915,13 @@ onUnmounted(() => {
|
|||||||
.rail-actions {
|
.rail-actions {
|
||||||
padding: 0 0.5rem 0.35rem;
|
padding: 0 0.5rem 0.35rem;
|
||||||
}
|
}
|
||||||
|
.rail-filters {
|
||||||
|
padding: 0 0.5rem 0.45rem;
|
||||||
|
}
|
||||||
|
.rail-tabs button.active {
|
||||||
|
background: rgba(13, 148, 136, 0.12);
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
.room-list {
|
.room-list {
|
||||||
max-height: none;
|
max-height: none;
|
||||||
}
|
}
|
||||||
@@ -2285,6 +2959,23 @@ onUnmounted(() => {
|
|||||||
top: 0;
|
top: 0;
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
}
|
}
|
||||||
|
.pin-banner {
|
||||||
|
position: sticky;
|
||||||
|
top: 3.25rem;
|
||||||
|
z-index: 2;
|
||||||
|
background: #ecfdf5;
|
||||||
|
}
|
||||||
|
.mention-sheet {
|
||||||
|
position: absolute;
|
||||||
|
left: 0.5rem;
|
||||||
|
right: 0.5rem;
|
||||||
|
bottom: calc(100% + 0.35rem);
|
||||||
|
max-height: 42vh;
|
||||||
|
z-index: 5;
|
||||||
|
}
|
||||||
|
.composer {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
.msg-stream {
|
.msg-stream {
|
||||||
padding: 0.75rem 0.7rem 0.5rem;
|
padding: 0.75rem 0.7rem 0.5rem;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3972,6 +3972,15 @@ export interface ConversationRoomDto {
|
|||||||
lastMessagePreview: string | null;
|
lastMessagePreview: string | null;
|
||||||
lastMessageAt: string | null;
|
lastMessageAt: string | null;
|
||||||
peerUserId: string | null;
|
peerUserId: string | null;
|
||||||
|
/** سنجاق در لیست گفتگو — فقط برای کاربر جاری */
|
||||||
|
pinned?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConversationPinnedMessageDto {
|
||||||
|
messageId: string;
|
||||||
|
preview: string;
|
||||||
|
pinnedByUserId: string;
|
||||||
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateConversationDmRequest {
|
export interface CreateConversationDmRequest {
|
||||||
|
|||||||
Reference in New Issue
Block a user