import { directMessages } from '../mock-data.js'; import { state } from '../state.js'; import { renderUserAvatar } from '../components/avatar-image.js'; import { loadProfileSnapshot } from '../services/user-profile-params.js'; import { resolveDmVisualState } from './messages/dm-visual-resolver.js'; import { getPreview, getUnread } from './messages/dm-lab-store.js'; import { makeProfileRoute } from '../services/shine-routes.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; } // Аватар: реальное фото через renderUserAvatar (lazy), при отсутствии — аккуратные инициалы. // Ошибка загрузки снапшота не ломает карточку (catch → инициалы остаются). function createDmAvatar(login, { upgrade = true, name = '', photo = '' } = {}) { const cleanLogin = String(login || '').trim(); const title = cleanLogin ? `Профиль ${cleanLogin}` : ''; // Инициалы для fallback берём из имени диалога («Марина К.» → «МК»), а не из служебного id. const parts = String(name || '').trim().split(/\s+/).filter(Boolean); const firstName = parts[0] || ''; const lastName = parts[1] || ''; const avatarEl = renderUserAvatar({ login: cleanLogin || 'unknown', firstName, lastName, size: 'small', title }); // Тестовое фото (demo, «как в Связях»): pravatar поверх инициалов через .has-image; офлайн/ошибка → инициалы. const photoUrl = String(photo || '').trim(); if (photoUrl) { const img = document.createElement('img'); // eager (не lazy): аватары у верха списка; lazy в некоторых движках/headless не догружает фото. img.alt = ''; img.loading = 'eager'; img.decoding = 'async'; img.addEventListener('load', () => avatarEl.classList.add('has-image')); img.addEventListener('error', () => { try { img.remove(); } catch (e) { /* остаются инициалы */ } avatarEl.classList.remove('has-image'); }); img.src = photoUrl; avatarEl.append(img); } // upgrade=false (demo/lab) → остаёмся на тестовом фото/инициалах, без сетевого запроса профиля. if (!cleanLogin || !upgrade) return avatarEl; void loadDmAvatarSnapshot(cleanLogin).then((snapshot) => { if (!avatarEl.isConnected) return; const upgraded = renderUserAvatar({ login: cleanLogin, firstName, lastName, avatar: snapshot?.avatar?.txId ? { ar: String(snapshot.avatar.txId || '').trim(), sha256Hex: String(snapshot?.avatar?.sha256Hex || '').trim().toLowerCase() } : null, size: 'small', title, }); upgraded.classList.add('avatar'); avatarEl.replaceWith(upgraded); }); return avatarEl; } // Иконки: галочка-подтверждён (gold, БЕЗ текста), цепочка (значок «связь через кого»), шеврон. const SVG_CHECK = ''; const SVG_LINK = ''; const SVG_CHEVRON = ''; export function render({ navigate, route }) { const screen = document.createElement('section'); screen.className = 'dm-screen dm-list-screen'; // demo/lab: гость без сессии (маршрут /messages-list/lab или ?demo=1). В demo НЕ ходим в сеть за фото // профиля — иначе висящие listUserParams не дают сети уйти в idle и ломают скриншоты (остаются initials). const isDemo = route?.params?.mode === 'lab' || (typeof window !== 'undefined' && /[?&]demo=1(?:&|$)/.test(window.location.search || '')); // Слева сверху — имя владельца аккаунта (на проде реальный логин; в demo — заглушка, НЕ «shine», // чтобы не дублировать центральный бренд «Shine»). const login = String(state.session.login || '').trim() || 'Aidar007'; // DM-шапка: grid 1fr auto 1fr (бренд слева, title строго по центру, «+» справа). const head = document.createElement('header'); head.className = 'dm-head'; head.innerHTML = `
${(login[0] || 'A').toUpperCase()}
${login}

Shine

