Refining the CRM chat interface
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user