Adding the typing... indicator, loading older messages, and managing members/admins after creation.

This commit is contained in:
2026-09-30 11:53:04 +03:30
parent b978708487
commit e1d456ad11
9 changed files with 992 additions and 28 deletions
+154 -25
View File
@@ -214,8 +214,47 @@ const messages = shallowRef<ChatMessage[]>([]);
const activeRoomId = ref('');
const unreadTotal = ref(0);
const pageOpen = ref(false);
const hasMoreMessages = ref(false);
const loadingOlderMessages = ref(false);
/** تعداد خام پیام‌های گرفته‌شده از API (برای offset) */
let fetchedRawCount = 0;
const typingMap = ref<Record<string, Array<{ userId: string; at: number }>>>({});
let typingSweepTimer: ReturnType<typeof setInterval> | null = null;
const PAGE_SIZE = 200;
function startTypingSweep() {
if (typingSweepTimer) return;
typingSweepTimer = setInterval(() => {
const now = Date.now();
let changed = false;
const next: Record<string, Array<{ userId: string; at: number }>> = {};
for (const [roomId, list] of Object.entries(typingMap.value)) {
const kept = list.filter((t) => now - t.at < 3500);
if (kept.length) next[roomId] = kept;
if (kept.length !== list.length) changed = true;
}
if (changed) typingMap.value = next;
}, 1000);
}
function applyTypingEvent(roomId: string, userId: string) {
const rid = String(roomId || '').trim();
const uid = String(userId || '').trim();
if (!rid || !uid) 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() });
typingMap.value = { ...typingMap.value, [rid]: list };
}
function clearTypingForRoom(roomId: string) {
if (!typingMap.value[roomId]) return;
const next = { ...typingMap.value };
delete next[roomId];
typingMap.value = next;
}
const PAGE_SIZE = 40;
function messageTs(item: ChatMessage): number {
const t = new Date(item.createdAt).getTime();
@@ -732,7 +771,24 @@ function onSocketMessage(event: MessageEvent) {
try {
const parsed = JSON.parse(raw) as Record<string, unknown>;
const type = String(parsed.type ?? parsed.event ?? '').toLowerCase();
if (type === 'pong' || type === 'ping' || type === 'typing' || type === 'read') return;
if (type === 'pong' || type === 'ping' || type === 'read') return;
if (type === 'typing') {
const roomId = String(
parsed.room_id ??
parsed.roomId ??
((parsed.message as Record<string, unknown> | undefined)?.room_id as string) ??
activeRoomId.value,
);
const userId = String(
parsed.user_id ??
parsed.sender_id ??
parsed.from_user_id ??
parsed.userId ??
'',
);
applyTypingEvent(roomId, userId);
return;
}
if (type === 'seen') {
applySeenPayload(parsed);
return;
@@ -959,9 +1015,39 @@ export function useOrgChat() {
pageOpen.value = open;
}
function normalizeMessageList(
list: Record<string, unknown>[],
roomId: string,
hiddenSet: Set<string>,
myId: string,
): ChatMessage[] {
for (const item of list) {
const rawContent = String(item.content ?? item.text ?? item.body ?? '').trim();
const target = parseHideCommand(rawContent);
if (target) hiddenSet.add(target);
}
rememberHidden(roomId, [...hiddenSet]);
const normalized: ChatMessage[] = [];
for (const item of list) {
const m = normalizeIncoming(item, roomId);
if (!m || hiddenSet.has(m.id)) continue;
normalized.push(
m.senderId === myId
? { ...m, delivery: (m.readAt ? 'read' : 'sent') as ChatDelivery }
: m,
);
}
return sortAsc(normalized);
}
async function loadMessages(roomId: string) {
activeRoomId.value = roomId;
loadingMessages.value = true;
loadingOlderMessages.value = false;
fetchedRawCount = 0;
hasMoreMessages.value = false;
clearTypingForRoom(roomId);
startHiddenPoll(roomId);
try {
const session = await ensureSession();
@@ -981,35 +1067,72 @@ export function useOrgChat() {
if (!res.ok) throw new Error('messages failed');
const body = await res.json();
const list = Array.isArray(body) ? body : body?.data ?? [];
// Collect hide-command targets from history so we filter even without CRM row yet
for (const item of list as Record<string, unknown>[]) {
const rawContent = String(
item.content ?? item.text ?? item.body ?? '',
).trim();
const target = parseHideCommand(rawContent);
if (target) hiddenSet.add(target);
}
rememberHidden(roomId, [...hiddenSet]);
const myId = session.chatUserId;
const normalized: ChatMessage[] = [];
for (const item of list as Record<string, unknown>[]) {
const m = normalizeIncoming(item, roomId);
if (!m || hiddenSet.has(m.id)) continue;
normalized.push(
m.senderId === myId
? { ...m, delivery: (m.readAt ? 'read' : 'sent') as ChatDelivery }
: m,
);
}
messages.value = sortAsc(normalized);
const rows = list as Record<string, unknown>[];
fetchedRawCount = rows.length;
hasMoreMessages.value = rows.length >= PAGE_SIZE;
messages.value = normalizeMessageList(rows, roomId, hiddenSet, session.chatUserId);
sendRead(roomId);
} finally {
loadingMessages.value = false;
}
}
async function loadOlderMessages(roomId: string): Promise<boolean> {
if (!roomId || loadingOlderMessages.value || loadingMessages.value) return false;
if (!hasMoreMessages.value) return false;
loadingOlderMessages.value = true;
try {
const session = await ensureSession();
const hiddenIds = await fetchHiddenFromServer(roomId);
const hiddenSet = new Set(hiddenIds);
const params = new URLSearchParams({
api_key: new URL(session.wsUrl).searchParams.get('api_key') ?? '',
room_id: roomId,
user_id: session.chatUserId,
limit: String(PAGE_SIZE),
offset: String(fetchedRawCount),
});
const url = `${session.messagesBaseUrl}/api/messages?${params.toString()}`;
const res = await fetch(url, { headers: { Accept: 'application/json' }, cache: 'no-store' });
if (!res.ok) throw new Error('older messages failed');
const body = await res.json();
const list = Array.isArray(body) ? body : body?.data ?? [];
const rows = list as Record<string, unknown>[];
fetchedRawCount += rows.length;
hasMoreMessages.value = rows.length >= PAGE_SIZE;
if (!rows.length) return false;
const older = normalizeMessageList(rows, roomId, hiddenSet, session.chatUserId);
const existingIds = new Set(messages.value.map((m) => m.id));
const fresh = older.filter((m) => !existingIds.has(m.id));
messages.value = sortAsc([...fresh, ...messages.value]);
return true;
} finally {
loadingOlderMessages.value = false;
}
}
let lastTypingSentAt = 0;
async function sendTyping(roomId: string) {
const rid = String(roomId || '').trim();
if (!rid) return;
const now = Date.now();
if (now - lastTypingSentAt < 1800) return;
lastTypingSentAt = now;
try {
await connectSocket();
if (!socket || socket.readyState !== WebSocket.OPEN) return;
socket.send(JSON.stringify({ type: 'typing', room_id: rid }));
} catch {
// ignore
}
}
function typingUserIdsForRoom(roomId: string): string[] {
const list = typingMap.value[roomId] || [];
const now = Date.now();
return list.filter((t) => now - t.at < 3500).map((t) => t.userId);
}
async function sendText(roomId: string, text: string, reply: string | null = null) {
const trimmed = text.trim();
if (!trimmed) return;
@@ -1398,7 +1521,13 @@ export function useOrgChat() {
messages: computed(() => messages.value),
unreadTotal: computed(() => unreadTotal.value),
loadingMessages,
hasMoreMessages: computed(() => hasMoreMessages.value),
loadingOlderMessages: computed(() => loadingOlderMessages.value),
typingMap: computed(() => typingMap.value),
loadMessages,
loadOlderMessages,
sendTyping,
typingUserIdsForRoom,
sendText,
sendFile,
editMessage,