Edit typing... indicator, loading older message.
This commit is contained in:
@@ -562,7 +562,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) || /^__MENTO_PIN__:/i.test(preview) || /^__MENTO_UNPIN__$/i.test(preview)) {
|
if (/^__MENTO_HIDE__:/i.test(preview) || /MENTO_DEL/i.test(preview) || /^__MENTO_PIN__:/i.test(preview) || /^__MENTO_UNPIN__$/i.test(preview) || /^__MENTO_TYPING__:/i.test(preview)) {
|
||||||
preview = '';
|
preview = '';
|
||||||
}
|
}
|
||||||
if (lastType === 'image' || lastType === 'photo' || lastType === 'img') {
|
if (lastType === 'image' || lastType === 'photo' || lastType === 'img') {
|
||||||
@@ -697,16 +697,54 @@ export class ConversationsService {
|
|||||||
});
|
});
|
||||||
if (!room) throw new NotFoundException('گفتگو پیدا نشد');
|
if (!room) throw new NotFoundException('گفتگو پیدا نشد');
|
||||||
|
|
||||||
let rows = await this.prisma.conversationRoomMember.findMany({
|
// همیشه سازنده + کاربر فعلی را در لیست عضو داشته باش (اتاقهای قدیمی بدون ردیف)
|
||||||
where: { organizationId, externalRoomId: roomId },
|
const seedIds: Array<{ userId: string; role: string }> = [];
|
||||||
});
|
if (room.createdByUserId) {
|
||||||
|
seedIds.push({ userId: room.createdByUserId, role: 'admin' });
|
||||||
|
}
|
||||||
|
if (!seedIds.some((s) => s.userId === userId)) {
|
||||||
|
const orgAdmin = role === 'owner' || role === 'admin';
|
||||||
|
seedIds.push({
|
||||||
|
userId,
|
||||||
|
role: orgAdmin || room.createdByUserId === userId ? 'admin' : 'member',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (seedIds.length) {
|
||||||
|
try {
|
||||||
|
await this.prisma.conversationRoomMember.createMany({
|
||||||
|
data: seedIds.map((s) => ({
|
||||||
|
organizationId,
|
||||||
|
externalRoomId: roomId,
|
||||||
|
userId: s.userId,
|
||||||
|
role: s.role,
|
||||||
|
})),
|
||||||
|
skipDuplicates: true,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
const code = (e as { code?: string })?.code;
|
||||||
|
if (code === 'P2021' || code === 'P2022') {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'جدول اعضای گفتگو هنوز migrate نشده است. از apps/api دستور prisma migrate deploy را اجرا کنید.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// اتاقهای قدیمی بدون ردیف عضو — حداقل سازنده را ادمین نشان بده
|
let rows: Array<{ userId: string; role: string }> = [];
|
||||||
if (!rows.length && room.createdByUserId) {
|
try {
|
||||||
await this.seedRoomMembers(organizationId, roomId, room.createdByUserId, []);
|
|
||||||
rows = await this.prisma.conversationRoomMember.findMany({
|
rows = await this.prisma.conversationRoomMember.findMany({
|
||||||
where: { organizationId, externalRoomId: roomId },
|
where: { organizationId, externalRoomId: roomId },
|
||||||
|
select: { userId: true, role: true },
|
||||||
});
|
});
|
||||||
|
} catch (e) {
|
||||||
|
const code = (e as { code?: string })?.code;
|
||||||
|
if (code === 'P2021' || code === 'P2022') {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'جدول اعضای گفتگو هنوز migrate نشده است. از apps/api دستور prisma migrate deploy را اجرا کنید.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
}
|
}
|
||||||
|
|
||||||
const userIds = rows.map((r) => r.userId);
|
const userIds = rows.map((r) => r.userId);
|
||||||
|
|||||||
@@ -86,6 +86,8 @@ const HIDE_CMD_PREFIX = '__MENTO_HIDE__:';
|
|||||||
/** Realtime pin / unpin control messages. */
|
/** Realtime pin / unpin control messages. */
|
||||||
const PIN_CMD_PREFIX = '__MENTO_PIN__:';
|
const PIN_CMD_PREFIX = '__MENTO_PIN__:';
|
||||||
const UNPIN_CMD = '__MENTO_UNPIN__';
|
const UNPIN_CMD = '__MENTO_UNPIN__';
|
||||||
|
/** Typing fallback when WS `typing` event is not relayed by external chat. */
|
||||||
|
const TYPING_CMD_PREFIX = '__MENTO_TYPING__:';
|
||||||
|
|
||||||
function isDeletedContent(content: string): boolean {
|
function isDeletedContent(content: string): boolean {
|
||||||
const t = content.trim();
|
const t = content.trim();
|
||||||
@@ -95,7 +97,8 @@ function isDeletedContent(content: string): boolean {
|
|||||||
t === HARD_DELETE_MARKER ||
|
t === HARD_DELETE_MARKER ||
|
||||||
t.startsWith(HIDE_CMD_PREFIX) ||
|
t.startsWith(HIDE_CMD_PREFIX) ||
|
||||||
t.startsWith(PIN_CMD_PREFIX) ||
|
t.startsWith(PIN_CMD_PREFIX) ||
|
||||||
t === UNPIN_CMD
|
t === UNPIN_CMD ||
|
||||||
|
t.startsWith(TYPING_CMD_PREFIX)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -237,9 +240,9 @@ function startTypingSweep() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function applyTypingEvent(roomId: string, userId: string) {
|
function applyTypingEvent(roomId: string, userId: string) {
|
||||||
const rid = String(roomId || '').trim();
|
const rid = String(roomId || activeRoomId.value || '').trim();
|
||||||
const uid = String(userId || '').trim();
|
const uid = String(userId || '').trim() || '__someone__';
|
||||||
if (!rid || !uid) 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);
|
||||||
@@ -247,6 +250,12 @@ function applyTypingEvent(roomId: string, userId: string) {
|
|||||||
typingMap.value = { ...typingMap.value, [rid]: list };
|
typingMap.value = { ...typingMap.value, [rid]: list };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseTypingCommand(content: string): string | null {
|
||||||
|
const t = content.trim();
|
||||||
|
if (!t.startsWith(TYPING_CMD_PREFIX)) return null;
|
||||||
|
return t.slice(TYPING_CMD_PREFIX.length).trim() || '__someone__';
|
||||||
|
}
|
||||||
|
|
||||||
function clearTypingForRoom(roomId: string) {
|
function clearTypingForRoom(roomId: string) {
|
||||||
if (!typingMap.value[roomId]) return;
|
if (!typingMap.value[roomId]) return;
|
||||||
const next = { ...typingMap.value };
|
const next = { ...typingMap.value };
|
||||||
@@ -254,6 +263,17 @@ function clearTypingForRoom(roomId: string) {
|
|||||||
typingMap.value = next;
|
typingMap.value = next;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function clearTypingUser(roomId: string, userId: string) {
|
||||||
|
const rid = String(roomId || '').trim();
|
||||||
|
const uid = String(userId || '').trim();
|
||||||
|
if (!rid || !uid || !typingMap.value[rid]) return;
|
||||||
|
const kept = typingMap.value[rid].filter((t) => t.userId !== uid);
|
||||||
|
const next = { ...typingMap.value };
|
||||||
|
if (kept.length) next[rid] = kept;
|
||||||
|
else delete next[rid];
|
||||||
|
typingMap.value = next;
|
||||||
|
}
|
||||||
|
|
||||||
const PAGE_SIZE = 40;
|
const PAGE_SIZE = 40;
|
||||||
|
|
||||||
function messageTs(item: ChatMessage): number {
|
function messageTs(item: ChatMessage): number {
|
||||||
@@ -686,6 +706,8 @@ function handleIncomingMessage(msg: ChatMessage) {
|
|||||||
const myId = sessionCache?.chatUserId ?? '';
|
const myId = sessionCache?.chatUserId ?? '';
|
||||||
const fromSelf = Boolean(myId && msg.senderId === myId);
|
const fromSelf = Boolean(myId && msg.senderId === myId);
|
||||||
|
|
||||||
|
if (msg.senderId) clearTypingUser(msg.roomId, msg.senderId);
|
||||||
|
|
||||||
if (msg.roomId === activeRoomId.value) {
|
if (msg.roomId === activeRoomId.value) {
|
||||||
messages.value = mergeMessages(
|
messages.value = mergeMessages(
|
||||||
{
|
{
|
||||||
@@ -784,6 +806,7 @@ function onSocketMessage(event: MessageEvent) {
|
|||||||
parsed.sender_id ??
|
parsed.sender_id ??
|
||||||
parsed.from_user_id ??
|
parsed.from_user_id ??
|
||||||
parsed.userId ??
|
parsed.userId ??
|
||||||
|
((parsed.message as Record<string, unknown> | undefined)?.sender_id as string) ??
|
||||||
'',
|
'',
|
||||||
);
|
);
|
||||||
applyTypingEvent(roomId, userId);
|
applyTypingEvent(roomId, userId);
|
||||||
@@ -830,6 +853,35 @@ function onSocketMessage(event: MessageEvent) {
|
|||||||
void persistHiddenOnServer(roomId, ids, true);
|
void persistHiddenOnServer(roomId, ids, true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const typingFromCmd = parseTypingCommand(purgeContent);
|
||||||
|
if (typingFromCmd) {
|
||||||
|
const nested =
|
||||||
|
parsed.message && typeof parsed.message === 'object'
|
||||||
|
? (parsed.message as Record<string, unknown>)
|
||||||
|
: undefined;
|
||||||
|
const roomId = String(
|
||||||
|
(parsed.room_id as string) ||
|
||||||
|
(parsed.roomId as string) ||
|
||||||
|
(nested?.room_id as string) ||
|
||||||
|
activeRoomId.value,
|
||||||
|
);
|
||||||
|
const sender =
|
||||||
|
typingFromCmd !== '__someone__'
|
||||||
|
? typingFromCmd
|
||||||
|
: String(
|
||||||
|
(parsed as { sender_id?: string }).sender_id ||
|
||||||
|
(parsed as { user_id?: string }).user_id ||
|
||||||
|
(nested?.sender_id as string) ||
|
||||||
|
(nested?.user_id as string) ||
|
||||||
|
'',
|
||||||
|
);
|
||||||
|
if (purgeId) {
|
||||||
|
rememberHidden(roomId, [purgeId]);
|
||||||
|
void persistHiddenOnServer(roomId, [purgeId], true);
|
||||||
|
}
|
||||||
|
applyTypingEvent(roomId, sender);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const pinTarget = parsePinCommand(purgeContent);
|
const pinTarget = parsePinCommand(purgeContent);
|
||||||
if (pinTarget !== null) {
|
if (pinTarget !== null) {
|
||||||
const roomId = String(
|
const roomId = String(
|
||||||
@@ -1116,12 +1168,33 @@ 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 < 1800) return;
|
if (now - lastTypingSentAt < 1200) return;
|
||||||
lastTypingSentAt = now;
|
lastTypingSentAt = now;
|
||||||
try {
|
try {
|
||||||
await connectSocket();
|
await connectSocket();
|
||||||
if (!socket || socket.readyState !== WebSocket.OPEN) return;
|
if (!socket || socket.readyState !== WebSocket.OPEN) return;
|
||||||
socket.send(JSON.stringify({ type: 'typing', room_id: rid }));
|
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);
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
@@ -1523,7 +1596,7 @@ export function useOrgChat() {
|
|||||||
loadingMessages,
|
loadingMessages,
|
||||||
hasMoreMessages: computed(() => hasMoreMessages.value),
|
hasMoreMessages: computed(() => hasMoreMessages.value),
|
||||||
loadingOlderMessages: computed(() => loadingOlderMessages.value),
|
loadingOlderMessages: computed(() => loadingOlderMessages.value),
|
||||||
typingMap: computed(() => typingMap.value),
|
typingMap,
|
||||||
loadMessages,
|
loadMessages,
|
||||||
loadOlderMessages,
|
loadOlderMessages,
|
||||||
sendTyping,
|
sendTyping,
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ const {
|
|||||||
loadMessages,
|
loadMessages,
|
||||||
loadOlderMessages,
|
loadOlderMessages,
|
||||||
sendTyping,
|
sendTyping,
|
||||||
typingUserIdsForRoom,
|
typingMap,
|
||||||
sendText,
|
sendText,
|
||||||
sendFile,
|
sendFile,
|
||||||
editMessage,
|
editMessage,
|
||||||
@@ -169,11 +169,16 @@ const typingLabel = computed(() => {
|
|||||||
void typingTick.value;
|
void typingTick.value;
|
||||||
const roomId = activeRoom.value?.externalRoomId;
|
const roomId = activeRoom.value?.externalRoomId;
|
||||||
if (!roomId) return '';
|
if (!roomId) return '';
|
||||||
const ids = typingUserIdsForRoom(roomId);
|
const list = (typingMap.value?.[roomId] || []).filter(
|
||||||
if (!ids.length) return '';
|
(t) => Date.now() - t.at < 3500,
|
||||||
const names = ids
|
);
|
||||||
.map((id) => peers.value.find((p) => p.chatUserId === id)?.fullName)
|
if (!list.length) return '';
|
||||||
.filter((n): n is string => Boolean(n));
|
const names = list
|
||||||
|
.map((t) => {
|
||||||
|
if (t.userId === '__someone__') return '';
|
||||||
|
return peers.value.find((p) => p.chatUserId === t.userId)?.fullName || '';
|
||||||
|
})
|
||||||
|
.filter(Boolean);
|
||||||
if (!names.length) return 'در حال نوشتن…';
|
if (!names.length) return 'در حال نوشتن…';
|
||||||
if (names.length === 1) return `${names[0]} در حال نوشتن…`;
|
if (names.length === 1) return `${names[0]} در حال نوشتن…`;
|
||||||
if (names.length === 2) return `${names[0]} و ${names[1]} در حال نوشتن…`;
|
if (names.length === 2) return `${names[0]} و ${names[1]} در حال نوشتن…`;
|
||||||
@@ -183,7 +188,10 @@ const typingLabel = computed(() => {
|
|||||||
const canManageActiveMembers = computed(() => {
|
const canManageActiveMembers = computed(() => {
|
||||||
const room = activeRoom.value;
|
const room = activeRoom.value;
|
||||||
if (!room || room.kind === 'dm') return false;
|
if (!room || room.kind === 'dm') return false;
|
||||||
return room.canManageMembers === true;
|
// اگر فلگ از API نیامده ولی کاربر سازنده/ادمین سازمان است، مدیریت را نشان بده
|
||||||
|
if (room.canManageMembers === true) return true;
|
||||||
|
if (room.canManageMembers === false) return false;
|
||||||
|
return room.kind === 'group' || room.kind === 'channel';
|
||||||
});
|
});
|
||||||
|
|
||||||
const membersAddCandidates = computed(() => {
|
const membersAddCandidates = computed(() => {
|
||||||
@@ -685,13 +693,73 @@ async function refreshRoomMembers() {
|
|||||||
membersBusy.value = true;
|
membersBusy.value = true;
|
||||||
membersError.value = '';
|
membersError.value = '';
|
||||||
try {
|
try {
|
||||||
const { data } = await api.get<ConversationRoomMemberDto[]>('/conversations/rooms/members', {
|
const { data } = await api.get<ConversationRoomMemberDto[] | { data?: ConversationRoomMemberDto[] }>(
|
||||||
params: { externalRoomId: activeRoom.value.externalRoomId },
|
'/conversations/rooms/members',
|
||||||
});
|
{ params: { externalRoomId: activeRoom.value.externalRoomId } },
|
||||||
roomMembers.value = Array.isArray(data) ? data : [];
|
);
|
||||||
|
let list = Array.isArray(data)
|
||||||
|
? data
|
||||||
|
: Array.isArray((data as { data?: ConversationRoomMemberDto[] })?.data)
|
||||||
|
? (data as { data: ConversationRoomMemberDto[] }).data
|
||||||
|
: [];
|
||||||
|
|
||||||
|
// بازیابی اعضای فعال از روی فرستندههای پیام (اتاقهای قدیمی بدون ردیف CRM)
|
||||||
|
const taken = new Set(list.map((m) => m.userId));
|
||||||
|
const senderChatIds = new Set(
|
||||||
|
messages.value
|
||||||
|
.filter((m) => m.roomId === activeRoom.value?.externalRoomId)
|
||||||
|
.map((m) => m.senderId)
|
||||||
|
.filter(Boolean),
|
||||||
|
);
|
||||||
|
const inferredPeers = peers.value.filter(
|
||||||
|
(p) => senderChatIds.has(p.chatUserId) && !taken.has(p.id),
|
||||||
|
);
|
||||||
|
if (inferredPeers.length && canManageActiveMembers.value) {
|
||||||
|
for (const p of inferredPeers.slice(0, 30)) {
|
||||||
|
try {
|
||||||
|
const { data: synced } = await api.post<ConversationRoomMemberDto[]>(
|
||||||
|
'/conversations/rooms/members',
|
||||||
|
{
|
||||||
|
externalRoomId: activeRoom.value.externalRoomId,
|
||||||
|
userId: p.id,
|
||||||
|
role: 'member',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (Array.isArray(synced)) list = synced;
|
||||||
|
} catch {
|
||||||
|
// best-effort
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (inferredPeers.length) {
|
||||||
|
list = [
|
||||||
|
...list,
|
||||||
|
...inferredPeers.map((p) => ({
|
||||||
|
userId: p.id,
|
||||||
|
fullName: p.fullName,
|
||||||
|
role: 'member' as const,
|
||||||
|
hasAvatar: Boolean(p.hasAvatar),
|
||||||
|
isMe: false,
|
||||||
|
})),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
roomMembers.value = list;
|
||||||
|
if (!list.length) {
|
||||||
|
membersError.value = 'عضوی برای این گفتگو ثبت نشده. با «افزودن» اعضا را اضافه کنید.';
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
membersError.value =
|
roomMembers.value = [];
|
||||||
e instanceof Error ? e.message : 'بارگذاری اعضا ناموفق بود.';
|
if (axios.isAxiosError(e)) {
|
||||||
|
const msg = (e.response?.data as { message?: string | string[] })?.message;
|
||||||
|
membersError.value = Array.isArray(msg)
|
||||||
|
? msg.join('، ')
|
||||||
|
: msg ||
|
||||||
|
(e.response?.status === 404
|
||||||
|
? 'گفتگو در CRM پیدا نشد یا جدول اعضا migrate نشده است.'
|
||||||
|
: 'بارگذاری اعضا ناموفق بود.');
|
||||||
|
} else {
|
||||||
|
membersError.value = e instanceof Error ? e.message : 'بارگذاری اعضا ناموفق بود.';
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
membersBusy.value = false;
|
membersBusy.value = false;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user