import { directMessages } from '../mock-data.js'; import { getChatMessages, isSessionInvalidError, normalizeDmChatId, setContacts, state, terminateCurrentSession, } from '../state.js'; import { loadCurrentRelations } from '../services/user-connections.js'; import { renderUserAvatar } from '../components/avatar-image.js'; import { createOverflowDots } from '../components/overflow-dots.js'; import { loadProfileSnapshot } from '../services/user-profile-params.js'; import { parseDmTechBlocks } from '../services/dm-tech-blocks.js'; export const pageMeta = { id: 'messages-list', title: 'Личные сообщения' }; const dmAvatarSnapshotCache = new Map(); const dmAvatarPendingByLogin = new Map(); async function loadDmAvatarSnapshot(login) { const cleanLogin = String(login || '').trim(); if (!cleanLogin) return null; const key = cleanLogin.toLowerCase(); if (dmAvatarSnapshotCache.has(key)) return dmAvatarSnapshotCache.get(key); if (dmAvatarPendingByLogin.has(key)) return dmAvatarPendingByLogin.get(key); const pending = loadProfileSnapshot(cleanLogin) .then((snapshot) => { dmAvatarSnapshotCache.set(key, snapshot || null); dmAvatarPendingByLogin.delete(key); return snapshot || null; }) .catch(() => { dmAvatarSnapshotCache.set(key, null); dmAvatarPendingByLogin.delete(key); return null; }); dmAvatarPendingByLogin.set(key, pending); return pending; } function createDmAvatar(login, { className = '' } = {}) { const cleanLogin = String(login || '').trim(); const title = cleanLogin ? `Профиль ${cleanLogin}` : ''; const avatarEl = renderUserAvatar({ login: cleanLogin || 'unknown', size: 'small', title, className, }); if (!cleanLogin) return avatarEl; void loadDmAvatarSnapshot(cleanLogin).then((snapshot) => { if (!avatarEl.isConnected) return; const upgraded = renderUserAvatar({ login: cleanLogin, avatar: snapshot?.avatar?.txId ? { ar: String(snapshot.avatar.txId || '').trim(), sha256Hex: String(snapshot?.avatar?.sha256Hex || '').trim().toLowerCase(), } : null, size: 'small', title, className, }); upgraded.classList.add('avatar'); avatarEl.replaceWith(upgraded); }); return avatarEl; } function resolveLastMessagePreview(text = '') { const parsed = parseDmTechBlocks(String(text || '')); const display = String(parsed.displayText || '').trim(); return display || ''; } function formatChatRowTime(ts) { const value = Number(ts || 0); if (!Number.isFinite(value) || value <= 0) return ''; return new Intl.DateTimeFormat('ru-RU', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit', }).format(new Date(value)); } function compareChatRows(a, b) { const timeA = Number(a?.lastTimeMs || 0); const timeB = Number(b?.lastTimeMs || 0); if (timeA !== timeB) return timeB - timeA; const nameA = String(a?.name || '').toLowerCase(); const nameB = String(b?.name || '').toLowerCase(); return nameA.localeCompare(nameB, 'ru'); } export function render({ navigate, chrome }) { const screen = document.createElement('section'); screen.className = 'stack dm-screen dm-list-screen'; const head = document.createElement('header'); head.className = 'dm-head'; head.innerHTML = `

Контакты

`; const menuButton = head.querySelector('.dm-head-menu-btn'); menuButton?.append(createOverflowDots()); const menuWrap = head.querySelector('.dm-head-menu-wrap'); const menuTemplate = head.querySelector('.dm-head-menu'); // The header itself is mounted into chrome/topbar, whose hit-test area ends at the // header bounds. A dropdown overflowing below it can look visible but clicks may // land on the content layer underneath. Render the open menu as a body portal. menuTemplate?.remove(); let menuPortal = null; const closeHeadMenu = () => { menuPortal?.remove(); menuPortal = null; menuButton?.setAttribute('aria-expanded', 'false'); menuWrap?.classList.remove('is-open'); }; const positionHeadMenu = () => { if (!menuPortal || !menuButton) return; const rect = menuButton.getBoundingClientRect(); const margin = 10; const menuWidth = menuPortal.offsetWidth || 206; const left = Math.max(margin, Math.min(window.innerWidth - menuWidth - margin, rect.right - menuWidth)); menuPortal.style.left = `${Math.round(left)}px`; menuPortal.style.top = `${Math.round(rect.bottom + 7)}px`; }; const openHeadMenu = () => { if (!menuButton || menuPortal) return; const portal = document.createElement('div'); portal.className = 'dm-head-menu dm-head-menu--portal'; portal.setAttribute('role', 'menu'); portal.innerHTML = ` `; portal.querySelector('[data-action="search-contacts"]')?.addEventListener('click', (event) => { event.preventDefault(); event.stopPropagation(); closeHeadMenu(); navigate('contact-search-view'); }); portal.addEventListener('click', (event) => event.stopPropagation()); document.body.append(portal); menuPortal = portal; menuButton.setAttribute('aria-expanded', 'true'); menuWrap?.classList.add('is-open'); positionHeadMenu(); }; menuButton?.addEventListener('click', (event) => { event.preventDefault(); event.stopPropagation(); if (menuPortal) closeHeadMenu(); else openHeadMenu(); }); const onOutsideClick = (event) => { if (!menuPortal) return; if (menuPortal.contains(event.target) || menuButton?.contains(event.target)) return; closeHeadMenu(); }; const onMenuKeydown = (event) => { if (event.key !== 'Escape' || !menuPortal) return; closeHeadMenu(); menuButton?.focus(); }; const onMenuViewportChange = () => positionHeadMenu(); document.addEventListener('click', onOutsideClick); document.addEventListener('keydown', onMenuKeydown); window.addEventListener('resize', onMenuViewportChange, { passive: true }); window.addEventListener('scroll', onMenuViewportChange, { passive: true, capture: true }); const list = document.createElement('div'); list.className = 'stack dm-list'; function renderRow(item) { const row = document.createElement('article'); row.className = 'list-item dm-dialog-card'; const avatarEl = createDmAvatar(item.id); avatarEl.classList.add('avatar'); const avatarWrap = document.createElement('div'); avatarWrap.className = 'dm-av dm-av--default'; avatarWrap.append(avatarEl); row.innerHTML = `
${item.notInContacts ? 'не в контактах' : ''}

${item.unread ? `${item.unread > 99 ? '99+' : item.unread}` : ''}
${item.time ? '' : ''}
`; const titleEl = row.querySelector('.dm-row-title'); const previewEl = row.querySelector('.dm-row-last-message'); const timeEl = row.querySelector('.dm-row-time'); if (titleEl) titleEl.textContent = String(item.name || ''); if (previewEl) previewEl.textContent = resolveLastMessagePreview(item.lastMessage) || 'Диалог пока пуст.'; if (timeEl) timeEl.textContent = String(item.time || ''); row.prepend(avatarWrap); row.addEventListener('click', () => navigate(`chat/${encodeURIComponent(normalizeDmChatId(item.id))}`)); return row; } async function loadList() { if (state.session.isLocalDemo) { const empty = document.createElement('div'); empty.className = 'card meta-muted'; empty.textContent = 'Локальный тестовый режим: реальные сообщения и контакты не загружаются.'; list.replaceChildren(empty); return; } try { const relations = await loadCurrentRelations(); const contacts = relations.outContacts || []; setContacts(contacts); list.innerHTML = ''; const contactRows = contacts.map((login) => { const preview = directMessages.find((item) => item.id.toLowerCase() === login.toLowerCase()); const canonicalLogin = normalizeDmChatId(login); const chat = getChatMessages(canonicalLogin); const lastChat = chat[chat.length - 1]; const unread = chat.filter((m) => m?.from === 'in' && m?.unread).length; const lastTimeMs = Number(lastChat?.createdAtMs || 0); return { id: canonicalLogin, name: preview?.name || login, lastMessage: lastChat?.text || preview?.lastMessage || 'Диалог пока пуст.', time: formatChatRowTime(lastTimeMs), unread, notInContacts: false, lastTimeMs, }; }); const allChatIds = Object.keys(state.chats || {}) .filter((id) => id && id.toLowerCase() !== String(state.session.login || '').toLowerCase()) .filter((id) => (getChatMessages(id) || []).length > 0); const contactKeys = new Set(contacts.map((x) => String(x || '').toLowerCase())); const extraRows = allChatIds .filter((login) => !contactKeys.has(String(login || '').toLowerCase())) .map((login) => { const chat = getChatMessages(login); const lastChat = chat[chat.length - 1]; const unread = chat.filter((m) => m?.from === 'in' && m?.unread).length; const lastTimeMs = Number(lastChat?.createdAtMs || 0); return { id: login, name: login, lastMessage: lastChat?.text || 'Диалог пока пуст.', time: formatChatRowTime(lastTimeMs), unread, notInContacts: true, lastTimeMs, }; }); const rows = [...contactRows, ...extraRows].sort(compareChatRows); if (!rows.length) { const empty = document.createElement('div'); empty.className = 'card meta-muted'; empty.textContent = 'Пока нет ни контактов, ни сообщений'; list.append(empty); return; } rows.forEach((item) => list.append(renderRow(item))); } catch (error) { if (isSessionInvalidError(error)) { list.innerHTML = ''; const card = document.createElement('div'); card.className = 'card stack'; const title = document.createElement('strong'); title.textContent = 'Сессия устарела'; const details = document.createElement('p'); details.className = 'meta-muted'; details.textContent = 'Ваша сессия больше не действует. Авторизуйтесь заново.'; const okBtn = document.createElement('button'); okBtn.type = 'button'; okBtn.className = 'primary-btn'; okBtn.textContent = 'ОК'; okBtn.addEventListener('click', async () => { await terminateCurrentSession({ infoMessage: 'Ваша сессия устарела. Выполните вход заново.', }); navigate('start-view'); }); card.append(title, details, okBtn); list.append(card); return; } list.innerHTML = ''; const fail = document.createElement('div'); fail.className = 'card meta-muted'; fail.textContent = `Не удалось загрузить сообщения: ${error.message || 'unknown'}`; list.append(fail); } } chrome?.setTopbar(head); screen.append(list); loadList(); screen.cleanup = () => { closeHeadMenu(); document.removeEventListener('click', onOutsideClick); document.removeEventListener('keydown', onMenuKeydown); window.removeEventListener('resize', onMenuViewportChange); window.removeEventListener('scroll', onMenuViewportChange, true); }; return screen; }