Refining the CRM chat interface

This commit is contained in:
2026-09-29 11:56:32 +03:30
parent 0670067e7f
commit 125976f3eb
2 changed files with 90 additions and 20 deletions
+69 -18
View File
@@ -22,16 +22,33 @@ const messages = shallowRef<ChatMessage[]>([]);
const activeRoomId = ref('');
let pingTimer: ReturnType<typeof setInterval> | null = null;
function normalizeIncoming(raw: Record<string, unknown>, roomFallback: string): ChatMessage | null {
function messageTs(item: ChatMessage): number {
const t = new Date(item.createdAt).getTime();
return Number.isNaN(t) ? 0 : t;
}
/** قدیمی → جدید (آخرین پیام پایین لیست) */
function sortAsc(items: ChatMessage[]): ChatMessage[] {
return [...items].sort((a, b) => {
const d = messageTs(a) - messageTs(b);
if (d !== 0) return d;
return a.id.localeCompare(b.id);
});
}
function normalizeIncoming(
raw: Record<string, unknown>,
roomFallback: string,
): ChatMessage | null {
const base =
raw.message && typeof raw.message === 'object'
? (raw.message as Record<string, unknown>)
: raw;
const content = String(base.content ?? base.text ?? base.body ?? '').trim();
if (!content && !base.attachments) return null;
const id = String(base.id ?? base.message_id ?? crypto.randomUUID());
const id = String(base.id ?? base.message_id ?? '').trim();
return {
id,
id: id || `tmp-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
roomId: String(base.room_id ?? raw.room_id ?? roomFallback),
senderId: String(
base.sender_id ?? base.user_id ?? base.from_user_id ?? raw.sender_id ?? '',
@@ -45,6 +62,36 @@ function normalizeIncoming(raw: Record<string, unknown>, roomFallback: string):
};
}
/** ادغام: همان id جایگزین می‌شود؛ optimistic مشابه با echo سوکت یکی می‌شود */
function mergeMessages(incoming: ChatMessage, list: ChatMessage[]): ChatMessage[] {
const byId = list.findIndex((m) => m.id === incoming.id);
if (byId >= 0) {
const next = [...list];
next[byId] = { ...list[byId], ...incoming, optimistic: false };
return sortAsc(next);
}
// echo سوکت بعد از optimistic با همان متن/فرستنده/اتاق
const optimisticIdx = list.findIndex(
(m) =>
m.optimistic &&
m.roomId === incoming.roomId &&
m.senderId === incoming.senderId &&
m.content === incoming.content,
);
if (optimisticIdx >= 0) {
const next = [...list];
next[optimisticIdx] = {
...list[optimisticIdx],
...incoming,
optimistic: false,
};
return sortAsc(next);
}
return sortAsc([...list, incoming]);
}
async function ensureSession(): Promise<ConversationSessionDto> {
if (sessionCache) return sessionCache;
const { data } = await api.get<ConversationSessionDto>('/conversations/session');
@@ -68,6 +115,21 @@ function disconnectSocket() {
status.value = 'closed';
}
function onSocketMessage(event: MessageEvent) {
const raw = String(event.data ?? '').trim();
if (!raw || raw === 'pong' || raw === 'ping') return;
try {
const parsed = JSON.parse(raw) as Record<string, unknown>;
const type = String(parsed.type ?? '').toLowerCase();
if (type === 'seen' || type === 'typing' || type === 'read' || type === 'pong') return;
const msg = normalizeIncoming(parsed, activeRoomId.value);
if (!msg || !activeRoomId.value || msg.roomId !== activeRoomId.value) return;
messages.value = mergeMessages(msg, messages.value);
} catch {
// ignore
}
}
async function connectSocket() {
const session = await ensureSession();
if (socket?.readyState === WebSocket.OPEN) return;
@@ -88,19 +150,7 @@ async function connectSocket() {
clearPing();
});
socket.addEventListener('message', (event) => {
const raw = String(event.data ?? '').trim();
if (!raw || raw === 'pong' || raw === 'ping') return;
try {
const parsed = JSON.parse(raw) as Record<string, unknown>;
if (parsed.type === 'seen' || parsed.type === 'typing' || parsed.type === 'read') return;
const msg = normalizeIncoming(parsed, activeRoomId.value);
if (!msg || msg.roomId !== activeRoomId.value) return;
messages.value = [...messages.value, msg];
} catch {
// ignore
}
});
socket.addEventListener('message', onSocketMessage);
await new Promise<void>((resolve, reject) => {
if (!socket) return reject(new Error('socket missing'));
@@ -145,9 +195,10 @@ export function useOrgChat() {
if (!res.ok) throw new Error('messages failed');
const body = await res.json();
const list = Array.isArray(body) ? body : body?.data ?? [];
messages.value = list
const normalized = list
.map((item: Record<string, unknown>) => normalizeIncoming(item, roomId))
.filter(Boolean) as ChatMessage[];
messages.value = sortAsc(normalized);
} finally {
loadingMessages.value = false;
}
@@ -177,7 +228,7 @@ export function useOrgChat() {
reply,
optimistic: true,
};
messages.value = [...messages.value, optimistic];
messages.value = mergeMessages(optimistic, messages.value);
socket.send(JSON.stringify(payload));
sendRead(roomId);
}
+21 -2
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue';
import { computed, nextTick, onMounted, ref, watch } from 'vue';
import { RouterLink } from 'vue-router';
import axios from 'axios';
import { api } from '@/lib/api';
@@ -29,9 +29,17 @@ const groupMemberIds = ref<string[]>([]);
const dmPeerId = ref('');
const { messages, loadingMessages, status, loadMessages, sendText, sendRead } = useOrgChat();
const msgStreamEl = ref<HTMLElement | null>(null);
const canCreateGroup = computed(() => access.value?.canCreateGroup ?? false);
async function scrollToBottom() {
await nextTick();
const el = msgStreamEl.value;
if (!el) return;
el.scrollTop = el.scrollHeight;
}
const sortedRooms = computed(() =>
[...rooms.value].sort((a, b) => {
const ta = a.lastMessageAt ? new Date(a.lastMessageAt).getTime() : 0;
@@ -154,6 +162,17 @@ watch(activeRoom, () => {
draft.value = '';
});
watch(
() => messages.value.length,
() => {
void scrollToBottom();
},
);
watch(loadingMessages, (v) => {
if (!v) void scrollToBottom();
});
onMounted(() => {
void bootstrap();
});
@@ -230,7 +249,7 @@ onMounted(() => {
</div>
<div v-if="loadingMessages" class="muted center pad">در حال دریافت پیام‌ها…</div>
<ul v-else class="msg-stream">
<ul v-else ref="msgStreamEl" class="msg-stream">
<li
v-for="msg in messages"
:key="msg.id"