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 { createTopBar } from '../components/topbar.js'; import { createShineConnectionsLogo } from '../components/shine-logo.js'; import { loadProfileSnapshot } from '../services/user-profile-params.js'; import { parseDmTechBlocks } from '../services/dm-tech-blocks.js'; import { formatRelativeTime } from '../services/channels-ux.js'; export const pageMeta = { id: 'messages-list', title: 'Личные сообщения' }; const PREVIEW_MAX_LEN = 200; const SVG_CHEVRON = ` `; const DM_BLOB_PREVIEW_CACHE = new Map(); const DM_BLOB_PREVIEW_PENDING = new Map(); const dmAvatarSnapshotCache = new Map(); const dmAvatarPendingByLogin = new Map(); const RELATION_ORDER = new Map([ ['close_friend', 0], ['friend', 1], ['contact', 2], ['none', 3], ]); 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 = '', avatar = null, firstName = '', lastName = '' } = {}) { const cleanLogin = String(login || '').trim(); const title = cleanLogin ? `Профиль ${cleanLogin}` : ''; const avatarEl = renderUserAvatar({ login: cleanLogin || 'unknown', firstName: String(firstName || '').trim(), lastName: String(lastName || '').trim(), avatar: avatar?.ar ? { ar: String(avatar.ar || '').trim(), sha256Hex: String(avatar.sha256Hex || '').trim().toLowerCase() } : null, size: 'lg', title, className, }); if (!cleanLogin || avatar?.ar) 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: 'lg', title, className, }); upgraded.classList.add('avatar'); avatarEl.replaceWith(upgraded); }); return avatarEl; } 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 brand = document.createElement('div'); brand.className = 'dm-head-brand'; const logoWrap = document.createElement('span'); logoWrap.className = 'dm-head-logo-wrap'; logoWrap.setAttribute('aria-hidden', 'true'); logoWrap.append(createShineConnectionsLogo({ className: 'dm-head-logo' })); brand.append(logoWrap); let currentChatFilter = 'all'; const filterTitle = document.createElement('button'); filterTitle.type = 'button'; filterTitle.className = 'dm-head-filter-title'; filterTitle.textContent = 'Чаты'; const filterLabels = { all: 'Чаты', close_friend: 'Близкие друзья', friend: 'Друзья', contact: 'Контакты', none: 'Новые', }; let reloadForFilter = () => {}; const chatFilterMenu = createDropdownMenu({ transparent: true, anchorEl: filterTitle, placement: 'bottom-start', 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 searchIconHtml = ` `; const head = createTopBar({ left: brand, center: filterTitle, actions: [ { iconNode: createOverflowDots(), title: 'Меню чатов', ariaLabel: 'Меню чатов', className: 'messages-topbar-menu-btn', menu: { minWidth: 210, items: [ { label: 'Поиск пользователей', iconHtml: searchIconHtml, action: () => navigate('contact-search-view') }, ], }, }, ], }); 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 = createDmAvatar(item.peerLogin, { avatar: item.avatar, firstName: item.firstName, lastName: item.lastName, }); avatarEl.classList.add('avatar'); const avatarWrap = document.createElement('div'); avatarWrap.className = 'dm-av dm-av--default'; avatarWrap.append(avatarEl); row.innerHTML = `