Updating ConversationsView wiring and the nav badge next.
This commit is contained in:
@@ -16,10 +16,13 @@ import type {
|
||||
NotificationsListDto,
|
||||
} from '@mento/shared';
|
||||
import { canManageSettings, canViewAllRecords } from '@/lib/roles';
|
||||
import { refreshUnreadTotal, useOrgChat } from '@/lib/useOrgChat';
|
||||
|
||||
const auth = useAuthStore();
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const { unreadTotal: chatUnreadTotal, ensureConnected: ensureChatConnected } = useOrgChat();
|
||||
const chatBadge = computed(() => chatUnreadTotal.value);
|
||||
|
||||
const canSettings = computed(() => canManageSettings(auth.user?.role));
|
||||
const showOwnRecordsBanner = computed(
|
||||
@@ -282,6 +285,16 @@ async function loadActivityBadge() {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadChatBadge() {
|
||||
if (!moduleAllowed('conversations')) return;
|
||||
try {
|
||||
await refreshUnreadTotal();
|
||||
await ensureChatConnected();
|
||||
} catch {
|
||||
// keep previous
|
||||
}
|
||||
}
|
||||
|
||||
async function markRead(item: NotificationDto) {
|
||||
if (!item.readAt) {
|
||||
await api.patch(`/notifications/${item.id}/read`);
|
||||
@@ -361,7 +374,11 @@ onMounted(() => {
|
||||
}
|
||||
void refreshNotifs();
|
||||
void loadActivityBadge();
|
||||
pollTimer = setInterval(() => void refreshNotifs(), 45000);
|
||||
void loadChatBadge();
|
||||
pollTimer = setInterval(() => {
|
||||
void refreshNotifs();
|
||||
void refreshUnreadTotal().catch(() => undefined);
|
||||
}, 45000);
|
||||
mobileMq = window.matchMedia('(max-width: 900px)');
|
||||
isMobileNav.value = mobileMq.matches;
|
||||
mobileMq.addEventListener('change', onMobileMqChange);
|
||||
@@ -503,6 +520,7 @@ onUnmounted(() => {
|
||||
>
|
||||
<UiIcon name="chat" :size="collapsed ? 20 : 18" />
|
||||
<span>گفتگو</span>
|
||||
<span v-if="chatBadge" class="nav-badge">{{ toFaDigits(chatBadge) }}</span>
|
||||
</RouterLink>
|
||||
|
||||
<RouterLink
|
||||
@@ -853,6 +871,7 @@ onUnmounted(() => {
|
||||
>
|
||||
<UiIcon name="chat" :size="18" />
|
||||
<span>گفتگو</span>
|
||||
<span v-if="chatBadge" class="nav-badge">{{ toFaDigits(chatBadge) }}</span>
|
||||
</RouterLink>
|
||||
<button
|
||||
class="nav-item nav-fold-btn"
|
||||
|
||||
+241
-56
@@ -1,7 +1,9 @@
|
||||
import { computed, onUnmounted, ref, shallowRef } from 'vue';
|
||||
import type { ConversationSessionDto } from '@mento/shared';
|
||||
import { computed, ref, shallowRef } from 'vue';
|
||||
import type { ConversationRoomDto, ConversationSessionDto } from '@mento/shared';
|
||||
import { api } from '@/lib/api';
|
||||
|
||||
export type ChatDelivery = 'sending' | 'sent' | 'read';
|
||||
|
||||
export type ChatMessage = {
|
||||
id: string;
|
||||
roomId: string;
|
||||
@@ -11,23 +13,55 @@ export type ChatMessage = {
|
||||
createdAt: string;
|
||||
reply: string | null;
|
||||
optimistic?: boolean;
|
||||
delivery?: ChatDelivery;
|
||||
readAt?: string | null;
|
||||
};
|
||||
|
||||
export type RoomLiveEvent = {
|
||||
roomId: string;
|
||||
preview: string;
|
||||
at: string;
|
||||
fromSelf: boolean;
|
||||
};
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
let socket: WebSocket | null = null;
|
||||
let sessionCache: ConversationSessionDto | null = null;
|
||||
let connectPromise: Promise<void> | null = null;
|
||||
let pingTimer: ReturnType<typeof setInterval> | null = null;
|
||||
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let wantSocket = false;
|
||||
|
||||
const status = ref<'idle' | 'connecting' | 'open' | 'closed'>('idle');
|
||||
const messages = shallowRef<ChatMessage[]>([]);
|
||||
const activeRoomId = ref('');
|
||||
let pingTimer: ReturnType<typeof setInterval> | null = null;
|
||||
const unreadTotal = ref(0);
|
||||
const pageOpen = ref(false);
|
||||
|
||||
type RoomListener = (event: RoomLiveEvent) => void;
|
||||
const roomListeners = new Set<RoomListener>();
|
||||
|
||||
export function onRoomLiveEvent(listener: RoomListener) {
|
||||
roomListeners.add(listener);
|
||||
return () => roomListeners.delete(listener);
|
||||
}
|
||||
|
||||
function emitRoomLive(event: RoomLiveEvent) {
|
||||
for (const listener of roomListeners) {
|
||||
try {
|
||||
listener(event);
|
||||
} catch {
|
||||
// ignore listener errors
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -47,6 +81,7 @@ function normalizeIncoming(
|
||||
const content = String(base.content ?? base.text ?? base.body ?? '').trim();
|
||||
if (!content && !base.attachments) return null;
|
||||
const id = String(base.id ?? base.message_id ?? '').trim();
|
||||
const readAt = base.read_at ?? base.readAt ?? null;
|
||||
return {
|
||||
id: id || `tmp-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
roomId: String(base.room_id ?? raw.room_id ?? roomFallback),
|
||||
@@ -59,19 +94,29 @@ function normalizeIncoming(
|
||||
base.created_at ?? base.createdAt ?? raw.created_at ?? new Date().toISOString(),
|
||||
),
|
||||
reply: base.reply ? String(base.reply) : null,
|
||||
readAt: readAt ? String(readAt) : null,
|
||||
delivery: readAt ? 'read' : 'sent',
|
||||
};
|
||||
}
|
||||
|
||||
/** ادغام: همان id جایگزین میشود؛ optimistic مشابه با echo سوکت یکی میشود */
|
||||
function mergeMessages(incoming: ChatMessage, list: ChatMessage[]): ChatMessage[] {
|
||||
const byId = list.findIndex((m) => m.id === incoming.id);
|
||||
if (byId >= 0) {
|
||||
const prev = list[byId];
|
||||
const next = [...list];
|
||||
next[byId] = { ...list[byId], ...incoming, optimistic: false };
|
||||
next[byId] = {
|
||||
...prev,
|
||||
...incoming,
|
||||
optimistic: false,
|
||||
delivery:
|
||||
incoming.delivery === 'read' || prev.delivery === 'read'
|
||||
? 'read'
|
||||
: incoming.delivery || prev.delivery || 'sent',
|
||||
readAt: incoming.readAt ?? prev.readAt,
|
||||
};
|
||||
return sortAsc(next);
|
||||
}
|
||||
|
||||
// echo سوکت بعد از optimistic با همان متن/فرستنده/اتاق
|
||||
const optimisticIdx = list.findIndex(
|
||||
(m) =>
|
||||
m.optimistic &&
|
||||
@@ -80,11 +125,13 @@ function mergeMessages(incoming: ChatMessage, list: ChatMessage[]): ChatMessage[
|
||||
m.content === incoming.content,
|
||||
);
|
||||
if (optimisticIdx >= 0) {
|
||||
const prev = list[optimisticIdx];
|
||||
const next = [...list];
|
||||
next[optimisticIdx] = {
|
||||
...list[optimisticIdx],
|
||||
...prev,
|
||||
...incoming,
|
||||
optimistic: false,
|
||||
delivery: incoming.readAt ? 'read' : 'sent',
|
||||
};
|
||||
return sortAsc(next);
|
||||
}
|
||||
@@ -92,6 +139,38 @@ function mergeMessages(incoming: ChatMessage, list: ChatMessage[]): ChatMessage[
|
||||
return sortAsc([...list, incoming]);
|
||||
}
|
||||
|
||||
function applySeenPayload(payload: Record<string, unknown>) {
|
||||
const roomId = String(payload.room_id ?? '');
|
||||
if (!roomId || roomId !== activeRoomId.value) return;
|
||||
|
||||
const myId = sessionCache?.chatUserId ?? '';
|
||||
const readerId = String(payload.reader_id ?? '');
|
||||
// وقتی طرف مقابل خوانده، پیامهای من تیک دوتایی میخورند
|
||||
if (!readerId || readerId === myId) return;
|
||||
|
||||
const ids = new Set(
|
||||
Array.isArray(payload.seen_message_ids)
|
||||
? payload.seen_message_ids.map((id) => String(id))
|
||||
: [],
|
||||
);
|
||||
if (!ids.size) {
|
||||
// اگر id لیست نبود، همه پیامهای خودم در این room را read کن
|
||||
messages.value = messages.value.map((m) =>
|
||||
m.roomId === roomId && m.senderId === myId
|
||||
? { ...m, delivery: 'read' as const, readAt: String(payload.seen_at ?? new Date().toISOString()) }
|
||||
: m,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const seenAt = String(payload.seen_at ?? new Date().toISOString());
|
||||
messages.value = messages.value.map((m) => {
|
||||
if (!ids.has(m.id)) return m;
|
||||
if (m.senderId !== myId) return m;
|
||||
return { ...m, delivery: 'read' as const, readAt: seenAt };
|
||||
});
|
||||
}
|
||||
|
||||
async function ensureSession(): Promise<ConversationSessionDto> {
|
||||
if (sessionCache) return sessionCache;
|
||||
const { data } = await api.get<ConversationSessionDto>('/conversations/session');
|
||||
@@ -106,13 +185,62 @@ function clearPing() {
|
||||
}
|
||||
}
|
||||
|
||||
function clearReconnect() {
|
||||
if (reconnectTimer) {
|
||||
clearTimeout(reconnectTimer);
|
||||
reconnectTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleReconnect() {
|
||||
if (!wantSocket || reconnectTimer) return;
|
||||
reconnectTimer = setTimeout(() => {
|
||||
reconnectTimer = null;
|
||||
void connectSocket().catch(() => {
|
||||
scheduleReconnect();
|
||||
});
|
||||
}, 2500);
|
||||
}
|
||||
|
||||
function disconnectSocket() {
|
||||
clearPing();
|
||||
clearReconnect();
|
||||
if (socket) {
|
||||
socket.onmessage = null;
|
||||
socket.onclose = null;
|
||||
socket.onerror = null;
|
||||
socket.close();
|
||||
socket = null;
|
||||
}
|
||||
status.value = 'closed';
|
||||
connectPromise = null;
|
||||
}
|
||||
|
||||
function handleIncomingMessage(msg: ChatMessage) {
|
||||
const myId = sessionCache?.chatUserId ?? '';
|
||||
const fromSelf = Boolean(myId && msg.senderId === myId);
|
||||
|
||||
if (msg.roomId === activeRoomId.value) {
|
||||
messages.value = mergeMessages(
|
||||
{
|
||||
...msg,
|
||||
delivery: fromSelf ? msg.delivery || 'sent' : msg.delivery,
|
||||
},
|
||||
messages.value,
|
||||
);
|
||||
if (!fromSelf && pageOpen.value) {
|
||||
sendRead(msg.roomId);
|
||||
}
|
||||
} else if (!fromSelf) {
|
||||
unreadTotal.value += 1;
|
||||
}
|
||||
|
||||
emitRoomLive({
|
||||
roomId: msg.roomId,
|
||||
preview: msg.content,
|
||||
at: msg.createdAt,
|
||||
fromSelf,
|
||||
});
|
||||
}
|
||||
|
||||
function onSocketMessage(event: MessageEvent) {
|
||||
@@ -120,63 +248,101 @@ function onSocketMessage(event: MessageEvent) {
|
||||
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 type = String(parsed.type ?? parsed.event ?? '').toLowerCase();
|
||||
if (type === 'pong' || type === 'ping' || type === 'typing' || type === 'read') return;
|
||||
if (type === 'seen') {
|
||||
applySeenPayload(parsed);
|
||||
return;
|
||||
}
|
||||
const msg = normalizeIncoming(parsed, activeRoomId.value);
|
||||
if (!msg || !activeRoomId.value || msg.roomId !== activeRoomId.value) return;
|
||||
messages.value = mergeMessages(msg, messages.value);
|
||||
if (!msg) return;
|
||||
handleIncomingMessage(msg);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
async function connectSocket() {
|
||||
const session = await ensureSession();
|
||||
wantSocket = true;
|
||||
if (socket?.readyState === WebSocket.OPEN) return;
|
||||
if (connectPromise) return connectPromise;
|
||||
|
||||
disconnectSocket();
|
||||
status.value = 'connecting';
|
||||
socket = new WebSocket(session.wsUrl);
|
||||
connectPromise = (async () => {
|
||||
const session = await ensureSession();
|
||||
if (socket?.readyState === WebSocket.OPEN) return;
|
||||
|
||||
socket.addEventListener('open', () => {
|
||||
status.value = 'open';
|
||||
pingTimer = setInterval(() => {
|
||||
if (socket?.readyState === WebSocket.OPEN) socket.send('ping');
|
||||
}, 15000);
|
||||
disconnectSocket();
|
||||
wantSocket = true;
|
||||
status.value = 'connecting';
|
||||
socket = new WebSocket(session.wsUrl);
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
if (!socket) return reject(new Error('socket missing'));
|
||||
const t = setTimeout(() => reject(new Error('ws timeout')), 12000);
|
||||
socket.addEventListener(
|
||||
'open',
|
||||
() => {
|
||||
clearTimeout(t);
|
||||
status.value = 'open';
|
||||
pingTimer = setInterval(() => {
|
||||
if (socket?.readyState === WebSocket.OPEN) socket.send('ping');
|
||||
}, 15000);
|
||||
resolve();
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
socket.addEventListener(
|
||||
'error',
|
||||
() => {
|
||||
clearTimeout(t);
|
||||
reject(new Error('ws error'));
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
});
|
||||
|
||||
if (!socket) return;
|
||||
socket.addEventListener('message', onSocketMessage);
|
||||
socket.addEventListener('close', () => {
|
||||
status.value = 'closed';
|
||||
clearPing();
|
||||
socket = null;
|
||||
connectPromise = null;
|
||||
if (wantSocket) scheduleReconnect();
|
||||
});
|
||||
})().finally(() => {
|
||||
connectPromise = null;
|
||||
});
|
||||
|
||||
socket.addEventListener('close', () => {
|
||||
status.value = 'closed';
|
||||
clearPing();
|
||||
});
|
||||
return connectPromise;
|
||||
}
|
||||
|
||||
socket.addEventListener('message', onSocketMessage);
|
||||
function sendRead(roomId: string) {
|
||||
if (!socket || socket.readyState !== WebSocket.OPEN || !roomId) return;
|
||||
socket.send(JSON.stringify({ type: 'read', room_id: roomId }));
|
||||
}
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
if (!socket) return reject(new Error('socket missing'));
|
||||
const t = setTimeout(() => reject(new Error('ws timeout')), 12000);
|
||||
socket.addEventListener(
|
||||
'open',
|
||||
() => {
|
||||
clearTimeout(t);
|
||||
resolve();
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
socket.addEventListener(
|
||||
'error',
|
||||
() => {
|
||||
clearTimeout(t);
|
||||
reject(new Error('ws error'));
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
});
|
||||
export async function refreshUnreadTotal() {
|
||||
try {
|
||||
const { data } = await api.get<ConversationRoomDto[]>('/conversations/rooms');
|
||||
unreadTotal.value = data.reduce((sum, room) => sum + (room.unreadCount || 0), 0);
|
||||
return unreadTotal.value;
|
||||
} catch {
|
||||
return unreadTotal.value;
|
||||
}
|
||||
}
|
||||
|
||||
export function useOrgChat() {
|
||||
const loadingMessages = ref(false);
|
||||
|
||||
async function ensureConnected() {
|
||||
await connectSocket();
|
||||
}
|
||||
|
||||
function setConversationsPageOpen(open: boolean) {
|
||||
pageOpen.value = open;
|
||||
}
|
||||
|
||||
async function loadMessages(roomId: string) {
|
||||
activeRoomId.value = roomId;
|
||||
loadingMessages.value = true;
|
||||
@@ -195,10 +361,18 @@ export function useOrgChat() {
|
||||
if (!res.ok) throw new Error('messages failed');
|
||||
const body = await res.json();
|
||||
const list = Array.isArray(body) ? body : body?.data ?? [];
|
||||
const normalized = list
|
||||
const myId = session.chatUserId;
|
||||
const normalized = (list
|
||||
.map((item: Record<string, unknown>) => normalizeIncoming(item, roomId))
|
||||
.filter(Boolean) as ChatMessage[];
|
||||
.filter(Boolean) as ChatMessage[]).map((m) => {
|
||||
if (m.senderId !== myId) return m;
|
||||
return {
|
||||
...m,
|
||||
delivery: (m.readAt ? 'read' : 'sent') as ChatDelivery,
|
||||
};
|
||||
});
|
||||
messages.value = sortAsc(normalized);
|
||||
sendRead(roomId);
|
||||
} finally {
|
||||
loadingMessages.value = false;
|
||||
}
|
||||
@@ -227,36 +401,47 @@ export function useOrgChat() {
|
||||
createdAt: new Date().toISOString(),
|
||||
reply,
|
||||
optimistic: true,
|
||||
delivery: 'sending',
|
||||
};
|
||||
messages.value = mergeMessages(optimistic, messages.value);
|
||||
emitRoomLive({
|
||||
roomId,
|
||||
preview: trimmed,
|
||||
at: optimistic.createdAt,
|
||||
fromSelf: true,
|
||||
});
|
||||
socket.send(JSON.stringify(payload));
|
||||
sendRead(roomId);
|
||||
messages.value = messages.value.map((m) =>
|
||||
m.id === id ? { ...m, optimistic: false, delivery: 'sent' as const } : m,
|
||||
);
|
||||
}
|
||||
|
||||
function sendRead(roomId: string) {
|
||||
if (!socket || socket.readyState !== WebSocket.OPEN) return;
|
||||
socket.send(JSON.stringify({ type: 'read', room_id: roomId }));
|
||||
function clearActiveRoom() {
|
||||
activeRoomId.value = '';
|
||||
messages.value = [];
|
||||
}
|
||||
|
||||
function resetSession() {
|
||||
wantSocket = false;
|
||||
sessionCache = null;
|
||||
disconnectSocket();
|
||||
messages.value = [];
|
||||
activeRoomId.value = '';
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
disconnectSocket();
|
||||
});
|
||||
|
||||
return {
|
||||
status: computed(() => status.value),
|
||||
messages: computed(() => messages.value),
|
||||
unreadTotal: computed(() => unreadTotal.value),
|
||||
loadingMessages,
|
||||
loadMessages,
|
||||
sendText,
|
||||
sendRead,
|
||||
ensureConnected,
|
||||
setConversationsPageOpen,
|
||||
clearActiveRoom,
|
||||
resetSession,
|
||||
ensureSession,
|
||||
refreshUnreadTotal,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onMounted, ref, watch } from 'vue';
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
|
||||
import axios from 'axios';
|
||||
import { api } from '@/lib/api';
|
||||
import type {
|
||||
@@ -8,7 +8,12 @@ import type {
|
||||
ConversationRoomDto,
|
||||
} from '@mento/shared';
|
||||
import { formatClockFa, toFaDigits } from '@/lib/jalali';
|
||||
import { useOrgChat } from '@/lib/useOrgChat';
|
||||
import {
|
||||
onRoomLiveEvent,
|
||||
useOrgChat,
|
||||
type ChatDelivery,
|
||||
type RoomLiveEvent,
|
||||
} from '@/lib/useOrgChat';
|
||||
import UiIcon from '@/components/UiIcon.vue';
|
||||
import UserAvatar from '@/components/UserAvatar.vue';
|
||||
|
||||
@@ -24,9 +29,22 @@ const showNewGroup = ref(false);
|
||||
const groupName = ref('');
|
||||
const groupMemberIds = ref<string[]>([]);
|
||||
const dmPeerId = ref('');
|
||||
const chatUserId = ref('');
|
||||
|
||||
const { messages, loadingMessages, status, loadMessages, sendText, sendRead } = useOrgChat();
|
||||
const {
|
||||
messages,
|
||||
loadingMessages,
|
||||
status,
|
||||
loadMessages,
|
||||
sendText,
|
||||
sendRead,
|
||||
ensureConnected,
|
||||
setConversationsPageOpen,
|
||||
refreshUnreadTotal,
|
||||
clearActiveRoom,
|
||||
} = useOrgChat();
|
||||
const msgStreamEl = ref<HTMLElement | null>(null);
|
||||
let stopRoomLive: (() => boolean) | undefined;
|
||||
|
||||
const canCreateGroup = computed(() => access.value?.canCreateGroup ?? false);
|
||||
|
||||
@@ -45,8 +63,6 @@ const sortedRooms = computed(() =>
|
||||
}),
|
||||
);
|
||||
|
||||
const chatUserId = ref('');
|
||||
|
||||
function roomTitle(room: ConversationRoomDto) {
|
||||
if (room.kind === 'group') return room.name;
|
||||
const peer = peers.value.find((p) => p.id === room.peerUserId);
|
||||
@@ -62,6 +78,44 @@ function formatListTime(iso: string | null) {
|
||||
}
|
||||
}
|
||||
|
||||
function deliveryLabel(delivery?: ChatDelivery) {
|
||||
if (delivery === 'read') return '✓✓';
|
||||
if (delivery === 'sent') return '✓';
|
||||
return '…';
|
||||
}
|
||||
|
||||
function applyRoomLive(event: RoomLiveEvent) {
|
||||
const idx = rooms.value.findIndex((r) => r.externalRoomId === event.roomId);
|
||||
if (idx < 0) {
|
||||
void refreshRoomsSoft();
|
||||
return;
|
||||
}
|
||||
const room = rooms.value[idx];
|
||||
const isActive = activeRoom.value?.externalRoomId === event.roomId;
|
||||
const next: ConversationRoomDto = {
|
||||
...room,
|
||||
lastMessagePreview: event.preview,
|
||||
lastMessageAt: event.at,
|
||||
unreadCount: event.fromSelf || isActive ? 0 : (room.unreadCount || 0) + 1,
|
||||
};
|
||||
const nextRooms = [...rooms.value];
|
||||
nextRooms[idx] = next;
|
||||
rooms.value = nextRooms;
|
||||
if (isActive && activeRoom.value) {
|
||||
activeRoom.value = next;
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshRoomsSoft() {
|
||||
try {
|
||||
const { data } = await api.get<ConversationRoomDto[]>('/conversations/rooms');
|
||||
rooms.value = data;
|
||||
await refreshUnreadTotal();
|
||||
} catch {
|
||||
// keep previous
|
||||
}
|
||||
}
|
||||
|
||||
async function bootstrap() {
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
@@ -87,6 +141,7 @@ async function bootstrap() {
|
||||
}
|
||||
if (roomsOutcome.status === 'fulfilled') {
|
||||
rooms.value = roomsOutcome.value.data;
|
||||
await refreshUnreadTotal();
|
||||
} else {
|
||||
const e = roomsOutcome.reason;
|
||||
if (axios.isAxiosError(e)) {
|
||||
@@ -101,6 +156,8 @@ async function bootstrap() {
|
||||
error.value = 'بارگذاری لیست گفتگو ناموفق بود.';
|
||||
}
|
||||
}
|
||||
|
||||
await ensureConnected();
|
||||
} catch (e) {
|
||||
if (axios.isAxiosError(e) && e.response?.status === 403) {
|
||||
error.value = 'ماژول گفتگو برای این محیط فعال نیست.';
|
||||
@@ -118,9 +175,14 @@ async function bootstrap() {
|
||||
}
|
||||
|
||||
async function openRoom(room: ConversationRoomDto) {
|
||||
activeRoom.value = room;
|
||||
const cleared = { ...room, unreadCount: 0 };
|
||||
activeRoom.value = cleared;
|
||||
rooms.value = rooms.value.map((r) =>
|
||||
r.externalRoomId === room.externalRoomId ? cleared : r,
|
||||
);
|
||||
await loadMessages(room.externalRoomId);
|
||||
sendRead(room.externalRoomId);
|
||||
void refreshUnreadTotal();
|
||||
}
|
||||
|
||||
async function submitMessage() {
|
||||
@@ -193,8 +255,16 @@ watch(loadingMessages, (v) => {
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
setConversationsPageOpen(true);
|
||||
stopRoomLive = onRoomLiveEvent(applyRoomLive);
|
||||
void bootstrap();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
setConversationsPageOpen(false);
|
||||
stopRoomLive?.();
|
||||
clearActiveRoom();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -274,7 +344,21 @@ onMounted(() => {
|
||||
>
|
||||
<div class="bubble">
|
||||
<p>{{ msg.content }}</p>
|
||||
<time>{{ formatListTime(msg.createdAt) }}</time>
|
||||
<div class="bubble-meta">
|
||||
<time>{{ formatListTime(msg.createdAt) }}</time>
|
||||
<span
|
||||
v-if="msg.senderId === chatUserId || msg.optimistic"
|
||||
class="ticks"
|
||||
:class="msg.delivery"
|
||||
:title="
|
||||
msg.delivery === 'read'
|
||||
? 'خوانده شد'
|
||||
: msg.delivery === 'sent'
|
||||
? 'ارسال شد'
|
||||
: 'در حال ارسال'
|
||||
"
|
||||
>{{ deliveryLabel(msg.delivery) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
@@ -572,10 +656,32 @@ onMounted(() => {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
.bubble-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
.bubble time {
|
||||
font-size: 0.68rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
.ticks {
|
||||
font-size: 0.72rem;
|
||||
letter-spacing: -0.08em;
|
||||
line-height: 1;
|
||||
color: #94a3b8;
|
||||
user-select: none;
|
||||
}
|
||||
.ticks.sending {
|
||||
color: #cbd5e1;
|
||||
}
|
||||
.ticks.sent {
|
||||
color: #64748b;
|
||||
}
|
||||
.ticks.read {
|
||||
color: #0d9488;
|
||||
}
|
||||
.composer {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
|
||||
Reference in New Issue
Block a user