SHiNE-server/shine-UI/js/pages/messages-list.js

233 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M8.4 12.4l2.5 2.5 4.7-5.1"/></svg>';
const SVG_LINK = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10.5 13a4 4 0 0 0 5.66 0l1.84-1.84a4 4 0 1 0-5.66-5.66l-1 1"/><path d="M13.5 11a4 4 0 0 0-5.66 0L6 12.84a4 4 0 1 0 5.66 5.66l1-1"/></svg>';
const SVG_CHEVRON = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9 6l6 6-6 6"/></svg>';
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 = `
<div class="dm-head-brand">
<div class="dm-head-hex">${(login[0] || 'A').toUpperCase()}</div>
<div class="dm-head-id">
<span class="dm-head-name">${login}</span>
</div>
</div>
<h1 class="dm-head-title dm-head-shine">Shine</h1>
<button type="button" class="dm-head-plus" aria-label="Новый диалог">+</button>
`;
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 ? `<span class="dm-name-check" title="Подтверждён" aria-label="Подтверждён">${SVG_CHECK}</span>` : '';
const unreadHtml = v.unread ? `<span class="dm-unread-badge">${v.unread.label}</span>` : '';
row.innerHTML = `
<div class="dm-row-main">
<div class="dm-row-titleline">
<strong class="dm-row-title">${name}</strong>
${checkHtml}
</div>
<p class="dm-row-last-message">${preview}</p>
</div>
<div class="dm-row-meta">${unreadHtml}<span class="dm-chevron">${SVG_CHEVRON}</span></div>
`;
// Значок «связь через кого» (вместо слова «Связь»): цепочка + мини-аватар первого посредника, сразу за галочкой.
// Тап (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 = `<span class="dm-via-icon">${SVG_LINK}</span>`; // только иконка (без мини-аватара/«+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;
}