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?
|
||||
conversationRooms ConversationRoom[]
|
||||
conversationHiddenMessages ConversationHiddenMessage[]
|
||||
conversationPinnedMessages ConversationPinnedMessage[]
|
||||
conversationRoomUserPins ConversationRoomUserPin[]
|
||||
}
|
||||
|
||||
model User {
|
||||
@@ -2113,3 +2115,32 @@ model ConversationHiddenMessage {
|
||||
@@unique([organizationId, externalRoomId, messageId, scope, hiddenByUserId])
|
||||
@@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 { CurrentUser } from '../auth/current-user.decorator';
|
||||
import { RequireOrgModule } from '../billing/org-module.decorator';
|
||||
@@ -7,6 +7,9 @@ import {
|
||||
CreateConversationDmDto,
|
||||
CreateConversationGroupDto,
|
||||
HideConversationMessagesDto,
|
||||
PinConversationMessageDto,
|
||||
PinConversationRoomDto,
|
||||
UnpinConversationMessageDto,
|
||||
} from './dto';
|
||||
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')
|
||||
hideMessages(
|
||||
@CurrentUser() user: { userId: string; organizationId: string; role: string },
|
||||
@@ -90,4 +107,58 @@ export class ConversationsController {
|
||||
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 {
|
||||
ConversationAccessDto,
|
||||
ConversationPeerDto,
|
||||
ConversationPinnedMessageDto,
|
||||
ConversationRoomDto,
|
||||
ConversationSessionDto,
|
||||
ConversationSettingsDto,
|
||||
@@ -188,19 +189,21 @@ export class ConversationsService {
|
||||
where: { organizationId },
|
||||
});
|
||||
const trackedByExternal = new Map(tracked.map((t) => [t.externalRoomId, t]));
|
||||
const pinnedIds = await this.userPinnedRoomIds(organizationId, userId);
|
||||
|
||||
try {
|
||||
const external = await this.chat.listUserRooms(creds.host, creds.apiKey, chatUserId);
|
||||
return external.map((row) =>
|
||||
this.toRoomDto(row, trackedByExternal, organizationId, userId),
|
||||
);
|
||||
return external.map((row) => {
|
||||
const dto = this.toRoomDto(row, trackedByExternal, organizationId, userId);
|
||||
return { ...dto, pinned: pinnedIds.has(dto.externalRoomId) };
|
||||
});
|
||||
} catch (e) {
|
||||
// اگر سرویس چت در دسترس نبود، حداقل roomهای ثبتشده در CRM را نشان بده
|
||||
if (tracked.length) {
|
||||
return tracked.map((t) => ({
|
||||
id: t.id,
|
||||
externalRoomId: t.externalRoomId,
|
||||
kind: t.kind === 'group' ? 'group' : 'dm',
|
||||
kind: (t.kind === 'group' ? 'group' : 'dm') as 'dm' | 'group',
|
||||
name: t.name || (t.kind === 'group' ? 'گروه' : 'گفتگو'),
|
||||
unreadCount: 0,
|
||||
lastMessagePreview: null,
|
||||
@@ -213,6 +216,7 @@ export class ConversationsService {
|
||||
? t.dmUserMinId
|
||||
: null
|
||||
: null,
|
||||
pinned: pinnedIds.has(t.externalRoomId),
|
||||
}));
|
||||
}
|
||||
throw e;
|
||||
@@ -443,7 +447,7 @@ export class ConversationsService {
|
||||
let preview = lastMsg
|
||||
? String(lastMsg.content ?? lastMsg.text ?? lastMsg.body ?? '').trim()
|
||||
: '';
|
||||
if (/^__MENTO_HIDE__:/i.test(preview) || /MENTO_DEL/i.test(preview)) {
|
||||
if (/^__MENTO_HIDE__:/i.test(preview) || /MENTO_DEL/i.test(preview) || /^__MENTO_PIN__:/i.test(preview) || /^__MENTO_UNPIN__$/i.test(preview)) {
|
||||
preview = '';
|
||||
}
|
||||
if (lastType === 'image' || lastType === 'photo' || lastType === 'img') {
|
||||
@@ -491,9 +495,134 @@ export class ConversationsService {
|
||||
lastMessagePreview: preview || null,
|
||||
lastMessageAt: lastAtRaw ? String(lastAtRaw) : null,
|
||||
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(
|
||||
userId: string,
|
||||
organizationId: string,
|
||||
|
||||
@@ -68,3 +68,37 @@ export class HideConversationMessagesDto {
|
||||
@IsBoolean()
|
||||
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'
|
||||
| 'chevron'
|
||||
| 'star'
|
||||
| 'pin'
|
||||
| 'filter'
|
||||
| 'funnel'
|
||||
| 'lightbulb'
|
||||
@@ -247,6 +248,10 @@ defineProps<{
|
||||
<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" />
|
||||
</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'">
|
||||
<path d="M4 6h16M7 12h10M10 18h4" />
|
||||
</template>
|
||||
|
||||
+128
-19
@@ -33,7 +33,49 @@ export type RoomLiveEvent = {
|
||||
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_WEBP_QUALITY = 0.82;
|
||||
const MAX_UPLOAD_BYTES = 40 * 1024 * 1024;
|
||||
@@ -41,6 +83,9 @@ const MAX_UPLOAD_BYTES = 40 * 1024 * 1024;
|
||||
const HARD_DELETE_MARKER = '__MENTO_DEL__';
|
||||
/** Realtime hide command delivered as a normal text message (external WS delivers text reliably). */
|
||||
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 {
|
||||
const t = content.trim();
|
||||
@@ -48,7 +93,9 @@ function isDeletedContent(content: string): boolean {
|
||||
/^(?:\[?\s*deleted\s*\]?|این پیام حذف شد|پیام حذف شد)$/i.test(t) ||
|
||||
t.includes('MENTO_DEL') ||
|
||||
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;
|
||||
}
|
||||
|
||||
/** 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) {
|
||||
const key = String(id || '').trim();
|
||||
if (!key) return;
|
||||
@@ -159,23 +215,7 @@ const activeRoomId = ref('');
|
||||
const unreadTotal = ref(0);
|
||||
const pageOpen = ref(false);
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
const PAGE_SIZE = 200;
|
||||
|
||||
function messageTs(item: ChatMessage): number {
|
||||
const t = new Date(item.createdAt).getTime();
|
||||
@@ -734,6 +774,27 @@ function onSocketMessage(event: MessageEvent) {
|
||||
void persistHiddenOnServer(roomId, ids, true);
|
||||
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)) {
|
||||
purgeMessageById(purgeId);
|
||||
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() {
|
||||
stopHiddenPoll();
|
||||
activeRoomId.value = '';
|
||||
@@ -1298,6 +1405,8 @@ export function useOrgChat() {
|
||||
deleteMessage,
|
||||
deleteMessages,
|
||||
forwardMessages,
|
||||
pinChatMessage,
|
||||
unpinChatMessage,
|
||||
sendRead,
|
||||
ensureConnected,
|
||||
setConversationsPageOpen,
|
||||
|
||||
@@ -5,16 +5,19 @@ import { api } from '@/lib/api';
|
||||
import type {
|
||||
ConversationAccessDto,
|
||||
ConversationPeerDto,
|
||||
ConversationPinnedMessageDto,
|
||||
ConversationRoomDto,
|
||||
} from '@mento/shared';
|
||||
import { formatClockFa, toFaDigits } from '@/lib/jalali';
|
||||
import {
|
||||
classifyChatFile,
|
||||
onMessagePinEvent,
|
||||
onRoomLiveEvent,
|
||||
previewLabelForMessage,
|
||||
useOrgChat,
|
||||
type ChatDelivery,
|
||||
type ChatMessage,
|
||||
type MessagePinEvent,
|
||||
type RoomLiveEvent,
|
||||
} from '@/lib/useOrgChat';
|
||||
import UiIcon from '@/components/UiIcon.vue';
|
||||
@@ -72,6 +75,8 @@ const {
|
||||
deleteMessage,
|
||||
deleteMessages,
|
||||
forwardMessages,
|
||||
pinChatMessage,
|
||||
unpinChatMessage,
|
||||
sendRead,
|
||||
ensureConnected,
|
||||
setConversationsPageOpen,
|
||||
@@ -80,6 +85,7 @@ const {
|
||||
} = useOrgChat();
|
||||
const msgStreamEl = ref<HTMLElement | null>(null);
|
||||
let stopRoomLive: (() => boolean) | undefined;
|
||||
let stopPinLive: (() => boolean) | undefined;
|
||||
let mediaRecorder: MediaRecorder | null = null;
|
||||
let recordChunks: Blob[] = [];
|
||||
let recordTimer: ReturnType<typeof setInterval> | null = null;
|
||||
@@ -89,6 +95,14 @@ const selectMode = ref(false);
|
||||
const selectedIds = ref<string[]>([]);
|
||||
const showForward = 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);
|
||||
|
||||
@@ -101,18 +115,112 @@ async function scrollToBottom() {
|
||||
|
||||
const sortedRooms = computed(() =>
|
||||
[...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 tb = b.lastMessageAt ? new Date(b.lastMessageAt).getTime() : 0;
|
||||
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) {
|
||||
if (room.kind === 'group') return room.name;
|
||||
const peer = peers.value.find((p) => p.id === room.peerUserId);
|
||||
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) {
|
||||
if (!iso) return '';
|
||||
try {
|
||||
@@ -145,6 +253,9 @@ function closeMobileChat() {
|
||||
emojiOpen.value = false;
|
||||
msgMenu.value = null;
|
||||
showForward.value = false;
|
||||
closeChatSearch();
|
||||
mentionState.value = null;
|
||||
pinnedMessage.value = null;
|
||||
if (recording.value) mediaRecorder?.stop();
|
||||
}
|
||||
|
||||
@@ -207,10 +318,12 @@ async function deleteSelected() {
|
||||
if (otherCount) parts.push(`${otherCount} پیام دریافتی فقط برای شما`);
|
||||
if (!window.confirm(`حذف شود؟\n${parts.join('\n')}`)) return;
|
||||
try {
|
||||
const pinnedId = pinnedMessage.value?.messageId;
|
||||
await deleteMessages(
|
||||
activeRoom.value.externalRoomId,
|
||||
list.map((m) => ({ id: m.id, forEveryone: isMineMessage(m) })),
|
||||
);
|
||||
if (pinnedId && list.some((m) => m.id === pinnedId)) pinnedMessage.value = null;
|
||||
exitSelectMode();
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'حذف پیام ناموفق بود.';
|
||||
@@ -309,6 +422,147 @@ function scrollToMessage(id: string | null | undefined) {
|
||||
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) {
|
||||
msgMenu.value = null;
|
||||
if (!activeRoom.value || msg.optimistic || msg.deleted) return;
|
||||
@@ -324,6 +578,7 @@ async function confirmDeleteMessage(msg: ChatMessage) {
|
||||
forEveryone: mine,
|
||||
});
|
||||
if (editingMessageId.value === msg.id) cancelEdit();
|
||||
if (pinnedMessage.value?.messageId === msg.id) pinnedMessage.value = null;
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'حذف پیام ناموفق بود.';
|
||||
}
|
||||
@@ -350,6 +605,30 @@ function insertComposerNewline() {
|
||||
}
|
||||
|
||||
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;
|
||||
// موبایل: Enter = خط بعد؛ ارسال فقط با دکمه ارسال
|
||||
if (isMobileComposer()) return;
|
||||
@@ -652,7 +931,10 @@ async function openRoom(room: ConversationRoomDto) {
|
||||
rooms.value = rooms.value.map((r) =>
|
||||
r.externalRoomId === room.externalRoomId ? cleared : r,
|
||||
);
|
||||
closeChatSearch();
|
||||
mentionState.value = null;
|
||||
await loadMessages(room.externalRoomId);
|
||||
await refreshPinnedMessage(room.externalRoomId);
|
||||
sendRead(room.externalRoomId);
|
||||
void refreshUnreadTotal();
|
||||
}
|
||||
@@ -769,6 +1051,8 @@ watch(activeRoom, () => {
|
||||
emojiOpen.value = false;
|
||||
msgMenu.value = null;
|
||||
showForward.value = false;
|
||||
closeChatSearch();
|
||||
mentionState.value = null;
|
||||
if (recording.value) {
|
||||
mediaRecorder?.stop();
|
||||
}
|
||||
@@ -777,23 +1061,36 @@ watch(activeRoom, () => {
|
||||
watch(
|
||||
() => messages.value.length,
|
||||
() => {
|
||||
if (chatSearchOpen.value) return;
|
||||
void scrollToBottom();
|
||||
},
|
||||
);
|
||||
|
||||
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(() => {
|
||||
setConversationsPageOpen(true);
|
||||
stopRoomLive = onRoomLiveEvent(applyRoomLive);
|
||||
stopPinLive = onMessagePinEvent(applyMessagePin);
|
||||
void bootstrap();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
setConversationsPageOpen(false);
|
||||
stopRoomLive?.();
|
||||
stopPinLive?.();
|
||||
clearActiveRoom();
|
||||
clearPendingAttaches();
|
||||
if (recording.value) mediaRecorder?.stop();
|
||||
@@ -838,19 +1135,65 @@ onUnmounted(() => {
|
||||
گروه
|
||||
</button>
|
||||
</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">
|
||||
<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
|
||||
type="button"
|
||||
class="room-chip"
|
||||
:class="{ active: activeRoom?.externalRoomId === room.externalRoomId }"
|
||||
:class="{ active: activeRoom?.externalRoomId === room.externalRoomId, pinned: room.pinned }"
|
||||
@click="openRoom(room)"
|
||||
>
|
||||
<span class="avatar-ring">
|
||||
<UserAvatar :name="roomTitle(room)" :size="40" />
|
||||
</span>
|
||||
<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>
|
||||
</span>
|
||||
<span v-if="room.unreadCount" class="unread">{{ toFaDigits(room.unreadCount) }}</span>
|
||||
@@ -901,6 +1244,48 @@ onUnmounted(() => {
|
||||
</button>
|
||||
</div>
|
||||
</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>
|
||||
<button type="button" class="back-btn" aria-label="بازگشت" @click="closeMobileChat">
|
||||
<UiIcon name="arrow-right" :size="20" />
|
||||
@@ -912,9 +1297,45 @@ onUnmounted(() => {
|
||||
<h2>{{ roomTitle(activeRoom) }}</h2>
|
||||
<span class="kind-tag">{{ activeRoom.kind === 'group' ? 'گروه' : 'خصوصی' }}</span>
|
||||
</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>
|
||||
</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>
|
||||
<ul v-else ref="msgStreamEl" class="msg-stream" :class="{ selecting: selectMode }">
|
||||
<li
|
||||
@@ -925,6 +1346,7 @@ onUnmounted(() => {
|
||||
:class="{
|
||||
mine: msg.senderId === chatUserId || msg.optimistic,
|
||||
selected: isSelected(msg.id),
|
||||
'search-hit': activeSearchHitId === msg.id,
|
||||
}"
|
||||
@contextmenu="openMsgMenu(msg, $event)"
|
||||
@click="selectMode ? toggleSelect(msg) : undefined"
|
||||
@@ -1015,7 +1437,19 @@ onUnmounted(() => {
|
||||
آپلود {{ toFaDigits(msg.uploadProgress) }}٪
|
||||
</span>
|
||||
</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">
|
||||
<span v-if="msg.edited" class="edited-mark">ویرایششده</span>
|
||||
<time>{{ formatListTime(msg.createdAt) }}</time>
|
||||
@@ -1037,6 +1471,19 @@ onUnmounted(() => {
|
||||
</ul>
|
||||
|
||||
<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">
|
||||
<button
|
||||
v-for="mark in CHAT_EMOJIS"
|
||||
@@ -1118,6 +1565,9 @@ onUnmounted(() => {
|
||||
enterkeyhint="enter"
|
||||
:disabled="sending || recording"
|
||||
@focus="emojiOpen = false"
|
||||
@input="onDraftInput"
|
||||
@click="onDraftInput"
|
||||
@keyup="onDraftInput"
|
||||
@keydown="onComposerKeydown"
|
||||
/>
|
||||
</div>
|
||||
@@ -1165,6 +1615,14 @@ onUnmounted(() => {
|
||||
<UiIcon name="chat" :size="16" />
|
||||
پاسخ
|
||||
</button>
|
||||
<button type="button" @click="togglePinMessage(menuMessage()!)">
|
||||
<UiIcon name="pin" :size="16" />
|
||||
{{
|
||||
pinnedMessage?.messageId === menuMessage()?.id
|
||||
? 'برداشتن سنجاق'
|
||||
: 'سنجاق کردن'
|
||||
}}
|
||||
</button>
|
||||
<button type="button" @click="enterSelectMode(menuMessage()!)">
|
||||
<UiIcon name="check-plain" :size="16" />
|
||||
انتخاب
|
||||
@@ -1370,6 +1828,66 @@ onUnmounted(() => {
|
||||
display: flex;
|
||||
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 {
|
||||
flex: 1;
|
||||
border: none;
|
||||
@@ -1422,9 +1940,15 @@ onUnmounted(() => {
|
||||
min-width: 0;
|
||||
}
|
||||
.room-meta strong {
|
||||
display: block;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
.room-pin-ic {
|
||||
color: #0d9488;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.room-meta small {
|
||||
color: var(--muted);
|
||||
font-size: 0.78rem;
|
||||
@@ -1529,6 +2053,148 @@ onUnmounted(() => {
|
||||
opacity: 0.4;
|
||||
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 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -1731,6 +2397,7 @@ onUnmounted(() => {
|
||||
border-top: 1px solid var(--line);
|
||||
background: #f0f2f5;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
}
|
||||
.composer-row {
|
||||
display: flex;
|
||||
@@ -2248,6 +2915,13 @@ onUnmounted(() => {
|
||||
.rail-actions {
|
||||
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 {
|
||||
max-height: none;
|
||||
}
|
||||
@@ -2285,6 +2959,23 @@ onUnmounted(() => {
|
||||
top: 0;
|
||||
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 {
|
||||
padding: 0.75rem 0.7rem 0.5rem;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user