`; head.querySelector('.dm-head-plus').addEventListener('click', () => navigate('contact-search-view')); const divider = document.createElement('div'); divider.className = 'dm-divider'; const list = document.createElement('div'); list.className = 'dm-list'; function renderRow(item) { // В demo превью/непрочитанные берём из dm-lab-store (обновляются после отправки/открытия чата). const resolverItem = isDemo ? { ...item, unreadCount: getUnread(item.id) } : item; const v = resolveDmVisualState(resolverItem); // { tone, shining, confirmed, via, unread } const cardVariant = v.tone === 'family' ? ' dm-card--family' : (v.tone === 'shining' ? ' dm-card--shining' : ''); const name = item.name || item.id; const preview = (isDemo ? getPreview(item.id, item.preview || item.lastMessage || '') : (item.preview || item.lastMessage || '')) || 'Диалог пока пуст.'; const row = document.createElement('article'); row.className = `dm-dialog-card${cardVariant}`; row.tabIndex = 0; row.setAttribute('role', 'button'); // Галочка-подтверждён — у имени, БЕЗ слова «Подтверждён». const checkHtml = v.confirmed ? `${SVG_CHECK}` : ''; const unreadHtml = v.unread ? `${v.unread.label}` : ''; row.innerHTML = `
${name} ${checkHtml}

${preview}

${unreadHtml}${SVG_CHEVRON}
`; // Значок «связь через кого» (вместо слова «Связь»): цепочка + мини-аватар первого посредника, сразу за галочкой. // Тап (stopPropagation) → попап пути «Ты → … → он»; сама карточка по-прежнему открывает чат. if (v.via && v.via.length) { const titleline = row.querySelector('.dm-row-titleline'); const viaBtn = document.createElement('button'); viaBtn.type = 'button'; viaBtn.className = 'dm-via'; viaBtn.setAttribute('aria-label', `Связь через ${v.via.map((x) => x.name).join(', ')}`); viaBtn.innerHTML = `${SVG_LINK}`; // только иконка (без мини-аватара/«+N») titleline.appendChild(viaBtn); // Попап пути: Ты → …посредники… → целевой. Каждый узел — фото-аватар + имя. // Тап по человеку (кроме «Ты») → его профиль (makeProfileRoute), чтобы отследить цепочку. const pop = document.createElement('div'); pop.className = 'dm-via-path'; const chain = [ { name: 'Ты', me: true }, ...v.via.map((x) => ({ name: x.name, login: x.login || '', photo: x.photo || '' })), { name, login: item.login || item.id, photo: item.photo || '' }, ]; chain.forEach((node, i) => { if (i) { const arr = document.createElement('span'); arr.className = 'dm-via-arrow'; arr.textContent = '→'; pop.appendChild(arr); } const clickable = !node.me && Boolean(node.login); const el = document.createElement(clickable ? 'button' : 'span'); el.className = 'dm-via-node'; const ava = document.createElement('span'); ava.className = 'dm-via-node-ava'; if (node.me) { const me = document.createElement('span'); me.className = 'dm-via-me'; me.textContent = (login[0] || 'A').toUpperCase(); ava.appendChild(me); } else { ava.appendChild(createDmAvatar(node.login || node.name, { upgrade: false, name: node.name, photo: node.photo })); } const nm = document.createElement('span'); nm.className = 'dm-via-node-name'; nm.textContent = node.name; el.append(ava, nm); if (clickable) { el.type = 'button'; el.addEventListener('click', (e) => { e.stopPropagation(); navigate(makeProfileRoute(node.login)); }); } pop.appendChild(el); }); row.appendChild(pop); const toggle = (e) => { e.stopPropagation(); pop.classList.toggle('is-open'); }; viaBtn.addEventListener('click', toggle); viaBtn.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggle(e); } }); } // Аватар: фото/инициалы без кольца; у сияющего — свечение (класс is-shine). const avWrap = document.createElement('div'); avWrap.className = `dm-av dm-av--${v.tone}${v.shining ? ' is-shine' : ''}`; const avatarEl = createDmAvatar(item.id, { upgrade: !isDemo, name, photo: isDemo ? item.photo : '' }); avatarEl.classList.add('avatar'); avWrap.appendChild(avatarEl); row.prepend(avWrap); const go = () => navigate(isDemo ? `messages-list/lab/chat/${encodeURIComponent(item.id)}` : `chat-view/${encodeURIComponent(item.id)}`); row.addEventListener('click', go); row.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); go(); } }); return row; } // Оффлайн-демо: список из мока directMessages (с семантическими полями). // На проде источник заменяется на реальные relations (relationFlagsForTarget/shineConfirmed/shine) — // карточки и резолвер не меняются. const items = Array.isArray(directMessages) ? directMessages : []; if (!items.length) { const empty = document.createElement('div'); empty.className = 'card meta-muted'; empty.textContent = 'Пока нет диалогов'; list.append(empty); } else { items.forEach((item) => list.append(renderRow(item))); } screen.append(head, divider, list); return screen; }