Editing and error correction (in progress)
This commit is contained in:
@@ -14,6 +14,7 @@ import {
|
|||||||
AddConversationRoomMemberDto,
|
AddConversationRoomMemberDto,
|
||||||
RemoveConversationRoomMemberDto,
|
RemoveConversationRoomMemberDto,
|
||||||
UpdateConversationRoomMemberRoleDto,
|
UpdateConversationRoomMemberRoleDto,
|
||||||
|
ConversationTypingDto,
|
||||||
} from './dto';
|
} from './dto';
|
||||||
import { ConversationsService } from './conversations.service';
|
import { ConversationsService } from './conversations.service';
|
||||||
|
|
||||||
@@ -43,6 +44,32 @@ export class ConversationsController {
|
|||||||
return this.conversations.listRooms(user.userId, user.organizationId, user.role);
|
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')
|
@Post('rooms/dm')
|
||||||
createDm(
|
createDm(
|
||||||
@CurrentUser() user: { userId: string; organizationId: string; role: string },
|
@CurrentUser() user: { userId: string; organizationId: string; role: string },
|
||||||
|
|||||||
@@ -26,12 +26,78 @@ import {
|
|||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ConversationsService {
|
export class ConversationsService {
|
||||||
|
/** presence: org|room|userId → epoch ms (بدون DB؛ کافی برای typing کوتاهعمر) */
|
||||||
|
private readonly typingPresence = new Map<string, number>();
|
||||||
|
private typingSweepAt = 0;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly crypto: TahesabCryptoService,
|
private readonly crypto: TahesabCryptoService,
|
||||||
private readonly chat: ChatExternalClient,
|
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> {
|
async getAccess(userId: string, organizationId: string, role: string): Promise<ConversationAccessDto> {
|
||||||
const access = await this.loadAccess(userId, organizationId, role);
|
const access = await this.loadAccess(userId, organizationId, role);
|
||||||
return access;
|
return access;
|
||||||
|
|||||||
@@ -162,3 +162,10 @@ export class UpdateConversationRoomMemberRoleDto {
|
|||||||
@IsString()
|
@IsString()
|
||||||
role!: 'admin' | 'member';
|
role!: 'admin' | 'member';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class ConversationTypingDto {
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
@MaxLength(200)
|
||||||
|
externalRoomId!: string;
|
||||||
|
}
|
||||||
|
|||||||
@@ -221,17 +221,20 @@ const hasMoreMessages = ref(false);
|
|||||||
const loadingOlderMessages = ref(false);
|
const loadingOlderMessages = ref(false);
|
||||||
/** تعداد خام پیامهای گرفتهشده از API (برای offset) */
|
/** تعداد خام پیامهای گرفتهشده از API (برای offset) */
|
||||||
let fetchedRawCount = 0;
|
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 typingSweepTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
let typingPollTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
function startTypingSweep() {
|
function startTypingSweep() {
|
||||||
if (typingSweepTimer) return;
|
if (typingSweepTimer) return;
|
||||||
typingSweepTimer = setInterval(() => {
|
typingSweepTimer = setInterval(() => {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
let changed = false;
|
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)) {
|
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) next[roomId] = kept;
|
||||||
if (kept.length !== list.length) changed = true;
|
if (kept.length !== list.length) changed = true;
|
||||||
}
|
}
|
||||||
@@ -239,17 +242,51 @@ function startTypingSweep() {
|
|||||||
}, 1000);
|
}, 1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyTypingEvent(roomId: string, userId: string) {
|
function applyTypingEvent(roomId: string, userId: string, name?: string) {
|
||||||
const rid = String(roomId || activeRoomId.value || '').trim();
|
const rid = String(roomId || activeRoomId.value || '').trim();
|
||||||
const uid = String(userId || '').trim() || '__someone__';
|
const uid = String(userId || '').trim() || '__someone__';
|
||||||
if (!rid) return;
|
if (!rid) return;
|
||||||
if (sessionCache?.chatUserId && uid === sessionCache.chatUserId) return;
|
if (sessionCache?.chatUserId && uid === sessionCache.chatUserId) return;
|
||||||
startTypingSweep();
|
startTypingSweep();
|
||||||
const list = [...(typingMap.value[rid] || [])].filter((t) => t.userId !== uid);
|
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 };
|
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 {
|
function parseTypingCommand(content: string): string | null {
|
||||||
const t = content.trim();
|
const t = content.trim();
|
||||||
if (!t.startsWith(TYPING_CMD_PREFIX)) return null;
|
if (!t.startsWith(TYPING_CMD_PREFIX)) return null;
|
||||||
@@ -274,6 +311,34 @@ function clearTypingUser(roomId: string, userId: string) {
|
|||||||
typingMap.value = next;
|
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;
|
const PAGE_SIZE = 40;
|
||||||
|
|
||||||
function messageTs(item: ChatMessage): number {
|
function messageTs(item: ChatMessage): number {
|
||||||
@@ -1101,6 +1166,7 @@ export function useOrgChat() {
|
|||||||
hasMoreMessages.value = false;
|
hasMoreMessages.value = false;
|
||||||
clearTypingForRoom(roomId);
|
clearTypingForRoom(roomId);
|
||||||
startHiddenPoll(roomId);
|
startHiddenPoll(roomId);
|
||||||
|
startTypingPoll(roomId);
|
||||||
try {
|
try {
|
||||||
const session = await ensureSession();
|
const session = await ensureSession();
|
||||||
await connectSocket();
|
await connectSocket();
|
||||||
@@ -1168,33 +1234,28 @@ export function useOrgChat() {
|
|||||||
const rid = String(roomId || '').trim();
|
const rid = String(roomId || '').trim();
|
||||||
if (!rid) return;
|
if (!rid) return;
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
if (now - lastTypingSentAt < 1200) return;
|
if (now - lastTypingSentAt < 1100) return;
|
||||||
lastTypingSentAt = now;
|
lastTypingSentAt = now;
|
||||||
|
// مسیر اصلی: CRM presence (سرویس خارجی typing را relay نمیکند)
|
||||||
|
try {
|
||||||
|
await api.post('/conversations/rooms/typing', { externalRoomId: rid });
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
// best-effort native WS typing
|
||||||
try {
|
try {
|
||||||
await connectSocket();
|
await connectSocket();
|
||||||
if (!socket || socket.readyState !== WebSocket.OPEN) return;
|
if (socket && socket.readyState === WebSocket.OPEN) {
|
||||||
const session = await ensureSession();
|
const session = await ensureSession();
|
||||||
// Native typing event (if server relays it)
|
socket.send(
|
||||||
socket.send(
|
JSON.stringify({
|
||||||
JSON.stringify({
|
type: 'typing',
|
||||||
type: 'typing',
|
room_id: rid,
|
||||||
room_id: rid,
|
user_id: session.chatUserId,
|
||||||
user_id: session.chatUserId,
|
sender_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);
|
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
@@ -1576,6 +1637,7 @@ export function useOrgChat() {
|
|||||||
|
|
||||||
function clearActiveRoom() {
|
function clearActiveRoom() {
|
||||||
stopHiddenPoll();
|
stopHiddenPoll();
|
||||||
|
stopTypingPoll();
|
||||||
activeRoomId.value = '';
|
activeRoomId.value = '';
|
||||||
messages.value = [];
|
messages.value = [];
|
||||||
}
|
}
|
||||||
@@ -1584,6 +1646,7 @@ export function useOrgChat() {
|
|||||||
wantSocket = false;
|
wantSocket = false;
|
||||||
sessionCache = null;
|
sessionCache = null;
|
||||||
stopHiddenPoll();
|
stopHiddenPoll();
|
||||||
|
stopTypingPoll();
|
||||||
disconnectSocket();
|
disconnectSocket();
|
||||||
messages.value = [];
|
messages.value = [];
|
||||||
activeRoomId.value = '';
|
activeRoomId.value = '';
|
||||||
|
|||||||
@@ -170,13 +170,18 @@ const typingLabel = computed(() => {
|
|||||||
const roomId = activeRoom.value?.externalRoomId;
|
const roomId = activeRoom.value?.externalRoomId;
|
||||||
if (!roomId) return '';
|
if (!roomId) return '';
|
||||||
const list = (typingMap.value?.[roomId] || []).filter(
|
const list = (typingMap.value?.[roomId] || []).filter(
|
||||||
(t) => Date.now() - t.at < 3500,
|
(t) => Date.now() - t.at < 4000,
|
||||||
);
|
);
|
||||||
if (!list.length) return '';
|
if (!list.length) return '';
|
||||||
const names = list
|
const names = list
|
||||||
.map((t) => {
|
.map((t) => {
|
||||||
|
if (t.name) return t.name;
|
||||||
if (t.userId === '__someone__') return '';
|
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);
|
.filter(Boolean);
|
||||||
if (!names.length) return 'در حال نوشتن…';
|
if (!names.length) return 'در حال نوشتن…';
|
||||||
|
|||||||
Reference in New Issue
Block a user