Editing and error correction (in progress)

This commit is contained in:
2026-09-30 12:43:19 +03:30
parent a3ed6187e9
commit 3c0ed3369e
5 changed files with 199 additions and 31 deletions
@@ -14,6 +14,7 @@ import {
AddConversationRoomMemberDto,
RemoveConversationRoomMemberDto,
UpdateConversationRoomMemberRoleDto,
ConversationTypingDto,
} from './dto';
import { ConversationsService } from './conversations.service';
@@ -43,6 +44,32 @@ export class ConversationsController {
return this.conversations.listRooms(user.userId, user.organizationId, user.role);
}
@Post('rooms/typing')
signalTyping(
@CurrentUser() user: { userId: string; organizationId: string; role: string },
@Body() dto: ConversationTypingDto,
) {
return this.conversations.signalTyping(
user.userId,
user.organizationId,
user.role,
dto.externalRoomId,
);
}
@Get('rooms/typing')
listTyping(
@CurrentUser() user: { userId: string; organizationId: string; role: string },
@Query('externalRoomId') externalRoomId: string,
) {
return this.conversations.listTyping(
user.userId,
user.organizationId,
user.role,
externalRoomId || '',
);
}
@Post('rooms/dm')
createDm(
@CurrentUser() user: { userId: string; organizationId: string; role: string },
@@ -26,12 +26,78 @@ import {
@Injectable()
export class ConversationsService {
/** presence: org|room|userId → epoch ms (بدون DB؛ کافی برای typing کوتاه‌عمر) */
private readonly typingPresence = new Map<string, number>();
private typingSweepAt = 0;
constructor(
private readonly prisma: PrismaService,
private readonly crypto: TahesabCryptoService,
private readonly chat: ChatExternalClient,
) {}
private typingKey(organizationId: string, roomId: string, userId: string) {
return `${organizationId}|${roomId}|${userId}`;
}
private sweepTypingPresence(now = Date.now()) {
if (now - this.typingSweepAt < 10_000) return;
this.typingSweepAt = now;
for (const [k, at] of this.typingPresence) {
if (now - at > 8000) this.typingPresence.delete(k);
}
}
async signalTyping(
userId: string,
organizationId: string,
role: string,
externalRoomId: string,
): Promise<{ ok: true }> {
await this.loadAccess(userId, organizationId, role);
const roomId = externalRoomId.trim();
if (!roomId) throw new BadRequestException('شناسه اتاق خالی است');
const now = Date.now();
this.typingPresence.set(this.typingKey(organizationId, roomId, userId), now);
this.sweepTypingPresence(now);
return { ok: true };
}
async listTyping(
userId: string,
organizationId: string,
role: string,
externalRoomId: string,
): Promise<Array<{ userId: string; fullName: string; chatUserId: string }>> {
await this.loadAccess(userId, organizationId, role);
const roomId = externalRoomId.trim();
if (!roomId) return [];
const now = Date.now();
this.sweepTypingPresence(now);
const prefix = `${organizationId}|${roomId}|`;
const ids: string[] = [];
for (const [k, at] of this.typingPresence) {
if (!k.startsWith(prefix)) continue;
if (now - at > 4000) {
this.typingPresence.delete(k);
continue;
}
const uid = k.slice(prefix.length);
if (!uid || uid === userId) continue;
ids.push(uid);
}
if (!ids.length) return [];
const users = await this.prisma.user.findMany({
where: { id: { in: ids } },
select: { id: true, fullName: true },
});
return users.map((u) => ({
userId: u.id,
fullName: u.fullName || 'کاربر',
chatUserId: buildChatUserId(organizationId, u.id),
}));
}
async getAccess(userId: string, organizationId: string, role: string): Promise<ConversationAccessDto> {
const access = await this.loadAccess(userId, organizationId, role);
return access;
+7
View File
@@ -162,3 +162,10 @@ export class UpdateConversationRoomMemberRoleDto {
@IsString()
role!: 'admin' | 'member';
}
export class ConversationTypingDto {
@IsString()
@MinLength(1)
@MaxLength(200)
externalRoomId!: string;
}
+92 -29
View File
@@ -221,17 +221,20 @@ const hasMoreMessages = ref(false);
const loadingOlderMessages = ref(false);
/** تعداد خام پیام‌های گرفته‌شده از API (برای offset) */
let fetchedRawCount = 0;
const typingMap = ref<Record<string, Array<{ userId: string; at: number }>>>({});
const typingMap = ref<
Record<string, Array<{ userId: string; name?: string; at: number }>>
>({});
let typingSweepTimer: ReturnType<typeof setInterval> | null = null;
let typingPollTimer: ReturnType<typeof setInterval> | null = null;
function startTypingSweep() {
if (typingSweepTimer) return;
typingSweepTimer = setInterval(() => {
const now = Date.now();
let changed = false;
const next: Record<string, Array<{ userId: string; at: number }>> = {};
const next: Record<string, Array<{ userId: string; name?: string; at: number }>> = {};
for (const [roomId, list] of Object.entries(typingMap.value)) {
const kept = list.filter((t) => now - t.at < 3500);
const kept = list.filter((t) => now - t.at < 4000);
if (kept.length) next[roomId] = kept;
if (kept.length !== list.length) changed = true;
}
@@ -239,17 +242,51 @@ function startTypingSweep() {
}, 1000);
}
function applyTypingEvent(roomId: string, userId: string) {
function applyTypingEvent(roomId: string, userId: string, name?: string) {
const rid = String(roomId || activeRoomId.value || '').trim();
const uid = String(userId || '').trim() || '__someone__';
if (!rid) return;
if (sessionCache?.chatUserId && uid === sessionCache.chatUserId) return;
startTypingSweep();
const list = [...(typingMap.value[rid] || [])].filter((t) => t.userId !== uid);
list.push({ userId: uid, at: Date.now() });
list.push({ userId: uid, name: name || undefined, at: Date.now() });
typingMap.value = { ...typingMap.value, [rid]: list };
}
function applyTypingFromServer(
roomId: string,
typers: Array<{ userId: string; fullName: string; chatUserId: string }>,
) {
const rid = String(roomId || '').trim();
if (!rid) return;
const now = Date.now();
const list = typers
.map((t) => ({
userId: t.chatUserId || t.userId,
name: t.fullName || undefined,
at: now,
}))
.filter((t) => t.userId && t.userId !== sessionCache?.chatUserId);
const prev = typingMap.value[rid] || [];
// نگه داشتن رویدادهای خیلی تازه از WS اگر سرور هنوز خالی است
const merged = new Map<string, { userId: string; name?: string; at: number }>();
for (const t of prev) {
if (now - t.at < 4000) merged.set(t.userId, t);
}
for (const t of list) merged.set(t.userId, t);
const nextList = [...merged.values()];
if (!nextList.length) {
if (prev.length) {
const next = { ...typingMap.value };
delete next[rid];
typingMap.value = next;
}
return;
}
startTypingSweep();
typingMap.value = { ...typingMap.value, [rid]: nextList };
}
function parseTypingCommand(content: string): string | null {
const t = content.trim();
if (!t.startsWith(TYPING_CMD_PREFIX)) return null;
@@ -274,6 +311,34 @@ function clearTypingUser(roomId: string, userId: string) {
typingMap.value = next;
}
function stopTypingPoll() {
if (typingPollTimer) {
clearInterval(typingPollTimer);
typingPollTimer = null;
}
}
function startTypingPoll(roomId: string) {
stopTypingPoll();
const rid = String(roomId || '').trim();
if (!rid) return;
const tick = async () => {
if (activeRoomId.value !== rid) return;
try {
const { data } = await api.get<
Array<{ userId: string; fullName: string; chatUserId: string }>
>('/conversations/rooms/typing', { params: { externalRoomId: rid } });
applyTypingFromServer(rid, Array.isArray(data) ? data : []);
} catch {
// ignore
}
};
void tick();
typingPollTimer = setInterval(() => {
void tick();
}, 1400);
}
const PAGE_SIZE = 40;
function messageTs(item: ChatMessage): number {
@@ -1101,6 +1166,7 @@ export function useOrgChat() {
hasMoreMessages.value = false;
clearTypingForRoom(roomId);
startHiddenPoll(roomId);
startTypingPoll(roomId);
try {
const session = await ensureSession();
await connectSocket();
@@ -1168,33 +1234,28 @@ export function useOrgChat() {
const rid = String(roomId || '').trim();
if (!rid) return;
const now = Date.now();
if (now - lastTypingSentAt < 1200) return;
if (now - lastTypingSentAt < 1100) return;
lastTypingSentAt = now;
// مسیر اصلی: CRM presence (سرویس خارجی typing را relay نمی‌کند)
try {
await api.post('/conversations/rooms/typing', { externalRoomId: rid });
} catch {
// ignore
}
// best-effort native WS typing
try {
await connectSocket();
if (!socket || socket.readyState !== WebSocket.OPEN) return;
const session = await ensureSession();
// Native typing event (if server relays it)
socket.send(
JSON.stringify({
type: 'typing',
room_id: rid,
user_id: session.chatUserId,
sender_id: session.chatUserId,
}),
);
// Fallback control text — external chat reliably delivers text; filtered from UI
const controlId = crypto.randomUUID();
rememberHidden(rid, [controlId]);
socket.send(
JSON.stringify({
id: controlId,
type: 'text',
room_id: rid,
content: `${TYPING_CMD_PREFIX}${session.chatUserId}`,
}),
);
void persistHiddenOnServer(rid, [controlId], true);
if (socket && socket.readyState === WebSocket.OPEN) {
const session = await ensureSession();
socket.send(
JSON.stringify({
type: 'typing',
room_id: rid,
user_id: session.chatUserId,
sender_id: session.chatUserId,
}),
);
}
} catch {
// ignore
}
@@ -1576,6 +1637,7 @@ export function useOrgChat() {
function clearActiveRoom() {
stopHiddenPoll();
stopTypingPoll();
activeRoomId.value = '';
messages.value = [];
}
@@ -1584,6 +1646,7 @@ export function useOrgChat() {
wantSocket = false;
sessionCache = null;
stopHiddenPoll();
stopTypingPoll();
disconnectSocket();
messages.value = [];
activeRoomId.value = '';
+7 -2
View File
@@ -170,13 +170,18 @@ const typingLabel = computed(() => {
const roomId = activeRoom.value?.externalRoomId;
if (!roomId) return '';
const list = (typingMap.value?.[roomId] || []).filter(
(t) => Date.now() - t.at < 3500,
(t) => Date.now() - t.at < 4000,
);
if (!list.length) return '';
const names = list
.map((t) => {
if (t.name) return t.name;
if (t.userId === '__someone__') return '';
return peers.value.find((p) => p.chatUserId === t.userId)?.fullName || '';
return (
peers.value.find((p) => p.chatUserId === t.userId)?.fullName ||
peers.value.find((p) => p.id === t.userId)?.fullName ||
''
);
})
.filter(Boolean);
if (!names.length) return 'در حال نوشتن…';