import { authService, getChatMessages, isSessionInvalidError, normalizeDmChatId, setContacts, state, terminateCurrentSession, } from '../state.js'; import { renderUserAvatar } from '../components/avatar-image.js'; import { createOverflowDots } from '../components/overflow-dots.js'; import { createDropdownMenu } from '../components/dropdown-menu.js'; import { createShineConnectionsLogo } from '../components/shine-logo.js'; import { parseDmTechBlocks } from '../services/dm-tech-blocks.js'; import { formatRelativeTime } from '../services/channels-ux.js'; import { userDisplayName } from '../services/user-display.js'; export const pageMeta = { id: 'messages-list', title: 'Личные сообщения' }; const PREVIEW_MAX_LEN = 200; const SVG_CHEVRON = ` `; const RELATION_ORDER = new Map([ ['close_friend', 0], ['friend', 1], ['contact', 2], ['none', 99], ]); const DM_BLOB_PREVIEW_CACHE = new Map(); const DM_BLOB_PREVIEW_PENDING = new Map(); function normalizeRelationFlag(value) { const clean = String(value || '').trim().toLowerCase(); if (clean === 'close_friend' || clean === 'friend' || clean === 'contact') return clean; return 'none'; } function relationOrder(flag) { return RELATION_ORDER.get(normalizeRelationFlag(flag)) ?? 99; } function relationLabel(flag) { switch (normalizeRelationFlag(flag)) { case 'close_friend': return 'близкий друг'; case 'friend': return 'друг'; case 'contact': return 'контакт'; default: return 'не в контактах'; } } function clipPreviewText(text, maxLen = PREVIEW_MAX_LEN) { const normalized = String(text || '').replace(/\s+/g, ' ').trim(); if (!normalized) return ''; if (normalized.length <= maxLen) return normalized; return `${normalized.slice(0, maxLen - 1)}…`; } async function resolveDialogPreview(dialog) { const localText = clipPreviewText(String(dialog?.lastMessageText || '').trim()); if (localText) return localText; const blobB64 = String(dialog?.lastMessageBlobB64 || '').trim(); if (!blobB64) return 'Диалог пока пуст.'; const cacheKey = [ blobB64, String(state.session.login || '').trim().toLowerCase(), String(state.session.storagePwdInMemory || '').trim(), ].join('|'); if (DM_BLOB_PREVIEW_CACHE.has(cacheKey)) return DM_BLOB_PREVIEW_CACHE.get(cacheKey); if (DM_BLOB_PREVIEW_PENDING.has(cacheKey)) return DM_BLOB_PREVIEW_PENDING.get(cacheKey); const pending = (async () => { try { const parsed = authService.parseSignedMessageBlob(blobB64); const decrypted = await authService.decryptSignedMessageContent({ parsed, blobB64, login: state.session.login, storagePwd: state.session.storagePwdInMemory, }); const parsedText = parseDmTechBlocks(String(decrypted?.text || '')); const display = clipPreviewText(String(parsedText.displayText || '').trim()); const result = display || 'Сообщение'; DM_BLOB_PREVIEW_CACHE.set(cacheKey, result); return result; } catch { const fallback = 'Сообщение недоступно'; DM_BLOB_PREVIEW_CACHE.set(cacheKey, fallback); return fallback; } finally { DM_BLOB_PREVIEW_PENDING.delete(cacheKey); } })(); DM_BLOB_PREVIEW_PENDING.set(cacheKey, pending); return pending; } function resolveLocalMessageTimeMs(message) { const keys = [message?.baseKey, message?.messageKey]; for (const key of keys) { const parts = String(key || '').split('|'); const timeMs = Number(parts[2] || 0); if (parts.length >= 4 && Number.isFinite(timeMs) && timeMs > 0) return timeMs; } const tempParts = String(message?.tempId || '').split('-'); const tempTimeMs = Number(tempParts[1] || 0); if (tempParts[0] === 'tmp' && Number.isFinite(tempTimeMs) && tempTimeMs > 0) return tempTimeMs; const createdAtMs = Number(message?.createdAtMs || message?.ts || 0); return Number.isFinite(createdAtMs) && createdAtMs > 0 ? createdAtMs : 0; } function latestLocalDialogMessage(peerLogin) { const messages = getChatMessages(peerLogin); const latest = [...messages].sort((a, b) => resolveLocalMessageTimeMs(b) - resolveLocalMessageTimeMs(a))[0]; if (!latest) return null; const parsed = parseDmTechBlocks(String(latest?.text || '')); const text = clipPreviewText(String(parsed.displayText || parsed.visibleText || '').trim()) || 'Сообщение'; return { text, timeMs: resolveLocalMessageTimeMs(latest) }; } function formatChatRowTime(ts) { return formatRelativeTime(ts); } function compareChatRows(a, b) { const timeA = Number(a?.lastMessageTimeMs || 0); const timeB = Number(b?.lastMessageTimeMs || 0); if (timeA !== timeB) return timeB - timeA; const nameA = String(a?.peerLogin || '').toLowerCase(); const nameB = String(b?.peerLogin || '').toLowerCase(); return nameA.localeCompare(nameB, 'ru'); } export function render({ navigate, chrome }) { const login = String(state.session.login || '').trim(); const screen = document.createElement('section'); screen.className = 'stack dm-screen dm-list-screen'; const head = document.createElement('header'); head.className = 'dm-head'; head.innerHTML = `
`; head.querySelector('.dm-head-logo-wrap')?.append( createShineConnectionsLogo({ className: 'dm-head-logo' }), ); const menuButton = head.querySelector('.dm-head-menu-btn'); menuButton?.append(createOverflowDots()); let currentChatFilter = 'all'; const filterTitle = head.querySelector('#dm-chat-filter-title'); const filterLabels = { all: 'Чаты', close_friend: 'Близкие друзья', friend: 'Друзья', contact: 'Контакты', none: 'Новые', }; let reloadForFilter = () => {}; const chatFilterMenu = createDropdownMenu({ anchorEl: filterTitle, align: 'left', leftShift: 72, minWidth: 225, items: [ { label: 'Все чаты', action: () => { currentChatFilter = 'all'; filterTitle.textContent = filterLabels.all; reloadForFilter(); } }, { label: 'Близкие друзья', action: () => { currentChatFilter = 'close_friend'; filterTitle.textContent = filterLabels.close_friend; reloadForFilter(); } }, { label: 'Друзья', action: () => { currentChatFilter = 'friend'; filterTitle.textContent = filterLabels.friend; reloadForFilter(); } }, { label: 'Контакты', action: () => { currentChatFilter = 'contact'; filterTitle.textContent = filterLabels.contact; reloadForFilter(); } }, { label: 'Новые', action: () => { currentChatFilter = 'none'; filterTitle.textContent = filterLabels.none; reloadForFilter(); } }, ], }); 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`; const titleRect = filterTitle?.getBoundingClientRect?.(); menuPortal.style.top = `${Math.round((titleRect?.bottom || rect.bottom) + 7)}px`; }; const openHeadMenu = () => { if (!menuButton || menuPortal) return; document.dispatchEvent(new CustomEvent('shine:dropdown-open', { detail: { owner: menuButton } })); 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 onPeerDropdownOpen = (event) => { if (!menuPortal || event?.detail?.owner === menuButton) 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); document.addEventListener('shine:dropdown-open', onPeerDropdownOpen); 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 relationFlag = normalizeRelationFlag(item.relationFlag); const relationBadge = relationFlag === 'none' ? 'не в контактах' : relationLabel(relationFlag); const avatarEl = renderUserAvatar({ login: item.peerLogin, firstName: item.firstName, lastName: item.lastName, avatar: item.avatarAr ? { ar: String(item.avatarAr).trim() } : null, size: 'lg', title: `Профиль ${item.peerLogin}`, }); avatarEl.classList.add('avatar'); const avatarWrap = document.createElement('div'); avatarWrap.className = 'dm-av dm-av--default'; avatarWrap.append(avatarEl); row.innerHTML = `
${relationBadge}

${item.unreadCount ? `${item.unreadCount > 99 ? '99+' : item.unreadCount}` : ''}
${item.lastMessageTimeMs ? '' : ''} ${SVG_CHEVRON}
`; 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 = userDisplayName(item); if (previewEl) previewEl.textContent = 'Загрузка…'; if (timeEl) timeEl.textContent = formatChatRowTime(item.lastMessageTimeMs); row.prepend(avatarWrap); void resolveDialogPreview(item).then((text) => { if (!previewEl?.isConnected) return; previewEl.textContent = String(text || '').trim() || 'Диалог пока пуст.'; }); row.addEventListener('click', () => navigate(`chat/${encodeURIComponent(normalizeDmChatId(item.peerLogin))}`)); 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 payload = await authService.listContacts(); const dialogs = Array.isArray(payload?.dialogs) ? payload.dialogs : []; const contacts = Array.isArray(payload?.contacts) ? payload.contacts : []; setContacts(contacts); list.innerHTML = ''; const byPeer = new Map(); dialogs.forEach((dialog) => { const peerLogin = String(dialog?.peerLogin || '').trim(); if (!peerLogin) return; const key = peerLogin.toLowerCase(); const relationFlag = normalizeRelationFlag(dialog?.relationFlag); const next = { id: peerLogin, peerLogin, firstName: String(dialog?.firstName || '').trim(), lastName: String(dialog?.lastName || '').trim(), avatarAr: String(dialog?.avatarAr || '').trim(), accountRole: String(dialog?.accountRole || '').trim(), shineStatus: String(dialog?.shineStatus || '').trim(), relationFlag, lastMessageBlobB64: String(dialog?.lastMessageBlobB64 || ''), lastMessageTimeMs: Number(dialog?.lastMessageTimeMs || 0), unreadCount: Number(dialog?.unreadCount || 0), hasDialog: Boolean(dialog?.hasDialog), }; const localLatest = latestLocalDialogMessage(peerLogin); if (localLatest && localLatest.timeMs >= next.lastMessageTimeMs) { next.lastMessageText = localLatest.text; next.lastMessageTimeMs = localLatest.timeMs; } const current = byPeer.get(key); if (!current) { byPeer.set(key, next); return; } const newest = next.lastMessageTimeMs > current.lastMessageTimeMs ? next : current; newest.relationFlag = relationOrder(relationFlag) < relationOrder(current.relationFlag) ? relationFlag : current.relationFlag; newest.unreadCount = Math.max(Number(current.unreadCount || 0), Number(next.unreadCount || 0)); newest.hasDialog = Boolean(current.hasDialog || next.hasDialog); byPeer.set(key, newest); }); const rows = Array.from(byPeer.values()) .filter((item) => currentChatFilter === 'all' || normalizeRelationFlag(item.relationFlag) === currentChatFilter) .sort((a, b) => { const orderA = relationOrder(a.relationFlag); const orderB = relationOrder(b.relationFlag); if (orderA !== orderB) return orderA - orderB; return compareChatRows(a, b); }); if (!rows.length) { const empty = document.createElement('div'); empty.className = 'card meta-muted'; empty.textContent = 'Пока нет диалогов'; list.append(empty); return; } let dividerInserted = false; rows.forEach((item) => { if (!dividerInserted && normalizeRelationFlag(item.relationFlag) === 'none' && list.childNodes.length > 0) { const divider = document.createElement('div'); divider.className = 'dm-divider'; list.append(divider); dividerInserted = true; } 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); } } reloadForFilter = () => { void loadList(); }; chrome?.setTopbar(head); screen.append(list); loadList(); screen.cleanup = () => { closeHeadMenu(); chatFilterMenu.destroy(); document.removeEventListener('click', onOutsideClick); document.removeEventListener('keydown', onMenuKeydown); document.removeEventListener('shine:dropdown-open', onPeerDropdownOpen); window.removeEventListener('resize', onMenuViewportChange); window.removeEventListener('scroll', onMenuViewportChange, true); }; return screen; }